From d3061e846e389ae41a413417ccdfaf24fae45183 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:03:08 -0400 Subject: [PATCH 01/82] test(leveling): preserve channel identity during plane fit --- tests/core/test_leveling.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index ff6474a..39fbdde 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -37,3 +37,35 @@ def test_align_rows() -> None: ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) leveled = leveling.align_rows(ch, method="median") assert np.allclose(leveled.data, 0.0) + + +def test_plane_fit_returns_new_channel_without_mutating_input( + tilted_surface: SPMChannel, +) -> None: + """Plane fitting must preserve the input and channel identity metadata.""" + original_data = tilted_surface.data.copy() + original_metadata = dict(tilted_surface.metadata) + + leveled = leveling.plane_fit(tilted_surface) + + # Un objeto nuevo, no el mismo canal. + assert leveled is not tilted_surface + + # El canal original no fue alterado. + assert np.array_equal(tilted_surface.data, original_data) + assert tilted_surface.metadata == original_metadata + + # Los datos nivelados viven en otro array. + assert leveled.data is not tilted_surface.data + + # Se conserva la identidad física del canal. + assert leveled.name == tilted_surface.name + assert leveled.unit == tilted_surface.unit + assert leveled.x_range == tilted_surface.x_range + assert leveled.y_range == tilted_surface.y_range + assert leveled.direction == tilted_surface.direction + assert leveled.group == tilted_surface.group + + # with_data crea un diccionario exterior nuevo. + assert leveled.metadata == tilted_surface.metadata + assert leveled.metadata is not tilted_surface.metadata From 50ee15527873b0acfdfd939fd61579740980405d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:40:26 -0400 Subject: [PATCH 02/82] feat(leveling): add strict zero-mean leveling --- src/spmkit/core/analysis/leveling.py | 17 +++++ tests/core/test_leveling.py | 104 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 695ce93..1ed3281 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -12,6 +12,23 @@ from spmkit.core.models import SPMChannel +def zero_mean(channel: SPMChannel) -> SPMChannel: + """Shift the vertical reference so the arithmetic mean is zero.""" + data = np.asarray(channel.data) + + if data.ndim != 2: + raise ValueError("zero_mean requires a 2D channel") + if data.size == 0: + raise ValueError("zero_mean requires non-empty data") + if not np.issubdtype(data.dtype, np.number): + raise TypeError("zero_mean requires numeric data") + if not np.all(np.isfinite(data)): + raise ValueError("zero_mean requires finite data") + + mean_height = np.mean(data) + return channel.with_data(data - mean_height) + + def plane_fit(channel: SPMChannel) -> SPMChannel: """Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``. diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 39fbdde..3b144ef 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pytest from spmkit.core.analysis import leveling from spmkit.core.models import SPMChannel @@ -69,3 +70,106 @@ def test_plane_fit_returns_new_channel_without_mutating_input( # with_data crea un diccionario exterior nuevo. assert leveled.metadata == tilted_surface.metadata assert leveled.metadata is not tilted_surface.metadata + + +def test_zero_mean_sets_arithmetic_mean_to_zero() -> None: + """Zero-mean leveling must shift only the vertical reference.""" + data = np.array( + [ + [10.0, 12.0], + [14.0, 20.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=2e-6, + y_range=3e-6, + direction="backward", + group="Scan backward", + metadata={"source": "synthetic"}, + ) + + original_data = data.copy() + + result = leveling.zero_mean(channel) + + assert np.isclose(np.mean(result.data), 0.0) + # The operation returns independent data without mutating the input. + assert result is not channel + assert result.data is not channel.data + assert np.array_equal(channel.data, original_data) + + # Physical channel context is preserved. + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + + # with_data copies the outer metadata dictionary. + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + # Subtracting a constant must preserve all relative heights. + assert np.allclose( + result.data - result.data[0, 0], + data - data[0, 0], + ) + + +@pytest.mark.parametrize( + ("data", "error_type", "message"), + [ + ( + np.array([1.0, 2.0]), + ValueError, + "zero_mean requires a 2D channel", + ), + ( + np.empty((0, 2), dtype=float), + ValueError, + "zero_mean requires non-empty data", + ), + ( + np.array([["a", "b"], ["c", "d"]]), + TypeError, + "zero_mean requires numeric data", + ), + ( + np.array([[1.0, np.nan], [2.0, 3.0]]), + ValueError, + "zero_mean requires finite data", + ), + ( + np.array([[1.0, np.inf], [2.0, 3.0]]), + ValueError, + "zero_mean requires finite data", + ), + ], + ids=[ + "one-dimensional", + "empty", + "non-numeric", + "nan", + "infinite", + ], +) +def test_zero_mean_rejects_invalid_data( + data: np.ndarray, + error_type: type[Exception], + message: str, +) -> None: + """Invalid inputs must fail explicitly instead of producing bad data.""" + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=2e-6, + y_range=2e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.zero_mean(channel) From 435da3e40cb1054cb99737aa87dfbae21e486b72 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:43:38 -0400 Subject: [PATCH 03/82] refactor(leveling): centralize strict data validation --- src/spmkit/core/analysis/leveling.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 1ed3281..809bb36 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -12,19 +12,25 @@ from spmkit.core.models import SPMChannel -def zero_mean(channel: SPMChannel) -> SPMChannel: - """Shift the vertical reference so the arithmetic mean is zero.""" +def _validated_data(channel: SPMChannel, *, operation: str) -> np.ndarray: + """Return valid 2D, numeric, finite channel data.""" data = np.asarray(channel.data) if data.ndim != 2: - raise ValueError("zero_mean requires a 2D channel") + raise ValueError(f"{operation} requires a 2D channel") if data.size == 0: - raise ValueError("zero_mean requires non-empty data") + raise ValueError(f"{operation} requires non-empty data") if not np.issubdtype(data.dtype, np.number): - raise TypeError("zero_mean requires numeric data") + raise TypeError(f"{operation} requires numeric data") if not np.all(np.isfinite(data)): - raise ValueError("zero_mean requires finite data") + raise ValueError(f"{operation} requires finite data") + + return data + +def zero_mean(channel: SPMChannel) -> SPMChannel: + """Shift the vertical reference so the arithmetic mean is zero.""" + data = _validated_data(channel, operation="zero_mean") mean_height = np.mean(data) return channel.with_data(data - mean_height) From 9e540f5545d3ddd87992f67bab0615c139fd97b0 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:48:04 -0400 Subject: [PATCH 04/82] feat(leveling): add strict minimum-zero leveling --- src/spmkit/core/analysis/leveling.py | 7 +++++ tests/core/test_leveling.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 809bb36..ec0f237 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -35,6 +35,13 @@ def zero_mean(channel: SPMChannel) -> SPMChannel: return channel.with_data(data - mean_height) +def zero_minimum(channel: SPMChannel) -> SPMChannel: + """Shift the vertical reference so the minimum height is zero.""" + data = _validated_data(channel, operation="zero_minimum") + minimum_height = np.min(data) + return channel.with_data(data - minimum_height) + + def plane_fit(channel: SPMChannel) -> SPMChannel: """Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``. diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 3b144ef..adba3b5 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -173,3 +173,47 @@ def test_zero_mean_rejects_invalid_data( with pytest.raises(error_type, match=message): leveling.zero_mean(channel) + + +def test_zero_minimum_sets_lowest_height_to_zero() -> None: + """Minimum leveling must shift only the vertical reference.""" + data = np.array( + [ + [-3.0, 1.0], + [4.0, 9.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=2e-6, + y_range=3e-6, + direction="backward", + group="Scan backward", + metadata={"source": "synthetic"}, + ) + original_data = data.copy() + + result = leveling.zero_minimum(channel) + + assert np.isclose(np.min(result.data), 0.0) + + # Subtracting a constant must preserve all relative heights. + assert np.allclose( + result.data - result.data[0, 0], + data - data[0, 0], + ) + + # Input data and physical channel context are preserved. + assert result is not channel + assert result.data is not channel.data + assert np.array_equal(channel.data, original_data) + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata From 8b29b58760fb01bd46eaafe97f071508103372b3 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:50:25 -0400 Subject: [PATCH 05/82] feat(leveling): add strict vertical shifting --- src/spmkit/core/analysis/leveling.py | 20 ++++++ tests/core/test_leveling.py | 102 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index ec0f237..9c0e6b9 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -42,6 +42,26 @@ def zero_minimum(channel: SPMChannel) -> SPMChannel: return channel.with_data(data - minimum_height) +def shift_vertical(channel: SPMChannel, *, offset: float) -> SPMChannel: + """Add a finite scalar offset to every height value.""" + data = _validated_data(channel, operation="shift_vertical") + offset_array = np.asarray(offset) + + if ( + offset_array.ndim != 0 + or not np.issubdtype(offset_array.dtype, np.number) + or np.iscomplexobj(offset_array) + ): + raise TypeError("shift_vertical requires a real numeric scalar offset") + + offset_value = float(offset_array.item()) + + if not np.isfinite(offset_value): + raise ValueError("shift_vertical requires a finite offset") + + return channel.with_data(data + offset_value) + + def plane_fit(channel: SPMChannel) -> SPMChannel: """Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``. diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index adba3b5..6717dfe 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -217,3 +217,105 @@ def test_zero_minimum_sets_lowest_height_to_zero() -> None: assert result.group == channel.group assert result.metadata == channel.metadata assert result.metadata is not channel.metadata + + +def test_shift_vertical_adds_requested_offset() -> None: + """Vertical shifting must add the requested offset to every pixel.""" + data = np.array( + [ + [-2.0, 0.0], + [3.0, 7.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=2e-6, + y_range=3e-6, + direction="forward", + group="Scan forward", + metadata={"source": "synthetic"}, + ) + original_data = data.copy() + + result = leveling.shift_vertical(channel, offset=2.5) + + assert np.allclose(result.data, data + 2.5) + + # A constant shift preserves all relative heights. + assert np.allclose( + result.data - result.data[0, 0], + data - data[0, 0], + ) + + # The input remains unchanged and the channel context is preserved. + assert np.array_equal(channel.data, original_data) + assert result is not channel + assert result.data is not channel.data + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize( + ("offset", "error_type", "message"), + [ + ( + "2.5", + TypeError, + "shift_vertical requires a real numeric scalar offset", + ), + ( + [2.5], + TypeError, + "shift_vertical requires a real numeric scalar offset", + ), + ( + True, + TypeError, + "shift_vertical requires a real numeric scalar offset", + ), + ( + 1.0 + 2.0j, + TypeError, + "shift_vertical requires a real numeric scalar offset", + ), + ( + np.nan, + ValueError, + "shift_vertical requires a finite offset", + ), + ( + np.inf, + ValueError, + "shift_vertical requires a finite offset", + ), + ], + ids=[ + "string", + "array", + "boolean", + "complex", + "nan", + "infinite", + ], +) +def test_shift_vertical_rejects_invalid_offsets( + offset: object, + error_type: type[Exception], + message: str, +) -> None: + """Invalid offsets must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.array([[1.0, 2.0], [3.0, 4.0]]), + unit="nm", + x_range=2e-6, + y_range=2e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.shift_vertical(channel, offset=offset) # type: ignore[arg-type] From 97ca58eab5931251fd332dc536ea82c5c5d4ca1c Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:00:35 -0400 Subject: [PATCH 06/82] feat(leveling): add masked plane fitting --- src/spmkit/core/analysis/leveling.py | 88 ++++++++++++++++--- tests/core/test_leveling.py | 122 +++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 9c0e6b9..2c45721 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -7,6 +7,8 @@ from __future__ import annotations +from typing import Literal + import numpy as np from spmkit.core.models import SPMChannel @@ -28,6 +30,42 @@ def _validated_data(channel: SPMChannel, *, operation: str) -> np.ndarray: return data +def _fit_selection( + data: np.ndarray, + *, + mask: np.ndarray | None, + mask_mode: Literal["ignore", "include", "exclude"], + operation: str, + minimum_points: int, +) -> np.ndarray: + """Return pixels selected for a background fit.""" + allowed_modes = {"ignore", "include", "exclude"} + + if mask_mode not in allowed_modes: + raise ValueError(f"{operation} mask_mode must be 'ignore', 'include', or 'exclude'") + + if mask_mode == "ignore": + return np.ones(data.shape, dtype=bool) + + if mask is None: + raise ValueError(f"{operation} requires a mask when mask_mode is " f"'{mask_mode}'") + + mask_data = np.asarray(mask) + + if mask_data.shape != data.shape: + raise ValueError(f"{operation} requires mask shape to match channel data") + + if mask_data.dtype != np.bool_: + raise TypeError(f"{operation} requires a boolean mask") + + selection = mask_data if mask_mode == "include" else ~mask_data + + if np.count_nonzero(selection) < minimum_points: + raise ValueError(f"{operation} requires at least {minimum_points} selected points") + + return selection + + def zero_mean(channel: SPMChannel) -> SPMChannel: """Shift the vertical reference so the arithmetic mean is zero.""" data = _validated_data(channel, operation="zero_mean") @@ -62,18 +100,46 @@ def shift_vertical(channel: SPMChannel, *, offset: float) -> SPMChannel: return channel.with_data(data + offset_value) -def plane_fit(channel: SPMChannel) -> SPMChannel: - """Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``. - - Es la corrección de inclinación más común para topografía AFM. - """ - z = channel.data - rows, cols = z.shape +def plane_fit( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> SPMChannel: + """Subtract a least-squares plane from a two-dimensional channel.""" + data = _validated_data(channel, operation="plane_fit") + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="plane_fit", + minimum_points=3, + ) + + rows, cols = data.shape yy, xx = np.mgrid[0:rows, 0:cols] - a_mat = np.column_stack([xx.ravel(), yy.ravel(), np.ones(z.size)]) - coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None) - plane = (a_mat @ coeffs).reshape(z.shape) - return channel.with_data(z - plane) + + design = np.column_stack( + ( + xx.ravel(), + yy.ravel(), + np.ones(data.size), + ) + ) + selected = selection.ravel() + + coefficients, _, rank, _ = np.linalg.lstsq( + design[selected], + data.ravel()[selected], + rcond=None, + ) + + if rank < 3: + raise ValueError("plane_fit selected points do not define a unique plane") + + plane = coefficients[0] * xx + coefficients[1] * yy + coefficients[2] + + return channel.with_data(data - plane) def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 6717dfe..afed303 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -319,3 +319,125 @@ def test_shift_vertical_rejects_invalid_offsets( with pytest.raises(error_type, match=message): leveling.shift_vertical(channel, offset=offset) # type: ignore[arg-type] + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_plane_fit_mask_controls_fit_selection(mask_mode: str) -> None: + """Masked plane fitting must ignore an excluded surface feature.""" + rows, cols = 7, 7 + yy, xx = np.mgrid[0:rows, 0:cols] + + background = 2.0 * xx - 0.5 * yy + 10.0 + data = background.copy() + data[3, 3] += 1000.0 + + excluded = np.zeros_like(data, dtype=bool) + excluded[3, 3] = True + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=7e-6, + y_range=7e-6, + ) + + result = leveling.plane_fit( + channel, + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + assert np.allclose(result.data[~excluded], 0.0, atol=1e-10) + assert np.isclose(result.data[3, 3], 1000.0, atol=1e-10) + + +@pytest.mark.parametrize( + ("mask", "mask_mode", "error_type", "message"), + [ + ( + None, + "include", + ValueError, + "plane_fit requires a mask", + ), + ( + np.ones((2, 3), dtype=bool), + "exclude", + ValueError, + "plane_fit requires mask shape to match channel data", + ), + ( + np.ones((4, 4), dtype=int), + "exclude", + TypeError, + "plane_fit requires a boolean mask", + ), + ( + np.zeros((4, 4), dtype=bool), + "include", + ValueError, + "plane_fit requires at least 3 selected points", + ), + ( + None, + "invalid", + ValueError, + "plane_fit mask_mode must be", + ), + ], + ids=[ + "missing-mask", + "wrong-shape", + "non-boolean", + "too-few-points", + "invalid-mode", + ], +) +def test_plane_fit_rejects_invalid_mask_configuration( + mask: object, + mask_mode: str, + error_type: type[Exception], + message: str, +) -> None: + """Invalid mask configurations must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(16.0).reshape(4, 4), + unit="nm", + x_range=4e-6, + y_range=4e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.plane_fit( + channel, + mask=mask, # type: ignore[arg-type] + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + +def test_plane_fit_rejects_collinear_selected_points() -> None: + """Three collinear pixels cannot determine a unique plane.""" + mask = np.zeros((3, 3), dtype=bool) + mask[0, :] = True + + channel = SPMChannel( + name="Z-Axis", + data=np.arange(9.0).reshape(3, 3), + unit="nm", + x_range=3e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match="selected points do not define a unique plane", + ): + leveling.plane_fit( + channel, + mask=mask, + mask_mode="include", + ) From d9a94e77e41c11b4e725199518ffa69995f4087a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:04:54 -0400 Subject: [PATCH 07/82] feat(leveling): add three-point plane leveling --- src/spmkit/core/analysis/leveling.py | 53 ++++++++++++++ tests/core/test_leveling.py | 100 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 2c45721..f543988 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -142,6 +142,59 @@ def plane_fit( return channel.with_data(data - plane) +def three_point_level( + channel: SPMChannel, + *, + points: tuple[ + tuple[int, int], + tuple[int, int], + tuple[int, int], + ], +) -> SPMChannel: + """Subtract the plane defined by three non-collinear reference pixels.""" + data = _validated_data(channel, operation="three_point_level") + point_data = np.asarray(points) + + if point_data.shape != (3, 2): + raise ValueError("three_point_level requires exactly three (row, column) points") + + if not np.issubdtype(point_data.dtype, np.integer): + raise TypeError("three_point_level requires integer pixel coordinates") + + point_rows = point_data[:, 0] + point_columns = point_data[:, 1] + + rows, columns = data.shape + out_of_bounds = ( + np.any(point_rows < 0) + or np.any(point_rows >= rows) + or np.any(point_columns < 0) + or np.any(point_columns >= columns) + ) + + if out_of_bounds: + raise ValueError("three_point_level requires points within channel bounds") + + design = np.column_stack( + ( + point_columns.astype(float), + point_rows.astype(float), + np.ones(3), + ) + ) + + if np.linalg.matrix_rank(design) < 3: + raise ValueError("three_point_level requires three non-collinear points") + + heights = data[point_rows, point_columns] + coefficients = np.linalg.solve(design, heights) + + yy, xx = np.mgrid[0:rows, 0:columns] + plane = coefficients[0] * xx + coefficients[1] * yy + coefficients[2] + + return channel.with_data(data - plane) + + def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: """Resta una superficie polinómica 2D de grado ``order``. diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index afed303..d77d80b 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -441,3 +441,103 @@ def test_plane_fit_rejects_collinear_selected_points() -> None: mask=mask, mask_mode="include", ) + + +def test_three_point_level_subtracts_plane_defined_by_reference_points() -> None: + """Three reference pixels must define the plane subtracted from the channel.""" + rows, cols = 5, 6 + yy, xx = np.mgrid[0:rows, 0:cols] + + background = 1.5 * xx - 0.25 * yy + 7.0 + data = background.copy() + data[2, 3] += 4.0 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=6e-6, + y_range=5e-6, + direction="forward", + group="Scan forward", + metadata={"source": "synthetic"}, + ) + original_data = data.copy() + + points = ((0, 0), (0, 5), (4, 0)) + result = leveling.three_point_level(channel, points=points) + + # The three reference pixels define zero height after leveling. + for row, column in points: + assert np.isclose(result.data[row, column], 0.0, atol=1e-12) + + feature_mask = np.ones(data.shape, dtype=bool) + feature_mask[2, 3] = False + + assert np.allclose(result.data[feature_mask], 0.0, atol=1e-12) + assert np.isclose(result.data[2, 3], 4.0, atol=1e-12) + + # Input and physical channel context are preserved. + assert np.array_equal(channel.data, original_data) + assert result is not channel + assert result.data is not channel.data + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize( + ("points", "error_type", "message"), + [ + ( + ((0, 0), (0, 1)), + ValueError, + "three_point_level requires exactly three", + ), + ( + ((0.0, 0.0), (0.0, 2.0), (2.0, 0.0)), + TypeError, + "three_point_level requires integer pixel coordinates", + ), + ( + ((0, 0), (0, 2), (8, 0)), + ValueError, + "three_point_level requires points within channel bounds", + ), + ( + ((0, 0), (0, 1), (0, 2)), + ValueError, + "three_point_level requires three non-collinear points", + ), + ], + ids=[ + "wrong-count", + "non-integer", + "out-of-bounds", + "collinear", + ], +) +def test_three_point_level_rejects_invalid_points( + points: object, + error_type: type[Exception], + message: str, +) -> None: + """Invalid reference-point configurations must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(16.0).reshape(4, 4), + unit="nm", + x_range=4e-6, + y_range=4e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.three_point_level( + channel, + points=points, # type: ignore[arg-type] + ) From 76ed1c8e445caecea3ddcd2b752b220ab7b8d1f7 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:11:39 -0400 Subject: [PATCH 08/82] feat(leveling): add masked polynomial background fitting --- src/spmkit/core/analysis/leveling.py | 140 +++++++++++++++++++-- tests/core/test_leveling.py | 174 +++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 12 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index f543988..8c527fe 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -66,6 +66,27 @@ def _fit_selection( return selection +def _nonnegative_integer( + value: object, + *, + name: str, + operation: str, +) -> int: + """Validate and return a non-negative integer parameter.""" + if isinstance(value, (bool, np.bool_)) or not isinstance( + value, + (int, np.integer), + ): + raise TypeError(f"{operation} requires {name} to be a non-negative integer") + + integer_value = int(value) + + if integer_value < 0: + raise ValueError(f"{operation} requires {name} to be non-negative") + + return integer_value + + def zero_mean(channel: SPMChannel) -> SPMChannel: """Shift the vertical reference so the arithmetic mean is zero.""" data = _validated_data(channel, operation="zero_mean") @@ -195,23 +216,118 @@ def three_point_level( return channel.with_data(data - plane) +def polynomial_background( + channel: SPMChannel, + *, + degree_mode: Literal["total", "independent"] = "total", + degree: int = 2, + x_degree: int | None = None, + y_degree: int | None = None, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> SPMChannel: + """Subtract a fitted two-dimensional polynomial background.""" + data = _validated_data( + channel, + operation="polynomial_background", + ) + + if degree_mode not in {"total", "independent"}: + raise ValueError("polynomial_background degree_mode must be " "'total' or 'independent'") + + if degree_mode == "total": + if x_degree is not None or y_degree is not None: + raise ValueError( + "polynomial_background total degree mode does not accept " "x_degree or y_degree" + ) + + total_degree = _nonnegative_integer( + degree, + name="degree", + operation="polynomial_background", + ) + powers = [ + (x_power, y_power) + for x_power in range(total_degree + 1) + for y_power in range(total_degree + 1 - x_power) + ] + + else: + if x_degree is None or y_degree is None: + raise ValueError( + "polynomial_background independent degree mode requires " "x_degree and y_degree" + ) + + horizontal_degree = _nonnegative_integer( + x_degree, + name="x_degree", + operation="polynomial_background", + ) + vertical_degree = _nonnegative_integer( + y_degree, + name="y_degree", + operation="polynomial_background", + ) + + powers = [ + (x_power, y_power) + for x_power in range(horizontal_degree + 1) + for y_power in range(vertical_degree + 1) + ] + + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="polynomial_background", + minimum_points=len(powers), + ) + + rows, columns = data.shape + + x_coordinates = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(columns) + y_coordinates = np.linspace(-1.0, 1.0, rows) if rows > 1 else np.zeros(rows) + xx, yy = np.meshgrid(x_coordinates, y_coordinates) + + terms = [(xx**x_power) * (yy**y_power) for x_power, y_power in powers] + design = np.column_stack([term.ravel() for term in terms]) + selected = selection.ravel() + + coefficients, _, rank, _ = np.linalg.lstsq( + design[selected], + data.ravel()[selected], + rcond=None, + ) + + if rank < len(powers): + raise ValueError( + "polynomial_background selected points do not define " "a unique polynomial background" + ) + + background = (design @ coefficients).reshape(data.shape) + return channel.with_data(data - background) + + def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: - """Resta una superficie polinómica 2D de grado ``order``. + """Subtract a limited-total-degree polynomial background. - Útil cuando hay curvatura (bow) además de inclinación. + This function preserves the original SPMKit API. New code should use + :func:`polynomial_background`. """ + if isinstance(order, (bool, np.bool_)) or not isinstance( + order, + (int, np.integer), + ): + raise TypeError("order debe ser un entero") + if order < 1: raise ValueError("order debe ser >= 1") - z = channel.data - rows, cols = z.shape - yy, xx = np.mgrid[0:rows, 0:cols] - x = xx.ravel().astype(np.float64) - y = yy.ravel().astype(np.float64) - terms = [(x**i) * (y**j) for i in range(order + 1) for j in range(order + 1 - i)] - a_mat = np.column_stack(terms) - coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None) - surface = (a_mat @ coeffs).reshape(z.shape) - return channel.with_data(z - surface) + + return polynomial_background( + channel, + degree_mode="total", + degree=int(order), + ) def align_rows(channel: SPMChannel, method: str = "median") -> SPMChannel: diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index d77d80b..3bfcd47 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -541,3 +541,177 @@ def test_three_point_level_rejects_invalid_points( channel, points=points, # type: ignore[arg-type] ) + + +def test_polynomial_background_total_degree_excludes_masked_feature() -> None: + """Total-degree fitting must preserve an excluded surface feature.""" + rows, cols = 9, 10 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, cols)[np.newaxis, :] + + background = 4.0 + 2.0 * x - 3.0 * y + 0.5 * x**2 + 0.75 * x * y - 0.25 * y**2 + data = background.copy() + data[4, 5] += 50.0 + + excluded = np.zeros(data.shape, dtype=bool) + excluded[4, 5] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=10e-6, + y_range=9e-6, + ) + + result = leveling.polynomial_background( + channel, + degree_mode="total", + degree=2, + mask=excluded, + mask_mode="exclude", + ) + + assert np.allclose(result.data[~excluded], 0.0, atol=1e-10) + assert np.isclose(result.data[4, 5], 50.0, atol=1e-10) + + +def test_polynomial_background_supports_independent_degrees() -> None: + """Independent degrees must permit terms beyond the total-degree limit.""" + rows, cols = 8, 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, cols)[np.newaxis, :] + + # x**3 * y requires independent degrees (3, 1). + background = 1.0 + 0.5 * x**2 - 0.25 * y + 2.0 * x**3 * y + + channel = SPMChannel( + name="Z-Axis", + data=background, + unit="nm", + x_range=9e-6, + y_range=8e-6, + ) + + result = leveling.polynomial_background( + channel, + degree_mode="independent", + x_degree=3, + y_degree=1, + ) + + assert np.allclose(result.data, 0.0, atol=1e-10) + + +def test_polynomial_legacy_api_matches_total_degree_background() -> None: + """The legacy polynomial API must retain its current total-degree meaning.""" + rows, cols = 6, 7 + yy, xx = np.mgrid[0:rows, 0:cols] + data = 3.0 + 2.0 * xx - yy + 0.25 * xx**2 + 0.5 * xx * yy + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=7e-6, + y_range=6e-6, + ) + + legacy = leveling.polynomial(channel, order=2) + explicit = leveling.polynomial_background( + channel, + degree_mode="total", + degree=2, + ) + + assert np.allclose(legacy.data, explicit.data, atol=1e-10) + + +@pytest.mark.parametrize( + ("kwargs", "error_type", "message"), + [ + ( + {"degree_mode": "unknown"}, + ValueError, + "polynomial_background degree_mode must be", + ), + ( + {"degree_mode": "total", "degree": True}, + TypeError, + "polynomial_background requires degree to be a non-negative integer", + ), + ( + {"degree_mode": "total", "degree": -1}, + ValueError, + "polynomial_background requires degree to be non-negative", + ), + ( + { + "degree_mode": "total", + "degree": 2, + "x_degree": 2, + }, + ValueError, + "total degree mode does not accept x_degree or y_degree", + ), + ( + {"degree_mode": "independent"}, + ValueError, + "independent degree mode requires x_degree and y_degree", + ), + ], + ids=[ + "invalid-mode", + "boolean-degree", + "negative-degree", + "total-with-axis-degree", + "independent-missing-degrees", + ], +) +def test_polynomial_background_rejects_invalid_degree_configuration( + kwargs: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + """Invalid polynomial degree configurations must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(25.0).reshape(5, 5), + unit="nm", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.polynomial_background( + channel, + **kwargs, # type: ignore[arg-type] + ) + + +def test_polynomial_background_rejects_rank_deficient_selection() -> None: + """Selected pixels must determine every requested polynomial term.""" + data = np.arange(25.0).reshape(5, 5) + mask = np.zeros(data.shape, dtype=bool) + mask[0, :] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises( + ValueError, + match="selected points do not define a unique polynomial background", + ): + leveling.polynomial_background( + channel, + degree_mode="independent", + x_degree=1, + y_degree=1, + mask=mask, + mask_mode="include", + ) From 318f1fef787699dde19fb5c60bfafa0cb702aaeb Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:19:55 -0400 Subject: [PATCH 09/82] feat(leveling): add masked robust row alignment --- src/spmkit/core/analysis/leveling.py | 109 +++++++++++++-- tests/core/test_leveling.py | 189 +++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 12 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 8c527fe..d59baab 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -87,6 +87,43 @@ def _nonnegative_integer( return integer_value +def _trim_fraction(value: object, *, operation: str) -> float: + """Validate a trimming fraction in the closed interval [0, 0.5].""" + fraction_data = np.asarray(value) + + if ( + fraction_data.ndim != 0 + or not np.issubdtype(fraction_data.dtype, np.number) + or np.iscomplexobj(fraction_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires trim_fraction to be a real scalar") + + fraction = float(fraction_data.item()) + + if not np.isfinite(fraction): + raise ValueError(f"{operation} requires trim_fraction to be finite") + + if fraction < 0.0 or fraction > 0.5: + raise ValueError(f"{operation} requires trim_fraction between 0 and 0.5") + + return fraction + + +def _trimmed_mean(values: np.ndarray, fraction: float) -> float: + """Return the symmetrically trimmed mean of one-dimensional values.""" + if fraction == 0.5: + return float(np.median(values)) + + ordered = np.sort(values) + trim_count = int(np.floor(fraction * ordered.size)) + + if trim_count == 0: + return float(np.mean(ordered)) + + return float(np.mean(ordered[trim_count:-trim_count])) + + def zero_mean(channel: SPMChannel) -> SPMChannel: """Shift the vertical reference so the arithmetic mean is zero.""" data = _validated_data(channel, operation="zero_mean") @@ -330,17 +367,65 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: ) -def align_rows(channel: SPMChannel, method: str = "median") -> SPMChannel: - """Alinea filas restando su estadístico (corrige saltos línea a línea). +def align_rows( + channel: SPMChannel, + method: Literal["median", "mean", "trimmed_mean"] = "median", + *, + trim_fraction: float = 0.0, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + preserve_mean: bool = False, +) -> SPMChannel: + """Align rows by subtracting a representative value from each row. - Args: - method: ``"median"`` (robusto) o ``"mean"``. + ``preserve_mean=False`` retains the historical SPMKit behaviour. + ``preserve_mean=True`` keeps the mean correction at zero, matching + the absolute-level convention used by Gwyddion. """ - z = channel.data - if method == "median": - baseline = np.median(z, axis=1, keepdims=True) - elif method == "mean": - baseline = np.mean(z, axis=1, keepdims=True) - else: - raise ValueError("method debe ser 'median' o 'mean'") - return channel.with_data(z - baseline) + data = _validated_data(channel, operation="align_rows") + + allowed_methods = {"median", "mean", "trimmed_mean"} + if method not in allowed_methods: + raise ValueError("align_rows method must be 'median', 'mean', " "or 'trimmed_mean'") + + if not isinstance(preserve_mean, (bool, np.bool_)): + raise TypeError("align_rows requires preserve_mean to be boolean") + + fraction = _trim_fraction( + trim_fraction, + operation="align_rows", + ) + + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="align_rows", + minimum_points=1, + ) + + selected_per_row = np.count_nonzero(selection, axis=1) + if np.any(selected_per_row < 1): + raise ValueError("align_rows requires at least 1 selected point in every row") + + baselines = np.empty(data.shape[0], dtype=float) + + for row_index in range(data.shape[0]): + row_values = data[row_index, selection[row_index]] + + if method == "median": + baselines[row_index] = np.median(row_values) + elif method == "mean": + baselines[row_index] = np.mean(row_values) + else: + baselines[row_index] = _trimmed_mean( + row_values, + fraction, + ) + + corrections = baselines + + if preserve_mean: + corrections = corrections - np.mean(corrections) + + return channel.with_data(data - corrections[:, np.newaxis]) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 3bfcd47..2a39c2c 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -715,3 +715,192 @@ def test_polynomial_background_rejects_rank_deficient_selection() -> None: mask=mask, mask_mode="include", ) + + +def test_align_rows_can_preserve_global_mean() -> None: + """Mean-preserving alignment must keep the absolute global level.""" + data = np.array( + [ + [1.0, 1.0, 1.0], + [2.0, 2.0, 2.0], + [6.0, 6.0, 6.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=3e-6, + y_range=3e-6, + ) + + result = leveling.align_rows( + channel, + method="median", + preserve_mean=True, + ) + + expected_level = np.mean(data) + + assert np.allclose( + np.mean(result.data, axis=1), + expected_level, + ) + assert np.isclose(np.mean(result.data), np.mean(data)) + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_align_rows_mask_controls_row_statistic(mask_mode: str) -> None: + """Masked row alignment must ignore excluded surface features.""" + data = np.array( + [ + [1.0, 1.0, 100.0], + [2.0, 2.0, 200.0], + ] + ) + excluded = np.zeros(data.shape, dtype=bool) + excluded[:, 2] = True + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=3e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="mean", + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + assert np.allclose(result.data[~excluded], 0.0) + assert np.allclose(result.data[:, 2], [99.0, 198.0]) + + +@pytest.mark.parametrize( + ("trim_fraction", "reference_method"), + [ + (0.0, "mean"), + (0.5, "median"), + ], + ids=["no-trimming-is-mean", "maximum-trimming-is-median"], +) +def test_align_rows_trimmed_mean_endpoints( + trim_fraction: float, + reference_method: str, +) -> None: + """Trimmed mean must interpolate between mean and median.""" + data = np.array( + [ + [0.0, 1.0, 2.0, 100.0], + [4.0, 5.0, 6.0, 200.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=4e-6, + y_range=2e-6, + ) + + trimmed = leveling.align_rows( + channel, + method="trimmed_mean", + trim_fraction=trim_fraction, + ) + reference = leveling.align_rows( + channel, + method=reference_method, # type: ignore[arg-type] + ) + + assert np.allclose(trimmed.data, reference.data) + + +@pytest.mark.parametrize( + ("kwargs", "error_type", "message"), + [ + ( + {"method": "unknown"}, + ValueError, + "align_rows method must be", + ), + ( + {"method": "trimmed_mean", "trim_fraction": True}, + TypeError, + "align_rows requires trim_fraction to be a real scalar", + ), + ( + {"method": "trimmed_mean", "trim_fraction": -0.1}, + ValueError, + "align_rows requires trim_fraction between 0 and 0.5", + ), + ( + {"method": "trimmed_mean", "trim_fraction": 0.6}, + ValueError, + "align_rows requires trim_fraction between 0 and 0.5", + ), + ( + {"preserve_mean": "yes"}, + TypeError, + "align_rows requires preserve_mean to be boolean", + ), + ], + ids=[ + "unknown-method", + "boolean-trim", + "negative-trim", + "excessive-trim", + "non-boolean-preserve-mean", + ], +) +def test_align_rows_rejects_invalid_configuration( + kwargs: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + """Invalid row-alignment configurations must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(12.0).reshape(3, 4), + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.align_rows( + channel, + **kwargs, # type: ignore[arg-type] + ) + + +def test_align_rows_rejects_rows_without_selected_points() -> None: + """Every row must contain data selected for its statistic.""" + data = np.arange(12.0).reshape(3, 4) + mask = np.ones(data.shape, dtype=bool) + mask[1, :] = False + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match="align_rows requires at least 1 selected point in every row", + ): + leveling.align_rows( + channel, + method="median", + mask=mask, + mask_mode="include", + ) From f00b0abc3f9e563411d37df8bf77ed3639a8cc6a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:30:32 -0400 Subject: [PATCH 10/82] feat(leveling): add polynomial row alignment --- src/spmkit/core/analysis/leveling.py | 102 +++++++++++---- tests/core/test_leveling.py | 179 +++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 22 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index d59baab..20c6d72 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -369,14 +369,20 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: def align_rows( channel: SPMChannel, - method: Literal["median", "mean", "trimmed_mean"] = "median", + method: Literal[ + "median", + "mean", + "trimmed_mean", + "polynomial", + ] = "median", *, trim_fraction: float = 0.0, + polynomial_degree: int = 1, mask: np.ndarray | None = None, mask_mode: Literal["ignore", "include", "exclude"] = "ignore", preserve_mean: bool = False, ) -> SPMChannel: - """Align rows by subtracting a representative value from each row. + """Align rows by subtracting a fitted or representative row background. ``preserve_mean=False`` retains the historical SPMKit behaviour. ``preserve_mean=True`` keeps the mean correction at zero, matching @@ -384,9 +390,17 @@ def align_rows( """ data = _validated_data(channel, operation="align_rows") - allowed_methods = {"median", "mean", "trimmed_mean"} + allowed_methods = { + "median", + "mean", + "trimmed_mean", + "polynomial", + } + if method not in allowed_methods: - raise ValueError("align_rows method must be 'median', 'mean', " "or 'trimmed_mean'") + raise ValueError( + "align_rows method must be 'median', 'mean', " "'trimmed_mean', or 'polynomial'" + ) if not isinstance(preserve_mean, (bool, np.bool_)): raise TypeError("align_rows requires preserve_mean to be boolean") @@ -404,28 +418,72 @@ def align_rows( minimum_points=1, ) + if method == "polynomial": + degree = _nonnegative_integer( + polynomial_degree, + name="polynomial_degree", + operation="align_rows", + ) + required_points = degree + 1 + else: + degree = 0 + required_points = 1 + selected_per_row = np.count_nonzero(selection, axis=1) - if np.any(selected_per_row < 1): - raise ValueError("align_rows requires at least 1 selected point in every row") - - baselines = np.empty(data.shape[0], dtype=float) - - for row_index in range(data.shape[0]): - row_values = data[row_index, selection[row_index]] - - if method == "median": - baselines[row_index] = np.median(row_values) - elif method == "mean": - baselines[row_index] = np.mean(row_values) - else: - baselines[row_index] = _trimmed_mean( - row_values, - fraction, + + if np.any(selected_per_row < required_points): + point_word = "point" if required_points == 1 else "points" + raise ValueError( + f"align_rows requires at least {required_points} selected " f"{point_word} in every row" + ) + + if method == "polynomial": + columns = data.shape[1] + x_coordinates = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(columns) + + design = np.vander( + x_coordinates, + N=degree + 1, + increasing=True, + ) + + corrections = np.empty(data.shape, dtype=float) + + for row_index in range(data.shape[0]): + selected = selection[row_index] + + coefficients, _, rank, _ = np.linalg.lstsq( + design[selected], + data[row_index, selected], + rcond=None, ) - corrections = baselines + if rank < degree + 1: + raise ValueError( + "align_rows selected points do not define " "a unique polynomial in every row" + ) + + corrections[row_index] = design @ coefficients + + else: + baselines = np.empty(data.shape[0], dtype=float) + + for row_index in range(data.shape[0]): + row_values = data[row_index, selection[row_index]] + + if method == "median": + baselines[row_index] = np.median(row_values) + elif method == "mean": + baselines[row_index] = np.mean(row_values) + else: + baselines[row_index] = _trimmed_mean( + row_values, + fraction, + ) + + corrections = baselines[:, np.newaxis] if preserve_mean: corrections = corrections - np.mean(corrections) - return channel.with_data(data - corrections[:, np.newaxis]) + return channel.with_data(data - corrections) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 2a39c2c..90b0c29 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -904,3 +904,182 @@ def test_align_rows_rejects_rows_without_selected_points() -> None: mask=mask, mask_mode="include", ) + + +def test_align_rows_polynomial_removes_row_background_and_preserves_feature() -> None: + """Polynomial row alignment must preserve excluded surface features.""" + rows, columns = 4, 9 + x = np.linspace(-1.0, 1.0, columns) + + offsets = np.array([1.0, 3.0, -2.0, 5.0]) + slopes = np.array([0.5, -1.0, 2.0, -0.25]) + curvatures = np.array([0.2, -0.4, 0.75, 0.1]) + + data = np.vstack( + [offsets[row] + slopes[row] * x + curvatures[row] * x**2 for row in range(rows)] + ) + + data[2, 4] += 12.0 + + excluded = np.zeros(data.shape, dtype=bool) + excluded[2, 4] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=4e-6, + metadata={"source": "synthetic"}, + ) + + result = leveling.align_rows( + channel, + method="polynomial", + polynomial_degree=2, + mask=excluded, + mask_mode="exclude", + ) + + assert np.allclose(result.data[~excluded], 0.0, atol=1e-10) + assert np.isclose(result.data[2, 4], 12.0, atol=1e-10) + + +def test_align_rows_polynomial_degree_zero_matches_mean() -> None: + """A degree-zero row polynomial must reproduce mean alignment.""" + data = np.array( + [ + [1.0, 2.0, 6.0], + [4.0, 8.0, 12.0], + ] + ) + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=3e-6, + y_range=2e-6, + ) + + polynomial = leveling.align_rows( + channel, + method="polynomial", + polynomial_degree=0, + ) + mean = leveling.align_rows( + channel, + method="mean", + ) + + assert np.allclose(polynomial.data, mean.data) + + +def test_align_rows_polynomial_can_preserve_global_mean() -> None: + """Mean-preserving polynomial alignment must retain the global level.""" + columns = 7 + x = np.linspace(-1.0, 1.0, columns) + + data = np.vstack( + [ + 5.0 + x, + 8.0 - 2.0 * x, + 12.0 + 0.5 * x, + ] + ) + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=7e-6, + y_range=3e-6, + ) + + result = leveling.align_rows( + channel, + method="polynomial", + polynomial_degree=1, + preserve_mean=True, + ) + + assert np.isclose(np.mean(result.data), np.mean(data)) + assert np.allclose( + result.data, + np.mean(data), + atol=1e-10, + ) + + +@pytest.mark.parametrize( + ("degree", "error_type", "message"), + [ + ( + True, + TypeError, + "align_rows requires polynomial_degree to be a non-negative integer", + ), + ( + 1.5, + TypeError, + "align_rows requires polynomial_degree to be a non-negative integer", + ), + ( + -1, + ValueError, + "align_rows requires polynomial_degree to be non-negative", + ), + ], + ids=[ + "boolean", + "non-integer", + "negative", + ], +) +def test_align_rows_polynomial_rejects_invalid_degree( + degree: object, + error_type: type[Exception], + message: str, +) -> None: + """Polynomial row degree must be a valid non-negative integer.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(12.0).reshape(3, 4), + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.align_rows( + channel, + method="polynomial", + polynomial_degree=degree, # type: ignore[arg-type] + ) + + +def test_align_rows_polynomial_rejects_rank_deficient_row() -> None: + """Every row must contain enough independent points for its polynomial.""" + data = np.arange(15.0).reshape(3, 5) + mask = np.ones(data.shape, dtype=bool) + mask[1, :] = False + mask[1, :2] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match="align_rows requires at least 3 selected points in every row", + ): + leveling.align_rows( + channel, + method="polynomial", + polynomial_degree=2, + mask=mask, + mask_mode="include", + ) From 8f871c79b99f6bf90fed9828e5789864c8d2eb16 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:42:39 -0400 Subject: [PATCH 11/82] feat(leveling): add robust row-difference alignment --- src/spmkit/core/analysis/leveling.py | 89 +++++++++++- tests/core/test_leveling.py | 195 +++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 2 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 20c6d72..2e1a3bb 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -367,6 +367,62 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: ) +def _difference_row_corrections( + data: np.ndarray, + selection: np.ndarray, + *, + statistic: Literal["median", "trimmed_mean"], + trim_fraction: float, + preserve_tilt: bool, +) -> np.ndarray: + """Estimate cumulative row offsets from vertical neighbour differences.""" + + row_count = data.shape[0] + + corrections = np.zeros(row_count, dtype=float) + + for row_index in range(1, row_count): + + shared_selection = selection[row_index - 1] & selection[row_index] + + if not np.any(shared_selection): + + raise ValueError("align_rows requires adjacent rows to share selected points") + + differences = data[row_index, shared_selection] - data[row_index - 1, shared_selection] + + if statistic == "median": + + increment = float(np.median(differences)) + + else: + + increment = _trimmed_mean( + differences, + trim_fraction, + ) + + corrections[row_index] = corrections[row_index - 1] + increment + + if preserve_tilt and row_count > 1: + + row_coordinates = np.arange(row_count, dtype=float) + + centered_rows = row_coordinates - np.mean(row_coordinates) + + centered_corrections = corrections - np.mean(corrections) + + denominator = float(np.dot(centered_rows, centered_rows)) + + if denominator > 0.0: + + correction_slope = float(np.dot(centered_rows, centered_corrections) / denominator) + + corrections = corrections - correction_slope * centered_rows + + return corrections + + def align_rows( channel: SPMChannel, method: Literal[ @@ -374,6 +430,8 @@ def align_rows( "mean", "trimmed_mean", "polynomial", + "median_difference", + "trimmed_mean_difference", ] = "median", *, trim_fraction: float = 0.0, @@ -381,6 +439,7 @@ def align_rows( mask: np.ndarray | None = None, mask_mode: Literal["ignore", "include", "exclude"] = "ignore", preserve_mean: bool = False, + preserve_tilt: bool = True, ) -> SPMChannel: """Align rows by subtracting a fitted or representative row background. @@ -395,16 +454,23 @@ def align_rows( "mean", "trimmed_mean", "polynomial", + "median_difference", + "trimmed_mean_difference", } if method not in allowed_methods: raise ValueError( - "align_rows method must be 'median', 'mean', " "'trimmed_mean', or 'polynomial'" + "align_rows method must be 'median', 'mean', " + "'trimmed_mean', 'polynomial', 'median_difference', " + "or 'trimmed_mean_difference'" ) if not isinstance(preserve_mean, (bool, np.bool_)): raise TypeError("align_rows requires preserve_mean to be boolean") + if not isinstance(preserve_tilt, (bool, np.bool_)): + raise TypeError("align_rows requires preserve_tilt to be boolean") + fraction = _trim_fraction( trim_fraction, operation="align_rows", @@ -437,7 +503,26 @@ def align_rows( f"align_rows requires at least {required_points} selected " f"{point_word} in every row" ) - if method == "polynomial": + difference_methods = { + "median_difference", + "trimmed_mean_difference", + } + + if method in difference_methods: + statistic: Literal["median", "trimmed_mean"] = ( + "median" if method == "median_difference" else "trimmed_mean" + ) + + row_corrections = _difference_row_corrections( + data, + selection, + statistic=statistic, + trim_fraction=fraction, + preserve_tilt=bool(preserve_tilt), + ) + corrections = row_corrections[:, np.newaxis] + + elif method == "polynomial": columns = data.shape[1] x_coordinates = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(columns) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 90b0c29..cf3e848 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1083,3 +1083,198 @@ def test_align_rows_polynomial_rejects_rank_deficient_row() -> None: mask=mask, mask_mode="include", ) + + +def test_align_rows_median_difference_preserves_large_feature() -> None: + """Median differences must align offsets without flattening shared features.""" + base_profile = np.array([0.0, 0.0, 8.0, 8.0, 8.0, 0.0, 0.0]) + row_offsets = np.array([1.0, -1.0, -1.0, 1.0]) + + data = base_profile[np.newaxis, :] + row_offsets[:, np.newaxis] + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=7e-6, + y_range=4e-6, + ) + + result = leveling.align_rows( + channel, + method="median_difference", + ) + + expected = base_profile + row_offsets[0] + + assert np.allclose( + result.data, + expected[np.newaxis, :], + atol=1e-12, + ) + + +def test_align_rows_median_difference_preserves_global_tilt() -> None: + """Difference alignment must preserve the linear slow-axis trend.""" + rows, columns = 7, 9 + row_coordinate = np.arange(rows, dtype=float) + base_profile = np.linspace(-2.0, 3.0, columns) + + global_tilt = 1.75 * row_coordinate + row_defects = np.array([0.0, 2.0, -1.0, 1.5, -2.0, 1.0, 0.0]) + + data = base_profile[np.newaxis, :] + global_tilt[:, np.newaxis] + row_defects[:, np.newaxis] + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=7e-6, + ) + + result = leveling.align_rows( + channel, + method="median_difference", + preserve_tilt=True, + preserve_mean=True, + ) + + original_slope = np.polyfit( + row_coordinate, + np.mean(data, axis=1), + deg=1, + )[0] + corrected_slope = np.polyfit( + row_coordinate, + np.mean(result.data, axis=1), + deg=1, + )[0] + + assert np.isclose(corrected_slope, original_slope, atol=1e-12) + + +def test_align_rows_median_difference_can_remove_global_tilt() -> None: + """Tilt preservation must be explicitly disableable.""" + rows, columns = 5, 6 + row_coordinate = np.arange(rows, dtype=float) + base_profile = np.linspace(0.0, 2.0, columns) + + data = base_profile[np.newaxis, :] + 3.0 * row_coordinate[:, np.newaxis] + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=6e-6, + y_range=5e-6, + ) + + result = leveling.align_rows( + channel, + method="median_difference", + preserve_tilt=False, + ) + + assert np.allclose( + result.data, + result.data[0], + atol=1e-12, + ) + + +def test_align_rows_trimmed_mean_difference_half_matches_median_difference() -> None: + """Maximum trimming must reproduce median-difference alignment.""" + data = np.array( + [ + [0.0, 1.0, 2.0, 80.0, 4.0], + [2.0, 3.0, 4.0, 150.0, 6.0], + [-1.0, 0.0, 1.0, -100.0, 3.0], + ] + ) + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=3e-6, + ) + + trimmed = leveling.align_rows( + channel, + method="trimmed_mean_difference", + trim_fraction=0.5, + ) + median = leveling.align_rows( + channel, + method="median_difference", + ) + + assert np.allclose(trimmed.data, median.data) + + +@pytest.mark.parametrize( + ("preserve_tilt", "error_type", "message"), + [ + ( + "yes", + TypeError, + "align_rows requires preserve_tilt to be boolean", + ), + ( + 1, + TypeError, + "align_rows requires preserve_tilt to be boolean", + ), + ], + ids=["string", "integer"], +) +def test_align_rows_rejects_invalid_preserve_tilt( + preserve_tilt: object, + error_type: type[Exception], + message: str, +) -> None: + """Tilt-preservation configuration must be explicitly boolean.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(12.0).reshape(3, 4), + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.align_rows( + channel, + method="median_difference", + preserve_tilt=preserve_tilt, # type: ignore[arg-type] + ) + + +def test_align_rows_difference_requires_shared_selected_pixels() -> None: + """Adjacent rows must share at least one selected column.""" + data = np.arange(12.0).reshape(3, 4) + mask = np.zeros(data.shape, dtype=bool) + mask[0, :2] = True + mask[1, 2:] = True + mask[2, 2:] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match="align_rows requires adjacent rows to share selected points", + ): + leveling.align_rows( + channel, + method="median_difference", + mask=mask, + mask_mode="include", + ) From 5488eb496e20d010cb46054464a2d0fc939942af Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:32:44 -0400 Subject: [PATCH 12/82] feat(leveling): add flat-segment row matching --- src/spmkit/core/analysis/leveling.py | 112 ++++++++++++++--- tests/core/test_leveling.py | 178 +++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 17 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 2e1a3bb..77febb9 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -367,6 +367,87 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: ) +def _without_linear_row_component( + corrections: np.ndarray, +) -> np.ndarray: + """Remove the least-squares linear component from row corrections.""" + row_count = corrections.size + + if row_count <= 1: + return corrections.copy() + + row_coordinates = np.arange(row_count, dtype=float) + centered_rows = row_coordinates - np.mean(row_coordinates) + centered_corrections = corrections - np.mean(corrections) + + denominator = float(np.dot(centered_rows, centered_rows)) + + if denominator == 0.0: + return corrections.copy() + + correction_slope = float(np.dot(centered_rows, centered_corrections) / denominator) + + return corrections - correction_slope * centered_rows + + +def _matching_row_corrections( + data: np.ndarray, + selection: np.ndarray, + *, + preserve_tilt: bool, +) -> np.ndarray: + """Estimate row offsets by matching locally flat neighbouring segments.""" + row_count = data.shape[0] + corrections = np.zeros(row_count, dtype=float) + + for row_index in range(1, row_count): + shared_selection = selection[row_index - 1] & selection[row_index] + shared_edges = shared_selection[:-1] & shared_selection[1:] + + if not np.any(shared_edges): + raise ValueError( + "align_rows matching requires adjacent rows to share " + "selected neighbouring pixels" + ) + + previous_row = data[row_index - 1] + current_row = data[row_index] + + vertical_differences = 0.5 * ( + current_row[:-1] - previous_row[:-1] + current_row[1:] - previous_row[1:] + ) + + previous_slopes = np.diff(previous_row) + current_slopes = np.diff(current_row) + + local_flatness = np.abs(previous_slopes) + np.abs(current_slopes) + selected_flatness = local_flatness[shared_edges] + + scale = float(np.median(selected_flatness)) + epsilon = np.finfo(float).eps + + if scale <= epsilon: + positive_flatness = selected_flatness[selected_flatness > epsilon] + scale = float(np.median(positive_flatness)) if positive_flatness.size else 1.0 + + normalized_flatness = selected_flatness / scale + weights = 1.0 / np.square(np.hypot(1.0, normalized_flatness)) + + increment = float( + np.average( + vertical_differences[shared_edges], + weights=weights, + ) + ) + + corrections[row_index] = corrections[row_index - 1] + increment + + if preserve_tilt: + corrections = _without_linear_row_component(corrections) + + return corrections + + def _difference_row_corrections( data: np.ndarray, selection: np.ndarray, @@ -404,21 +485,8 @@ def _difference_row_corrections( corrections[row_index] = corrections[row_index - 1] + increment - if preserve_tilt and row_count > 1: - - row_coordinates = np.arange(row_count, dtype=float) - - centered_rows = row_coordinates - np.mean(row_coordinates) - - centered_corrections = corrections - np.mean(corrections) - - denominator = float(np.dot(centered_rows, centered_rows)) - - if denominator > 0.0: - - correction_slope = float(np.dot(centered_rows, centered_corrections) / denominator) - - corrections = corrections - correction_slope * centered_rows + if preserve_tilt: + corrections = _without_linear_row_component(corrections) return corrections @@ -432,6 +500,7 @@ def align_rows( "polynomial", "median_difference", "trimmed_mean_difference", + "matching", ] = "median", *, trim_fraction: float = 0.0, @@ -456,13 +525,14 @@ def align_rows( "polynomial", "median_difference", "trimmed_mean_difference", + "matching", } if method not in allowed_methods: raise ValueError( "align_rows method must be 'median', 'mean', " "'trimmed_mean', 'polynomial', 'median_difference', " - "or 'trimmed_mean_difference'" + "'trimmed_mean_difference', or 'matching'" ) if not isinstance(preserve_mean, (bool, np.bool_)): @@ -508,7 +578,15 @@ def align_rows( "trimmed_mean_difference", } - if method in difference_methods: + if method == "matching": + row_corrections = _matching_row_corrections( + data, + selection, + preserve_tilt=bool(preserve_tilt), + ) + corrections = row_corrections[:, np.newaxis] + + elif method in difference_methods: statistic: Literal["median", "trimmed_mean"] = ( "median" if method == "median_difference" else "trimmed_mean" ) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index cf3e848..0341cab 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1278,3 +1278,181 @@ def test_align_rows_difference_requires_shared_selected_pixels() -> None: mask=mask, mask_mode="include", ) + + +def test_align_rows_matching_downweights_local_slope_mismatch() -> None: + """Matching must downweight a local defect with incompatible slopes.""" + columns = 9 + base_profile = np.linspace(-1.0, 1.0, columns) + + data = np.vstack( + [ + base_profile, + base_profile + 2.0, + ] + ) + data[1, 4] += 100.0 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="matching", + preserve_tilt=False, + ) + + clean_columns = np.ones(columns, dtype=bool) + clean_columns[4] = False + + assert np.allclose( + result.data[1, clean_columns], + result.data[0, clean_columns], + atol=1e-2, + ) + assert result.data[1, 4] - result.data[0, 4] > 99.0 + + +def test_align_rows_matching_aligns_constant_row_offsets() -> None: + """Matching must exactly align rows differing only by vertical offsets.""" + base_profile = np.array([0.0, 1.0, 3.0, 2.0, -1.0, 4.0, 5.0]) + offsets = np.array([0.0, 3.0, -2.0, 1.0]) + + data = base_profile[np.newaxis, :] + offsets[:, np.newaxis] + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=7e-6, + y_range=4e-6, + ) + + result = leveling.align_rows( + channel, + method="matching", + preserve_tilt=False, + ) + + assert np.allclose( + result.data, + base_profile[np.newaxis, :], + atol=1e-12, + ) + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_align_rows_matching_respects_mask_selection( + mask_mode: str, +) -> None: + """Matching must estimate offsets only from selected neighbouring pixels.""" + data = np.array( + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [3.0, 3.0, 3.0, 50.0, 50.0, 50.0], + ] + ) + + excluded = np.zeros(data.shape, dtype=bool) + excluded[:, 3:] = True + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=6e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="matching", + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + preserve_tilt=False, + ) + + assert np.allclose(result.data[1, :3], 0.0, atol=1e-12) + assert np.allclose(result.data[1, 3:], 47.0, atol=1e-12) + + +def test_align_rows_matching_preserves_global_tilt() -> None: + """Matching must preserve slow-axis tilt when requested.""" + rows, columns = 6, 8 + row_coordinate = np.arange(rows, dtype=float) + base_profile = np.linspace(-2.0, 3.0, columns) + + global_tilt = 1.25 * row_coordinate + row_defects = np.array([0.0, 2.0, -1.0, 1.5, -2.0, 0.5]) + + data = base_profile[np.newaxis, :] + global_tilt[:, np.newaxis] + row_defects[:, np.newaxis] + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=8e-6, + y_range=6e-6, + ) + + result = leveling.align_rows( + channel, + method="matching", + preserve_tilt=True, + preserve_mean=True, + ) + + original_slope = np.polyfit( + row_coordinate, + np.mean(data, axis=1), + deg=1, + )[0] + corrected_slope = np.polyfit( + row_coordinate, + np.mean(result.data, axis=1), + deg=1, + )[0] + + assert np.isclose( + corrected_slope, + original_slope, + atol=1e-12, + ) + + +def test_align_rows_matching_requires_shared_selected_edges() -> None: + """Adjacent rows must share a selected neighbouring-pixel pair.""" + data = np.arange(15.0).reshape(3, 5) + + mask = np.zeros(data.shape, dtype=bool) + mask[0, [0, 2, 4]] = True + mask[1, [0, 2, 4]] = True + mask[2, [0, 2, 4]] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match=( + "align_rows matching requires adjacent rows to share " "selected neighbouring pixels" + ), + ): + leveling.align_rows( + channel, + method="matching", + mask=mask, + mask_mode="include", + ) From 8eaedd8ae4a072b96380c30d0b1bf438f2163e98 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:38:49 -0400 Subject: [PATCH 13/82] feat(leveling): add robust modal row alignment --- src/spmkit/core/analysis/leveling.py | 38 ++++++- tests/core/test_leveling.py | 145 +++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 77febb9..1dca465 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -367,6 +367,38 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: ) +def _half_sample_mode(values: np.ndarray) -> float: + """Estimate the mode using the deterministic half-sample method.""" + ordered = np.sort(np.asarray(values, dtype=float)) + + while True: + count = ordered.size + + if count == 1: + return float(ordered[0]) + + if count == 2: + return float(np.mean(ordered)) + + if count == 3: + left_width = ordered[1] - ordered[0] + right_width = ordered[2] - ordered[1] + + if left_width < right_width: + return float(np.mean(ordered[:2])) + + if right_width < left_width: + return float(np.mean(ordered[1:])) + + return float(ordered[1]) + + interval_size = (count + 1) // 2 + widths = ordered[interval_size - 1 :] - ordered[: count - interval_size + 1] + start = int(np.argmin(widths)) + + ordered = ordered[start : start + interval_size] + + def _without_linear_row_component( corrections: np.ndarray, ) -> np.ndarray: @@ -496,6 +528,7 @@ def align_rows( method: Literal[ "median", "mean", + "mode", "trimmed_mean", "polynomial", "median_difference", @@ -521,6 +554,7 @@ def align_rows( allowed_methods = { "median", "mean", + "mode", "trimmed_mean", "polynomial", "median_difference", @@ -530,7 +564,7 @@ def align_rows( if method not in allowed_methods: raise ValueError( - "align_rows method must be 'median', 'mean', " + "align_rows method must be 'median', 'mean', 'mode', " "'trimmed_mean', 'polynomial', 'median_difference', " "'trimmed_mean_difference', or 'matching'" ) @@ -638,6 +672,8 @@ def align_rows( baselines[row_index] = np.median(row_values) elif method == "mean": baselines[row_index] = np.mean(row_values) + elif method == "mode": + baselines[row_index] = _half_sample_mode(row_values) else: baselines[row_index] = _trimmed_mean( row_values, diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 0341cab..f0cec1f 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1456,3 +1456,148 @@ def test_align_rows_matching_requires_shared_selected_edges() -> None: mask=mask, mask_mode="include", ) + + +def test_align_rows_mode_tracks_dominant_row_level() -> None: + """Mode alignment must follow the densest cluster in each row.""" + data = np.array( + [ + [1.0, 1.0, 1.0, 8.0, 20.0], + [4.0, 4.0, 4.0, -10.0, 30.0], + ] + ) + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="mode", + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0, 7.0, 19.0], + [0.0, 0.0, 0.0, -14.0, 26.0], + ] + ) + + assert np.allclose(result.data, expected) + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_align_rows_mode_respects_mask_selection( + mask_mode: str, +) -> None: + """Modal row level must be estimated only from selected pixels.""" + data = np.array( + [ + [1.0, 1.0, 1.0, 100.0, 200.0], + [2.0, 2.0, 2.0, -50.0, 80.0], + ] + ) + + excluded = np.zeros(data.shape, dtype=bool) + excluded[:, 3:] = True + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="mode", + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + assert np.allclose(result.data[:, :3], 0.0) + assert np.allclose( + result.data[:, 3:], + np.array( + [ + [99.0, 199.0], + [-52.0, 78.0], + ] + ), + ) + + +def test_align_rows_mode_can_preserve_global_mean() -> None: + """Mean-preserving mode alignment must retain the global level.""" + data = np.array( + [ + [1.0, 1.0, 1.0, 9.0], + [4.0, 4.0, 4.0, 20.0], + [8.0, 8.0, 8.0, -5.0], + ] + ) + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + result = leveling.align_rows( + channel, + method="mode", + preserve_mean=True, + ) + + assert np.isclose( + np.mean(result.data), + np.mean(data), + ) + + +def test_align_rows_mode_supports_one_selected_point_per_row() -> None: + """A single selected pixel must define the modal row level.""" + data = np.array( + [ + [1.0, 5.0, 9.0], + [2.0, 6.0, 10.0], + ] + ) + + mask = np.zeros(data.shape, dtype=bool) + mask[:, 1] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=3e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="mode", + mask=mask, + mask_mode="include", + ) + + assert np.allclose(result.data[:, 1], 0.0) + assert np.allclose( + result.data, + np.array( + [ + [-4.0, 0.0, 4.0], + [-4.0, 0.0, 4.0], + ] + ), + ) From 77b9ffc44ea8962da7f0ded63a4141a27e87bd77 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:42:18 -0400 Subject: [PATCH 14/82] feat(leveling): add facet-based row tilt correction --- src/spmkit/core/analysis/leveling.py | 51 ++++++++- tests/core/test_leveling.py | 155 +++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 1dca465..1168959 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -399,6 +399,45 @@ def _half_sample_mode(values: np.ndarray) -> float: ordered = ordered[start : start + interval_size] +def _facet_tilt_row_corrections( + data: np.ndarray, + selection: np.ndarray, +) -> np.ndarray: + """Estimate per-row tilt from the prevalent local slope.""" + row_count, column_count = data.shape + + if column_count < 2: + raise ValueError( + "align_rows facet_tilt requires selected " "neighbouring pixels in every row" + ) + + x_coordinates = np.linspace( + -1.0, + 1.0, + column_count, + ) + x_coordinates = x_coordinates - np.mean(x_coordinates) + x_steps = np.diff(x_coordinates) + + corrections = np.empty(data.shape, dtype=float) + + for row_index in range(row_count): + selected_edges = selection[row_index, :-1] & selection[row_index, 1:] + + if not np.any(selected_edges): + raise ValueError( + "align_rows facet_tilt requires selected " "neighbouring pixels in every row" + ) + + local_slopes = np.diff(data[row_index]) / x_steps + + prevalent_slope = _half_sample_mode(local_slopes[selected_edges]) + + corrections[row_index] = prevalent_slope * x_coordinates + + return corrections + + def _without_linear_row_component( corrections: np.ndarray, ) -> np.ndarray: @@ -534,6 +573,7 @@ def align_rows( "median_difference", "trimmed_mean_difference", "matching", + "facet_tilt", ] = "median", *, trim_fraction: float = 0.0, @@ -560,13 +600,14 @@ def align_rows( "median_difference", "trimmed_mean_difference", "matching", + "facet_tilt", } if method not in allowed_methods: raise ValueError( "align_rows method must be 'median', 'mean', 'mode', " "'trimmed_mean', 'polynomial', 'median_difference', " - "'trimmed_mean_difference', or 'matching'" + "'trimmed_mean_difference', 'matching', or 'facet_tilt'" ) if not isinstance(preserve_mean, (bool, np.bool_)): @@ -612,7 +653,13 @@ def align_rows( "trimmed_mean_difference", } - if method == "matching": + if method == "facet_tilt": + corrections = _facet_tilt_row_corrections( + data, + selection, + ) + + elif method == "matching": row_corrections = _matching_row_corrections( data, selection, diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index f0cec1f..ed90286 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1601,3 +1601,158 @@ def test_align_rows_mode_supports_one_selected_point_per_row() -> None: ] ), ) + + +def test_align_rows_facet_tilt_removes_row_slopes_preserving_offsets() -> None: + """Facet tilt must remove row slopes without changing row offsets.""" + columns = 9 + x = np.linspace(-1.0, 1.0, columns) + + offsets = np.array([2.0, -3.0, 7.0]) + slopes = np.array([1.5, -2.0, 0.75]) + + data = np.vstack([offsets[row] + slopes[row] * x for row in range(offsets.size)]) + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=3e-6, + ) + + result = leveling.align_rows( + channel, + method="facet_tilt", + ) + + expected = np.repeat( + offsets[:, np.newaxis], + columns, + axis=1, + ) + + assert np.allclose(result.data, expected, atol=1e-12) + assert np.allclose( + np.mean(result.data, axis=1), + np.mean(data, axis=1), + atol=1e-12, + ) + + +def test_align_rows_facet_tilt_is_robust_to_local_spike() -> None: + """A local spike must not dominate the prevalent row slope.""" + columns = 9 + x = np.linspace(-1.0, 1.0, columns) + + data = np.vstack( + [ + 3.0 + 2.0 * x, + -4.0 - 1.5 * x, + ] + ) + data[0, 4] += 100.0 + data[1, 5] -= 80.0 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="facet_tilt", + ) + + clean_first = np.ones(columns, dtype=bool) + clean_first[4] = False + + clean_second = np.ones(columns, dtype=bool) + clean_second[5] = False + + assert np.allclose( + result.data[0, clean_first], + 3.0, + atol=1e-12, + ) + assert np.allclose( + result.data[1, clean_second], + -4.0, + atol=1e-12, + ) + assert np.isclose(result.data[0, 4], 103.0, atol=1e-12) + assert np.isclose(result.data[1, 5], -84.0, atol=1e-12) + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_align_rows_facet_tilt_respects_mask_selection( + mask_mode: str, +) -> None: + """Facet tilt must estimate slopes only from selected adjacent pixels.""" + columns = 8 + x = np.linspace(-1.0, 1.0, columns) + + data = np.vstack( + [ + 2.0 + 3.0 * x, + -1.0 - 2.0 * x, + ] + ) + + data[:, 5:] += np.array([[20.0], [-30.0]]) + + excluded = np.zeros(data.shape, dtype=bool) + excluded[:, 5:] = True + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=8e-6, + y_range=2e-6, + ) + + result = leveling.align_rows( + channel, + method="facet_tilt", + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + assert np.allclose(result.data[0, :5], 2.0, atol=1e-12) + assert np.allclose(result.data[1, :5], -1.0, atol=1e-12) + + assert np.allclose(result.data[0, 5:], 22.0, atol=1e-12) + assert np.allclose(result.data[1, 5:], -31.0, atol=1e-12) + + +def test_align_rows_facet_tilt_requires_selected_adjacent_pixels() -> None: + """Each row must contain a selected neighbouring-pixel pair.""" + data = np.arange(15.0).reshape(3, 5) + + mask = np.zeros(data.shape, dtype=bool) + mask[:, [0, 2, 4]] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=3e-6, + ) + + with pytest.raises( + ValueError, + match=("align_rows facet_tilt requires selected " "neighbouring pixels in every row"), + ): + leveling.align_rows( + channel, + method="facet_tilt", + mask=mask, + mask_mode="include", + ) From 37b5cb7190dfa5a0e076f28a821d2bc79bf3a89a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:48:11 -0400 Subject: [PATCH 15/82] feat(leveling): add iterative two-dimensional facet leveling --- src/spmkit/core/analysis/leveling.py | 183 ++++++++++++++++++++++ tests/core/test_leveling.py | 217 +++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 1168959..1823722 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -124,6 +124,55 @@ def _trimmed_mean(values: np.ndarray, fraction: float) -> float: return float(np.mean(ordered[trim_count:-trim_count])) +def _positive_integer( + value: object, + *, + name: str, + operation: str, +) -> int: + """Validate and return a strictly positive integer.""" + if isinstance(value, (bool, np.bool_)) or not isinstance( + value, + (int, np.integer), + ): + raise TypeError(f"{operation} requires {name} to be a positive integer") + + integer_value = int(value) + + if integer_value <= 0: + raise ValueError(f"{operation} requires {name} to be positive") + + return integer_value + + +def _positive_real_scalar( + value: object, + *, + name: str, + operation: str, +) -> float: + """Validate and return a finite strictly positive real scalar.""" + scalar_data = np.asarray(value) + + if ( + scalar_data.ndim != 0 + or not np.issubdtype(scalar_data.dtype, np.number) + or np.iscomplexobj(scalar_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires {name} to be a positive real scalar") + + scalar = float(scalar_data.item()) + + if not np.isfinite(scalar): + raise ValueError(f"{operation} requires {name} to be finite") + + if scalar <= 0.0: + raise ValueError(f"{operation} requires {name} to be positive") + + return scalar + + def zero_mean(channel: SPMChannel) -> SPMChannel: """Shift the vertical reference so the arithmetic mean is zero.""" data = _validated_data(channel, operation="zero_mean") @@ -200,6 +249,140 @@ def plane_fit( return channel.with_data(data - plane) +def _selected_local_facet_slopes( + data: np.ndarray, + selection: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """Return local x/y facet slopes for fully selected pixel cells.""" + rows, columns = data.shape + + if rows < 2 or columns < 2: + raise ValueError("facet_level requires selected neighbouring pixel cells") + + selected_cells = ( + selection[:-1, :-1] & selection[:-1, 1:] & selection[1:, :-1] & selection[1:, 1:] + ) + + if not np.any(selected_cells): + raise ValueError("facet_level requires selected neighbouring pixel cells") + + x_coordinates = np.linspace(-1.0, 1.0, columns) + y_coordinates = np.linspace(-1.0, 1.0, rows) + + x_step = float(x_coordinates[1] - x_coordinates[0]) + y_step = float(y_coordinates[1] - y_coordinates[0]) + + x_slopes = (data[:-1, 1:] - data[:-1, :-1] + data[1:, 1:] - data[1:, :-1]) / (2.0 * x_step) + + y_slopes = (data[1:, :-1] - data[:-1, :-1] + data[1:, 1:] - data[:-1, 1:]) / (2.0 * y_step) + + return ( + x_slopes[selected_cells], + y_slopes[selected_cells], + ) + + +def _dominant_facet_slopes( + x_slopes: np.ndarray, + y_slopes: np.ndarray, +) -> tuple[float, float]: + """Estimate the dominant local facet slope using Gaussian reweighting.""" + seed_x = _half_sample_mode(x_slopes) + seed_y = _half_sample_mode(y_slopes) + + squared_distances = np.square(x_slopes - seed_x) + np.square(y_slopes - seed_y) + + epsilon = np.finfo(float).eps + positive_distances = squared_distances[squared_distances > epsilon] + + if positive_distances.size == 0: + return seed_x, seed_y + + scale = float(np.median(positive_distances)) + gaussian_constant = 1.0 / 20.0 + + weights = np.exp(-0.5 * squared_distances / (gaussian_constant * scale)) + + if float(np.sum(weights)) <= np.finfo(float).tiny: + return seed_x, seed_y + + return ( + float(np.average(x_slopes, weights=weights)), + float(np.average(y_slopes, weights=weights)), + ) + + +def facet_level( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + max_iterations: int = 20, + tolerance: float = 1e-12, + preserve_mean: bool = False, +) -> SPMChannel: + """Level a surface using the prevalent orientation of local facets.""" + data = _validated_data( + channel, + operation="facet_level", + ) + + iterations = _positive_integer( + max_iterations, + name="max_iterations", + operation="facet_level", + ) + convergence_tolerance = _positive_real_scalar( + tolerance, + name="tolerance", + operation="facet_level", + ) + + if not isinstance(preserve_mean, (bool, np.bool_)): + raise TypeError("facet_level requires preserve_mean to be boolean") + + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="facet_level", + minimum_points=4, + ) + + rows, columns = data.shape + x_coordinates = np.linspace(-1.0, 1.0, columns) + y_coordinates = np.linspace(-1.0, 1.0, rows) + xx, yy = np.meshgrid(x_coordinates, y_coordinates) + + corrections = np.zeros(data.shape, dtype=float) + working = data.astype(float, copy=True) + + for _ in range(iterations): + x_slopes, y_slopes = _selected_local_facet_slopes( + working, + selection, + ) + dominant_x, dominant_y = _dominant_facet_slopes( + x_slopes, + y_slopes, + ) + + if np.hypot(dominant_x, dominant_y) <= convergence_tolerance: + break + + plane_tilt = dominant_x * xx + dominant_y * yy + + corrections += plane_tilt + working -= plane_tilt + + if preserve_mean: + corrections -= np.mean(corrections) + else: + corrections += float(np.mean(working[selection])) + + return channel.with_data(data - corrections) + + def three_point_level( channel: SPMChannel, *, diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index ed90286..e8d8bdf 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1756,3 +1756,220 @@ def test_align_rows_facet_tilt_requires_selected_adjacent_pixels() -> None: mask=mask, mask_mode="include", ) + + +def test_facet_level_flattens_exact_plane_and_preserves_context() -> None: + """Facet levelling must remove an exact plane without mutating the input.""" + rows, columns = 9, 10 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + data = 4.0 + 2.0 * x - 3.0 * y + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=10e-6, + y_range=9e-6, + direction="forward", + group="Scan forward", + metadata={"source": "synthetic"}, + ) + original_data = data.copy() + + result = leveling.facet_level(channel) + + assert np.allclose(result.data, 0.0, atol=1e-12) + + assert np.array_equal(channel.data, original_data) + assert result is not channel + assert result.data is not channel.data + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +def test_facet_level_preserves_large_raised_feature() -> None: + """Facet levelling must remove tilt without flattening a raised plateau.""" + rows, columns = 11, 12 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + background = 5.0 + 1.25 * x - 0.75 * y + data = background.copy() + + feature = np.zeros(data.shape, dtype=bool) + feature[4:7, 5:8] = True + data[feature] += 20.0 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=12e-6, + y_range=11e-6, + ) + + result = leveling.facet_level(channel) + + base_values = result.data[~feature] + feature_values = result.data[feature] + + assert np.allclose( + base_values, + np.mean(base_values), + atol=1e-10, + ) + assert np.allclose( + feature_values, + np.mean(feature_values), + atol=1e-10, + ) + assert np.isclose( + np.mean(feature_values) - np.mean(base_values), + 20.0, + atol=1e-10, + ) + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_facet_level_respects_mask_selection(mask_mode: str) -> None: + """Facet levelling must estimate its plane only from selected regions.""" + rows, columns = 11, 12 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + background = 3.0 - 2.0 * x + 0.5 * y + data = background.copy() + + excluded = np.zeros(data.shape, dtype=bool) + excluded[4:7, 5:8] = True + data[excluded] += 15.0 + + mask = ~excluded if mask_mode == "include" else excluded + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=12e-6, + y_range=11e-6, + ) + + result = leveling.facet_level( + channel, + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + assert np.allclose(result.data[~excluded], 0.0, atol=1e-10) + assert np.allclose(result.data[excluded], 15.0, atol=1e-10) + + +def test_facet_level_can_preserve_global_mean() -> None: + """Optional mean preservation must retain the absolute vertical level.""" + rows, columns = 8, 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + data = 7.0 + 1.5 * x - 2.5 * y + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=9e-6, + y_range=8e-6, + ) + + result = leveling.facet_level( + channel, + preserve_mean=True, + ) + + assert np.isclose(np.mean(result.data), np.mean(data)) + assert np.allclose(result.data, np.mean(data), atol=1e-12) + + +@pytest.mark.parametrize( + ("kwargs", "error_type", "message"), + [ + ( + {"max_iterations": True}, + TypeError, + "facet_level requires max_iterations to be a positive integer", + ), + ( + {"max_iterations": 0}, + ValueError, + "facet_level requires max_iterations to be positive", + ), + ( + {"tolerance": 0.0}, + ValueError, + "facet_level requires tolerance to be positive", + ), + ( + {"preserve_mean": "yes"}, + TypeError, + "facet_level requires preserve_mean to be boolean", + ), + ], + ids=[ + "boolean-iterations", + "zero-iterations", + "zero-tolerance", + "non-boolean-preserve-mean", + ], +) +def test_facet_level_rejects_invalid_configuration( + kwargs: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + """Invalid facet-level configuration must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(25.0).reshape(5, 5), + unit="nm", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.facet_level( + channel, + **kwargs, # type: ignore[arg-type] + ) + + +def test_facet_level_requires_selected_local_facets() -> None: + """Selected pixels must form at least one complete local facet.""" + data = np.arange(36.0).reshape(6, 6) + + mask = np.zeros(data.shape, dtype=bool) + mask[::2, ::2] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=6e-6, + y_range=6e-6, + ) + + with pytest.raises( + ValueError, + match="facet_level requires selected neighbouring pixel cells", + ): + leveling.facet_level( + channel, + mask=mask, + mask_mode="include", + ) From 06d634248799355d8e3e0add0d6ac6da2dfdcf6d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:57:26 -0400 Subject: [PATCH 16/82] feat(core): add physical geometry primitives --- src/spmkit/core/geometry.py | 287 ++++++++++++++++++++++++++++++++++++ tests/core/test_geometry.py | 191 ++++++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/spmkit/core/geometry.py create mode 100644 tests/core/test_geometry.py diff --git a/src/spmkit/core/geometry.py b/src/spmkit/core/geometry.py new file mode 100644 index 0000000..f144d57 --- /dev/null +++ b/src/spmkit/core/geometry.py @@ -0,0 +1,287 @@ +"""Physical geometry primitives for SPM image transformations. + +Coordinates use physical pixel centres. Lateral ranges are expressed in +metres, while channel heights can be converted from supported length units. +The module depends only on NumPy so geometric Core operations remain available +in the minimal SPMKit installation. +""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np + +FillMode = Literal["nearest", "constant"] + + +_LENGTH_SCALES_TO_METRES: dict[str, float] = { + "m": 1.0, + "metre": 1.0, + "meter": 1.0, + "mm": 1e-3, + "millimetre": 1e-3, + "millimeter": 1e-3, + "µm": 1e-6, + "um": 1e-6, + "micrometre": 1e-6, + "micrometer": 1e-6, + "nm": 1e-9, + "nanometre": 1e-9, + "nanometer": 1e-9, + "pm": 1e-12, + "picometre": 1e-12, + "picometer": 1e-12, + "å": 1e-10, + "ångström": 1e-10, + "angstrom": 1e-10, +} + + +def _positive_finite_scalar( + value: object, + *, + name: str, + operation: str, +) -> float: + """Validate a finite strictly positive real scalar.""" + scalar_data = np.asarray(value) + + if ( + scalar_data.ndim != 0 + or not np.issubdtype(scalar_data.dtype, np.number) + or np.iscomplexobj(scalar_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires {name} to be a positive real scalar") + + scalar = float(scalar_data.item()) + + if not np.isfinite(scalar): + raise ValueError(f"{operation} requires {name} to be finite") + + if scalar <= 0.0: + raise ValueError(f"{operation} requires {name} to be positive") + + return scalar + + +def _validated_shape( + shape: object, + *, + operation: str, +) -> tuple[int, int]: + """Validate a non-empty two-dimensional array shape.""" + if ( + not isinstance(shape, tuple) + or len(shape) != 2 + or any( + isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) + for value in shape + ) + ): + raise TypeError(f"{operation} requires shape to be a two-integer tuple") + + rows, columns = (int(shape[0]), int(shape[1])) + + if rows <= 0 or columns <= 0: + raise ValueError(f"{operation} requires a non-empty two-dimensional shape") + + return rows, columns + + +def length_scale_to_metres(unit: str) -> float: + """Return the multiplicative factor converting a length unit to metres.""" + if not isinstance(unit, str): + raise TypeError("length_scale_to_metres requires unit to be a string") + + normalized = unit.strip().replace("μ", "µ").lower() + + try: + return _LENGTH_SCALES_TO_METRES[normalized] + except KeyError as exc: + raise ValueError(f"unsupported geometric length unit: {unit!r}") from exc + + +def length_values_to_metres( + values: np.ndarray, + *, + unit: str, +) -> np.ndarray: + """Convert finite length values to metres.""" + data = np.asarray(values) + + if not np.issubdtype(data.dtype, np.number): + raise TypeError("length_values_to_metres requires numeric values") + + if np.iscomplexobj(data): + raise TypeError("length_values_to_metres requires real values") + + if not np.all(np.isfinite(data)): + raise ValueError("length_values_to_metres requires finite values") + + return data.astype(float, copy=False) * length_scale_to_metres(unit) + + +def length_values_from_metres( + values: np.ndarray, + *, + unit: str, +) -> np.ndarray: + """Convert finite metre values to the requested length unit.""" + data = np.asarray(values, dtype=float) + + if not np.all(np.isfinite(data)): + raise ValueError("length_values_from_metres requires finite values") + + return data / length_scale_to_metres(unit) + + +def pixel_center_axes( + shape: tuple[int, int], + *, + x_range: float, + y_range: float, +) -> tuple[np.ndarray, np.ndarray]: + """Return centred physical X and Y pixel-centre coordinates in metres.""" + rows, columns = _validated_shape( + shape, + operation="pixel_center_axes", + ) + physical_x_range = _positive_finite_scalar( + x_range, + name="x_range", + operation="pixel_center_axes", + ) + physical_y_range = _positive_finite_scalar( + y_range, + name="y_range", + operation="pixel_center_axes", + ) + + x_step = physical_x_range / columns + y_step = physical_y_range / rows + + x_coordinates = (np.arange(columns, dtype=float) + 0.5) * x_step - 0.5 * physical_x_range + + y_coordinates = (np.arange(rows, dtype=float) + 0.5) * y_step - 0.5 * physical_y_range + + return x_coordinates, y_coordinates + + +def physical_to_pixel_indices( + x_coordinates: np.ndarray, + y_coordinates: np.ndarray, + *, + shape: tuple[int, int], + x_range: float, + y_range: float, +) -> tuple[np.ndarray, np.ndarray]: + """Map centred physical coordinates to fractional pixel indices.""" + rows, columns = _validated_shape( + shape, + operation="physical_to_pixel_indices", + ) + physical_x_range = _positive_finite_scalar( + x_range, + name="x_range", + operation="physical_to_pixel_indices", + ) + physical_y_range = _positive_finite_scalar( + y_range, + name="y_range", + operation="physical_to_pixel_indices", + ) + + x_data, y_data = np.broadcast_arrays( + np.asarray(x_coordinates, dtype=float), + np.asarray(y_coordinates, dtype=float), + ) + + if not (np.all(np.isfinite(x_data)) and np.all(np.isfinite(y_data))): + raise ValueError("physical_to_pixel_indices requires finite coordinates") + + x_step = physical_x_range / columns + y_step = physical_y_range / rows + + x_indices = x_data / x_step + 0.5 * columns - 0.5 + y_indices = y_data / y_step + 0.5 * rows - 0.5 + + return x_indices, y_indices + + +def bilinear_sample( + data: np.ndarray, + *, + x_index: np.ndarray, + y_index: np.ndarray, + fill_mode: FillMode = "nearest", + fill_value: float = 0.0, +) -> np.ndarray: + """Sample a regular 2D grid at fractional pixel indices.""" + values = np.asarray(data) + + if values.ndim != 2 or values.size == 0: + raise ValueError("bilinear_sample requires non-empty two-dimensional data") + + if not np.issubdtype(values.dtype, np.number) or np.iscomplexobj(values): + raise TypeError("bilinear_sample requires real numeric data") + + if not np.all(np.isfinite(values)): + raise ValueError("bilinear_sample requires finite data") + + if fill_mode not in {"nearest", "constant"}: + raise ValueError("bilinear_sample fill_mode must be 'nearest' or 'constant'") + + x_data, y_data = np.broadcast_arrays( + np.asarray(x_index, dtype=float), + np.asarray(y_index, dtype=float), + ) + + if not (np.all(np.isfinite(x_data)) and np.all(np.isfinite(y_data))): + raise ValueError("bilinear_sample requires finite sample coordinates") + + rows, columns = values.shape + + outside = (x_data < 0.0) | (x_data > columns - 1) | (y_data < 0.0) | (y_data > rows - 1) + + sampled_x = np.clip(x_data, 0.0, columns - 1) + sampled_y = np.clip(y_data, 0.0, rows - 1) + + x0 = np.floor(sampled_x).astype(int) + y0 = np.floor(sampled_y).astype(int) + x1 = np.minimum(x0 + 1, columns - 1) + y1 = np.minimum(y0 + 1, rows - 1) + + x_weight = sampled_x - x0 + y_weight = sampled_y - y0 + + top = values[y0, x0] * (1.0 - x_weight) + values[y0, x1] * x_weight + bottom = values[y1, x0] * (1.0 - x_weight) + values[y1, x1] * x_weight + sampled = top * (1.0 - y_weight) + bottom * y_weight + + if fill_mode == "constant": + fill = _positive_or_zero_finite_scalar(fill_value) + sampled = np.where(outside, fill, sampled) + + return np.asarray(sampled, dtype=float) + + +def _positive_or_zero_finite_scalar(value: object) -> float: + """Validate a finite real scalar used as a fill value.""" + scalar_data = np.asarray(value) + + if ( + scalar_data.ndim != 0 + or not np.issubdtype(scalar_data.dtype, np.number) + or np.iscomplexobj(scalar_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError("bilinear_sample requires fill_value to be a real scalar") + + scalar = float(scalar_data.item()) + + if not np.isfinite(scalar): + raise ValueError("bilinear_sample requires fill_value to be finite") + + return scalar diff --git a/tests/core/test_geometry.py b/tests/core/test_geometry.py new file mode 100644 index 0000000..816a831 --- /dev/null +++ b/tests/core/test_geometry.py @@ -0,0 +1,191 @@ +"""Tests for physical geometry primitives.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.geometry import ( + bilinear_sample, + length_scale_to_metres, + length_values_from_metres, + length_values_to_metres, + physical_to_pixel_indices, + pixel_center_axes, +) + + +@pytest.mark.parametrize( + ("unit", "expected"), + [ + ("m", 1.0), + ("mm", 1e-3), + ("µm", 1e-6), + ("μm", 1e-6), + ("um", 1e-6), + ("nm", 1e-9), + ("pm", 1e-12), + ("Å", 1e-10), + ("angstrom", 1e-10), + ], +) +def test_length_scale_to_metres_supports_geometric_units( + unit: str, + expected: float, +) -> None: + assert length_scale_to_metres(unit) == expected + + +@pytest.mark.parametrize("unit", ["V", "A", "nN", "arbitrary"]) +def test_length_scale_to_metres_rejects_non_length_units( + unit: str, +) -> None: + with pytest.raises( + ValueError, + match="unsupported geometric length unit", + ): + length_scale_to_metres(unit) + + +def test_length_value_conversion_round_trip() -> None: + values = np.array([-3.0, 0.0, 12.5]) + + metres = length_values_to_metres(values, unit="nm") + recovered = length_values_from_metres(metres, unit="nm") + + assert np.allclose( + metres, + values * 1e-9, + ) + assert np.allclose(recovered, values) + + +def test_pixel_center_axes_use_physical_pixel_centres() -> None: + x_coordinates, y_coordinates = pixel_center_axes( + (2, 4), + x_range=4.0, + y_range=2.0, + ) + + assert np.allclose( + x_coordinates, + [-1.5, -0.5, 0.5, 1.5], + ) + assert np.allclose( + y_coordinates, + [-0.5, 0.5], + ) + + +def test_physical_coordinates_round_trip_to_pixel_indices() -> None: + shape = (3, 5) + x_coordinates, y_coordinates = pixel_center_axes( + shape, + x_range=10.0, + y_range=6.0, + ) + xx, yy = np.meshgrid( + x_coordinates, + y_coordinates, + ) + + x_indices, y_indices = physical_to_pixel_indices( + xx, + yy, + shape=shape, + x_range=10.0, + y_range=6.0, + ) + + expected_x, expected_y = np.meshgrid( + np.arange(shape[1], dtype=float), + np.arange(shape[0], dtype=float), + ) + + assert np.allclose(x_indices, expected_x) + assert np.allclose(y_indices, expected_y) + + +def test_bilinear_sample_is_exact_for_affine_surface() -> None: + yy, xx = np.mgrid[0:4, 0:5] + data = 2.0 * xx - 3.0 * yy + 7.0 + + sample_x = np.array([0.25, 1.5, 3.75]) + sample_y = np.array([0.5, 2.25, 1.75]) + + result = bilinear_sample( + data, + x_index=sample_x, + y_index=sample_y, + ) + expected = 2.0 * sample_x - 3.0 * sample_y + 7.0 + + assert np.allclose(result, expected) + + +def test_bilinear_sample_nearest_fill_clamps_to_border() -> None: + data = np.arange(9.0).reshape(3, 3) + + result = bilinear_sample( + data, + x_index=np.array([-2.0, 4.0]), + y_index=np.array([1.0, 1.0]), + fill_mode="nearest", + ) + + assert np.allclose(result, [3.0, 5.0]) + + +def test_bilinear_sample_constant_fill_marks_outside_domain() -> None: + data = np.arange(9.0).reshape(3, 3) + + result = bilinear_sample( + data, + x_index=np.array([-1.0, 1.0, 3.0]), + y_index=np.array([1.0, 1.0, 1.0]), + fill_mode="constant", + fill_value=-5.0, + ) + + assert np.allclose(result, [-5.0, 4.0, -5.0]) + + +@pytest.mark.parametrize( + ("shape", "x_range", "y_range", "error_type", "message"), + [ + ( + (0, 4), + 1.0, + 1.0, + ValueError, + "non-empty two-dimensional shape", + ), + ( + (3, 4), + 0.0, + 1.0, + ValueError, + "x_range to be positive", + ), + ( + (3, 4), + 1.0, + np.inf, + ValueError, + "y_range to be finite", + ), + ], +) +def test_pixel_center_axes_reject_invalid_geometry( + shape: tuple[int, int], + x_range: float, + y_range: float, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + pixel_center_axes( + shape, + x_range=x_range, + y_range=y_range, + ) From 6c72ac53a0712712ce465b004de2f3055a89076b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:14:46 -0400 Subject: [PATCH 17/82] feat(leveling): add physical level rotation --- src/spmkit/core/analysis/leveling.py | 219 +++++++++++++++++++++ tests/core/test_leveling.py | 273 +++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 1823722..75d170a 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -11,6 +11,13 @@ import numpy as np +from spmkit.core.geometry import ( + bilinear_sample, + length_values_from_metres, + length_values_to_metres, + physical_to_pixel_indices, + pixel_center_axes, +) from spmkit.core.models import SPMChannel @@ -249,6 +256,218 @@ def plane_fit( return channel.with_data(data - plane) +def _rotation_matrix_to_horizontal( + x_slope: float, + y_slope: float, +) -> np.ndarray: + """Return the minimal 3D rotation mapping a plane normal to +Z.""" + normal = np.array( + [-x_slope, -y_slope, 1.0], + dtype=float, + ) + normal /= np.linalg.norm(normal) + + target = np.array([0.0, 0.0, 1.0]) + cross = np.cross(normal, target) + sine = float(np.linalg.norm(cross)) + cosine = float(np.dot(normal, target)) + + if sine <= np.finfo(float).eps: + return np.eye(3) + + cross_matrix = np.array( + [ + [0.0, -cross[2], cross[1]], + [cross[2], 0.0, -cross[0]], + [-cross[1], cross[0], 0.0], + ] + ) + + return np.eye(3) + cross_matrix + cross_matrix @ cross_matrix * ((1.0 - cosine) / sine**2) + + +def rotate_level( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + interpolation: Literal["linear"] = "linear", + fill_mode: Literal["nearest", "constant"] = "nearest", + fill_value: float = 0.0, + preserve_mean: bool = False, +) -> SPMChannel: + """Flatten a fitted plane by approximate physical 3D image rotation. + + The fitted plane defines an inverse lateral mapping from the output grid + to the source grid. Heights are interpolated in the source field and then + rotated in physical XYZ coordinates. Shape and lateral ranges are kept. + """ + data = _validated_data( + channel, + operation="rotate_level", + ) + + if interpolation != "linear": + raise ValueError("rotate_level interpolation must be 'linear'") + + if fill_mode not in {"nearest", "constant"}: + raise ValueError("rotate_level fill_mode must be 'nearest' or 'constant'") + + if not isinstance(preserve_mean, (bool, np.bool_)): + raise TypeError("rotate_level requires preserve_mean to be boolean") + + fill_data = np.asarray(fill_value) + + if ( + fill_data.ndim != 0 + or not np.issubdtype(fill_data.dtype, np.number) + or np.iscomplexobj(fill_data) + or isinstance(fill_value, (bool, np.bool_)) + ): + raise TypeError("rotate_level requires fill_value to be a real scalar") + + fill_scalar = float(fill_data.item()) + + if not np.isfinite(fill_scalar): + raise ValueError("rotate_level requires fill_value to be finite") + + data_metres = length_values_to_metres( + data, + unit=channel.unit, + ) + fill_metres = float( + length_values_to_metres( + np.asarray(fill_scalar), + unit=channel.unit, + ) + ) + + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="rotate_level", + minimum_points=3, + ) + + x_coordinates, y_coordinates = pixel_center_axes( + data.shape, + x_range=channel.x_range, + y_range=channel.y_range, + ) + xx, yy = np.meshgrid( + x_coordinates, + y_coordinates, + ) + + design = np.column_stack( + ( + xx.ravel(), + yy.ravel(), + np.ones(data.size), + ) + ) + selected = selection.ravel() + + coefficients, _, rank, _ = np.linalg.lstsq( + design[selected], + data_metres.ravel()[selected], + rcond=None, + ) + + if rank < 3: + raise ValueError("rotate_level selected points do not define " "a unique plane") + + x_slope = float(coefficients[0]) + y_slope = float(coefficients[1]) + intercept = float(coefficients[2]) + + rotation = _rotation_matrix_to_horizontal( + x_slope, + y_slope, + ) + + output_plane_points = np.stack( + ( + xx.ravel(), + yy.ravel(), + np.zeros(data.size), + ) + ) + + source_plane_points = rotation.T @ output_plane_points + + source_x = source_plane_points[0].reshape(data.shape) + source_y = source_plane_points[1].reshape(data.shape) + + x_indices, y_indices = physical_to_pixel_indices( + source_x, + source_y, + shape=data.shape, + x_range=channel.x_range, + y_range=channel.y_range, + ) + + rows, columns = data.shape + + outside = ( + (x_indices < 0.0) | (x_indices > columns - 1) | (y_indices < 0.0) | (y_indices > rows - 1) + ) + + sampled_x_indices = np.clip( + x_indices, + 0.0, + columns - 1, + ) + sampled_y_indices = np.clip( + y_indices, + 0.0, + rows - 1, + ) + + sampled_heights = bilinear_sample( + data_metres, + x_index=sampled_x_indices, + y_index=sampled_y_indices, + fill_mode="nearest", + ) + + x_step = channel.x_range / columns + y_step = channel.y_range / rows + + effective_source_x = (sampled_x_indices + 0.5 - 0.5 * columns) * x_step + + effective_source_y = (sampled_y_indices + 0.5 - 0.5 * rows) * y_step + + actual_source_points = np.stack( + ( + effective_source_x.ravel(), + effective_source_y.ravel(), + (sampled_heights - intercept).ravel(), + ) + ) + + rotated_points = rotation @ actual_source_points + rotated_height_metres = rotated_points[2].reshape(data.shape) + + if fill_mode == "constant": + rotated_height_metres = np.where( + outside, + fill_metres, + rotated_height_metres, + ) + + rotated_data = length_values_from_metres( + rotated_height_metres, + unit=channel.unit, + ) + + if preserve_mean: + rotated_data = rotated_data + np.mean(data) - np.mean(rotated_data) + + return channel.with_data(rotated_data) + + def _selected_local_facet_slopes( data: np.ndarray, selection: np.ndarray, diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index e8d8bdf..015b425 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -1973,3 +1973,276 @@ def test_facet_level_requires_selected_local_facets() -> None: mask=mask, mask_mode="include", ) + + +def test_rotate_level_flattens_exact_physical_plane_and_preserves_context() -> None: + """Level rotation must flatten a physical plane without mutating its channel.""" + rows, columns = 13, 15 + x_range = 14e-6 + y_range = 12e-6 + + x = (np.arange(columns, dtype=float) + 0.5) * x_range / columns - 0.5 * x_range + y = (np.arange(rows, dtype=float) + 0.5) * y_range / rows - 0.5 * y_range + xx, yy = np.meshgrid(x, y) + + x_slope = 0.08 + y_slope = -0.05 + intercept = 7e-9 + + data = (intercept + x_slope * xx + y_slope * yy) / 1e-9 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=x_range, + y_range=y_range, + direction="forward", + group="Scan forward", + metadata={"source": "synthetic"}, + ) + original_data = data.copy() + + result = leveling.rotate_level(channel) + + assert np.allclose(result.data, 0.0, atol=1e-6) + + assert np.array_equal(channel.data, original_data) + assert result is not channel + assert result.data is not channel.data + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize("mask_mode", ["include", "exclude"]) +def test_rotate_level_respects_mask_selection_and_rotates_feature_height( + mask_mode: str, +) -> None: + """The mask must control plane fitting while relief is geometrically rotated.""" + rows = columns = 21 + x_range = y_range = 20e-6 + + x = (np.arange(columns, dtype=float) + 0.5) * x_range / columns - 0.5 * x_range + y = (np.arange(rows, dtype=float) + 0.5) * y_range / rows - 0.5 * y_range + xx, yy = np.meshgrid(x, y) + + x_slope = 0.10 + y_slope = -0.05 + intercept = 5e-9 + feature_height = 20.0 + + data = (intercept + x_slope * xx + y_slope * yy) / 1e-9 + + feature = np.zeros(data.shape, dtype=bool) + feature[7:15, 10:19] = True + data[feature] += feature_height + + mask = ~feature if mask_mode == "include" else feature + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=x_range, + y_range=y_range, + ) + + result = leveling.rotate_level( + channel, + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + base_probe = np.zeros(data.shape, dtype=bool) + base_probe[2:6, 2:6] = True + + feature_probe = np.zeros(data.shape, dtype=bool) + feature_probe[9:13, 12:17] = True + + normal_z = 1.0 / np.sqrt(1.0 + x_slope**2 + y_slope**2) + expected_feature_height = feature_height * normal_z + + assert np.allclose( + result.data[base_probe], + 0.0, + atol=1e-5, + ) + assert np.allclose( + result.data[feature_probe], + expected_feature_height, + atol=1e-5, + ) + + +def test_rotate_level_can_preserve_global_mean() -> None: + """Mean preservation must retain the original absolute vertical level.""" + rows, columns = 9, 11 + x_range = 10e-6 + y_range = 8e-6 + + x = (np.arange(columns, dtype=float) + 0.5) * x_range / columns - 0.5 * x_range + y = (np.arange(rows, dtype=float) + 0.5) * y_range / rows - 0.5 * y_range + xx, yy = np.meshgrid(x, y) + + data = (12e-9 + 0.04 * xx - 0.03 * yy) / 1e-9 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=x_range, + y_range=y_range, + ) + + result = leveling.rotate_level( + channel, + preserve_mean=True, + ) + + assert np.isclose( + np.mean(result.data), + np.mean(data), + atol=1e-12, + ) + assert np.allclose( + result.data, + np.mean(data), + atol=1e-6, + ) + + +def test_rotate_level_constant_fill_marks_exterior_pixels() -> None: + """Constant fill must explicitly identify pixels outside the source domain.""" + rows = columns = 11 + x_range = y_range = 10e-6 + + x = (np.arange(columns, dtype=float) + 0.5) * x_range / columns - 0.5 * x_range + y = (np.arange(rows, dtype=float) + 0.5) * y_range / rows - 0.5 * y_range + xx, yy = np.meshgrid(x, y) + + data = (3e-9 + 0.45 * xx - 0.30 * yy) / 1e-9 + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=x_range, + y_range=y_range, + ) + + result = leveling.rotate_level( + channel, + fill_mode="constant", + fill_value=-7.0, + ) + + assert np.all(np.isfinite(result.data)) + assert np.any(result.data == -7.0) + + inside = result.data != -7.0 + assert np.allclose( + result.data[inside], + 0.0, + atol=1e-5, + ) + + +def test_rotate_level_rejects_non_geometric_channel_unit() -> None: + """True geometric rotation requires Z to represent a physical length.""" + channel = SPMChannel( + name="Current", + data=np.arange(25.0).reshape(5, 5), + unit="V", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises( + ValueError, + match="unsupported geometric length unit", + ): + leveling.rotate_level(channel) + + +@pytest.mark.parametrize( + ("kwargs", "error_type", "message"), + [ + ( + {"interpolation": "cubic"}, + ValueError, + "rotate_level interpolation must be 'linear'", + ), + ( + {"fill_mode": "nan"}, + ValueError, + "rotate_level fill_mode must be 'nearest' or 'constant'", + ), + ( + {"preserve_mean": "yes"}, + TypeError, + "rotate_level requires preserve_mean to be boolean", + ), + ( + {"fill_value": True}, + TypeError, + "rotate_level requires fill_value to be a real scalar", + ), + ], + ids=[ + "unsupported-interpolation", + "unsupported-fill-mode", + "non-boolean-preserve-mean", + "boolean-fill-value", + ], +) +def test_rotate_level_rejects_invalid_configuration( + kwargs: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + """Invalid level-rotation settings must fail explicitly.""" + channel = SPMChannel( + name="Z-Axis", + data=np.arange(25.0).reshape(5, 5), + unit="nm", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises(error_type, match=message): + leveling.rotate_level( + channel, + **kwargs, # type: ignore[arg-type] + ) + + +def test_rotate_level_rejects_rank_deficient_plane_selection() -> None: + """Selected pixels must determine a unique physical plane.""" + data = np.arange(25.0).reshape(5, 5) + + mask = np.zeros(data.shape, dtype=bool) + mask[0, :] = True + + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="nm", + x_range=5e-6, + y_range=5e-6, + ) + + with pytest.raises( + ValueError, + match=("rotate_level selected points do not define " "a unique plane"), + ): + leveling.rotate_level( + channel, + mask=mask, + mask_mode="include", + ) From c9f58cdf84ba0f3301c2ca2b6de20dee45f0c94e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:41:39 -0400 Subject: [PATCH 18/82] build(core): promote scipy to required numerical dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c5cc5f8..62647c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ "numpy>=1.24", + "scipy>=1.14", "typer>=0.12", "rich>=13.0", "pyyaml>=6.0", # serialización de Recipe (pipeline reproducible) @@ -36,7 +37,6 @@ gwy = ["gwyfile>=0.3"] # interop con Gwyddion (.gwy) nanosurf = ["NSFopen>=2.2"] # lector .nhf validado (NanoSurf) afm = ["afmformats>=0.18"] # lectores de la cola larga (JPK QI, .ibw, HDF5, NT-MDT…) jpk = ["tifffile>=2023.7"] # curvas/mapas de fuerza JPK en formato TIFF -grains = ["scipy>=1.10"] # detección de granos/partículas parallel = ["joblib>=1.3"] # backend paralelo opcional para force-volumes grandes pandas = ["pandas>=2.0"] # export a DataFrame (batch, resultados) viz = [ # figuras de publicación From 09d7a0119ba891b13b9662b1e13c58c19953f601 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:51:02 -0400 Subject: [PATCH 19/82] fix(build): remove stale grains extra reference --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 62647c8..416a7da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dev = [ ] test-gui = ["pytest-qt>=4.4"] # tests de GUI (requiere también el extra 'gui') docs = ["mkdocs-material>=9.5"] -all = ["spmkit[hdf5,gwy,nanosurf,afm,jpk,grains,viz,report,gui]"] +all = ["spmkit[hdf5,gwy,nanosurf,afm,jpk,viz,report,gui]"] [project.urls] Homepage = "https://kegouro.github.io/spmkit/" From e930da62a8f52690a15ea92b2dc63fa98a61917f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:02:21 -0400 Subject: [PATCH 20/82] fix(build): align grains guidance with required scipy --- docs/api.md | 2 +- docs/user-guide.md | 4 ++-- docs/user-guide.tex | 4 ++-- src/spmkit/core/analysis/grains.py | 3 ++- src/spmkit/gui/viewmodels/grains_vm.py | 6 ++++-- tests/gui/test_grains_spectral.py | 7 ------- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/docs/api.md b/docs/api.md index b503f50..fc04286 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,7 +122,7 @@ print(segmentation.n_grains, segmentation.mean_diameter) print(segmentation.coverage, segmentation.density) ``` -`radial_psd()` returns `q` in `1/m`. Grain detection requires the `grains` extra, +`radial_psd()` returns `q` in `1/m`. Grain detection uses SciPy, a required SPMKit dependency, uses eight-connected components, and reports density in grains per µm². Automatic thresholding is an algorithmic default, not a scientifically universal segmentation rule; record or override it for a campaign. diff --git a/docs/user-guide.md b/docs/user-guide.md index 86e4f4e..282424d 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -410,7 +410,7 @@ KPFM work‑function calculation uses `Φ_sample = Φ_tip - e·CPD`. ### Grains (`grains`) **Purpose:** Detect particles/grains on a levelled topography channel. -Requires the `grains` extra (scipy). +Uses SciPy, which is a required SPMKit dependency. | Feature | Description | |---------|-------------| @@ -1264,7 +1264,7 @@ ValueError: unsupported extension: .xxx ``` ModuleNotFoundError: No module named 'scipy' ``` -**Fix:** Install the relevant extra (e.g. `pip install "spmkit[grains]"`). +**Fix:** Reinstall SPMKit so its required dependencies are restored (for a development checkout: `python -m pip install -e .`). ### Empty or unexpected channels ``` diff --git a/docs/user-guide.tex b/docs/user-guide.tex index aedeefb..9409d44 100644 --- a/docs/user-guide.tex +++ b/docs/user-guide.tex @@ -421,7 +421,7 @@ \subsection{Image perspectives} \multicolumn{2}{c}{\textbf{Module: image}}\\ \midrule image & Channel viewer, leveling (plane/poly/align-rows), colormap, line profile ROI, roughness + KPFM analysis, profile export.\\ -grains & Particle/grain detection on levelled topography; colour overlay, size statistics. Requires \texttt{grains} extra.\\ +grains & Particle/grain detection on levelled topography; colour overlay, size statistics. Uses SciPy, which is installed as a required SPMKit dependency.\\ spectral & Radial PSD (log-log), fractal dimension~D, Hurst exponent~H, correlation length.\\ resonance & Thermal tune: SHO fit $\rightarrow$ f$_0$, Q, spring constant by equipartition.\\ evaporation & Mass sensing: load series of tuning .nid files $\rightarrow$ f(t), mass, d\textsuperscript{2} law fit.\\ @@ -812,7 +812,7 @@ \section{Troubleshooting} \item[Missing GUI extra] \hfill \\ \texttt{pip install "spmkit[gui]"} \item[Qt platform plugin error] \hfill \\ Install Qt dependencies or set \texttt{QT\_QPA\_PLATFORM=offscreen}. \item[Unsupported format] \hfill \\ Check extension matches \texttt{.nid/.nhf/.gwy/.jpk-force}. For experimental readers, install \texttt{afm} or \texttt{jpk}. - \item[Missing optional dependency] \hfill \\ Install the relevant extra (e.g. \texttt{pip install "spmkit[grains]"}). + \item[Missing optional dependency] \hfill \\ Reinstall SPMKit so its required dependencies are restored (for a development checkout: \texttt{python -m pip install -e .}). \item[Large force-volume memory] \hfill \\ Use lazy loading or the \texttt{--fast} CPU vectorised path. \item[GPU fallback] \hfill \\ CuPy is not bundled. Install separately: \texttt{pip install cupy-cuda12x}. \item[Export failure] \hfill \\ Check write permissions. Use \texttt{--output} with a writable path. diff --git a/src/spmkit/core/analysis/grains.py b/src/spmkit/core/analysis/grains.py index 4864f3c..63fc58f 100644 --- a/src/spmkit/core/analysis/grains.py +++ b/src/spmkit/core/analysis/grains.py @@ -111,7 +111,8 @@ def detect( from scipy.ndimage import label as ndlabel except ImportError as exc: # pragma: no cover raise ImportError( - "La detección de granos requiere scipy. " "Instala con: pip install 'spmkit[grains]'" + "La detección de granos requiere SciPy, una dependencia obligatoria " + "de SPMKit. Reinstala el entorno con: python -m pip install -e ." ) from exc if not (0.0 < relative_height <= 1.0): diff --git a/src/spmkit/gui/viewmodels/grains_vm.py b/src/spmkit/gui/viewmodels/grains_vm.py index c6382bc..25e3d53 100644 --- a/src/spmkit/gui/viewmodels/grains_vm.py +++ b/src/spmkit/gui/viewmodels/grains_vm.py @@ -3,7 +3,7 @@ Corre ``core.analysis.grains.detect`` sobre el canal **nivelado** del hub de imagen (:class:`ImageViewModel`) con parámetros ajustables (tamaño mínimo, altura relativa). Paridad con JPK/ANA (conteo, tamaño, cobertura, densidad de granos). Requiere scipy -(extra ``grains``); si falta, avisa por ``statusChanged`` sin tumbar la app. +como dependencia obligatoria; si falta, avisa por ``statusChanged`` sin tumbar la app. """ from __future__ import annotations @@ -78,7 +78,9 @@ def detect(self) -> None: relative_height=self._relative_height, ) except ImportError: - self.statusChanged.emit("la detección de granos requiere scipy (extra 'grains')") + self.statusChanged.emit( + "la detección de granos requiere SciPy, una dependencia obligatoria de SPMKit" + ) return except Exception as exc: # noqa: BLE001 - parámetros/imagen inválidos: se informa self.statusChanged.emit(f"detección falló: {exc}") diff --git a/tests/gui/test_grains_spectral.py b/tests/gui/test_grains_spectral.py index 48590ae..ca0701d 100644 --- a/tests/gui/test_grains_spectral.py +++ b/tests/gui/test_grains_spectral.py @@ -2,18 +2,13 @@ from __future__ import annotations -import importlib.util - import numpy as np -import pytest from spmkit.core.models import SPMChannel, SPMData from spmkit.gui.panels.grains_canvas import GrainsCanvasPanel from spmkit.gui.panels.spectral_canvas import SpectralCanvasPanel from spmkit.gui.viewmodels import GrainsViewModel, ImageViewModel, SpectralViewModel -_HAS_SCIPY = importlib.util.find_spec("scipy") is not None - def _bumps() -> SPMData: z = np.zeros((32, 32), dtype=float) @@ -117,7 +112,6 @@ def test_grains_panel_auto_toggle(qtbot) -> None: # type: ignore[no-untyped-def assert vm.threshold is not None and abs(vm.threshold - 12e-9) < 1e-15 # nm → m -@pytest.mark.skipif(not _HAS_SCIPY, reason="grains requiere scipy (extra 'grains')") def test_grains_vm_detects() -> None: image_vm = ImageViewModel() image_vm.set_data(_bumps()) @@ -131,7 +125,6 @@ def test_grains_vm_detects() -> None: assert seen[-1] is vm.result -@pytest.mark.skipif(not _HAS_SCIPY, reason="grains requiere scipy (extra 'grains')") def test_grains_panel_overlay(qtbot) -> None: # type: ignore[no-untyped-def] image_vm = ImageViewModel() image_vm.set_data(_bumps()) From c697ea88173cf71f09ae60acb2326f4c6baeeb51 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:18:36 -0400 Subject: [PATCH 21/82] test(core): isolate Bruker GUI import assertion --- tests/core/test_bruker_spm.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/core/test_bruker_spm.py b/tests/core/test_bruker_spm.py index 6501fef..893ae63 100644 --- a/tests/core/test_bruker_spm.py +++ b/tests/core/test_bruker_spm.py @@ -188,6 +188,13 @@ def test_bruker_spm_uses_versioned_32bit_scale(tmp_path) -> None: # type: ignor def test_bruker_spm_does_not_import_gui(tmp_path) -> None: # type: ignore[no-untyped-def] p = tmp_path / "nogui.spm" _write_spm(p, np.ones((2, 2), dtype=np.int16), hard=1.0, sens=1.0, scan_um=1.0) + before_modules = set(sys.modules) + with pytest.warns(UserWarning): load_bruker_spm(p) - assert not any(name.startswith(("PyQt", "pyqtgraph")) for name in sys.modules) + + imported_modules = set(sys.modules) - before_modules + unexpected_gui_modules = sorted( + name for name in imported_modules if name.startswith(("PyQt", "pyqtgraph")) + ) + assert unexpected_gui_modules == [] From 21e2963454bbe2f168b55431f86809328e101ae4 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:44:19 -0400 Subject: [PATCH 22/82] test(gui): disable cyclic GC during Qt test sessions --- tests/gui/conftest.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py index abf65e6..472c23f 100644 --- a/tests/gui/conftest.py +++ b/tests/gui/conftest.py @@ -28,23 +28,34 @@ if _HAS_GUI: - @pytest.fixture(autouse=True) - def _flush_qt(): # type: ignore[no-untyped-def] - """Purga los widgets pendientes (deleteLater) entre tests. + @pytest.fixture(scope="session", autouse=True) + def _disable_cyclic_gc_for_gui_tests(): # type: ignore[no-untyped-def] + """Desactiva el GC cíclico durante la fase GUI de pytest. + + Los widgets Qt, los ViewBox de pyqtgraph y los FigureCanvas de Matplotlib + forman ciclos con callbacks nativos. Una recolección automática mientras + Qt procesa eventos puede destruir parcialmente esos objetos y provocar un + segmentation fault. El proceso de pytest es efímero, por lo que el sistema + operativo recupera sus recursos al terminar. + """ + gc.disable() + yield - Evita el segfault por acumulación de recursos nativos al correr muchos tests - de GUI pesados en un mismo proceso (cada uno crea un Workspace completo). + @pytest.fixture(autouse=True) + def _flush_qt(qapp): # type: ignore[no-untyped-def] + """Drena eliminaciones diferidas y eventos Qt entre tests. + + pytest-qt cierra los widgets registrados con ``qtbot.addWidget``. Aquí solo + procesamos ``DeferredDelete`` y eventos pendientes. No se fuerza + ``gc.collect()`` porque Qt, pyqtgraph y Matplotlib pueden conservar callbacks + nativos durante el teardown y recolectarlos en ese punto puede causar un + segmentation fault. """ yield - from PyQt6.QtWidgets import QApplication - - app = QApplication.instance() - if app is not None: - app.processEvents() - app.sendPostedEvents(None, 0) # ejecuta los deleteLater encolados - gc.collect() - if app is not None: - app.processEvents() + from PyQt6.QtCore import QCoreApplication, QEvent + + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + qapp.processEvents() def _hertz_curve( From 070c2c2b1e6978adcf286c2a7e2a8d95800b396d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:23:25 -0400 Subject: [PATCH 23/82] feat(background): add physical arc-revolution leveling --- docs/api.md | 47 +- docs/scientific-status.md | 1 + docs/theory/spmkit-workflows.md | 1 + src/spmkit/core/analysis/__init__.py | 8 + src/spmkit/core/analysis/background.py | 371 +++++++++ tests/core/test_arc_revolution_background.py | 792 +++++++++++++++++++ 6 files changed, 1219 insertions(+), 1 deletion(-) create mode 100644 src/spmkit/core/analysis/background.py create mode 100644 tests/core/test_arc_revolution_background.py diff --git a/docs/api.md b/docs/api.md index fc04286..a8d83da 100644 --- a/docs/api.md +++ b/docs/api.md @@ -88,6 +88,51 @@ Roughness expects a previously levelled spatial image. The result fields use the ISO-style capitalization shown above. The current implementation excludes non-finite values and centres the finite height population before calculating the metrics. +## Arc-revolution background + +SPM-Kit exposes physical arc-revolution background estimation through the +public Python API: + +```python +from spmkit.core.analysis import ( + estimate_arc_revolution_background, + remove_arc_revolution_background, +) + +background = estimate_arc_revolution_background( + height, + radius=2e-6, + direction="both", + side="below", + border="nearest", +) + +corrected = remove_arc_revolution_background( + height, + radius=2e-6, + direction="both", + side="below", + border="nearest", +) +``` + +`radius` is expressed in metres. Channel heights must use a supported +geometric Z unit. Heights are converted internally to metres and returned in +the original unit while preserving the channel context. + +`direction="horizontal"` processes rows, `"vertical"` processes columns, and +`"both"` applies horizontal followed by vertical. `side="above"` is defined +as the inversion dual of `"below"`. + +The current contract accepts finite data and the `"nearest"` and `"reflect"` +border policies. Masks, CLI and Fathom exposure are not available. The +estimated background remains separately inspectable and satisfies +`corrected + background == original` within floating-point tolerance. + +This implementation is LEVEL 1 — SOFTWARE_VERIFIED through synthetic tests +and an independent test-local one-dimensional oracle. Numerical equivalence +with Gwyddion has not been established. + ## KPFM statistics ```python @@ -234,7 +279,7 @@ The plugin contract is versioned, but the surrounding package remains alpha. | capability inspection | `inspect_any(path)` | `DatasetInfo` | | capability loading | `load_any(path, kind)` | `(payload, kind)` | | force loading | `load_force(path)` | `ForceVolume` | -| image preprocessing | `analysis.leveling.*` | new `SPMChannel` | +| image preprocessing | `analysis.leveling.*`, `analysis.background.*` | new `SPMChannel` | | numerical results | `analysis.*` | immutable result dataclasses or arrays | | open exports | `core.export.*`, `save_gwy()` | file path/output artifact | | extension discovery | `spmkit.plugins.v1` | registered `Reader`/`Domain` | diff --git a/docs/scientific-status.md b/docs/scientific-status.md index e401076..adb139d 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -35,6 +35,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Sa, Sq, Sz on public experimental GWY matrices | `core.analysis.roughness` | 12 cases, 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the shared-matrix algorithm track | Gwyddion 2.71 | Parser/end-to-end observations are separate: 10 equivalences and 2 preserved channel-count differences | | Limited Nanoscope III `.spm` images | `core.io.bruker_spm` | Six demonstrated files; 18/18 Sa/Sq/Sz comparisons within tolerance and zero reported pixel delta | NUMERICALLY_VERIFIED | Gwyddion 2.71 | `ACCIDENTAL_PRE_FREEZE_UNBLINDING`; partial variants only, no blind holdout or general Bruker support | | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | +| Physical arc-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | diff --git a/docs/theory/spmkit-workflows.md b/docs/theory/spmkit-workflows.md index 4c9dd76..1c452ec 100644 --- a/docs/theory/spmkit-workflows.md +++ b/docs/theory/spmkit-workflows.md @@ -11,6 +11,7 @@ |---|---|---|---|---|---| | file inspection/routing | `core.io.load_any`, `core.plugins`, built-in readers | automatic open route | `spmkit info` | format-specific Level 1/2 | support is variant-specific | | leveling | `core.analysis.leveling` | Imagen (`image`) | `roughness --level`, `analyze --level` | Level 1 + synthetic cases | changes the reference surface | +| arc-revolution background | `core.analysis.background` | not exposed | Python API | Level 1 with test-local 1D oracle and synthetic physical tests | finite geometric Z only; no masks, CLI, Fathom or external-equivalence claim | | Sa/Sq/Sz/Ssk/Sku | `core.analysis.roughness.statistics` | Imagen (`image`) | `spmkit roughness`, `analyze` | scoped Level 3 for Sa/Sq/Sz | external campaigns do not cover Ssk/Sku or every preprocessing route | | line profile | `core.analysis.profiles.line` | Imagen (`image`) | Python API | Level 1 | interpolation and coordinate choice matter | | grain segmentation | `core.analysis.grains.detect` | Granos (`grains`) | `spmkit grains` | Level 1 + synthetic tests | threshold/overlap/tip effects | diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 10ed367..c116882 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -1,6 +1,7 @@ """Análisis numérico de datos SPM.""" from spmkit.core.analysis import ( + background, calibration, forcecurve, forcevolume, @@ -14,6 +15,10 @@ simulation, spectral, ) +from spmkit.core.analysis.background import ( + estimate_arc_revolution_background, + remove_arc_revolution_background, +) from spmkit.core.analysis.forcecurve import ForceCurveFit from spmkit.core.analysis.forcevolume import VolumeResult, analyze_volume from spmkit.core.analysis.grains import GrainResult @@ -36,6 +41,9 @@ from spmkit.core.analysis.spectral import FractalResult, RadialPSD __all__ = [ + "background", + "estimate_arc_revolution_background", + "remove_arc_revolution_background", "calibration", "leveling", "roughness", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py new file mode 100644 index 0000000..9e7d7a6 --- /dev/null +++ b/src/spmkit/core/analysis/background.py @@ -0,0 +1,371 @@ +"""Physical background estimation for SPM images. + +This module contains local, physically dimensioned background estimators. +All lateral ranges and algorithm radii are expressed in metres. Channel +height values are converted to metres internally and returned in their +original geometric unit. +""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np +from scipy.ndimage import grey_opening + +from spmkit.core.geometry import ( + length_values_from_metres, + length_values_to_metres, +) +from spmkit.core.models import SPMChannel + +ArcDirection = Literal["horizontal", "vertical", "both"] +ArcSide = Literal["below", "above"] +ArcBorder = Literal["nearest", "reflect"] + + +def _validated_channel_data( + channel: SPMChannel, + *, + operation: str, +) -> np.ndarray: + """Return valid, finite, real, two-dimensional channel data.""" + data = np.asarray(channel.data) + + if data.ndim != 2: + raise ValueError(f"{operation} requires a 2D channel") + + if data.size == 0: + raise ValueError(f"{operation} requires non-empty data") + + if not np.issubdtype(data.dtype, np.number) or np.iscomplexobj(data): + raise TypeError(f"{operation} requires real numeric data") + + if not np.all(np.isfinite(data)): + raise ValueError(f"{operation} requires finite data") + + return data + + +def _positive_radius( + radius: object, + *, + operation: str, +) -> float: + """Validate a physical radius expressed in metres.""" + radius_data = np.asarray(radius) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(radius, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires radius to be a positive real scalar") + + value = float(radius_data.item()) + + if not np.isfinite(value): + raise ValueError(f"{operation} requires radius to be finite") + + if value <= 0.0: + raise ValueError(f"{operation} requires radius to be positive") + + return value + + +def _validated_choice( + value: str, + *, + name: str, + allowed: tuple[str, ...], + operation: str, +) -> str: + """Validate a string-valued public option.""" + if value not in allowed: + choices = ", ".join(repr(item) for item in allowed) + raise ValueError(f"{operation} {name} must be one of {choices}") + + return value + + +def _axis_spacing( + channel: SPMChannel, + *, + axis: int, + operation: str, +) -> float: + """Return the canonical physical pixel spacing for one image axis.""" + spacing = channel.pixel_size_x if axis == 1 else channel.pixel_size_y + spacing_data = np.asarray(spacing) + + if ( + spacing_data.ndim != 0 + or not np.issubdtype(spacing_data.dtype, np.number) + or np.iscomplexobj(spacing_data) + ): + raise TypeError(f"{operation} requires real numeric lateral pixel spacing") + + spacing_value = float(spacing_data.item()) + + if not np.isfinite(spacing_value): + raise ValueError(f"{operation} requires finite lateral pixel spacing") + + if spacing_value <= 0.0: + raise ValueError(f"{operation} requires positive lateral pixel spacing") + + return spacing_value + + +def _arc_structure( + *, + radius: float, + spacing: float, + sample_count: int, +) -> np.ndarray: + """Return the non-flat structure for an arc rolling below a profile.""" + if sample_count <= 1: + return np.array([0.0]) + + maximum_supported_offset = sample_count - 1 + radius_in_pixels = radius / spacing + + if not np.isfinite(radius_in_pixels) or radius_in_pixels >= maximum_supported_offset: + maximum_offset = maximum_supported_offset + else: + maximum_offset = int(np.floor(radius_in_pixels)) + + if maximum_offset == 0: + return np.array([0.0]) + + offsets = np.arange( + -maximum_offset, + maximum_offset + 1, + dtype=float, + ) + distances = offsets * spacing + normalized_distance = distances / radius + squared_ratio = np.square(normalized_distance) + + root = np.sqrt( + np.maximum( + 1.0 - squared_ratio, + 0.0, + ) + ) + + # Algebraically equivalent to + # radius - sqrt(radius**2 - distance**2), but numerically stable when + # radius is much larger than the lateral pixel spacing. + sagitta = radius * squared_ratio / (1.0 + root) + + # SciPy grey erosion subtracts the structure and grey dilation adds it. + # A negative sagitta represents the upper arc of a circle rolling beneath + # the measured surface. + return -sagitta + + +def _opening_along_axis( + data: np.ndarray, + *, + radius: float, + spacing: float, + axis: int, + border: ArcBorder, +) -> np.ndarray: + """Apply one separable physical arc-opening stage.""" + sample_count = data.shape[axis] + structure_1d = _arc_structure( + radius=radius, + spacing=spacing, + sample_count=sample_count, + ) + + if structure_1d.size == 1: + return data.copy() + + structure = structure_1d[np.newaxis, :] if axis == 1 else structure_1d[:, np.newaxis] + + return np.asarray( + grey_opening( + data, + structure=structure, + mode=border, + ), + dtype=float, + ) + + +def _estimate_below_metres( + data_metres: np.ndarray, + channel: SPMChannel, + *, + radius: float, + direction: ArcDirection, + border: ArcBorder, + operation: str, +) -> np.ndarray: + """Estimate the sequential arc envelope below the surface.""" + x_spacing = _axis_spacing( + channel, + axis=1, + operation=operation, + ) + y_spacing = _axis_spacing( + channel, + axis=0, + operation=operation, + ) + + if direction == "horizontal": + return _opening_along_axis( + data_metres, + radius=radius, + spacing=x_spacing, + axis=1, + border=border, + ) + + if direction == "vertical": + return _opening_along_axis( + data_metres, + radius=radius, + spacing=y_spacing, + axis=0, + border=border, + ) + + horizontal = _opening_along_axis( + data_metres, + radius=radius, + spacing=x_spacing, + axis=1, + border=border, + ) + + return _opening_along_axis( + horizontal, + radius=radius, + spacing=y_spacing, + axis=0, + border=border, + ) + + +def estimate_arc_revolution_background( + channel: SPMChannel, + radius: float, + *, + direction: ArcDirection = "both", + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> SPMChannel: + """Estimate a physical arc-revolution background. + + Parameters + ---------- + channel: + Two-dimensional channel whose Z unit must be a geometric length. + radius: + Physical arc radius in metres. + direction: + ``"horizontal"`` processes rows, ``"vertical"`` processes columns, + and ``"both"`` applies horizontal followed by vertical. + side: + ``"below"`` rolls the arc beneath the surface. ``"above"`` is the + exact dual obtained by inversion. + border: + SciPy boundary mode. Only ``"nearest"`` and ``"reflect"`` are + supported. + + Notes + ----- + Non-finite data and masks are not supported. A radius smaller than the + relevant pixel spacing produces a one-element structure and therefore + leaves that stage unchanged. + """ + operation = "estimate_arc_revolution_background" + + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _positive_radius( + radius, + operation=operation, + ) + + direction_value = _validated_choice( + direction, + name="direction", + allowed=("horizontal", "vertical", "both"), + operation=operation, + ) + side_value = _validated_choice( + side, + name="side", + allowed=("below", "above"), + operation=operation, + ) + border_value = _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ) + + data_metres = length_values_to_metres( + data, + unit=channel.unit, + ) + + if side_value == "below": + background_metres = _estimate_below_metres( + data_metres, + channel, + radius=radius_value, + direction=direction_value, + border=border_value, + operation=operation, + ) + else: + background_metres = -_estimate_below_metres( + -data_metres, + channel, + radius=radius_value, + direction=direction_value, + border=border_value, + operation=operation, + ) + + background = length_values_from_metres( + background_metres, + unit=channel.unit, + ) + + return channel.with_data(background) + + +def remove_arc_revolution_background( + channel: SPMChannel, + radius: float, + *, + direction: ArcDirection = "both", + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> SPMChannel: + """Subtract the estimated arc-revolution background from a channel.""" + background = estimate_arc_revolution_background( + channel, + radius, + direction=direction, + side=side, + border=border, + ) + + corrected = np.asarray(channel.data, dtype=float) - np.asarray( + background.data, + dtype=float, + ) + + return channel.with_data(corrected) diff --git a/tests/core/test_arc_revolution_background.py b/tests/core/test_arc_revolution_background.py new file mode 100644 index 0000000..0603ef3 --- /dev/null +++ b/tests/core/test_arc_revolution_background.py @@ -0,0 +1,792 @@ +"""Tests for physical arc-revolution background estimation.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis.background import ( + _arc_structure, + estimate_arc_revolution_background, + remove_arc_revolution_background, +) +from spmkit.core.models import SPMChannel + + +def _channel( + data: np.ndarray, + *, + unit: str = "m", + x_range: float | None = None, + y_range: float | None = None, +) -> SPMChannel: + rows, columns = data.shape + + return SPMChannel( + name="Z-Axis", + data=np.asarray(data), + unit=unit, + x_range=float(columns) if x_range is None else x_range, + y_range=float(rows) if y_range is None else y_range, + direction="backward", + group="Synthetic", + metadata={"source": "arc-test"}, + ) + + +def _nearest_index(index: int, size: int) -> int: + return min(max(index, 0), size - 1) + + +def _brute_force_below_nearest( + profile: np.ndarray, + *, + radius: float, + spacing: float, +) -> np.ndarray: + """Independent one-dimensional rolling-circle oracle.""" + values = np.asarray(profile, dtype=float) + maximum_offset = min( + int(np.floor(radius / spacing)), + values.size - 1, + ) + + offsets = np.arange( + -maximum_offset, + maximum_offset + 1, + dtype=int, + ) + distances = offsets.astype(float) * spacing + sagitta = radius - np.sqrt(np.maximum(radius**2 - distances**2, 0.0)) + + eroded = np.empty_like(values) + + for center in range(values.size): + candidates = [ + values[_nearest_index(center + offset, values.size)] + sag + for offset, sag in zip(offsets, sagitta, strict=True) + ] + eroded[center] = min(candidates) + + opened = np.empty_like(values) + + for position in range(values.size): + candidates = [ + eroded[_nearest_index(position - offset, values.size)] - sag + for offset, sag in zip(offsets, sagitta, strict=True) + ] + opened[position] = max(candidates) + + return opened + + +def test_flat_surface_is_preserved() -> None: + data = np.full((5, 7), 3.25) + channel = _channel(data) + + background = estimate_arc_revolution_background( + channel, + radius=3.0, + ) + corrected = remove_arc_revolution_background( + channel, + radius=3.0, + ) + + assert np.allclose(background.data, data) + assert np.allclose(corrected.data, 0.0) + + +def test_horizontal_matches_independent_one_dimensional_oracle() -> None: + profile = np.array([0.0, 0.2, 1.8, 0.5, 0.1, 0.0]) + data = np.vstack([profile, profile + 2.0]) + channel = _channel( + data, + x_range=float(profile.size), + y_range=2.0, + ) + + result = estimate_arc_revolution_background( + channel, + radius=2.5, + direction="horizontal", + border="nearest", + ) + + expected_first = _brute_force_below_nearest( + profile, + radius=2.5, + spacing=1.0, + ) + + assert np.allclose(result.data[0], expected_first) + assert np.allclose(result.data[1], expected_first + 2.0) + + +def test_above_is_exact_inversion_dual() -> None: + data = np.array( + [ + [0.0, -0.2, -1.5, -0.3, 0.0], + [0.1, -0.1, -1.0, -0.2, 0.2], + ] + ) + channel = _channel(data) + inverted = channel.with_data(-data) + + above = estimate_arc_revolution_background( + channel, + radius=2.0, + direction="horizontal", + side="above", + ) + below_inverted = estimate_arc_revolution_background( + inverted, + radius=2.0, + direction="horizontal", + side="below", + ) + + assert np.allclose(above.data, -below_inverted.data) + + +def test_reconstruction_identity() -> None: + yy, xx = np.mgrid[0:7, 0:9] + data = 0.02 * xx + 0.03 * yy + 2.0 * np.exp(-((xx - 4) ** 2 + (yy - 3) ** 2) / 2.0) + channel = _channel( + data, + x_range=9e-6, + y_range=14e-6, + ) + + background = estimate_arc_revolution_background( + channel, + radius=4e-6, + direction="both", + ) + corrected = remove_arc_revolution_background( + channel, + radius=4e-6, + direction="both", + ) + + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-12, + atol=1e-12, + ) + + +def test_radius_smaller_than_pixel_spacing_is_identity() -> None: + data = np.arange(12.0).reshape(3, 4) + channel = _channel( + data, + x_range=4.0, + y_range=3.0, + ) + + background = estimate_arc_revolution_background( + channel, + radius=0.5, + direction="both", + ) + + assert np.array_equal(background.data, data) + + +@pytest.mark.parametrize("radius", [0.0, -1.0, np.nan, np.inf]) +def test_invalid_radius_is_rejected(radius: float) -> None: + channel = _channel(np.ones((2, 2))) + + with pytest.raises((TypeError, ValueError)): + estimate_arc_revolution_background( + channel, + radius=radius, + ) + + +def test_vertical_matches_independent_one_dimensional_oracle() -> None: + profile = np.array([0.0, 0.3, 1.7, 0.4, 0.1, 0.0]) + data = np.column_stack((profile, profile + 1.5)) + channel = _channel( + data, + x_range=2.0, + y_range=float(profile.size), + ) + + result = estimate_arc_revolution_background( + channel, + radius=2.5, + direction="vertical", + border="nearest", + ) + + expected_first = _brute_force_below_nearest( + profile, + radius=2.5, + spacing=1.0, + ) + + assert np.allclose(result.data[:, 0], expected_first) + assert np.allclose(result.data[:, 1], expected_first + 1.5) + + +def test_both_is_horizontal_followed_by_vertical() -> None: + data = np.array( + [ + [0.0, 0.2, 0.0, 0.1, 0.0], + [0.3, 1.0, 2.5, 0.8, 0.2], + [0.0, 0.5, 4.0, 0.4, 0.0], + [0.2, 0.9, 2.0, 0.7, 0.1], + [0.0, 0.1, 0.0, 0.2, 0.0], + ] + ) + channel = _channel( + data, + x_range=5.0, + y_range=7.5, + ) + + horizontal = estimate_arc_revolution_background( + channel, + radius=2.5, + direction="horizontal", + ) + sequential = estimate_arc_revolution_background( + horizontal, + radius=2.5, + direction="vertical", + ) + combined = estimate_arc_revolution_background( + channel, + radius=2.5, + direction="both", + ) + + assert np.allclose(combined.data, sequential.data) + + +def test_positive_protrusion_is_retained_in_residual() -> None: + yy, xx = np.mgrid[0:9, 0:11] + data = 0.05 * xx + 0.03 * yy + data = data + 3.0 * np.exp(-((xx - 5) ** 2 + (yy - 4) ** 2) / 1.5) + channel = _channel( + data, + x_range=11.0, + y_range=9.0, + ) + + background = estimate_arc_revolution_background( + channel, + radius=3.0, + direction="both", + side="below", + ) + corrected = remove_arc_revolution_background( + channel, + radius=3.0, + direction="both", + side="below", + ) + + assert np.all(background.data <= data + 1e-12) + assert corrected.data[4, 5] > corrected.data[0, 0] + assert np.allclose(corrected.data + background.data, data) + + +def test_above_background_does_not_fall_below_surface() -> None: + yy, xx = np.mgrid[0:7, 0:9] + data = -2.0 * np.exp(-((xx - 4) ** 2 + (yy - 3) ** 2) / 1.5) + channel = _channel( + data, + x_range=9.0, + y_range=7.0, + ) + + background = estimate_arc_revolution_background( + channel, + radius=3.0, + side="above", + ) + corrected = remove_arc_revolution_background( + channel, + radius=3.0, + side="above", + ) + + assert np.all(background.data >= data - 1e-12) + assert np.allclose(corrected.data + background.data, data) + + +def test_anisotropic_pixel_spacing_changes_axis_response() -> None: + data = np.zeros((5, 5)) + data[2, 2] = 4.0 + channel = _channel( + data, + x_range=5.0, + y_range=10.0, + ) + + horizontal = estimate_arc_revolution_background( + channel, + radius=1.5, + direction="horizontal", + ) + vertical = estimate_arc_revolution_background( + channel, + radius=1.5, + direction="vertical", + ) + + # dx = 1 while dy = 2. The horizontal structure spans neighbours, + # whereas the vertical structure contains only its central sample. + assert horizontal.data[2, 2] < data[2, 2] + assert np.array_equal(vertical.data, data) + + +def test_lateral_range_controls_discrete_physical_structure() -> None: + data = np.array([[0.0, 0.0, 4.0, 0.0, 0.0]]) + + fine = _channel( + data, + x_range=5.0, + y_range=1.0, + ) + coarse = _channel( + data, + x_range=10.0, + y_range=1.0, + ) + + fine_background = estimate_arc_revolution_background( + fine, + radius=1.5, + direction="horizontal", + ) + coarse_background = estimate_arc_revolution_background( + coarse, + radius=1.5, + direction="horizontal", + ) + + assert fine_background.data[0, 2] < data[0, 2] + assert np.array_equal(coarse_background.data, data) + + +def test_equivalent_metres_and_nanometres_agree_physically() -> None: + data_metres = ( + np.array( + [ + [0.0, 0.2, 1.5, 0.2, 0.0], + [0.1, 0.4, 2.0, 0.3, 0.1], + [0.0, 0.2, 1.2, 0.2, 0.0], + ] + ) + * 1e-9 + ) + + channel_metres = _channel( + data_metres, + unit="m", + x_range=5e-6, + y_range=3e-6, + ) + channel_nanometres = _channel( + data_metres * 1e9, + unit="nm", + x_range=5e-6, + y_range=3e-6, + ) + + background_metres = estimate_arc_revolution_background( + channel_metres, + radius=2.5e-6, + ) + background_nanometres = estimate_arc_revolution_background( + channel_nanometres, + radius=2.5e-6, + ) + + assert np.allclose( + background_metres.data, + background_nanometres.data * 1e-9, + rtol=1e-12, + atol=1e-18, + ) + + +@pytest.mark.parametrize("border", ["nearest", "reflect"]) +def test_supported_borders_preserve_reconstruction(border: str) -> None: + data = np.array( + [ + [0.0, 0.5, 2.0, 0.2], + [0.1, 1.0, 3.0, 0.4], + [0.0, 0.3, 1.5, 0.1], + ] + ) + channel = _channel(data) + + background = estimate_arc_revolution_background( + channel, + radius=2.0, + border=border, + ) + corrected = remove_arc_revolution_background( + channel, + radius=2.0, + border=border, + ) + + assert np.all(np.isfinite(background.data)) + assert np.allclose(corrected.data + background.data, data) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[7.0]]), + np.array([[0.0, 1.0, 3.0, 1.0, 0.0]]), + np.array([[0.0], [1.0], [3.0], [1.0], [0.0]]), + ], + ids=["one-by-one", "one-by-n", "n-by-one"], +) +def test_degenerate_dimensions_are_defined(data: np.ndarray) -> None: + channel = _channel(data) + + background = estimate_arc_revolution_background( + channel, + radius=2.0, + ) + corrected = remove_arc_revolution_background( + channel, + radius=2.0, + ) + + assert background.shape == data.shape + assert corrected.shape == data.shape + assert np.all(np.isfinite(background.data)) + assert np.allclose(corrected.data + background.data, data) + + if data.shape == (1, 1): + assert np.array_equal(background.data, data) + assert np.array_equal(corrected.data, np.zeros_like(data)) + + +def test_radius_larger_than_domain_is_supported() -> None: + data = np.array( + [ + [0.0, 1.0, 3.0, 1.0], + [0.2, 1.5, 4.0, 0.5], + [0.0, 0.8, 2.0, 0.0], + ] + ) + channel = _channel( + data, + x_range=4.0, + y_range=3.0, + ) + + background = estimate_arc_revolution_background( + channel, + radius=100.0, + border="reflect", + ) + corrected = remove_arc_revolution_background( + channel, + radius=100.0, + border="reflect", + ) + + assert background.shape == data.shape + assert np.all(np.isfinite(background.data)) + assert np.allclose(corrected.data + background.data, data) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("direction", "diagonal"), + ("side", "inside"), + ("border", "constant"), + ], +) +def test_invalid_public_options_are_rejected( + parameter: str, + value: str, +) -> None: + channel = _channel(np.ones((2, 3))) + kwargs = {parameter: value} + + with pytest.raises(ValueError): + estimate_arc_revolution_background( + channel, + radius=1.0, + **kwargs, + ) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[0.0, np.nan], [1.0, 2.0]]), + np.array([[0.0, np.inf], [1.0, 2.0]]), + ], + ids=["nan", "infinity"], +) +def test_nonfinite_data_are_rejected(data: np.ndarray) -> None: + channel = _channel(data) + + with pytest.raises( + ValueError, + match="requires finite data", + ): + estimate_arc_revolution_background( + channel, + radius=1.0, + ) + + +def test_non_geometric_z_unit_is_rejected() -> None: + channel = _channel( + np.ones((3, 4)), + unit="V", + ) + + with pytest.raises( + ValueError, + match="unsupported geometric length unit", + ): + estimate_arc_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + "radius", + [ + True, + "1.0", + [1.0], + 1.0 + 2.0j, + ], + ids=["boolean", "string", "array", "complex"], +) +def test_non_real_scalar_radius_is_rejected(radius: object) -> None: + channel = _channel(np.ones((2, 2))) + + with pytest.raises(TypeError): + estimate_arc_revolution_background( + channel, + radius=radius, + ) + + +def test_input_is_not_mutated_and_context_is_preserved() -> None: + data = np.array( + [ + [0.0, 0.5, 2.0], + [0.2, 1.0, 3.0], + ] + ) + channel = _channel( + data, + unit="nm", + x_range=3e-6, + y_range=2e-6, + ) + original_data = channel.data.copy() + original_metadata = dict(channel.metadata) + + background = estimate_arc_revolution_background( + channel, + radius=2e-6, + ) + corrected = remove_arc_revolution_background( + channel, + radius=2e-6, + ) + + assert np.array_equal(channel.data, original_data) + assert channel.metadata == original_metadata + + for result in (background, corrected): + assert result is not channel + assert result.data is not channel.data + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize( + ("data", "error_type", "message"), + [ + ( + np.array([0.0, 1.0, 2.0]), + ValueError, + "requires a 2D channel", + ), + ( + np.empty((0, 3)), + ValueError, + "requires non-empty data", + ), + ( + np.array([["a", "b"], ["c", "d"]]), + TypeError, + "requires real numeric data", + ), + ( + np.array( + [ + [1.0 + 0.0j, 2.0 + 1.0j], + [3.0 + 0.0j, 4.0 + 0.0j], + ] + ), + TypeError, + "requires real numeric data", + ), + ], + ids=[ + "one-dimensional", + "empty", + "non-numeric", + "complex", + ], +) +def test_invalid_channel_data_are_rejected( + data: np.ndarray, + error_type: type[Exception], + message: str, +) -> None: + channel = SPMChannel( + name="Z-Axis", + data=data, + unit="m", + x_range=2.0, + y_range=2.0, + ) + + with pytest.raises(error_type, match=message): + estimate_arc_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + ("x_range", "y_range", "message"), + [ + (0.0, 2.0, "positive lateral pixel spacing"), + (-1.0, 2.0, "positive lateral pixel spacing"), + (np.inf, 2.0, "finite lateral pixel spacing"), + (2.0, 0.0, "positive lateral pixel spacing"), + (2.0, -1.0, "positive lateral pixel spacing"), + (2.0, np.inf, "finite lateral pixel spacing"), + ], +) +def test_invalid_lateral_geometry_is_rejected( + x_range: float, + y_range: float, + message: str, +) -> None: + channel = _channel( + np.ones((2, 3)), + x_range=x_range, + y_range=y_range, + ) + + with pytest.raises(ValueError, match=message): + estimate_arc_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + "direction", + ["horizontal", "vertical", "both"], +) +@pytest.mark.parametrize( + "side", + ["below", "above"], +) +def test_reconstruction_identity_for_every_mode( + direction: str, + side: str, +) -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.4, 3.0, 0.4], + [0.1, 0.5, 1.8, 0.2], + ] + ) + channel = _channel( + data, + x_range=4.0, + y_range=3.0, + ) + + background = estimate_arc_revolution_background( + channel, + radius=2.0, + direction=direction, + side=side, + ) + corrected = remove_arc_revolution_background( + channel, + radius=2.0, + direction=direction, + side=side, + ) + + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-13, + atol=1e-13, + ) + + +def test_functions_are_available_from_public_analysis_api() -> None: + from spmkit.core.analysis import ( + estimate_arc_revolution_background as public_estimate, + ) + from spmkit.core.analysis import ( + remove_arc_revolution_background as public_remove, + ) + + assert public_estimate is estimate_arc_revolution_background + assert public_remove is remove_arc_revolution_background + + +def test_arc_structure_preserves_small_sagitta_for_large_radius() -> None: + structure = _arc_structure( + radius=1e12, + spacing=1.0, + sample_count=3, + ) + + expected = np.array( + [ + -2e-12, + -5e-13, + 0.0, + -5e-13, + -2e-12, + ] + ) + + assert structure.shape == (5,) + assert np.allclose( + structure, + expected, + rtol=1e-12, + atol=0.0, + ) From d718ecff56be3bbefe375a8ddfb473ebd239c74f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:17:32 -0400 Subject: [PATCH 24/82] fix(background): harden arc-revolution option validation --- docs/scientific-status.md | 2 +- src/spmkit/core/analysis/background.py | 5 +- tests/core/test_arc_revolution_background.py | 109 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/docs/scientific-status.md b/docs/scientific-status.md index adb139d..e405de1 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -35,7 +35,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Sa, Sq, Sz on public experimental GWY matrices | `core.analysis.roughness` | 12 cases, 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the shared-matrix algorithm track | Gwyddion 2.71 | Parser/end-to-end observations are separate: 10 equivalences and 2 preserved channel-count differences | | Limited Nanoscope III `.spm` images | `core.io.bruker_spm` | Six demonstrated files; 18/18 Sa/Sq/Sz comparisons within tolerance and zero reported pixel delta | NUMERICALLY_VERIFIED | Gwyddion 2.71 | `ACCIDENTAL_PRE_FREEZE_UNBLINDING`; partial variants only, no blind holdout or general Bruker support | | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | -| Physical arc-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | +| Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 9e7d7a6..a394637 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -75,13 +75,16 @@ def _positive_radius( def _validated_choice( - value: str, + value: object, *, name: str, allowed: tuple[str, ...], operation: str, ) -> str: """Validate a string-valued public option.""" + if not isinstance(value, str): + raise TypeError(f"{operation} requires {name} to be a string") + if value not in allowed: choices = ", ".join(repr(item) for item in allowed) raise ValueError(f"{operation} {name} must be one of {choices}") diff --git a/tests/core/test_arc_revolution_background.py b/tests/core/test_arc_revolution_background.py index 0603ef3..625b8c1 100644 --- a/tests/core/test_arc_revolution_background.py +++ b/tests/core/test_arc_revolution_background.py @@ -38,6 +38,17 @@ def _nearest_index(index: int, size: int) -> int: return min(max(index, 0), size - 1) +def _reflect_index(index: int, size: int) -> int: + """Map an integer index using SciPy's half-sample reflect convention.""" + period = 2 * size + position = index % period + + if position < size: + return position + + return period - 1 - position + + def _brute_force_below_nearest( profile: np.ndarray, *, @@ -80,6 +91,50 @@ def _brute_force_below_nearest( return opened +def _brute_force_below_reflect( + profile: np.ndarray, + *, + radius: float, + spacing: float, +) -> np.ndarray: + """Independent rolling-circle oracle with reflected boundaries.""" + values = np.asarray(profile, dtype=float) + maximum_offset = min( + int(np.floor(radius / spacing)), + values.size - 1, + ) + + offsets = np.arange( + -maximum_offset, + maximum_offset + 1, + dtype=int, + ) + distances = offsets.astype(float) * spacing + ratio = distances / radius + squared_ratio = np.square(ratio) + sagitta = radius * squared_ratio / (1.0 + np.sqrt(np.maximum(1.0 - squared_ratio, 0.0))) + + eroded = np.empty_like(values) + + for center in range(values.size): + candidates = [ + values[_reflect_index(center + offset, values.size)] + sag + for offset, sag in zip(offsets, sagitta, strict=True) + ] + eroded[center] = min(candidates) + + opened = np.empty_like(values) + + for position in range(values.size): + candidates = [ + eroded[_reflect_index(position - offset, values.size)] - sag + for offset, sag in zip(offsets, sagitta, strict=True) + ] + opened[position] = max(candidates) + + return opened + + def test_flat_surface_is_preserved() -> None: data = np.full((5, 7), 3.25) channel = _channel(data) @@ -790,3 +845,57 @@ def test_arc_structure_preserves_small_sagitta_for_large_radius() -> None: rtol=1e-12, atol=0.0, ) + + +def test_horizontal_reflect_matches_independent_oracle() -> None: + profile = np.array([1.5, 0.1, 0.4, 2.5, 0.3, 1.2]) + channel = _channel( + profile[np.newaxis, :], + x_range=float(profile.size), + y_range=1.0, + ) + + result = estimate_arc_revolution_background( + channel, + radius=2.5, + direction="horizontal", + border="reflect", + ) + expected = _brute_force_below_reflect( + profile, + radius=2.5, + spacing=1.0, + ) + + assert np.allclose( + result.data[0], + expected, + rtol=1e-13, + atol=1e-13, + ) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("direction", None), + ("side", 1), + ("border", ["nearest"]), + ], +) +def test_non_string_public_options_are_rejected( + parameter: str, + value: object, +) -> None: + channel = _channel(np.ones((2, 3))) + kwargs = {parameter: value} + + with pytest.raises( + TypeError, + match=rf"requires {parameter} to be a string", + ): + estimate_arc_revolution_background( + channel, + radius=1.0, + **kwargs, + ) From d0825e07a5264cfcf421c314cfcbf6e6edc5f81d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:40:50 -0400 Subject: [PATCH 25/82] feat(background): add physical sphere-revolution leveling --- docs/api.md | 45 + docs/scientific-status.md | 1 + docs/theory/spmkit-workflows.md | 1 + src/spmkit/core/analysis/__init__.py | 4 + src/spmkit/core/analysis/background.py | 218 +++++ .../core/test_sphere_revolution_background.py | 878 ++++++++++++++++++ 6 files changed, 1147 insertions(+) create mode 100644 tests/core/test_sphere_revolution_background.py diff --git a/docs/api.md b/docs/api.md index a8d83da..8091b5d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -133,6 +133,51 @@ This implementation is LEVEL 1 — SOFTWARE_VERIFIED through synthetic tests and an independent test-local one-dimensional oracle. Numerical equivalence with Gwyddion has not been established. +## Sphere-revolution background + +Sphere Revolution uses a true two-dimensional spherical cap in physical XY +coordinates: + +```python +from spmkit.core.analysis import ( + estimate_sphere_revolution_background, + remove_sphere_revolution_background, +) + +background = estimate_sphere_revolution_background( + height, + radius=2e-6, + side="below", + border="nearest", +) + +corrected = remove_sphere_revolution_background( + height, + radius=2e-6, + side="below", + border="nearest", +) +``` + +`radius` is expressed in metres. Geometric Z values are converted internally +to metres and returned in the channel's original unit. + +The spherical footprint is circular in physical coordinates. With anisotropic +pixel spacing it can therefore appear elliptical in array-index coordinates. +This operation is genuinely two-dimensional and is not equivalent to applying +horizontal and vertical arc openings sequentially. + +`side="above"` is the exact inversion dual of `"below"`. The supported border +policies are `"nearest"` and `"reflect"`. Finite data are required; masks, CLI +and Fathom exposure are not available. + +The background remains separately inspectable and satisfies +`corrected + background == original` within floating-point tolerance. + +This implementation is LEVEL 1 — SOFTWARE_VERIFIED through synthetic tests +and independent test-local two-dimensional oracles for both supported border +policies. Numerical equivalence with Gwyddion has not been established. + ## KPFM statistics ```python diff --git a/docs/scientific-status.md b/docs/scientific-status.md index e405de1..a25f010 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -36,6 +36,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Limited Nanoscope III `.spm` images | `core.io.bruker_spm` | Six demonstrated files; 18/18 Sa/Sq/Sz comparisons within tolerance and zero reported pixel delta | NUMERICALLY_VERIFIED | Gwyddion 2.71 | `ACCIDENTAL_PRE_FREEZE_UNBLINDING`; partial variants only, no blind holdout or general Bruker support | | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | +| Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | diff --git a/docs/theory/spmkit-workflows.md b/docs/theory/spmkit-workflows.md index 1c452ec..c074228 100644 --- a/docs/theory/spmkit-workflows.md +++ b/docs/theory/spmkit-workflows.md @@ -12,6 +12,7 @@ | file inspection/routing | `core.io.load_any`, `core.plugins`, built-in readers | automatic open route | `spmkit info` | format-specific Level 1/2 | support is variant-specific | | leveling | `core.analysis.leveling` | Imagen (`image`) | `roughness --level`, `analyze --level` | Level 1 + synthetic cases | changes the reference surface | | arc-revolution background | `core.analysis.background` | not exposed | Python API | Level 1 with test-local 1D oracle and synthetic physical tests | finite geometric Z only; no masks, CLI, Fathom or external-equivalence claim | +| sphere-revolution background | `core.analysis.background` | not exposed | Python API | Level 1 with independent 2D nearest/reflect oracles and synthetic physical tests | true 2D spherical cap; no masks, CLI, Fathom, external-equivalence or performance claim | | Sa/Sq/Sz/Ssk/Sku | `core.analysis.roughness.statistics` | Imagen (`image`) | `spmkit roughness`, `analyze` | scoped Level 3 for Sa/Sq/Sz | external campaigns do not cover Ssk/Sku or every preprocessing route | | line profile | `core.analysis.profiles.line` | Imagen (`image`) | Python API | Level 1 | interpolation and coordinate choice matter | | grain segmentation | `core.analysis.grains.detect` | Granos (`grains`) | `spmkit grains` | Level 1 + synthetic tests | threshold/overlap/tip effects | diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index c116882..f9dcf63 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -17,7 +17,9 @@ ) from spmkit.core.analysis.background import ( estimate_arc_revolution_background, + estimate_sphere_revolution_background, remove_arc_revolution_background, + remove_sphere_revolution_background, ) from spmkit.core.analysis.forcecurve import ForceCurveFit from spmkit.core.analysis.forcevolume import VolumeResult, analyze_volume @@ -43,7 +45,9 @@ __all__ = [ "background", "estimate_arc_revolution_background", + "estimate_sphere_revolution_background", "remove_arc_revolution_background", + "remove_sphere_revolution_background", "calibration", "leveling", "roughness", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index a394637..22b38f9 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -372,3 +372,221 @@ def remove_arc_revolution_background( ) return channel.with_data(corrected) + + +def _sphere_structure( + *, + radius: float, + x_spacing: float, + y_spacing: float, + shape: tuple[int, int], +) -> tuple[np.ndarray, np.ndarray]: + """Return a physical spherical-cap structure and its circular footprint.""" + rows, columns = shape + + maximum_x_offset = columns - 1 + radius_in_x_pixels = radius / x_spacing + + if not np.isfinite(radius_in_x_pixels) or radius_in_x_pixels >= maximum_x_offset: + x_offset = maximum_x_offset + else: + x_offset = int(np.floor(radius_in_x_pixels)) + + maximum_y_offset = rows - 1 + radius_in_y_pixels = radius / y_spacing + + if not np.isfinite(radius_in_y_pixels) or radius_in_y_pixels >= maximum_y_offset: + y_offset = maximum_y_offset + else: + y_offset = int(np.floor(radius_in_y_pixels)) + + x_offsets = np.arange( + -x_offset, + x_offset + 1, + dtype=float, + ) + y_offsets = np.arange( + -y_offset, + y_offset + 1, + dtype=float, + ) + + normalized_x = x_offsets * x_spacing / radius + normalized_y = y_offsets * y_spacing / radius + + squared_ratio = np.square(normalized_y)[:, np.newaxis] + np.square(normalized_x)[np.newaxis, :] + + tolerance = 8.0 * np.finfo(float).eps + footprint = squared_ratio <= 1.0 + tolerance + + clipped_ratio = np.minimum( + squared_ratio, + 1.0, + ) + root = np.sqrt( + np.maximum( + 1.0 - clipped_ratio, + 0.0, + ) + ) + + # Stable form of radius - sqrt(radius**2 - distance**2). + sagitta = radius * clipped_ratio / (1.0 + root) + + # Values outside the footprint are ignored by SciPy. Keeping them at zero + # prevents irrelevant invalid or extreme structure values. + structure = np.where( + footprint, + -sagitta, + 0.0, + ) + + return structure, footprint + + +def _opening_with_sphere( + data: np.ndarray, + *, + radius: float, + x_spacing: float, + y_spacing: float, + border: ArcBorder, +) -> np.ndarray: + """Apply one physical two-dimensional spherical opening.""" + structure, footprint = _sphere_structure( + radius=radius, + x_spacing=x_spacing, + y_spacing=y_spacing, + shape=data.shape, + ) + + if footprint.size == 1: + return data.copy() + + return np.asarray( + grey_opening( + data, + footprint=footprint, + structure=structure, + mode=border, + ), + dtype=float, + ) + + +def _estimate_sphere_below_metres( + data_metres: np.ndarray, + channel: SPMChannel, + *, + radius: float, + border: ArcBorder, + operation: str, +) -> np.ndarray: + """Estimate the spherical envelope rolling below a surface.""" + x_spacing = _axis_spacing( + channel, + axis=1, + operation=operation, + ) + y_spacing = _axis_spacing( + channel, + axis=0, + operation=operation, + ) + + return _opening_with_sphere( + data_metres, + radius=radius, + x_spacing=x_spacing, + y_spacing=y_spacing, + border=border, + ) + + +def estimate_sphere_revolution_background( + channel: SPMChannel, + radius: float, + *, + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> SPMChannel: + """Estimate a physical spherical-revolution background. + + The structuring surface is a true spherical cap in physical XY + coordinates. With anisotropic pixels its footprint can therefore appear + elliptical in index space while remaining circular in physical space. + """ + operation = "estimate_sphere_revolution_background" + + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _positive_radius( + radius, + operation=operation, + ) + side_value = _validated_choice( + side, + name="side", + allowed=("below", "above"), + operation=operation, + ) + border_value = _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ) + + data_metres = length_values_to_metres( + data, + unit=channel.unit, + ) + + if side_value == "below": + background_metres = _estimate_sphere_below_metres( + data_metres, + channel, + radius=radius_value, + border=border_value, + operation=operation, + ) + else: + background_metres = -_estimate_sphere_below_metres( + -data_metres, + channel, + radius=radius_value, + border=border_value, + operation=operation, + ) + + background = length_values_from_metres( + background_metres, + unit=channel.unit, + ) + + return channel.with_data(background) + + +def remove_sphere_revolution_background( + channel: SPMChannel, + radius: float, + *, + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> SPMChannel: + """Subtract the estimated spherical-revolution background.""" + background = estimate_sphere_revolution_background( + channel, + radius, + side=side, + border=border, + ) + + corrected = np.asarray(channel.data, dtype=float) - np.asarray( + background.data, + dtype=float, + ) + + return channel.with_data(corrected) diff --git a/tests/core/test_sphere_revolution_background.py b/tests/core/test_sphere_revolution_background.py new file mode 100644 index 0000000..103ba78 --- /dev/null +++ b/tests/core/test_sphere_revolution_background.py @@ -0,0 +1,878 @@ +"""Tests for physical sphere-revolution background estimation.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis.background import ( + _sphere_structure, + estimate_arc_revolution_background, + estimate_sphere_revolution_background, + remove_sphere_revolution_background, +) +from spmkit.core.models import SPMChannel + + +def _channel( + data: np.ndarray, + *, + unit: str = "m", + x_range: float | None = None, + y_range: float | None = None, +) -> SPMChannel: + rows, columns = data.shape + + return SPMChannel( + name="Z-Axis", + data=np.asarray(data), + unit=unit, + x_range=float(columns) if x_range is None else x_range, + y_range=float(rows) if y_range is None else y_range, + direction="backward", + group="Synthetic", + metadata={"source": "sphere-test"}, + ) + + +def _nearest_index(index: int, size: int) -> int: + return min(max(index, 0), size - 1) + + +def _reflect_index(index: int, size: int) -> int: + """Map an integer index using SciPy's half-sample reflection.""" + period = 2 * size + position = index % period + + if position < size: + return position + + return period - 1 - position + + +def _brute_force_sphere_below_nearest( + data: np.ndarray, + *, + radius: float, + x_spacing: float, + y_spacing: float, +) -> np.ndarray: + """Independent two-dimensional spherical-opening oracle.""" + values = np.asarray(data, dtype=float) + rows, columns = values.shape + + maximum_x_offset = min( + int(np.floor(radius / x_spacing)), + columns - 1, + ) + maximum_y_offset = min( + int(np.floor(radius / y_spacing)), + rows - 1, + ) + + offsets: list[tuple[int, int, float]] = [] + + for y_offset in range( + -maximum_y_offset, + maximum_y_offset + 1, + ): + for x_offset in range( + -maximum_x_offset, + maximum_x_offset + 1, + ): + normalized_x = x_offset * x_spacing / radius + normalized_y = y_offset * y_spacing / radius + squared_ratio = normalized_x**2 + normalized_y**2 + + if squared_ratio > 1.0 + 8.0 * np.finfo(float).eps: + continue + + clipped_ratio = min(squared_ratio, 1.0) + root = np.sqrt(max(1.0 - clipped_ratio, 0.0)) + sagitta = radius * clipped_ratio / (1.0 + root) + + offsets.append( + ( + y_offset, + x_offset, + sagitta, + ) + ) + + eroded = np.empty_like(values) + + for row in range(rows): + for column in range(columns): + candidates = [ + values[ + _nearest_index(row + y_offset, rows), + _nearest_index(column + x_offset, columns), + ] + + sagitta + for y_offset, x_offset, sagitta in offsets + ] + eroded[row, column] = min(candidates) + + opened = np.empty_like(values) + + for row in range(rows): + for column in range(columns): + candidates = [ + eroded[ + _nearest_index(row - y_offset, rows), + _nearest_index(column - x_offset, columns), + ] + - sagitta + for y_offset, x_offset, sagitta in offsets + ] + opened[row, column] = max(candidates) + + return opened + + +def _brute_force_sphere_below_reflect( + data: np.ndarray, + *, + radius: float, + x_spacing: float, + y_spacing: float, +) -> np.ndarray: + """Independent spherical-opening oracle with reflected boundaries.""" + values = np.asarray(data, dtype=float) + rows, columns = values.shape + + maximum_x_offset = min( + int(np.floor(radius / x_spacing)), + columns - 1, + ) + maximum_y_offset = min( + int(np.floor(radius / y_spacing)), + rows - 1, + ) + + offsets: list[tuple[int, int, float]] = [] + + for y_offset in range( + -maximum_y_offset, + maximum_y_offset + 1, + ): + for x_offset in range( + -maximum_x_offset, + maximum_x_offset + 1, + ): + normalized_x = x_offset * x_spacing / radius + normalized_y = y_offset * y_spacing / radius + squared_ratio = normalized_x**2 + normalized_y**2 + + if squared_ratio > 1.0 + 8.0 * np.finfo(float).eps: + continue + + clipped_ratio = min(squared_ratio, 1.0) + root = np.sqrt(max(1.0 - clipped_ratio, 0.0)) + sagitta = radius * clipped_ratio / (1.0 + root) + + offsets.append( + ( + y_offset, + x_offset, + sagitta, + ) + ) + + eroded = np.empty_like(values) + + for row in range(rows): + for column in range(columns): + candidates = [ + values[ + _reflect_index(row + y_offset, rows), + _reflect_index(column + x_offset, columns), + ] + + sagitta + for y_offset, x_offset, sagitta in offsets + ] + eroded[row, column] = min(candidates) + + opened = np.empty_like(values) + + for row in range(rows): + for column in range(columns): + candidates = [ + eroded[ + _reflect_index(row - y_offset, rows), + _reflect_index(column - x_offset, columns), + ] + - sagitta + for y_offset, x_offset, sagitta in offsets + ] + opened[row, column] = max(candidates) + + return opened + + +def test_sphere_structure_uses_circular_physical_footprint() -> None: + structure, footprint = _sphere_structure( + radius=1.1, + x_spacing=1.0, + y_spacing=1.0, + shape=(5, 5), + ) + + expected_footprint = np.array( + [ + [False, True, False], + [True, True, True], + [False, True, False], + ] + ) + + assert structure.shape == (3, 3) + assert np.array_equal(footprint, expected_footprint) + assert structure[1, 1] == 0.0 + assert np.all(structure[footprint] <= 0.0) + + +def test_flat_surface_is_preserved() -> None: + data = np.full((5, 7), 3.25) + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=2.0, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=2.0, + ) + + assert np.allclose(background.data, data) + assert np.allclose(corrected.data, 0.0) + + +def test_nearest_matches_independent_two_dimensional_oracle() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + channel = _channel( + data, + x_range=4.0, + y_range=3.0, + ) + + result = estimate_sphere_revolution_background( + channel, + radius=1.5, + border="nearest", + ) + expected = _brute_force_sphere_below_nearest( + data, + radius=1.5, + x_spacing=1.0, + y_spacing=1.0, + ) + + assert np.allclose( + result.data, + expected, + rtol=1e-13, + atol=1e-13, + ) + + +def test_above_is_exact_inversion_dual() -> None: + data = np.array( + [ + [0.0, -0.2, -1.0, -0.1], + [-0.3, -1.5, -3.0, -0.4], + [-0.1, -0.6, -1.8, -0.2], + ] + ) + channel = _channel(data) + inverted = channel.with_data(-data) + + above = estimate_sphere_revolution_background( + channel, + radius=1.5, + side="above", + ) + below_inverted = estimate_sphere_revolution_background( + inverted, + radius=1.5, + side="below", + ) + + assert np.allclose( + above.data, + -below_inverted.data, + ) + + +def test_reconstruction_identity() -> None: + yy, xx = np.mgrid[0:7, 0:9] + data = 0.02 * xx + 0.03 * yy + 2.0 * np.exp(-((xx - 4) ** 2 + (yy - 3) ** 2) / 2.0) + channel = _channel( + data, + x_range=9e-6, + y_range=14e-6, + ) + + background = estimate_sphere_revolution_background( + channel, + radius=4e-6, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=4e-6, + ) + + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-12, + atol=1e-12, + ) + + +def test_sphere_structure_respects_anisotropic_physical_spacing() -> None: + structure, footprint = _sphere_structure( + radius=2.1, + x_spacing=1.0, + y_spacing=2.0, + shape=(5, 5), + ) + + expected_footprint = np.array( + [ + [False, False, True, False, False], + [True, True, True, True, True], + [False, False, True, False, False], + ] + ) + + assert structure.shape == (3, 5) + assert np.array_equal(footprint, expected_footprint) + + # ±2 pixels in X and ±1 pixel in Y are both physical distances of 2. + assert footprint[1, 0] + assert footprint[0, 2] + + # A diagonal offset of (1 px X, 1 px Y) has physical distance sqrt(5), + # which lies outside a sphere of radius 2.1. + assert not footprint[0, 1] + + +def test_sphere_is_not_separable_arc_revolution() -> None: + data = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 1.0, 2.0], + ] + ) + channel = _channel( + data, + x_range=3.0, + y_range=3.0, + ) + + sphere = estimate_sphere_revolution_background( + channel, + radius=1.1, + border="nearest", + ) + separable_arc = estimate_arc_revolution_background( + channel, + radius=1.1, + direction="both", + border="nearest", + ) + + assert not np.allclose( + sphere.data, + separable_arc.data, + ) + + +def test_reflect_matches_independent_two_dimensional_oracle() -> None: + data = np.array( + [ + [3.0, 0.2, 0.1, 2.0], + [0.4, 1.5, 0.3, 0.0], + [2.0, 0.6, 1.8, 4.0], + ] + ) + channel = _channel( + data, + x_range=4.0, + y_range=3.0, + ) + + result = estimate_sphere_revolution_background( + channel, + radius=1.5, + border="reflect", + ) + expected = _brute_force_sphere_below_reflect( + data, + radius=1.5, + x_spacing=1.0, + y_spacing=1.0, + ) + + assert np.allclose( + result.data, + expected, + rtol=1e-13, + atol=1e-13, + ) + + +def test_radius_smaller_than_both_pixel_spacings_is_identity() -> None: + data = np.array( + [ + [0.2, 1.0, 0.4], + [2.0, 0.1, 1.5], + ] + ) + channel = _channel( + data, + x_range=3.0, + y_range=2.0, + ) + + background = estimate_sphere_revolution_background( + channel, + radius=0.5, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=0.5, + ) + + assert np.array_equal(background.data, data) + assert np.array_equal(corrected.data, np.zeros_like(data)) + + +def test_radius_larger_than_domain_is_supported() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=1e12, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=1e12, + ) + + assert background.data.shape == data.shape + assert np.all(np.isfinite(background.data)) + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-13, + atol=1e-13, + ) + + +def test_sphere_structure_preserves_small_sagitta_for_large_radius() -> None: + structure, footprint = _sphere_structure( + radius=1e12, + x_spacing=1.0, + y_spacing=1.0, + shape=(3, 3), + ) + + assert structure.shape == (5, 5) + assert np.all(footprint) + assert structure[2, 2] == 0.0 + assert structure[2, 3] == pytest.approx(-5e-13, rel=1e-12) + assert structure[3, 3] == pytest.approx(-1e-12, rel=1e-12) + + +def test_equivalent_metres_and_nanometres_agree_physically() -> None: + data_metres = ( + np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + * 1e-9 + ) + + channel_metres = _channel( + data_metres, + unit="m", + x_range=4e-6, + y_range=3e-6, + ) + channel_nanometres = _channel( + data_metres * 1e9, + unit="nm", + x_range=4e-6, + y_range=3e-6, + ) + + background_metres = estimate_sphere_revolution_background( + channel_metres, + radius=1.5e-6, + ) + background_nanometres = estimate_sphere_revolution_background( + channel_nanometres, + radius=1.5e-6, + ) + + assert np.allclose( + background_metres.data, + background_nanometres.data * 1e-9, + rtol=1e-12, + atol=1e-18, + ) + + +def test_input_is_not_mutated_and_context_is_preserved() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0], + [0.3, 1.5, 0.4], + ] + ) + original = data.copy() + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=1.5, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=1.5, + ) + + assert np.array_equal(channel.data, original) + + for result in (background, corrected): + assert result is not channel + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize( + "nonfinite", + [ + np.nan, + np.inf, + -np.inf, + ], +) +def test_nonfinite_data_are_rejected(nonfinite: float) -> None: + data = np.ones((3, 4)) + data[1, 2] = nonfinite + channel = _channel(data) + + with pytest.raises( + ValueError, + match="requires finite data", + ): + estimate_sphere_revolution_background( + channel, + radius=1.0, + ) + + +def test_non_geometric_z_unit_is_rejected() -> None: + channel = _channel( + np.ones((3, 4)), + unit="V", + ) + + with pytest.raises((TypeError, ValueError)): + estimate_sphere_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + "radius", + [ + 0.0, + -1.0, + np.nan, + np.inf, + -np.inf, + ], +) +def test_invalid_radius_is_rejected(radius: float) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(ValueError): + estimate_sphere_revolution_background( + channel, + radius=radius, + ) + + +@pytest.mark.parametrize( + "radius", + [ + True, + None, + "1.0", + [1.0], + 1.0 + 0.0j, + ], +) +def test_non_real_scalar_radius_is_rejected(radius: object) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(TypeError): + estimate_sphere_revolution_background( + channel, + radius=radius, + ) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("side", "underneath"), + ("border", "wrap"), + ], +) +def test_invalid_public_options_are_rejected( + parameter: str, + value: str, +) -> None: + channel = _channel(np.ones((3, 4))) + kwargs = {parameter: value} + + with pytest.raises(ValueError): + estimate_sphere_revolution_background( + channel, + radius=1.0, + **kwargs, + ) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("side", None), + ("border", 1), + ], +) +def test_non_string_public_options_are_rejected( + parameter: str, + value: object, +) -> None: + channel = _channel(np.ones((3, 4))) + kwargs = {parameter: value} + + with pytest.raises( + TypeError, + match=rf"requires {parameter} to be a string", + ): + estimate_sphere_revolution_background( + channel, + radius=1.0, + **kwargs, + ) + + +@pytest.mark.parametrize( + "data", + [ + np.ones(4), + np.empty((0, 3)), + np.array([["a", "b"], ["c", "d"]]), + np.ones((2, 3), dtype=complex), + ], + ids=[ + "one-dimensional", + "empty", + "non-numeric", + "complex", + ], +) +def test_invalid_channel_data_are_rejected(data: np.ndarray) -> None: + channel = SPMChannel( + name="invalid", + data=data, + unit="m", + x_range=3.0, + y_range=2.0, + ) + + with pytest.raises((TypeError, ValueError)): + estimate_sphere_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + ("x_range", "y_range"), + [ + (0.0, 3.0), + (-1.0, 3.0), + (np.inf, 3.0), + (4.0, 0.0), + (4.0, -1.0), + (4.0, np.inf), + ], +) +def test_invalid_lateral_geometry_is_rejected( + x_range: float, + y_range: float, +) -> None: + channel = _channel( + np.ones((3, 4)), + x_range=x_range, + y_range=y_range, + ) + + with pytest.raises(ValueError): + estimate_sphere_revolution_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize("side", ["below", "above"]) +@pytest.mark.parametrize("border", ["nearest", "reflect"]) +def test_reconstruction_identity_for_every_mode( + side: str, + border: str, +) -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=1.5, + side=side, + border=border, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=1.5, + side=side, + border=border, + ) + + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-13, + atol=1e-13, + ) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[2.0]]), + np.array([[0.0, 2.0, 0.5, 1.0]]), + np.array([[0.0], [2.0], [0.5], [1.0]]), + ], + ids=[ + "one-by-one", + "one-row", + "one-column", + ], +) +def test_degenerate_dimensions_are_defined(data: np.ndarray) -> None: + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=2.0, + ) + corrected = remove_sphere_revolution_background( + channel, + radius=2.0, + ) + + assert background.data.shape == data.shape + assert corrected.data.shape == data.shape + assert np.all(np.isfinite(background.data)) + assert np.allclose( + corrected.data + background.data, + data, + ) + + +def test_below_background_does_not_exceed_surface() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=1.5, + side="below", + ) + + assert np.all(background.data <= data + 1e-13) + + +def test_above_background_does_not_fall_below_surface() -> None: + data = np.array( + [ + [0.0, -0.2, -1.0, -0.1], + [-0.3, -1.5, -3.0, -0.4], + [-0.1, -0.6, -1.8, -0.2], + ] + ) + channel = _channel(data) + + background = estimate_sphere_revolution_background( + channel, + radius=1.5, + side="above", + ) + + assert np.all(background.data >= data - 1e-13) + + +def test_functions_are_available_from_public_analysis_api() -> None: + from spmkit.core.analysis import ( + estimate_sphere_revolution_background as public_estimate, + ) + from spmkit.core.analysis import ( + remove_sphere_revolution_background as public_remove, + ) + + assert public_estimate is estimate_sphere_revolution_background + assert public_remove is remove_sphere_revolution_background From d2bcdd181e6cfaf9d182193e620993b11ac4fea4 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:34:39 -0400 Subject: [PATCH 26/82] feat(background): add Gwyddion-compatible median leveling --- src/spmkit/core/analysis/__init__.py | 4 + src/spmkit/core/analysis/background.py | 143 +++++++- tests/core/test_median_background.py | 481 +++++++++++++++++++++++++ 3 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 tests/core/test_median_background.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index f9dcf63..aed97ef 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -17,8 +17,10 @@ ) from spmkit.core.analysis.background import ( estimate_arc_revolution_background, + estimate_median_background, estimate_sphere_revolution_background, remove_arc_revolution_background, + remove_median_background, remove_sphere_revolution_background, ) from spmkit.core.analysis.forcecurve import ForceCurveFit @@ -45,8 +47,10 @@ __all__ = [ "background", "estimate_arc_revolution_background", + "estimate_median_background", "estimate_sphere_revolution_background", "remove_arc_revolution_background", + "remove_median_background", "remove_sphere_revolution_background", "calibration", "leveling", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 22b38f9..f353e35 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -1,9 +1,9 @@ """Physical background estimation for SPM images. -This module contains local, physically dimensioned background estimators. -All lateral ranges and algorithm radii are expressed in metres. Channel -height values are converted to metres internally and returned in their -original geometric unit. +This module contains local background estimators with explicit geometry. +Arc and sphere radii are expressed in metres; geometric channel heights are +converted to metres internally and returned in their original unit. Rank-based +methods declare their radii in pixels and preserve the original scalar Z unit. """ from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Literal import numpy as np -from scipy.ndimage import grey_opening +from scipy.ndimage import generic_filter, grey_opening from spmkit.core.geometry import ( length_values_from_metres, @@ -590,3 +590,136 @@ def remove_sphere_revolution_background( ) return channel.with_data(corrected) + + +def _positive_pixel_radius( + radius_pixels: object, + *, + operation: str, +) -> int: + """Validate a strictly positive integer radius expressed in pixels.""" + radius_data = np.asarray(radius_pixels) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.integer) + or isinstance(radius_pixels, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires radius_pixels to be a positive integer") + + value = int(radius_data.item()) + + if value <= 0: + raise ValueError(f"{operation} requires radius_pixels to be positive") + + return value + + +def _validated_median_radius( + radius_pixels: int, + *, + operation: str, +) -> int: + """Validate the Gwyddion Median Level integer-radius contract.""" + maximum_radius = 1024 + + if radius_pixels > maximum_radius: + raise ValueError(f"{operation}: radius_pixels must be in the range [1, {maximum_radius}]") + + return radius_pixels + + +def _median_disk_footprint( + radius_pixels: int, +) -> np.ndarray: + """Return Gwyddion's pixel-centre elliptic kernel rasterization. + + Gwyddion creates a square bounding box of side ``2*r + 1`` and + includes pixel centres inside the corresponding ellipse. For a + circular odd-sized kernel this is equivalent to + + ``(2*x)**2 + (2*y)**2 <= (2*r + 1)**2``. + + The integer form avoids floating-point boundary ambiguity. + """ + coordinates = 2 * np.arange( + -radius_pixels, + radius_pixels + 1, + dtype=np.int64, + ) + diameter = 2 * radius_pixels + 1 + + squared_distance = coordinates[:, np.newaxis] ** 2 + coordinates[np.newaxis, :] ** 2 + + return squared_distance <= diameter**2 + + +def _median_background_border_extend( + data: np.ndarray, + *, + radius_pixels: int, +) -> np.ndarray: + """Calculate a circular local median using nearest border extension.""" + footprint = _median_disk_footprint(radius_pixels) + + return np.asarray( + generic_filter( + np.asarray(data, dtype=float), + function=np.median, + footprint=footprint, + mode="nearest", + ), + dtype=float, + ) + + +def estimate_median_background( + channel: SPMChannel, + radius_pixels: int, +) -> SPMChannel: + """Estimate a local median background with a circular pixel kernel. + + The neighbourhood radius is an integer number of pixels. At image edges + values are extended using the nearest boundary sample. + The operation is rank-based and therefore does not require a geometric Z + unit. + """ + operation = "estimate_median_background" + + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _positive_pixel_radius( + radius_pixels, + operation=operation, + ) + radius_value = _validated_median_radius( + radius_value, + operation=operation, + ) + + background = _median_background_border_extend( + data, + radius_pixels=radius_value, + ) + + return channel.with_data(background) + + +def remove_median_background( + channel: SPMChannel, + radius_pixels: int, +) -> SPMChannel: + """Subtract a circular local-median background from a channel.""" + background = estimate_median_background( + channel, + radius_pixels, + ) + + corrected = np.asarray(channel.data, dtype=float) - np.asarray( + background.data, + dtype=float, + ) + + return channel.with_data(corrected) diff --git a/tests/core/test_median_background.py b/tests/core/test_median_background.py new file mode 100644 index 0000000..cf79cc4 --- /dev/null +++ b/tests/core/test_median_background.py @@ -0,0 +1,481 @@ +"""Tests for circular local-median background estimation.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis.background import ( + _median_disk_footprint, + estimate_median_background, + remove_median_background, +) +from spmkit.core.models import SPMChannel + + +def _channel( + data: np.ndarray, + *, + unit: str = "nm", + x_range: float | None = None, + y_range: float | None = None, +) -> SPMChannel: + rows, columns = data.shape + + return SPMChannel( + name="Signal", + data=np.asarray(data), + unit=unit, + x_range=float(columns) if x_range is None else x_range, + y_range=float(rows) if y_range is None else y_range, + direction="forward", + group="Synthetic", + metadata={"source": "median-background-test"}, + ) + + +def _disk_offsets( + radius_pixels: int, +) -> list[tuple[int, int]]: + """Independent pixel-centre ellipse oracle.""" + diameter = 2 * radius_pixels + 1 + + return [ + (row_offset, column_offset) + for row_offset in range( + -radius_pixels, + radius_pixels + 1, + ) + for column_offset in range( + -radius_pixels, + radius_pixels + 1, + ) + if (2 * row_offset) ** 2 + (2 * column_offset) ** 2 <= diameter**2 + ] + + +def _nearest_index(index: int, size: int) -> int: + """Map an index using nearest boundary extension.""" + return min(max(index, 0), size - 1) + + +def _brute_force_border_extend_median( + data: np.ndarray, + *, + radius_pixels: int, +) -> np.ndarray: + """Independent circular-median oracle with extended borders.""" + values = np.asarray(data, dtype=float) + rows, columns = values.shape + offsets = _disk_offsets(radius_pixels) + result = np.empty_like(values) + + for row in range(rows): + for column in range(columns): + neighbourhood = [ + values[ + _nearest_index(row + y_offset, rows), + _nearest_index(column + x_offset, columns), + ] + for y_offset, x_offset in offsets + ] + + result[row, column] = float(np.median(neighbourhood)) + + return result + + +def test_radius_one_uses_full_three_by_three_ellipse() -> None: + footprint = _median_disk_footprint(1) + + assert np.array_equal( + footprint, + np.ones((3, 3), dtype=bool), + ) + + data = np.array( + [ + [100.0, 0.0, 100.0], + [0.0, 1.0, 0.0], + [100.0, 0.0, 100.0], + ] + ) + + result = estimate_median_background( + _channel(data), + radius_pixels=1, + ) + + # A radius-one Euclidean-centre disk would be a five-pixel cross + # and would return zero here. Gwyddion's 3×3 ellipse returns one. + assert result.data[1, 1] == 1.0 + + +def test_border_uses_nearest_extension() -> None: + data = np.array( + [ + [0.0, 10.0], + [20.0, 30.0], + ] + ) + channel = _channel(data) + + result = estimate_median_background( + channel, + radius_pixels=1, + ) + expected = _brute_force_border_extend_median( + data, + radius_pixels=1, + ) + + assert np.array_equal(result.data, expected) + + # Gwyddion radius one is a full 3×3 elliptic kernel. Nearest + # extension produces four zeroes, two tens, two twenties and 30, + # so the middle value is 10. + assert result.data[0, 0] == 10.0 + + +def test_matches_independent_border_extend_oracle() -> None: + data = np.array( + [ + [8.0, 1.0, 7.0, 2.0], + [3.0, 9.0, 0.0, 6.0], + [5.0, 4.0, 2.0, 1.0], + ] + ) + channel = _channel(data) + + result = estimate_median_background( + channel, + radius_pixels=2, + ) + expected = _brute_force_border_extend_median( + data, + radius_pixels=2, + ) + + assert np.allclose( + result.data, + expected, + rtol=0.0, + atol=0.0, + ) + + +def test_flat_surface_is_preserved() -> None: + data = np.full((5, 7), 3.25) + channel = _channel(data) + + background = estimate_median_background( + channel, + radius_pixels=2, + ) + corrected = remove_median_background( + channel, + radius_pixels=2, + ) + + assert np.array_equal(background.data, data) + assert np.array_equal(corrected.data, np.zeros_like(data)) + + +def test_reconstruction_identity() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0, 0.1], + [0.3, 1.5, 3.0, 0.4], + [0.1, 0.6, 1.8, 0.2], + ] + ) + channel = _channel(data) + + background = estimate_median_background( + channel, + radius_pixels=2, + ) + corrected = remove_median_background( + channel, + radius_pixels=2, + ) + + assert np.allclose( + corrected.data + background.data, + data, + rtol=1e-13, + atol=1e-13, + ) + + +def test_non_geometric_scalar_unit_is_supported() -> None: + data = np.array( + [ + [0.1, 0.5, 0.2], + [0.7, 4.0, 0.3], + ] + ) + channel = _channel( + data, + unit="V", + ) + + result = estimate_median_background( + channel, + radius_pixels=1, + ) + + assert result.unit == "V" + assert np.all(np.isfinite(result.data)) + + +def test_input_is_not_mutated_and_context_is_preserved() -> None: + data = np.array( + [ + [0.0, 0.2, 1.0], + [0.3, 1.5, 0.4], + ] + ) + original = data.copy() + channel = _channel(data) + + background = estimate_median_background( + channel, + radius_pixels=1, + ) + corrected = remove_median_background( + channel, + radius_pixels=1, + ) + + assert np.array_equal(channel.data, original) + + for result in (background, corrected): + assert result is not channel + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + assert result.metadata is not channel.metadata + + +@pytest.mark.parametrize( + "radius_pixels", + [ + 0, + -1, + -20, + ], +) +def test_nonpositive_radius_is_rejected( + radius_pixels: int, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises( + ValueError, + match="radius_pixels to be positive", + ): + estimate_median_background( + channel, + radius_pixels=radius_pixels, + ) + + +@pytest.mark.parametrize( + "radius_pixels", + [ + True, + None, + 1.0, + "2", + [2], + ], +) +def test_non_integer_radius_is_rejected( + radius_pixels: object, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises( + TypeError, + match="radius_pixels to be a positive integer", + ): + estimate_median_background( + channel, + radius_pixels=radius_pixels, + ) + + +@pytest.mark.parametrize( + "nonfinite", + [ + np.nan, + np.inf, + -np.inf, + ], +) +def test_nonfinite_data_are_rejected( + nonfinite: float, +) -> None: + data = np.ones((3, 4)) + data[1, 2] = nonfinite + channel = _channel(data) + + with pytest.raises( + ValueError, + match="requires finite data", + ): + estimate_median_background( + channel, + radius_pixels=1, + ) + + +@pytest.mark.parametrize( + "data", + [ + np.ones(4), + np.empty((0, 3)), + np.array([["a", "b"], ["c", "d"]]), + np.ones((2, 3), dtype=complex), + ], + ids=[ + "one-dimensional", + "empty", + "non-numeric", + "complex", + ], +) +def test_invalid_channel_data_are_rejected( + data: np.ndarray, +) -> None: + channel = SPMChannel( + name="invalid", + data=data, + unit="nm", + x_range=3.0, + y_range=2.0, + ) + + with pytest.raises((TypeError, ValueError)): + estimate_median_background( + channel, + radius_pixels=1, + ) + + +def test_radius_above_gwyddion_limit_is_rejected_before_allocation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from spmkit.core.analysis import background as background_module + + def fail_if_allocated( + radius_pixels: int, + ) -> np.ndarray: + raise AssertionError(f"footprint was allocated for radius {radius_pixels}") + + monkeypatch.setattr( + background_module, + "_median_disk_footprint", + fail_if_allocated, + ) + + with pytest.raises( + ValueError, + match=r"\[1, 1024\]", + ): + background_module.estimate_median_background( + _channel(np.ones((2, 3), dtype=float)), + radius_pixels=10_000, + ) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[2.0]]), + np.array([[0.0, 2.0, 0.5, 1.0]]), + np.array([[0.0], [2.0], [0.5], [1.0]]), + ], + ids=[ + "one-by-one", + "one-row", + "one-column", + ], +) +def test_degenerate_dimensions_are_defined( + data: np.ndarray, +) -> None: + channel = _channel(data) + + background = estimate_median_background( + channel, + radius_pixels=1, + ) + corrected = remove_median_background( + channel, + radius_pixels=1, + ) + + assert background.data.shape == data.shape + assert corrected.data.shape == data.shape + assert np.all(np.isfinite(background.data)) + assert np.allclose( + corrected.data + background.data, + data, + ) + + +@pytest.mark.parametrize( + ("radius_pixels", "expected_count"), + [ + (1, 9), + (2, 21), + (3, 37), + (4, 69), + (5, 97), + (6, 137), + (7, 177), + (8, 225), + ], +) +def test_footprint_counts_match_gwyddion_271( + radius_pixels: int, + expected_count: int, +) -> None: + footprint = _median_disk_footprint(radius_pixels) + + assert int(np.count_nonzero(footprint)) == expected_count + + +def test_radius_two_matches_frozen_gwyddion_mask() -> None: + expected = np.array( + [ + [0, 1, 1, 1, 0], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [0, 1, 1, 1, 0], + ], + dtype=bool, + ) + + assert np.array_equal( + _median_disk_footprint(2), + expected, + ) + + +def test_median_background_functions_are_publicly_exported() -> None: + from spmkit.core import analysis + from spmkit.core.analysis.background import ( + estimate_median_background, + remove_median_background, + ) + + assert analysis.estimate_median_background is estimate_median_background + assert analysis.remove_median_background is remove_median_background From 5ae6c08cc89c1fc4b892ca984d0f863017d72269 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:47:50 -0400 Subject: [PATCH 27/82] feat(background): add structured background results --- src/spmkit/core/analysis/__init__.py | 8 + src/spmkit/core/analysis/background.py | 142 ++++++++++++++ tests/core/test_background_result.py | 247 +++++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 tests/core/test_background_result.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index aed97ef..d0a9fb1 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -16,6 +16,10 @@ spectral, ) from spmkit.core.analysis.background import ( + BackgroundResult, + analyze_arc_revolution_background, + analyze_median_background, + analyze_sphere_revolution_background, estimate_arc_revolution_background, estimate_median_background, estimate_sphere_revolution_background, @@ -46,6 +50,10 @@ __all__ = [ "background", + "BackgroundResult", + "analyze_arc_revolution_background", + "analyze_median_background", + "analyze_sphere_revolution_background", "estimate_arc_revolution_background", "estimate_median_background", "estimate_sphere_revolution_background", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index f353e35..6413fcd 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -8,6 +8,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Literal import numpy as np @@ -22,6 +23,49 @@ ArcDirection = Literal["horizontal", "vertical", "both"] ArcSide = Literal["below", "above"] ArcBorder = Literal["nearest", "reflect"] +BackgroundMethod = Literal[ + "arc_revolution", + "sphere_revolution", + "median", +] + + +@dataclass(frozen=True) +class BackgroundResult: + """Structured result of a background-removal operation. + + The complete background and corrected channels are retained for inspection. + ``parameters`` records the effective public algorithm configuration. + """ + + background: SPMChannel + corrected: SPMChannel + method: BackgroundMethod + parameters: dict[str, object] + + def to_dict(self) -> dict[str, object]: + """Return a serializable representation of the numerical result.""" + + def channel_payload(channel: SPMChannel) -> dict[str, object]: + data = np.asarray(channel.data) + + return { + "name": channel.name, + "unit": channel.unit, + "shape": list(channel.shape), + "x_range": float(channel.x_range), + "y_range": float(channel.y_range), + "direction": channel.direction, + "group": channel.group, + "data": data.tolist(), + } + + return { + "method": self.method, + "parameters": dict(self.parameters), + "background": channel_payload(self.background), + "corrected": channel_payload(self.corrected), + } def _validated_channel_data( @@ -723,3 +767,101 @@ def remove_median_background( ) return channel.with_data(corrected) + + +def _build_background_result( + channel: SPMChannel, + background: SPMChannel, + *, + method: BackgroundMethod, + parameters: dict[str, object], +) -> BackgroundResult: + """Build a structured result without recalculating the background.""" + corrected = channel.with_data( + np.asarray(channel.data, dtype=float) - np.asarray(background.data, dtype=float) + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method=method, + parameters=dict(parameters), + ) + + +def analyze_arc_revolution_background( + channel: SPMChannel, + radius: float, + *, + direction: ArcDirection = "both", + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> BackgroundResult: + """Estimate and subtract an arc-revolution background in one pass.""" + background = estimate_arc_revolution_background( + channel, + radius, + direction=direction, + side=side, + border=border, + ) + + return _build_background_result( + channel, + background, + method="arc_revolution", + parameters={ + "radius": float(radius), + "direction": direction, + "side": side, + "border": border, + }, + ) + + +def analyze_sphere_revolution_background( + channel: SPMChannel, + radius: float, + *, + side: ArcSide = "below", + border: ArcBorder = "nearest", +) -> BackgroundResult: + """Estimate and subtract a spherical background in one pass.""" + background = estimate_sphere_revolution_background( + channel, + radius, + side=side, + border=border, + ) + + return _build_background_result( + channel, + background, + method="sphere_revolution", + parameters={ + "radius": float(radius), + "side": side, + "border": border, + }, + ) + + +def analyze_median_background( + channel: SPMChannel, + radius_pixels: int, +) -> BackgroundResult: + """Estimate and subtract a local-median background in one pass.""" + background = estimate_median_background( + channel, + radius_pixels, + ) + + return _build_background_result( + channel, + background, + method="median", + parameters={ + "radius_pixels": int(radius_pixels), + "border": "nearest", + }, + ) diff --git a/tests/core/test_background_result.py b/tests/core/test_background_result.py new file mode 100644 index 0000000..8a4552e --- /dev/null +++ b/tests/core/test_background_result.py @@ -0,0 +1,247 @@ +"""Structured background-result contracts.""" + +from __future__ import annotations + +import json +from dataclasses import FrozenInstanceError + +import numpy as np +import pytest + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_arc_revolution_background, + analyze_median_background, + analyze_sphere_revolution_background, +) +from spmkit.core.analysis.background import ( + estimate_arc_revolution_background, + estimate_median_background, + estimate_sphere_revolution_background, + remove_arc_revolution_background, + remove_median_background, + remove_sphere_revolution_background, +) +from spmkit.core.models import SPMChannel + + +def _channel() -> SPMChannel: + data = ( + np.array( + [ + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + [1.0, 2.0, 5.0, 4.0, 5.0, 6.0], + [2.0, 4.0, 8.0, 7.0, 6.0, 7.0], + [3.0, 4.0, 7.0, 6.0, 5.0, 8.0], + [4.0, 5.0, 6.0, 7.0, 8.0, 9.0], + ], + dtype=float, + ) + * 1e-9 + ) + + return SPMChannel( + name="Topography", + data=data, + unit="m", + x_range=6e-6, + y_range=5e-6, + direction="forward", + group="Scan", + metadata={"source": "synthetic"}, + ) + + +def test_arc_result_matches_existing_public_functions() -> None: + channel = _channel() + radius = 5e-6 + + result = analyze_arc_revolution_background( + channel, + radius, + direction="both", + side="below", + border="nearest", + ) + + expected_background = estimate_arc_revolution_background( + channel, + radius, + direction="both", + side="below", + border="nearest", + ) + expected_corrected = remove_arc_revolution_background( + channel, + radius, + direction="both", + side="below", + border="nearest", + ) + + assert isinstance(result, BackgroundResult) + assert result.method == "arc_revolution" + assert result.parameters == { + "radius": radius, + "direction": "both", + "side": "below", + "border": "nearest", + } + assert np.array_equal( + result.background.data, + expected_background.data, + ) + assert np.array_equal( + result.corrected.data, + expected_corrected.data, + ) + + +def test_sphere_result_matches_existing_public_functions() -> None: + channel = _channel() + radius = 5e-6 + + result = analyze_sphere_revolution_background( + channel, + radius, + side="below", + border="nearest", + ) + + expected_background = estimate_sphere_revolution_background( + channel, + radius, + side="below", + border="nearest", + ) + expected_corrected = remove_sphere_revolution_background( + channel, + radius, + side="below", + border="nearest", + ) + + assert result.method == "sphere_revolution" + assert result.parameters == { + "radius": radius, + "side": "below", + "border": "nearest", + } + assert np.array_equal( + result.background.data, + expected_background.data, + ) + assert np.array_equal( + result.corrected.data, + expected_corrected.data, + ) + + +def test_median_result_matches_existing_public_functions() -> None: + channel = _channel() + + result = analyze_median_background( + channel, + radius_pixels=2, + ) + + expected_background = estimate_median_background( + channel, + radius_pixels=2, + ) + expected_corrected = remove_median_background( + channel, + radius_pixels=2, + ) + + assert result.method == "median" + assert result.parameters == { + "radius_pixels": 2, + "border": "nearest", + } + assert np.array_equal( + result.background.data, + expected_background.data, + ) + assert np.array_equal( + result.corrected.data, + expected_corrected.data, + ) + + +@pytest.mark.parametrize( + "analyzer", + [ + lambda channel: analyze_arc_revolution_background( + channel, + 5e-6, + ), + lambda channel: analyze_sphere_revolution_background( + channel, + 5e-6, + ), + lambda channel: analyze_median_background( + channel, + 2, + ), + ], +) +def test_result_channels_preserve_context(analyzer) -> None: + channel = _channel() + result = analyzer(channel) + + for output in (result.background, result.corrected): + assert output.name == channel.name + assert output.unit == channel.unit + assert output.x_range == channel.x_range + assert output.y_range == channel.y_range + assert output.direction == channel.direction + assert output.group == channel.group + assert output.metadata == channel.metadata + assert output.metadata is not channel.metadata + + +def test_background_result_is_frozen() -> None: + result = analyze_median_background( + _channel(), + radius_pixels=1, + ) + + with pytest.raises(FrozenInstanceError): + result.method = "arc_revolution" # type: ignore[misc] + + +def test_to_dict_is_json_serializable() -> None: + result = analyze_median_background( + _channel(), + radius_pixels=1, + ) + + payload = result.to_dict() + + assert payload["method"] == "median" + assert payload["parameters"] == { + "radius_pixels": 1, + "border": "nearest", + } + + background = payload["background"] + corrected = payload["corrected"] + + assert isinstance(background, dict) + assert isinstance(corrected, dict) + assert background["shape"] == [5, 6] + assert corrected["shape"] == [5, 6] + assert isinstance(background["data"], list) + assert isinstance(corrected["data"], list) + + json.dumps(payload) + + +def test_structured_background_api_is_public() -> None: + from spmkit.core import analysis + + assert analysis.BackgroundResult is BackgroundResult + assert analysis.analyze_arc_revolution_background is analyze_arc_revolution_background + assert analysis.analyze_sphere_revolution_background is analyze_sphere_revolution_background + assert analysis.analyze_median_background is analyze_median_background From 66ea55c1355dd3c3b5ca1ad539208e7a391888b0 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:35:51 -0400 Subject: [PATCH 28/82] feat(background): add physical rolling-ball estimation --- src/spmkit/core/analysis/__init__.py | 6 + src/spmkit/core/analysis/background.py | 311 +++++++++++- tests/core/test_background_result.py | 6 + tests/core/test_rolling_ball_background.py | 532 +++++++++++++++++++++ 4 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_rolling_ball_background.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index d0a9fb1..17f8708 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -19,12 +19,15 @@ BackgroundResult, analyze_arc_revolution_background, analyze_median_background, + analyze_rolling_ball_background, analyze_sphere_revolution_background, estimate_arc_revolution_background, estimate_median_background, + estimate_rolling_ball_background, estimate_sphere_revolution_background, remove_arc_revolution_background, remove_median_background, + remove_rolling_ball_background, remove_sphere_revolution_background, ) from spmkit.core.analysis.forcecurve import ForceCurveFit @@ -53,12 +56,15 @@ "BackgroundResult", "analyze_arc_revolution_background", "analyze_median_background", + "analyze_rolling_ball_background", "analyze_sphere_revolution_background", "estimate_arc_revolution_background", "estimate_median_background", + "estimate_rolling_ball_background", "estimate_sphere_revolution_background", "remove_arc_revolution_background", "remove_median_background", + "remove_rolling_ball_background", "remove_sphere_revolution_background", "calibration", "leveling", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 6413fcd..735df7b 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -12,7 +12,7 @@ from typing import Literal import numpy as np -from scipy.ndimage import generic_filter, grey_opening +from scipy.ndimage import generic_filter, grey_erosion, grey_opening from spmkit.core.geometry import ( length_values_from_metres, @@ -26,6 +26,7 @@ BackgroundMethod = Literal[ "arc_revolution", "sphere_revolution", + "rolling_ball", "median", ] @@ -118,6 +119,33 @@ def _positive_radius( return value +def _positive_vertical_radius( + vertical_radius: object, + *, + operation: str, +) -> float: + """Validate a vertical rolling-ball semiaxis in channel units.""" + radius_data = np.asarray(vertical_radius) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(vertical_radius, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires vertical_radius to be a positive real scalar") + + value = float(radius_data.item()) + + if not np.isfinite(value): + raise ValueError(f"{operation} requires vertical_radius to be finite") + + if value <= 0.0: + raise ValueError(f"{operation} requires vertical_radius to be positive") + + return value + + def _validated_choice( value: object, *, @@ -636,6 +664,259 @@ def remove_sphere_revolution_background( return channel.with_data(corrected) +def _rolling_ball_structure( + *, + radius: float, + vertical_radius: float, + x_spacing: float, + y_spacing: float, + shape: tuple[int, int], + spherical: bool, +) -> tuple[np.ndarray, np.ndarray]: + """Return a physical rolling-ball structure and footprint.""" + rows, columns = shape + + maximum_x_offset = columns - 1 + radius_in_x_pixels = radius / x_spacing + + if not np.isfinite(radius_in_x_pixels) or radius_in_x_pixels >= maximum_x_offset: + x_offset = maximum_x_offset + else: + x_offset = int(np.floor(radius_in_x_pixels)) + + maximum_y_offset = rows - 1 + radius_in_y_pixels = radius / y_spacing + + if not np.isfinite(radius_in_y_pixels) or radius_in_y_pixels >= maximum_y_offset: + y_offset = maximum_y_offset + else: + y_offset = int(np.floor(radius_in_y_pixels)) + + x_offsets = np.arange( + -x_offset, + x_offset + 1, + dtype=float, + ) + y_offsets = np.arange( + -y_offset, + y_offset + 1, + dtype=float, + ) + + x_distances = x_offsets * x_spacing + y_distances = y_offsets * y_spacing + + squared_distance = np.square(y_distances)[:, np.newaxis] + np.square(x_distances)[np.newaxis, :] + squared_radius = radius * radius + squared_ratio = squared_distance / squared_radius + + tolerance = 8.0 * np.finfo(float).eps + footprint = squared_ratio <= 1.0 + tolerance + + if spherical: + # Physical sphere and scikit-image ball_kernel arithmetic: + # height = sqrt(radius**2 - distance**2). + clipped_distance = np.minimum( + squared_distance, + squared_radius, + ) + height = np.sqrt( + np.maximum( + squared_radius - clipped_distance, + 0.0, + ) + ) + sagitta = radius - height + else: + # General ellipsoid and scikit-image ellipsoid_kernel arithmetic: + # height = vertical_radius * sqrt(1 - normalized distance**2). + clipped_ratio = np.minimum( + squared_ratio, + 1.0, + ) + root = np.sqrt( + np.maximum( + 1.0 - clipped_ratio, + 0.0, + ) + ) + height = vertical_radius * root + sagitta = vertical_radius - height + + # scipy.ndimage.grey_erosion calculates min(image - structure). + # A negative sagitta therefore evaluates min(image + sagitta). + structure = np.where( + footprint, + -sagitta, + 0.0, + ) + + return structure, footprint + + +def _estimate_rolling_ball_below( + data: np.ndarray, + channel: SPMChannel, + *, + radius: float, + vertical_radius: float, + spherical: bool, + operation: str, +) -> np.ndarray: + """Evaluate the rolling-ball apex field below a surface.""" + x_spacing = _axis_spacing( + channel, + axis=1, + operation=operation, + ) + y_spacing = _axis_spacing( + channel, + axis=0, + operation=operation, + ) + + structure, footprint = _rolling_ball_structure( + radius=radius, + vertical_radius=vertical_radius, + x_spacing=x_spacing, + y_spacing=y_spacing, + shape=data.shape, + spherical=spherical, + ) + + if footprint.size == 1: + return data.copy() + + return np.asarray( + grey_erosion( + data, + footprint=footprint, + structure=structure, + mode="constant", + cval=np.inf, + ), + dtype=float, + ) + + +def estimate_rolling_ball_background( + channel: SPMChannel, + radius: float, + *, + vertical_radius: float | None = None, + side: ArcSide = "below", +) -> SPMChannel: + """Estimate a background using a physical rolling ball. + + The lateral semiaxis ``radius`` is expressed in metres. When + ``vertical_radius`` is omitted, channel Z values must represent length; + they are converted to metres and a physical sphere with equal lateral + and vertical radii is used. + + An explicit ``vertical_radius`` is interpreted in the native channel + unit, permitting ellipsoidal kernels for voltage, phase, current and + other non-geometric channels. + + The estimator is the apex-height rolling-ball operation described by + Sternberg (1983), equivalent to non-flat grey erosion. Samples outside + the image are ignored by assigning them positive infinity. + + References + ---------- + S. R. Sternberg, "Biomedical Image Processing", Computer 16(1), + 22-34 (1983), doi:10.1109/MC.1983.1654163. + """ + operation = "estimate_rolling_ball_background" + + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _positive_radius( + radius, + operation=operation, + ) + side_value = _validated_choice( + side, + name="side", + allowed=("below", "above"), + operation=operation, + ) + + use_geometric_z = vertical_radius is None + + if use_geometric_z: + working_data = length_values_to_metres( + data, + unit=channel.unit, + ) + vertical_radius_value = radius_value + else: + working_data = np.asarray( + data, + dtype=float, + ) + vertical_radius_value = _positive_vertical_radius( + vertical_radius, + operation=operation, + ) + + if side_value == "below": + working_background = _estimate_rolling_ball_below( + working_data, + channel, + radius=radius_value, + vertical_radius=vertical_radius_value, + spherical=use_geometric_z, + operation=operation, + ) + else: + working_background = -_estimate_rolling_ball_below( + -working_data, + channel, + radius=radius_value, + vertical_radius=vertical_radius_value, + spherical=use_geometric_z, + operation=operation, + ) + + if use_geometric_z: + background = length_values_from_metres( + working_background, + unit=channel.unit, + ) + else: + background = working_background + + return channel.with_data(background) + + +def remove_rolling_ball_background( + channel: SPMChannel, + radius: float, + *, + vertical_radius: float | None = None, + side: ArcSide = "below", +) -> SPMChannel: + """Subtract a rolling-ball background from a channel.""" + background = estimate_rolling_ball_background( + channel, + radius, + vertical_radius=vertical_radius, + side=side, + ) + + corrected = np.asarray( + channel.data, + dtype=float, + ) - np.asarray( + background.data, + dtype=float, + ) + + return channel.with_data(corrected) + + def _positive_pixel_radius( radius_pixels: object, *, @@ -846,6 +1127,34 @@ def analyze_sphere_revolution_background( ) +def analyze_rolling_ball_background( + channel: SPMChannel, + radius: float, + *, + vertical_radius: float | None = None, + side: ArcSide = "below", +) -> BackgroundResult: + """Estimate and subtract a rolling-ball background in one pass.""" + background = estimate_rolling_ball_background( + channel, + radius, + vertical_radius=vertical_radius, + side=side, + ) + + return _build_background_result( + channel, + background, + method="rolling_ball", + parameters={ + "radius": float(radius), + "vertical_radius": (None if vertical_radius is None else float(vertical_radius)), + "side": side, + "boundary": "ignore", + }, + ) + + def analyze_median_background( channel: SPMChannel, radius_pixels: int, diff --git a/tests/core/test_background_result.py b/tests/core/test_background_result.py index 8a4552e..ab4687a 100644 --- a/tests/core/test_background_result.py +++ b/tests/core/test_background_result.py @@ -12,6 +12,7 @@ BackgroundResult, analyze_arc_revolution_background, analyze_median_background, + analyze_rolling_ball_background, analyze_sphere_revolution_background, ) from spmkit.core.analysis.background import ( @@ -180,6 +181,10 @@ def test_median_result_matches_existing_public_functions() -> None: channel, 5e-6, ), + lambda channel: analyze_rolling_ball_background( + channel, + 5e-6, + ), lambda channel: analyze_median_background( channel, 2, @@ -244,4 +249,5 @@ def test_structured_background_api_is_public() -> None: assert analysis.BackgroundResult is BackgroundResult assert analysis.analyze_arc_revolution_background is analyze_arc_revolution_background assert analysis.analyze_sphere_revolution_background is analyze_sphere_revolution_background + assert analysis.analyze_rolling_ball_background is analyze_rolling_ball_background assert analysis.analyze_median_background is analyze_median_background diff --git a/tests/core/test_rolling_ball_background.py b/tests/core/test_rolling_ball_background.py new file mode 100644 index 0000000..4eab45e --- /dev/null +++ b/tests/core/test_rolling_ball_background.py @@ -0,0 +1,532 @@ +"""Physical rolling-ball background estimation.""" + +from __future__ import annotations + +from math import floor + +import numpy as np +import pytest + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_rolling_ball_background, + estimate_rolling_ball_background, + remove_rolling_ball_background, +) +from spmkit.core.models import SPMChannel + + +def _channel( + data: np.ndarray, + *, + unit: str = "V", + x_range: float | None = None, + y_range: float | None = None, +) -> SPMChannel: + rows, columns = data.shape + + return SPMChannel( + name="Signal", + data=np.asarray(data), + unit=unit, + x_range=(float(columns) if x_range is None else x_range), + y_range=(float(rows) if y_range is None else y_range), + direction="forward", + group="Scan", + metadata={"source": "synthetic"}, + ) + + +def _oracle_below( + data: np.ndarray, + *, + radius: float, + vertical_radius: float, + x_spacing: float, + y_spacing: float, +) -> np.ndarray: + """Independent direct evaluation of the apex-height formula.""" + image = np.asarray(data, dtype=float) + rows, columns = image.shape + + x_offset = min( + columns - 1, + floor(radius / x_spacing), + ) + y_offset = min( + rows - 1, + floor(radius / y_spacing), + ) + + output = np.empty_like(image) + + for row in range(rows): + for column in range(columns): + minimum = np.inf + + for dy in range(-y_offset, y_offset + 1): + for dx in range(-x_offset, x_offset + 1): + source_row = row + dy + source_column = column + dx + + if not (0 <= source_row < rows and 0 <= source_column < columns): + continue + + squared_ratio = (dx * x_spacing / radius) ** 2 + (dy * y_spacing / radius) ** 2 + + if squared_ratio > 1.0: + continue + + cost = vertical_radius * (1.0 - np.sqrt(1.0 - squared_ratio)) + + candidate = image[source_row, source_column] + cost + minimum = min(minimum, candidate) + + output[row, column] = minimum + + return output + + +@pytest.mark.parametrize("side", ["below", "above"]) +def test_matches_independent_anisotropic_oracle( + side: str, +) -> None: + data = np.array( + [ + [0.0, 1.0, 3.0, 8.0, 5.0, 4.0], + [2.0, 4.0, 9.0, 7.0, 6.0, 3.0], + [1.0, 5.0, 8.0, 4.0, 2.0, 1.0], + [3.0, 6.0, 7.0, 5.0, 4.0, 2.0], + [4.0, 5.0, 6.0, 8.0, 7.0, 3.0], + ] + ) + channel = _channel( + data, + x_range=6.0, + y_range=10.0, + ) + + expected_below = _oracle_below( + data, + radius=2.5, + vertical_radius=4.0, + x_spacing=1.0, + y_spacing=2.0, + ) + expected = ( + expected_below + if side == "below" + else -_oracle_below( + -data, + radius=2.5, + vertical_radius=4.0, + x_spacing=1.0, + y_spacing=2.0, + ) + ) + + observed = estimate_rolling_ball_background( + channel, + radius=2.5, + vertical_radius=4.0, + side=side, + ) + + np.testing.assert_allclose( + observed.data, + expected, + rtol=1e-14, + atol=1e-14, + ) + + +def test_reference_corner_case_ignores_exterior() -> None: + channel = _channel( + np.array( + [ + [0.0, 10.0], + [20.0, 30.0], + ] + ) + ) + + background = estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=1.0, + ) + + expected = np.array( + [ + [0.0, 1.0], + [1.0, 11.0], + ] + ) + + assert np.array_equal( + background.data, + expected, + ) + + +def test_radius_smaller_than_pixel_spacing_is_identity() -> None: + channel = _channel( + np.arange(12, dtype=float).reshape(3, 4), + x_range=8.0, + y_range=6.0, + ) + + background = estimate_rolling_ball_background( + channel, + radius=0.5, + vertical_radius=2.0, + ) + + assert np.array_equal( + background.data, + channel.data, + ) + + +def test_geometric_automatic_sphere_matches_explicit_native_radius() -> None: + data_nm = np.array( + [ + [0.0, 1.0, 4.0, 2.0], + [1.0, 5.0, 8.0, 3.0], + [2.0, 4.0, 6.0, 1.0], + ] + ) + channel = _channel( + data_nm, + unit="nm", + x_range=4e-9, + y_range=3e-9, + ) + + automatic = estimate_rolling_ball_background( + channel, + radius=2e-9, + ) + explicit = estimate_rolling_ball_background( + channel, + radius=2e-9, + vertical_radius=2.0, + ) + + np.testing.assert_allclose( + automatic.data, + explicit.data, + rtol=1e-14, + atol=1e-14, + ) + + +def test_non_geometric_unit_requires_vertical_radius() -> None: + channel = _channel( + np.ones((3, 4)), + unit="V", + ) + + with pytest.raises( + ValueError, + match="unsupported geometric length unit", + ): + estimate_rolling_ball_background( + channel, + radius=1.0, + ) + + +@pytest.mark.parametrize( + "vertical_radius", + [ + 0.0, + -1.0, + np.nan, + np.inf, + -np.inf, + ], +) +def test_invalid_vertical_radius_value_is_rejected( + vertical_radius: float, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(ValueError): + estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=vertical_radius, + ) + + +@pytest.mark.parametrize( + "vertical_radius", + [ + True, + "1.0", + [1.0], + 1.0 + 0.0j, + ], +) +def test_invalid_vertical_radius_type_is_rejected( + vertical_radius: object, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(TypeError): + estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=vertical_radius, + ) + + +@pytest.mark.parametrize( + "radius", + [ + 0.0, + -1.0, + np.nan, + np.inf, + -np.inf, + ], +) +def test_invalid_lateral_radius_value_is_rejected( + radius: float, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(ValueError): + estimate_rolling_ball_background( + channel, + radius=radius, + vertical_radius=1.0, + ) + + +@pytest.mark.parametrize( + "radius", + [ + True, + None, + "1.0", + [1.0], + 1.0 + 0.0j, + ], +) +def test_invalid_lateral_radius_type_is_rejected( + radius: object, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(TypeError): + estimate_rolling_ball_background( + channel, + radius=radius, + vertical_radius=1.0, + ) + + +@pytest.mark.parametrize( + "side", + [ + "underneath", + "nearest", + ], +) +def test_invalid_side_value_is_rejected( + side: str, +) -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises(ValueError): + estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=1.0, + side=side, + ) + + +def test_non_string_side_is_rejected() -> None: + channel = _channel(np.ones((3, 4))) + + with pytest.raises( + TypeError, + match="requires side to be a string", + ): + estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=1.0, + side=None, + ) + + +@pytest.mark.parametrize( + ("x_range", "y_range"), + [ + (0.0, 3.0), + (-1.0, 3.0), + (np.inf, 3.0), + (4.0, 0.0), + (4.0, -1.0), + (4.0, np.inf), + ], +) +def test_invalid_lateral_geometry_is_rejected( + x_range: float, + y_range: float, +) -> None: + channel = _channel( + np.ones((3, 4)), + x_range=x_range, + y_range=y_range, + ) + + with pytest.raises(ValueError): + estimate_rolling_ball_background( + channel, + radius=1.0, + vertical_radius=1.0, + ) + + +def test_remove_reconstructs_original_data() -> None: + channel = _channel( + np.array( + [ + [0.0, 1.0, 5.0, 2.0], + [2.0, 6.0, 9.0, 3.0], + [1.0, 4.0, 7.0, 2.0], + ] + ) + ) + + background = estimate_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + ) + corrected = remove_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + ) + + assert np.array_equal( + corrected.data + background.data, + channel.data, + ) + + +def test_outputs_preserve_context_without_mutating_input() -> None: + channel = _channel( + np.arange(20, dtype=float).reshape(4, 5), + ) + original = channel.data.copy() + + background = estimate_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + ) + corrected = remove_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + ) + + assert np.array_equal(channel.data, original) + + for output in (background, corrected): + assert output.name == channel.name + assert output.unit == channel.unit + assert output.x_range == channel.x_range + assert output.y_range == channel.y_range + assert output.direction == channel.direction + assert output.group == channel.group + assert output.metadata == channel.metadata + assert output.metadata is not channel.metadata + + +def test_structured_result_matches_simple_functions() -> None: + channel = _channel( + np.arange(20, dtype=float).reshape(4, 5), + ) + + result = analyze_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + side="above", + ) + + expected_background = estimate_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + side="above", + ) + expected_corrected = remove_rolling_ball_background( + channel, + radius=2.0, + vertical_radius=3.0, + side="above", + ) + + assert isinstance(result, BackgroundResult) + assert result.method == "rolling_ball" + assert result.parameters == { + "radius": 2.0, + "vertical_radius": 3.0, + "side": "above", + "boundary": "ignore", + } + assert np.array_equal( + result.background.data, + expected_background.data, + ) + assert np.array_equal( + result.corrected.data, + expected_corrected.data, + ) + + +def test_geometric_structured_result_records_automatic_mode() -> None: + channel = _channel( + np.arange(12, dtype=float).reshape(3, 4), + unit="nm", + x_range=4e-9, + y_range=3e-9, + ) + + result = analyze_rolling_ball_background( + channel, + radius=2e-9, + ) + + assert result.parameters == { + "radius": 2e-9, + "vertical_radius": None, + "side": "below", + "boundary": "ignore", + } + + +def test_rolling_ball_api_is_public() -> None: + from spmkit.core import analysis + from spmkit.core.analysis.background import ( + analyze_rolling_ball_background as module_analyze, + ) + from spmkit.core.analysis.background import ( + estimate_rolling_ball_background as module_estimate, + ) + from spmkit.core.analysis.background import ( + remove_rolling_ball_background as module_remove, + ) + + assert analysis.analyze_rolling_ball_background is module_analyze + assert analysis.estimate_rolling_ball_background is module_estimate + assert analysis.remove_rolling_ball_background is module_remove From a00f3d5afcb9fdd296186cb35eb03985c5ad86ba Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:09:21 -0400 Subject: [PATCH 29/82] feat(background): add structured polynomial estimation --- src/spmkit/core/analysis/__init__.py | 6 + src/spmkit/core/analysis/background.py | 102 ++++++++++++ src/spmkit/core/analysis/leveling.py | 59 ++++--- tests/core/test_background_result.py | 7 + tests/core/test_polynomial_background.py | 192 +++++++++++++++++++++++ 5 files changed, 345 insertions(+), 21 deletions(-) create mode 100644 tests/core/test_polynomial_background.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 17f8708..c1e0532 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -19,14 +19,17 @@ BackgroundResult, analyze_arc_revolution_background, analyze_median_background, + analyze_polynomial_background, analyze_rolling_ball_background, analyze_sphere_revolution_background, estimate_arc_revolution_background, estimate_median_background, + estimate_polynomial_background, estimate_rolling_ball_background, estimate_sphere_revolution_background, remove_arc_revolution_background, remove_median_background, + remove_polynomial_background, remove_rolling_ball_background, remove_sphere_revolution_background, ) @@ -56,14 +59,17 @@ "BackgroundResult", "analyze_arc_revolution_background", "analyze_median_background", + "analyze_polynomial_background", "analyze_rolling_ball_background", "analyze_sphere_revolution_background", "estimate_arc_revolution_background", "estimate_median_background", + "estimate_polynomial_background", "estimate_rolling_ball_background", "estimate_sphere_revolution_background", "remove_arc_revolution_background", "remove_median_background", + "remove_polynomial_background", "remove_rolling_ball_background", "remove_sphere_revolution_background", "calibration", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 735df7b..aa7b721 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -28,6 +28,7 @@ "sphere_revolution", "rolling_ball", "median", + "polynomial", ] @@ -1050,6 +1051,70 @@ def remove_median_background( return channel.with_data(corrected) +def estimate_polynomial_background( + channel: SPMChannel, + *, + degree_mode: Literal["total", "independent"] = "total", + degree: int = 2, + x_degree: int | None = None, + y_degree: int | None = None, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> SPMChannel: + """Estimate a global two-dimensional polynomial background. + + Pixel-centre coordinates are normalized independently to ``[-1, 1]``. + ``degree_mode="total"`` includes terms with ``x_power + y_power <= degree``. + ``degree_mode="independent"`` includes the full tensor-product basis up to + ``x_degree`` and ``y_degree``. + + A mask controls only the points used for fitting. The fitted model is + evaluated over the complete image. + """ + from spmkit.core.analysis.leveling import ( + _estimate_polynomial_background_data, + ) + + background = _estimate_polynomial_background_data( + channel, + degree_mode=degree_mode, + degree=degree, + x_degree=x_degree, + y_degree=y_degree, + mask=mask, + mask_mode=mask_mode, + ) + + return channel.with_data(background) + + +def remove_polynomial_background( + channel: SPMChannel, + *, + degree_mode: Literal["total", "independent"] = "total", + degree: int = 2, + x_degree: int | None = None, + y_degree: int | None = None, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> SPMChannel: + """Subtract a global two-dimensional polynomial background.""" + background = estimate_polynomial_background( + channel, + degree_mode=degree_mode, + degree=degree, + x_degree=x_degree, + y_degree=y_degree, + mask=mask, + mask_mode=mask_mode, + ) + + data = np.asarray(channel.data, dtype=float) + background_data = np.asarray(background.data, dtype=float) + + return channel.with_data(data - background_data) + + def _build_background_result( channel: SPMChannel, background: SPMChannel, @@ -1155,6 +1220,43 @@ def analyze_rolling_ball_background( ) +def analyze_polynomial_background( + channel: SPMChannel, + *, + degree_mode: Literal["total", "independent"] = "total", + degree: int = 2, + x_degree: int | None = None, + y_degree: int | None = None, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> BackgroundResult: + """Estimate and subtract a polynomial background in one fit.""" + background = estimate_polynomial_background( + channel, + degree_mode=degree_mode, + degree=degree, + x_degree=x_degree, + y_degree=y_degree, + mask=mask, + mask_mode=mask_mode, + ) + + return _build_background_result( + channel, + background, + method="polynomial", + parameters={ + "degree_mode": degree_mode, + "degree": (int(degree) if degree_mode == "total" else None), + "x_degree": (int(x_degree) if x_degree is not None else None), + "y_degree": (int(y_degree) if y_degree is not None else None), + "mask_mode": mask_mode, + "mask_provided": mask is not None, + "coordinates": "normalized_-1_1", + }, + ) + + def analyze_median_background( channel: SPMChannel, radius_pixels: int, diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 75d170a..d16aa54 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -55,7 +55,7 @@ def _fit_selection( return np.ones(data.shape, dtype=bool) if mask is None: - raise ValueError(f"{operation} requires a mask when mask_mode is " f"'{mask_mode}'") + raise ValueError(f"{operation} requires a mask when mask_mode is '{mask_mode}'") mask_data = np.asarray(mask) @@ -376,7 +376,7 @@ def rotate_level( ) if rank < 3: - raise ValueError("rotate_level selected points do not define " "a unique plane") + raise ValueError("rotate_level selected points do not define a unique plane") x_slope = float(coefficients[0]) y_slope = float(coefficients[1]) @@ -655,7 +655,7 @@ def three_point_level( return channel.with_data(data - plane) -def polynomial_background( +def _estimate_polynomial_background_data( channel: SPMChannel, *, degree_mode: Literal["total", "independent"] = "total", @@ -664,20 +664,20 @@ def polynomial_background( y_degree: int | None = None, mask: np.ndarray | None = None, mask_mode: Literal["ignore", "include", "exclude"] = "ignore", -) -> SPMChannel: - """Subtract a fitted two-dimensional polynomial background.""" +) -> np.ndarray: + """Estimate a fitted two-dimensional polynomial background array.""" data = _validated_data( channel, operation="polynomial_background", ) if degree_mode not in {"total", "independent"}: - raise ValueError("polynomial_background degree_mode must be " "'total' or 'independent'") + raise ValueError("polynomial_background degree_mode must be 'total' or 'independent'") if degree_mode == "total": if x_degree is not None or y_degree is not None: raise ValueError( - "polynomial_background total degree mode does not accept " "x_degree or y_degree" + "polynomial_background total degree mode does not accept x_degree or y_degree" ) total_degree = _nonnegative_integer( @@ -694,7 +694,7 @@ def polynomial_background( else: if x_degree is None or y_degree is None: raise ValueError( - "polynomial_background independent degree mode requires " "x_degree and y_degree" + "polynomial_background independent degree mode requires x_degree and y_degree" ) horizontal_degree = _nonnegative_integer( @@ -740,10 +740,34 @@ def polynomial_background( if rank < len(powers): raise ValueError( - "polynomial_background selected points do not define " "a unique polynomial background" + "polynomial_background selected points do not define a unique polynomial background" ) background = (design @ coefficients).reshape(data.shape) + return background + + +def polynomial_background( + channel: SPMChannel, + *, + degree_mode: Literal["total", "independent"] = "total", + degree: int = 2, + x_degree: int | None = None, + y_degree: int | None = None, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", +) -> SPMChannel: + """Subtract a fitted two-dimensional polynomial background.""" + background = _estimate_polynomial_background_data( + channel, + degree_mode=degree_mode, + degree=degree, + x_degree=x_degree, + y_degree=y_degree, + mask=mask, + mask_mode=mask_mode, + ) + data = np.asarray(channel.data, dtype=float) return channel.with_data(data - background) @@ -809,9 +833,7 @@ def _facet_tilt_row_corrections( row_count, column_count = data.shape if column_count < 2: - raise ValueError( - "align_rows facet_tilt requires selected " "neighbouring pixels in every row" - ) + raise ValueError("align_rows facet_tilt requires selected neighbouring pixels in every row") x_coordinates = np.linspace( -1.0, @@ -828,7 +850,7 @@ def _facet_tilt_row_corrections( if not np.any(selected_edges): raise ValueError( - "align_rows facet_tilt requires selected " "neighbouring pixels in every row" + "align_rows facet_tilt requires selected neighbouring pixels in every row" ) local_slopes = np.diff(data[row_index]) / x_steps @@ -879,8 +901,7 @@ def _matching_row_corrections( if not np.any(shared_edges): raise ValueError( - "align_rows matching requires adjacent rows to share " - "selected neighbouring pixels" + "align_rows matching requires adjacent rows to share selected neighbouring pixels" ) previous_row = data[row_index - 1] @@ -936,21 +957,17 @@ def _difference_row_corrections( corrections = np.zeros(row_count, dtype=float) for row_index in range(1, row_count): - shared_selection = selection[row_index - 1] & selection[row_index] if not np.any(shared_selection): - raise ValueError("align_rows requires adjacent rows to share selected points") differences = data[row_index, shared_selection] - data[row_index - 1, shared_selection] if statistic == "median": - increment = float(np.median(differences)) else: - increment = _trimmed_mean( differences, trim_fraction, @@ -1047,7 +1064,7 @@ def align_rows( if np.any(selected_per_row < required_points): point_word = "point" if required_points == 1 else "points" raise ValueError( - f"align_rows requires at least {required_points} selected " f"{point_word} in every row" + f"align_rows requires at least {required_points} selected {point_word} in every row" ) difference_methods = { @@ -1106,7 +1123,7 @@ def align_rows( if rank < degree + 1: raise ValueError( - "align_rows selected points do not define " "a unique polynomial in every row" + "align_rows selected points do not define a unique polynomial in every row" ) corrections[row_index] = design @ coefficients diff --git a/tests/core/test_background_result.py b/tests/core/test_background_result.py index ab4687a..d09970e 100644 --- a/tests/core/test_background_result.py +++ b/tests/core/test_background_result.py @@ -12,6 +12,7 @@ BackgroundResult, analyze_arc_revolution_background, analyze_median_background, + analyze_polynomial_background, analyze_rolling_ball_background, analyze_sphere_revolution_background, ) @@ -185,6 +186,11 @@ def test_median_result_matches_existing_public_functions() -> None: channel, 5e-6, ), + lambda channel: analyze_polynomial_background( + channel, + degree_mode="total", + degree=2, + ), lambda channel: analyze_median_background( channel, 2, @@ -250,4 +256,5 @@ def test_structured_background_api_is_public() -> None: assert analysis.analyze_arc_revolution_background is analyze_arc_revolution_background assert analysis.analyze_sphere_revolution_background is analyze_sphere_revolution_background assert analysis.analyze_rolling_ball_background is analyze_rolling_ball_background + assert analysis.analyze_polynomial_background is analyze_polynomial_background assert analysis.analyze_median_background is analyze_median_background diff --git a/tests/core/test_polynomial_background.py b/tests/core/test_polynomial_background.py new file mode 100644 index 0000000..8f76165 --- /dev/null +++ b/tests/core/test_polynomial_background.py @@ -0,0 +1,192 @@ +"""Tests for global polynomial background estimation.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +from spmkit.core.analysis import ( + analyze_polynomial_background, + estimate_polynomial_background, + leveling, + remove_polynomial_background, +) +from spmkit.core.models import SPMChannel + + +def _channel(data: np.ndarray) -> SPMChannel: + return SPMChannel( + name="Z-Axis", + data=np.asarray(data, dtype=float), + unit="nm", + x_range=9e-6, + y_range=8e-6, + direction="forward", + group="Topography", + metadata={"source": "synthetic"}, + ) + + +def test_total_degree_estimates_exact_polynomial_surface() -> None: + rows, columns = 8, 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + expected = 4.0 + 1.5 * x - 0.75 * y + 0.4 * x * y + 0.2 * x**2 - 0.1 * y**2 + + channel = _channel(expected) + + observed = estimate_polynomial_background( + channel, + degree_mode="total", + degree=2, + ) + + assert np.allclose(observed.data, expected, atol=1e-12) + + +def test_independent_degree_supports_tensor_product_terms() -> None: + rows, columns = 8, 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + expected = 1.0 + 0.5 * x**2 - 0.25 * y + 2.0 * x**3 * y + + channel = _channel(expected) + + observed = estimate_polynomial_background( + channel, + degree_mode="independent", + x_degree=3, + y_degree=1, + ) + + assert np.allclose(observed.data, expected, atol=1e-12) + + +def test_remove_matches_legacy_leveling_api() -> None: + rows, columns = 7, 8 + yy, xx = np.mgrid[0:rows, 0:columns] + + data = 2.0 + 0.4 * xx - 0.3 * yy + 0.05 * xx * yy + np.sin(xx) + + channel = _channel(data) + + expected = leveling.polynomial_background( + channel, + degree_mode="total", + degree=2, + ) + observed = remove_polynomial_background( + channel, + degree_mode="total", + degree=2, + ) + + assert np.array_equal(observed.data, expected.data) + + +def test_mask_excludes_feature_from_fit() -> None: + rows = columns = 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + expected = 3.0 + 0.75 * x - 0.5 * y + data = expected.copy() + data[4, 4] += 100.0 + + mask = np.zeros(data.shape, dtype=bool) + mask[4, 4] = True + + observed = estimate_polynomial_background( + _channel(data), + degree_mode="total", + degree=1, + mask=mask, + mask_mode="exclude", + ) + + assert np.allclose(observed.data, expected, atol=1e-12) + + +def test_analyze_returns_model_corrected_and_provenance() -> None: + rows, columns = 8, 9 + y = np.linspace(-1.0, 1.0, rows)[:, np.newaxis] + x = np.linspace(-1.0, 1.0, columns)[np.newaxis, :] + + data = 2.0 + x - 0.5 * y + 0.25 * x * y + channel = _channel(data) + + result = analyze_polynomial_background( + channel, + degree_mode="total", + degree=2, + ) + + assert result.method == "polynomial" + assert result.parameters == { + "degree_mode": "total", + "degree": 2, + "x_degree": None, + "y_degree": None, + "mask_mode": "ignore", + "mask_provided": False, + "coordinates": "normalized_-1_1", + } + assert np.allclose(result.background.data, data, atol=1e-12) + assert np.allclose(result.corrected.data, 0.0, atol=1e-12) + assert np.allclose( + result.background.data + result.corrected.data, + channel.data, + atol=1e-12, + ) + + +def test_outputs_preserve_context_without_mutating_input() -> None: + data = np.arange(72.0).reshape(8, 9) + channel = _channel(data) + + original_data = channel.data.copy() + original_metadata = dict(channel.metadata) + + estimated = estimate_polynomial_background( + channel, + degree=2, + ) + corrected = remove_polynomial_background( + channel, + degree=2, + ) + + for output in (estimated, corrected): + assert output is not channel + assert output.name == channel.name + assert output.unit == channel.unit + assert output.x_range == channel.x_range + assert output.y_range == channel.y_range + assert output.direction == channel.direction + assert output.group == channel.group + assert output.metadata == channel.metadata + + assert np.array_equal(channel.data, original_data) + assert channel.metadata == original_metadata + + +def test_invalid_configuration_is_rejected_by_shared_solver() -> None: + channel = _channel(np.arange(72.0).reshape(8, 9)) + + with pytest.raises( + ValueError, + match="polynomial_background degree_mode must be", + ): + estimate_polynomial_background( + channel, + degree_mode="unknown", # type: ignore[arg-type] + ) + + +def test_polynomial_background_api_is_public() -> None: + assert analysis.estimate_polynomial_background is estimate_polynomial_background + assert analysis.remove_polynomial_background is remove_polynomial_background + assert analysis.analyze_polynomial_background is analyze_polynomial_background From 776e1cbc34008e36f238b0038bbae0db758261a7 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:39:37 -0400 Subject: [PATCH 30/82] feat(analysis): add matrix-free P-spline surface core --- src/spmkit/core/analysis/_pspline.py | 650 +++++++++++++++++++++++++++ tests/core/test_pspline_surface.py | 373 +++++++++++++++ 2 files changed, 1023 insertions(+) create mode 100644 src/spmkit/core/analysis/_pspline.py create mode 100644 tests/core/test_pspline_surface.py diff --git a/src/spmkit/core/analysis/_pspline.py b/src/spmkit/core/analysis/_pspline.py new file mode 100644 index 0000000..cb88fb3 --- /dev/null +++ b/src/spmkit/core/analysis/_pspline.py @@ -0,0 +1,650 @@ +"""Tensor-product penalized B-spline surface fitting. + +This module implements the P-spline construction introduced by Eilers and +Marx, Statistical Science 11 (1996), DOI: 10.1214/ss/1038425655. + +For coefficient matrix C, data Z, marginal B-spline bases Bx and By, and +difference operators Dx and Dy, the fitted surface minimizes + + ||W**0.5 (Z - By C Bx.T)||**2 + + smoothing_x ||C Dx.T||**2 + + smoothing_y ||Dy C||**2. + +The augmented least-squares system is exposed to LSMR as a LinearOperator. +The full tensor-product design matrix is never materialized. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import comb +from typing import Final + +import numpy as np +from numpy.typing import ArrayLike, NDArray +from scipy.interpolate import BSpline +from scipy.linalg import null_space +from scipy.sparse import csr_array, diags +from scipy.sparse.linalg import LinearOperator, lsmr + +FloatArray = NDArray[np.float64] + +_ACCEPTABLE_LSMR_STOPS: Final[frozenset[int]] = frozenset( + { + 1, + 2, + 4, + 5, + } +) + + +@dataclass(frozen=True) +class PSplineSurfaceFit: + """Result and diagnostics from a tensor-product P-spline fit.""" + + model: FloatArray + coefficients: FloatArray + knots_x: FloatArray + knots_y: FloatArray + degree_x: int + degree_y: int + penalty_order_x: int + penalty_order_y: int + smoothing_x: float + smoothing_y: float + selected_points: int + total_points: int + solver_stop_code: int + solver_iterations: int + augmented_residual_norm: float + normal_residual_norm: float + operator_norm: float + condition_estimate: float + coefficient_norm: float + weighted_data_residual_norm: float + penalty_x_norm: float + penalty_y_norm: float + x_min: float + x_max: float + y_min: float + y_max: float + + +def _readonly_float_array(values: ArrayLike) -> FloatArray: + result = np.array( + values, + dtype=float, + copy=True, + order="C", + ) + result.setflags(write=False) + return result + + +def _open_uniform_knots( + n_basis: int, + degree: int, +) -> FloatArray: + if degree < 0: + raise ValueError("spline degree must be non-negative") + + if n_basis < degree + 1: + raise ValueError("n_basis must be at least degree + 1") + + n_internal = n_basis - degree - 1 + + if n_internal: + interior = np.linspace( + 0.0, + 1.0, + n_internal + 2, + dtype=float, + )[1:-1] + else: + interior = np.empty(0, dtype=float) + + return np.concatenate( + ( + np.zeros(degree + 1, dtype=float), + interior, + np.ones(degree + 1, dtype=float), + ) + ) + + +def _difference_matrix( + size: int, + order: int, +) -> csr_array: + if order < 1: + raise ValueError("penalty order must be at least 1") + + if order >= size: + raise ValueError("penalty order must be smaller than n_basis") + + coefficients = np.array( + [(-1.0) ** (order - index) * comb(order, index) for index in range(order + 1)], + dtype=float, + ) + + return csr_array( + diags( + coefficients, + offsets=np.arange(order + 1), + shape=(size - order, size), + format="csr", + ) + ) + + +def _normalized_axis( + length: int, + values: ArrayLike | None, + *, + name: str, +) -> tuple[FloatArray, float, float]: + if length < 2: + raise ValueError(f"{name} axis must contain at least two points") + + if values is None: + original = np.arange( + length, + dtype=float, + ) + else: + original = np.asarray( + values, + dtype=float, + ) + + if original.ndim != 1: + raise ValueError(f"{name} coordinates must be one-dimensional") + + if original.size != length: + raise ValueError(f"{name} coordinate count does not match data") + + if not np.all(np.isfinite(original)): + raise ValueError(f"{name} coordinates must be finite") + + if not np.all(np.diff(original) > 0.0): + raise ValueError(f"{name} coordinates must be strictly increasing") + + lower = float(original[0]) + upper = float(original[-1]) + + normalized = (original - lower) / (upper - lower) + + return ( + np.asarray(normalized, dtype=float), + lower, + upper, + ) + + +def _validate_solver_parameter( + value: float, + *, + name: str, +) -> float: + validated = float(value) + + if not np.isfinite(validated) or validated <= 0.0: + raise ValueError(f"{name} must be finite and strictly positive") + + return validated + + +def _check_penalty_null_space_identifiability( + *, + basis_x: csr_array, + basis_y: csr_array, + difference_x: csr_array, + difference_y: csr_array, + selected: NDArray[np.intp], + sqrt_weights: FloatArray, + data_shape: tuple[int, int], +) -> None: + null_x = null_space(difference_x.toarray()) + null_y = null_space(difference_y.toarray()) + + null_surfaces: list[FloatArray] = [] + + for y_index in range(null_y.shape[1]): + for x_index in range(null_x.shape[1]): + coefficients = np.outer( + null_y[:, y_index], + null_x[:, x_index], + ) + + surface = np.asarray( + basis_y @ coefficients @ basis_x.T, + dtype=float, + order="C", + ) + + null_surfaces.append( + sqrt_weights + * surface.reshape( + data_shape, + order="C", + ).ravel(order="C")[selected] + ) + + null_design = np.column_stack(null_surfaces) + + rank = int(np.linalg.matrix_rank(null_design)) + + if rank != null_design.shape[1]: + raise ValueError("selected data do not identify the P-spline penalty null space") + + +def fit_pspline_surface( + data: ArrayLike, + *, + x: ArrayLike | None = None, + y: ArrayLike | None = None, + mask: ArrayLike | None = None, + weights: ArrayLike | None = None, + n_basis_x: int = 12, + n_basis_y: int = 12, + degree_x: int = 3, + degree_y: int = 3, + penalty_order_x: int = 2, + penalty_order_y: int = 2, + smoothing_x: float = 1.0, + smoothing_y: float = 1.0, + atol: float = 1e-12, + btol: float = 1e-12, + conlim: float = 1e12, + maxiter: int | None = None, +) -> PSplineSurfaceFit: + """Fit an anisotropic tensor-product P-spline surface. + + Coordinates are normalized independently to ``[0, 1]``. Consequently, + smoothing parameters are not silently rescaled when physical scan ranges + change. Physical anisotropy is represented explicitly through separate + X and Y basis counts and smoothing parameters. + + ``mask`` is a strict Boolean selection. Non-selected data may contain + non-finite values. ``weights`` must be finite and non-negative; zero + weight excludes a selected observation. + """ + + values = np.asarray( + data, + dtype=float, + ) + + if values.ndim != 2: + raise ValueError("P-spline surface data must be two-dimensional") + + rows, columns = values.shape + + normalized_x, x_min, x_max = _normalized_axis( + columns, + x, + name="x", + ) + normalized_y, y_min, y_max = _normalized_axis( + rows, + y, + name="y", + ) + + if not isinstance(n_basis_x, int) or isinstance( + n_basis_x, + bool, + ): + raise TypeError("n_basis_x must be an integer") + + if not isinstance(n_basis_y, int) or isinstance( + n_basis_y, + bool, + ): + raise TypeError("n_basis_y must be an integer") + + knots_x = _open_uniform_knots( + n_basis_x, + degree_x, + ) + knots_y = _open_uniform_knots( + n_basis_y, + degree_y, + ) + + difference_x = _difference_matrix( + n_basis_x, + penalty_order_x, + ) + difference_y = _difference_matrix( + n_basis_y, + penalty_order_y, + ) + + validated_smoothing_x = _validate_solver_parameter( + smoothing_x, + name="smoothing_x", + ) + validated_smoothing_y = _validate_solver_parameter( + smoothing_y, + name="smoothing_y", + ) + validated_atol = _validate_solver_parameter( + atol, + name="atol", + ) + validated_btol = _validate_solver_parameter( + btol, + name="btol", + ) + validated_conlim = _validate_solver_parameter( + conlim, + name="conlim", + ) + + if maxiter is not None: + if not isinstance(maxiter, int) or isinstance( + maxiter, + bool, + ): + raise TypeError("maxiter must be an integer") + + if maxiter < 1: + raise ValueError("maxiter must be strictly positive") + + if mask is None: + selected_mask = np.ones( + values.shape, + dtype=bool, + ) + else: + raw_mask = np.asarray(mask) + + if raw_mask.dtype != np.bool_: + raise TypeError("P-spline mask must be Boolean") + + if raw_mask.shape != values.shape: + raise ValueError("P-spline mask shape must match data") + + selected_mask = np.array( + raw_mask, + dtype=bool, + copy=True, + order="C", + ) + + if weights is None: + weight_values = np.ones( + values.shape, + dtype=float, + ) + else: + weight_values = np.asarray( + weights, + dtype=float, + ) + + if weight_values.shape != values.shape: + raise ValueError("P-spline weights shape must match data") + + if not np.all(np.isfinite(weight_values)): + raise ValueError("P-spline weights must be finite") + + if np.any(weight_values < 0.0): + raise ValueError("P-spline weights must be non-negative") + + active = selected_mask & (weight_values > 0.0) + selected = np.flatnonzero(active.ravel(order="C")) + + if selected.size == 0: + raise ValueError("P-spline fit requires selected observations") + + flat_values = values.ravel(order="C") + selected_values = flat_values[selected] + + if not np.all(np.isfinite(selected_values)): + raise ValueError("selected P-spline data must be finite") + + flat_weights = weight_values.ravel(order="C") + sqrt_weights = np.sqrt(flat_weights[selected]) + + basis_x = csr_array( + BSpline.design_matrix( + normalized_x, + knots_x, + degree_x, + ) + ) + basis_y = csr_array( + BSpline.design_matrix( + normalized_y, + knots_y, + degree_y, + ) + ) + + _check_penalty_null_space_identifiability( + basis_x=basis_x, + basis_y=basis_y, + difference_x=difference_x, + difference_y=difference_y, + selected=selected, + sqrt_weights=sqrt_weights, + data_shape=values.shape, + ) + + x_penalty_rows = n_basis_y * difference_x.shape[0] + y_penalty_rows = difference_y.shape[0] * n_basis_x + coefficient_count = n_basis_y * n_basis_x + + operator_rows = selected.size + x_penalty_rows + y_penalty_rows + + sqrt_smoothing_x = np.sqrt(validated_smoothing_x) + sqrt_smoothing_y = np.sqrt(validated_smoothing_y) + + def forward( + coefficient_vector: NDArray[np.float64], + ) -> FloatArray: + coefficients = np.asarray( + coefficient_vector, + dtype=float, + ).reshape( + n_basis_y, + n_basis_x, + order="C", + ) + + model = np.asarray( + basis_y @ coefficients @ basis_x.T, + dtype=float, + order="C", + ) + penalty_x = np.asarray( + coefficients @ difference_x.T, + dtype=float, + order="C", + ) + penalty_y = np.asarray( + difference_y @ coefficients, + dtype=float, + order="C", + ) + + return np.concatenate( + ( + sqrt_weights * model.ravel(order="C")[selected], + sqrt_smoothing_x * penalty_x.ravel(order="C"), + sqrt_smoothing_y * penalty_y.ravel(order="C"), + ) + ) + + def adjoint( + residual_vector: NDArray[np.float64], + ) -> FloatArray: + residuals = np.asarray( + residual_vector, + dtype=float, + ) + + position = 0 + + data_residual = sqrt_weights * residuals[position : position + selected.size] + position += selected.size + + penalty_x_residual = residuals[position : position + x_penalty_rows].reshape( + n_basis_y, + difference_x.shape[0], + order="C", + ) + position += x_penalty_rows + + penalty_y_residual = residuals[position:].reshape( + difference_y.shape[0], + n_basis_x, + order="C", + ) + + residual_grid = np.zeros( + values.shape, + dtype=float, + order="C", + ) + residual_grid.flat[selected] = data_residual + + gradient = np.asarray( + basis_y.T @ residual_grid @ basis_x, + dtype=float, + order="C", + ) + + gradient += sqrt_smoothing_x * np.asarray( + penalty_x_residual @ difference_x, + dtype=float, + ) + gradient += sqrt_smoothing_y * np.asarray( + difference_y.T @ penalty_y_residual, + dtype=float, + ) + + return np.asarray( + gradient, + dtype=float, + order="C", + ).ravel(order="C") + + operator = LinearOperator( + shape=( + operator_rows, + coefficient_count, + ), + matvec=forward, + rmatvec=adjoint, + dtype=float, + ) + + right_hand_side = np.concatenate( + ( + sqrt_weights * selected_values, + np.zeros( + operator_rows - selected.size, + dtype=float, + ), + ) + ) + + iteration_limit = ( + maxiter + if maxiter is not None + else max( + 1000, + 4 * coefficient_count, + ) + ) + + solution = lsmr( + operator, + right_hand_side, + atol=validated_atol, + btol=validated_btol, + conlim=validated_conlim, + maxiter=iteration_limit, + ) + + ( + coefficient_vector, + stop_code, + iterations, + augmented_residual_norm, + normal_residual_norm, + operator_norm, + condition_estimate, + coefficient_norm, + ) = solution + + zero_right_hand_side = bool(np.linalg.norm(right_hand_side) == 0.0) + + converged = stop_code in _ACCEPTABLE_LSMR_STOPS or (stop_code == 0 and zero_right_hand_side) + + if not converged: + raise RuntimeError( + "P-spline LSMR did not converge: " + f"stop_code={stop_code}, " + f"iterations={iterations}, " + f"condition_estimate={condition_estimate:.6g}" + ) + + coefficients = np.asarray( + coefficient_vector, + dtype=float, + ).reshape( + n_basis_y, + n_basis_x, + order="C", + ) + + model = np.asarray( + basis_y @ coefficients @ basis_x.T, + dtype=float, + order="C", + ) + + penalty_x = np.asarray( + coefficients @ difference_x.T, + dtype=float, + ) + penalty_y = np.asarray( + difference_y @ coefficients, + dtype=float, + ) + + weighted_data_residual = sqrt_weights * (model.ravel(order="C")[selected] - selected_values) + + return PSplineSurfaceFit( + model=_readonly_float_array(model), + coefficients=_readonly_float_array(coefficients), + knots_x=_readonly_float_array(knots_x), + knots_y=_readonly_float_array(knots_y), + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=validated_smoothing_x, + smoothing_y=validated_smoothing_y, + selected_points=int(selected.size), + total_points=int(values.size), + solver_stop_code=int(stop_code), + solver_iterations=int(iterations), + augmented_residual_norm=float(augmented_residual_norm), + normal_residual_norm=float(normal_residual_norm), + operator_norm=float(operator_norm), + condition_estimate=float(condition_estimate), + coefficient_norm=float(coefficient_norm), + weighted_data_residual_norm=float(np.linalg.norm(weighted_data_residual)), + penalty_x_norm=float(np.linalg.norm(penalty_x)), + penalty_y_norm=float(np.linalg.norm(penalty_y)), + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + ) diff --git a/tests/core/test_pspline_surface.py b/tests/core/test_pspline_surface.py new file mode 100644 index 0000000..dfe5f6b --- /dev/null +++ b/tests/core/test_pspline_surface.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import numpy as np +import pytest +from scipy.interpolate import BSpline +from scipy.sparse import eye, kron, vstack + +from spmkit.core.analysis._pspline import ( + _difference_matrix, + _open_uniform_knots, + fit_pspline_surface, +) + + +def _surface_from_coefficients( + coefficients: np.ndarray, + *, + rows: int, + columns: int, + degree_x: int, + degree_y: int, +) -> np.ndarray: + knots_x = _open_uniform_knots( + coefficients.shape[1], + degree_x, + ) + knots_y = _open_uniform_knots( + coefficients.shape[0], + degree_y, + ) + + basis_x = BSpline.design_matrix( + np.linspace(0.0, 1.0, columns), + knots_x, + degree_x, + ) + basis_y = BSpline.design_matrix( + np.linspace(0.0, 1.0, rows), + knots_y, + degree_y, + ) + + return np.asarray( + basis_y @ coefficients @ basis_x.T, + dtype=float, + order="C", + ) + + +def test_recovers_zero_penalty_tensor_surface() -> None: + rows = 15 + columns = 17 + n_basis_x = 8 + n_basis_y = 7 + + x_index = np.arange( + n_basis_x, + dtype=float, + )[None, :] + y_index = np.arange( + n_basis_y, + dtype=float, + )[:, None] + + expected_coefficients = 2.0 + 0.3 * x_index - 0.2 * y_index + 0.05 * x_index * y_index + + data = _surface_from_coefficients( + expected_coefficients, + rows=rows, + columns=columns, + degree_x=3, + degree_y=3, + ) + + result = fit_pspline_surface( + data, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=3, + degree_y=3, + penalty_order_x=2, + penalty_order_y=2, + smoothing_x=4.0, + smoothing_y=7.0, + atol=1e-14, + btol=1e-14, + ) + + np.testing.assert_allclose( + result.coefficients, + expected_coefficients, + rtol=0.0, + atol=2e-10, + ) + np.testing.assert_allclose( + result.model, + data, + rtol=0.0, + atol=2e-10, + ) + + assert result.penalty_x_norm < 2e-10 + assert result.penalty_y_norm < 2e-10 + + +def test_matches_explicit_dense_weighted_masked_oracle() -> None: + rows = 11 + columns = 13 + n_basis_x = 7 + n_basis_y = 6 + degree_x = 3 + degree_y = 3 + penalty_order_x = 2 + penalty_order_y = 2 + smoothing_x = 0.8 + smoothing_y = 2.1 + + rng = np.random.default_rng(20260801) + + data = rng.normal(size=(rows, columns)) + mask = np.ones( + data.shape, + dtype=bool, + ) + mask[3:7, 4:9] = False + + weights = np.linspace( + 0.4, + 1.6, + data.size, + ).reshape(data.shape) + + result = fit_pspline_surface( + data, + mask=mask, + weights=weights, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=smoothing_x, + smoothing_y=smoothing_y, + atol=1e-14, + btol=1e-14, + maxiter=4000, + ) + + knots_x = _open_uniform_knots( + n_basis_x, + degree_x, + ) + knots_y = _open_uniform_knots( + n_basis_y, + degree_y, + ) + + basis_x = BSpline.design_matrix( + np.linspace(0.0, 1.0, columns), + knots_x, + degree_x, + ) + basis_y = BSpline.design_matrix( + np.linspace(0.0, 1.0, rows), + knots_y, + degree_y, + ) + + difference_x = _difference_matrix( + n_basis_x, + penalty_order_x, + ) + difference_y = _difference_matrix( + n_basis_y, + penalty_order_y, + ) + + selected = np.flatnonzero(mask.ravel(order="C")) + sqrt_weights = np.sqrt(weights.ravel(order="C")[selected]) + + data_operator = kron( + basis_y, + basis_x, + format="csr", + )[selected] + + weighted_data_operator = data_operator.multiply(sqrt_weights[:, None]) + + penalty_x = kron( + eye(n_basis_y, format="csr"), + difference_x, + format="csr", + ) + penalty_y = kron( + difference_y, + eye(n_basis_x, format="csr"), + format="csr", + ) + + explicit_system = vstack( + ( + weighted_data_operator, + np.sqrt(smoothing_x) * penalty_x, + np.sqrt(smoothing_y) * penalty_y, + ), + format="csr", + ) + + right_hand_side = np.concatenate( + ( + sqrt_weights * data.ravel(order="C")[selected], + np.zeros( + explicit_system.shape[0] - selected.size, + dtype=float, + ), + ) + ) + + expected_vector = np.linalg.lstsq( + explicit_system.toarray(), + right_hand_side, + rcond=None, + )[0] + + expected_coefficients = expected_vector.reshape( + n_basis_y, + n_basis_x, + order="C", + ) + expected_model = np.asarray( + basis_y @ expected_coefficients @ basis_x.T, + dtype=float, + order="C", + ) + + np.testing.assert_allclose( + result.coefficients, + expected_coefficients, + rtol=0.0, + atol=2e-10, + ) + np.testing.assert_allclose( + result.model, + expected_model, + rtol=0.0, + atol=2e-10, + ) + + +def test_mask_can_exclude_nonfinite_data() -> None: + data = np.arange( + 99, + dtype=float, + ).reshape(9, 11) + + mask = np.ones( + data.shape, + dtype=bool, + ) + mask[4, 5] = False + data[4, 5] = np.nan + + result = fit_pspline_surface( + data, + mask=mask, + n_basis_x=6, + n_basis_y=6, + ) + + assert np.all(np.isfinite(result.model)) + assert result.selected_points == data.size - 1 + + +def test_selected_nonfinite_data_is_rejected() -> None: + data = np.ones( + (9, 11), + dtype=float, + ) + data[4, 5] = np.nan + + with pytest.raises( + ValueError, + match="selected P-spline data must be finite", + ): + fit_pspline_surface( + data, + n_basis_x=6, + n_basis_y=6, + ) + + +def test_penalty_null_space_must_be_identifiable() -> None: + data = np.ones( + (9, 11), + dtype=float, + ) + mask = np.zeros( + data.shape, + dtype=bool, + ) + mask[0, 0] = True + + with pytest.raises( + ValueError, + match="penalty null space", + ): + fit_pspline_surface( + data, + mask=mask, + n_basis_x=6, + n_basis_y=6, + ) + + +def test_input_is_not_mutated_and_results_are_read_only() -> None: + rng = np.random.default_rng(91) + data = rng.normal(size=(10, 12)) + original = data.copy() + + result = fit_pspline_surface( + data, + n_basis_x=6, + n_basis_y=6, + ) + + np.testing.assert_array_equal( + data, + original, + ) + + assert not result.model.flags.writeable + assert not result.coefficients.flags.writeable + assert not result.knots_x.flags.writeable + assert not result.knots_y.flags.writeable + + with pytest.raises(ValueError): + result.model[0, 0] = 0.0 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"n_basis_x": 3, "degree_x": 3}, + {"degree_x": -1}, + {"penalty_order_x": 0}, + { + "n_basis_x": 6, + "penalty_order_x": 6, + }, + {"smoothing_x": 0.0}, + {"smoothing_y": np.inf}, + {"atol": 0.0}, + {"btol": 0.0}, + {"conlim": 0.0}, + {"maxiter": 0}, + ], +) +def test_invalid_configuration_is_rejected( + kwargs: dict[str, object], +) -> None: + with pytest.raises( + (TypeError, ValueError), + ): + fit_pspline_surface( + np.ones( + (9, 11), + dtype=float, + ), + n_basis_x=6, + n_basis_y=6, + **kwargs, + ) From fcca54a291411afbf061d8dbdae2ea5052a0feb6 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:55:34 -0400 Subject: [PATCH 31/82] fix(analysis): harden P-spline numeric inputs --- src/spmkit/core/analysis/_pspline.py | 41 ++++++++++--- tests/core/test_pspline_surface.py | 87 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/spmkit/core/analysis/_pspline.py b/src/spmkit/core/analysis/_pspline.py index cb88fb3..c58df17 100644 --- a/src/spmkit/core/analysis/_pspline.py +++ b/src/spmkit/core/analysis/_pspline.py @@ -71,6 +71,23 @@ class PSplineSurfaceFit: y_max: float +def _as_real_numeric_array( + values: ArrayLike, + *, + name: str, +) -> FloatArray: + """Convert real numeric input without discarding complex components.""" + raw = np.asarray(values) + + if not np.issubdtype(raw.dtype, np.number) or np.iscomplexobj(raw): + raise TypeError(f"{name} must be real numeric") + + return np.asarray( + raw, + dtype=float, + ) + + def _readonly_float_array(values: ArrayLike) -> FloatArray: result = np.array( values, @@ -153,9 +170,9 @@ def _normalized_axis( dtype=float, ) else: - original = np.asarray( + original = _as_real_numeric_array( values, - dtype=float, + name=f"{name} coordinates", ) if original.ndim != 1: @@ -187,7 +204,17 @@ def _validate_solver_parameter( *, name: str, ) -> float: - validated = float(value) + raw = np.asarray(value) + + if ( + raw.ndim != 0 + or not np.issubdtype(raw.dtype, np.number) + or np.iscomplexobj(raw) + or raw.dtype == np.bool_ + ): + raise TypeError(f"{name} must be a real numeric scalar") + + validated = float(raw.item()) if not np.isfinite(validated) or validated <= 0.0: raise ValueError(f"{name} must be finite and strictly positive") @@ -271,9 +298,9 @@ def fit_pspline_surface( weight excludes a selected observation. """ - values = np.asarray( + values = _as_real_numeric_array( data, - dtype=float, + name="P-spline surface data", ) if values.ndim != 2: @@ -380,9 +407,9 @@ def fit_pspline_surface( dtype=float, ) else: - weight_values = np.asarray( + weight_values = _as_real_numeric_array( weights, - dtype=float, + name="P-spline weights", ) if weight_values.shape != values.shape: diff --git a/tests/core/test_pspline_surface.py b/tests/core/test_pspline_surface.py index dfe5f6b..1cbb286 100644 --- a/tests/core/test_pspline_surface.py +++ b/tests/core/test_pspline_surface.py @@ -371,3 +371,90 @@ def test_invalid_configuration_is_rejected( n_basis_y=6, **kwargs, ) + + +def test_complex_surface_data_is_rejected() -> None: + data = np.ones( + (9, 11), + dtype=complex, + ) + + with pytest.raises( + TypeError, + match="P-spline surface data must be real numeric", + ): + fit_pspline_surface( + data, + n_basis_x=6, + n_basis_y=6, + ) + + +def test_complex_weights_are_rejected() -> None: + data = np.ones( + (9, 11), + dtype=float, + ) + weights = np.ones( + data.shape, + dtype=complex, + ) + + with pytest.raises( + TypeError, + match="P-spline weights must be real numeric", + ): + fit_pspline_surface( + data, + weights=weights, + n_basis_x=6, + n_basis_y=6, + ) + + +def test_complex_coordinates_are_rejected() -> None: + data = np.ones( + (9, 11), + dtype=float, + ) + x = np.linspace( + 0.0, + 1.0, + data.shape[1], + ).astype(complex) + + with pytest.raises( + TypeError, + match="x coordinates must be real numeric", + ): + fit_pspline_surface( + data, + x=x, + n_basis_x=6, + n_basis_y=6, + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"smoothing_x": True}, + {"atol": np.array(True)}, + ], +) +def test_boolean_solver_parameters_are_rejected( + kwargs: dict[str, object], +) -> None: + with pytest.raises( + TypeError, + match="must be a real numeric scalar", + ): + fit_pspline_surface( + np.ones( + (9, 11), + dtype=float, + ), + n_basis_x=6, + n_basis_y=6, + **kwargs, + ) From 6697fd0fcbe44aaa43e62447e58129d000753f0d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:12:05 -0400 Subject: [PATCH 32/82] feat(background): add P-spline estimation adapter --- src/spmkit/core/analysis/background.py | 123 +++++++++- tests/core/test_spline_background.py | 303 +++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 tests/core/test_spline_background.py diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index aa7b721..9b36d2e 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -1,9 +1,8 @@ -"""Physical background estimation for SPM images. +"""Background estimation and removal for SPM images. -This module contains local background estimators with explicit geometry. -Arc and sphere radii are expressed in metres; geometric channel heights are -converted to metres internally and returned in their original unit. Rank-based -methods declare their radii in pixels and preserve the original scalar Z unit. +Local geometric estimators use explicit physical or pixel-based scales. +Global polynomial and spline estimators fit models over the complete image +using explicitly documented coordinate and mask conventions. """ from __future__ import annotations @@ -14,6 +13,10 @@ import numpy as np from scipy.ndimage import generic_filter, grey_erosion, grey_opening +from spmkit.core.analysis._pspline import ( + PSplineSurfaceFit, + fit_pspline_surface, +) from spmkit.core.geometry import ( length_values_from_metres, length_values_to_metres, @@ -1115,6 +1118,116 @@ def remove_polynomial_background( return channel.with_data(data - background_data) +def _fit_spline_background( + channel: SPMChannel, + *, + n_basis_x: int = 12, + n_basis_y: int = 12, + degree_x: int = 3, + degree_y: int = 3, + penalty_order_x: int = 2, + penalty_order_y: int = 2, + smoothing_x: float = 1.0, + smoothing_y: float = 1.0, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + weights: np.ndarray | None = None, + atol: float = 1e-12, + btol: float = 1e-12, + conlim: float = 1e12, + maxiter: int | None = None, +) -> PSplineSurfaceFit: + """Fit a P-spline background while preserving complete diagnostics.""" + from spmkit.core.analysis.leveling import _fit_selection + + data = np.asarray(channel.data) + + if data.ndim != 2: + raise ValueError("spline_background requires a 2D channel") + + rows, columns = data.shape + + if rows < 2 or columns < 2: + raise ValueError("spline_background requires at least two rows and two columns") + + selection = _fit_selection( + data, + mask=mask, + mask_mode=mask_mode, + operation="spline_background", + minimum_points=1, + ) + + x_coordinates = (np.arange(columns, dtype=float) + 0.5) * float(channel.pixel_size_x) + y_coordinates = (np.arange(rows, dtype=float) + 0.5) * float(channel.pixel_size_y) + + return fit_pspline_surface( + data, + x=x_coordinates, + y=y_coordinates, + mask=selection, + weights=weights, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=smoothing_x, + smoothing_y=smoothing_y, + atol=atol, + btol=btol, + conlim=conlim, + maxiter=maxiter, + ) + + +def estimate_spline_background( + channel: SPMChannel, + *, + n_basis_x: int = 12, + n_basis_y: int = 12, + degree_x: int = 3, + degree_y: int = 3, + penalty_order_x: int = 2, + penalty_order_y: int = 2, + smoothing_x: float = 1.0, + smoothing_y: float = 1.0, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + weights: np.ndarray | None = None, +) -> SPMChannel: + """Estimate a global anisotropic tensor-product P-spline background. + + The basis is evaluated at physical pixel-centre coordinates. Each axis is + normalized independently inside the P-spline solver. A mask controls only + observations used for fitting; the model is evaluated over the full image. + """ + fit = _fit_spline_background( + channel, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=smoothing_x, + smoothing_y=smoothing_y, + mask=mask, + mask_mode=mask_mode, + weights=weights, + ) + + background = np.array( + fit.model, + dtype=float, + copy=True, + order="C", + ) + + return channel.with_data(background) + + def _build_background_result( channel: SPMChannel, background: SPMChannel, diff --git a/tests/core/test_spline_background.py b/tests/core/test_spline_background.py new file mode 100644 index 0000000..66fdeca --- /dev/null +++ b/tests/core/test_spline_background.py @@ -0,0 +1,303 @@ +"""Tests for the private P-spline background adapter.""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.interpolate import BSpline + +import spmkit.core.analysis as analysis +from spmkit.core.analysis._pspline import ( + _open_uniform_knots, + fit_pspline_surface, +) +from spmkit.core.analysis.background import ( + _fit_spline_background, + estimate_spline_background, +) +from spmkit.core.models import SPMChannel + + +def _channel(data: np.ndarray) -> SPMChannel: + return SPMChannel( + name="Z-Axis", + data=np.asarray(data), + unit="nm", + x_range=17e-6, + y_range=15e-6, + direction="forward", + group="Topography", + metadata={"source": "synthetic"}, + ) + + +def _zero_penalty_surface( + *, + rows: int = 15, + columns: int = 17, + n_basis_x: int = 6, + n_basis_y: int = 6, +) -> np.ndarray: + degree = 3 + + knots_x = _open_uniform_knots( + n_basis_x, + degree, + ) + knots_y = _open_uniform_knots( + n_basis_y, + degree, + ) + + basis_x = BSpline.design_matrix( + np.linspace(0.0, 1.0, columns), + knots_x, + degree, + ) + basis_y = BSpline.design_matrix( + np.linspace(0.0, 1.0, rows), + knots_y, + degree, + ) + + x_index = np.arange( + n_basis_x, + dtype=float, + )[None, :] + y_index = np.arange( + n_basis_y, + dtype=float, + )[:, None] + + coefficients = 2.0 + 0.3 * x_index - 0.2 * y_index + 0.05 * x_index * y_index + + return np.asarray( + basis_y @ coefficients @ basis_x.T, + dtype=float, + order="C", + ) + + +def test_adapter_uses_physical_pixel_centres() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + + fit = _fit_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + smoothing_x=2.0, + smoothing_y=3.0, + ) + + assert fit.x_min == pytest.approx( + 0.5 * channel.pixel_size_x, + ) + assert fit.x_max == pytest.approx( + channel.x_range - 0.5 * channel.pixel_size_x, + ) + assert fit.y_min == pytest.approx( + 0.5 * channel.pixel_size_y, + ) + assert fit.y_max == pytest.approx( + channel.y_range - 0.5 * channel.pixel_size_y, + ) + + +def test_adapter_matches_direct_core_fit() -> None: + rng = np.random.default_rng(20260801) + data = rng.normal( + size=(15, 17), + ) + channel = _channel(data) + + weights = np.linspace( + 0.5, + 1.5, + data.size, + ).reshape(data.shape) + + x = (np.arange(data.shape[1], dtype=float) + 0.5) * channel.pixel_size_x + y = (np.arange(data.shape[0], dtype=float) + 0.5) * channel.pixel_size_y + + expected = fit_pspline_surface( + data, + x=x, + y=y, + mask=np.ones( + data.shape, + dtype=bool, + ), + weights=weights, + n_basis_x=6, + n_basis_y=6, + smoothing_x=0.8, + smoothing_y=1.7, + ) + + observed = _fit_spline_background( + channel, + weights=weights, + n_basis_x=6, + n_basis_y=6, + smoothing_x=0.8, + smoothing_y=1.7, + ) + + np.testing.assert_array_equal( + observed.model, + expected.model, + ) + np.testing.assert_array_equal( + observed.coefficients, + expected.coefficients, + ) + + +def test_exclude_mask_removes_feature_from_fit() -> None: + expected = _zero_penalty_surface() + data = expected.copy() + data[7, 8] += 100.0 + + mask = np.zeros( + data.shape, + dtype=bool, + ) + mask[7, 8] = True + + observed = estimate_spline_background( + _channel(data), + n_basis_x=6, + n_basis_y=6, + smoothing_x=4.0, + smoothing_y=7.0, + mask=mask, + mask_mode="exclude", + ) + + np.testing.assert_allclose( + observed.data, + expected, + rtol=0.0, + atol=3e-10, + ) + + +def test_include_mask_can_exclude_nonfinite_data() -> None: + data = _zero_penalty_surface() + mask = np.ones( + data.shape, + dtype=bool, + ) + + mask[7, 8] = False + data[7, 8] = np.nan + + observed = estimate_spline_background( + _channel(data), + n_basis_x=6, + n_basis_y=6, + mask=mask, + mask_mode="include", + ) + + assert np.all(np.isfinite(observed.data)) + + +def test_ignore_mode_selects_nonfinite_data() -> None: + data = _zero_penalty_surface() + data[7, 8] = np.nan + + with pytest.raises( + ValueError, + match="selected P-spline data must be finite", + ): + estimate_spline_background( + _channel(data), + n_basis_x=6, + n_basis_y=6, + mask_mode="ignore", + ) + + +@pytest.mark.parametrize( + ("mask", "mask_mode", "message"), + [ + ( + None, + "include", + "requires a mask", + ), + ( + np.ones( + (15, 17), + dtype=int, + ), + "include", + "boolean mask", + ), + ( + np.ones( + (15, 17), + dtype=bool, + ), + "unknown", + "mask_mode must be", + ), + ], +) +def test_shared_mask_contract_is_enforced( + mask: np.ndarray | None, + mask_mode: str, + message: str, +) -> None: + with pytest.raises( + (TypeError, ValueError), + match=message, + ): + estimate_spline_background( + _channel(_zero_penalty_surface()), + n_basis_x=6, + n_basis_y=6, + mask=mask, + mask_mode=mask_mode, # type: ignore[arg-type] + ) + + +def test_output_preserves_context_without_mutation() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + + original_data = channel.data.copy() + original_metadata = dict(channel.metadata) + + observed = estimate_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + ) + + assert observed is not channel + assert observed.name == channel.name + assert observed.unit == channel.unit + assert observed.x_range == channel.x_range + assert observed.y_range == channel.y_range + assert observed.direction == channel.direction + assert observed.group == channel.group + assert observed.metadata == channel.metadata + assert observed.data.flags.c_contiguous + assert observed.data.flags.writeable + + np.testing.assert_array_equal( + channel.data, + original_data, + ) + assert channel.metadata == original_metadata + + +def test_estimator_is_not_public_yet() -> None: + assert not hasattr( + analysis, + "estimate_spline_background", + ) + assert "estimate_spline_background" not in getattr(analysis, "__all__", ()) From a1ed0c1b4b45d0725d9f2e23ae3e7e5492a7b2a9 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:19:47 -0400 Subject: [PATCH 33/82] feat(background): add P-spline background removal --- src/spmkit/core/analysis/background.py | 37 +++++++++++++++++++ tests/core/test_spline_background.py | 50 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 9b36d2e..5a4bc23 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -1228,6 +1228,43 @@ def estimate_spline_background( return channel.with_data(background) +def remove_spline_background( + channel: SPMChannel, + *, + n_basis_x: int = 12, + n_basis_y: int = 12, + degree_x: int = 3, + degree_y: int = 3, + penalty_order_x: int = 2, + penalty_order_y: int = 2, + smoothing_x: float = 1.0, + smoothing_y: float = 1.0, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + weights: np.ndarray | None = None, +) -> SPMChannel: + """Subtract a global anisotropic tensor-product P-spline background.""" + background = estimate_spline_background( + channel, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=smoothing_x, + smoothing_y=smoothing_y, + mask=mask, + mask_mode=mask_mode, + weights=weights, + ) + + data = np.asarray(channel.data, dtype=float) + background_data = np.asarray(background.data, dtype=float) + + return channel.with_data(data - background_data) + + def _build_background_result( channel: SPMChannel, background: SPMChannel, diff --git a/tests/core/test_spline_background.py b/tests/core/test_spline_background.py index 66fdeca..aa45fde 100644 --- a/tests/core/test_spline_background.py +++ b/tests/core/test_spline_background.py @@ -14,6 +14,7 @@ from spmkit.core.analysis.background import ( _fit_spline_background, estimate_spline_background, + remove_spline_background, ) from spmkit.core.models import SPMChannel @@ -301,3 +302,52 @@ def test_estimator_is_not_public_yet() -> None: "estimate_spline_background", ) assert "estimate_spline_background" not in getattr(analysis, "__all__", ()) + + +def test_remove_matches_input_minus_estimate() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + + background = estimate_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + ) + corrected = remove_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + ) + + np.testing.assert_allclose( + corrected.data, + channel.data - background.data, + rtol=0.0, + atol=1e-12, + ) + + +def test_remove_preserves_context_without_mutation() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + original = channel.data.copy() + + corrected = remove_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + ) + + assert corrected is not channel + assert corrected.name == channel.name + assert corrected.unit == channel.unit + assert corrected.x_range == channel.x_range + assert corrected.y_range == channel.y_range + assert corrected.direction == channel.direction + assert corrected.group == channel.group + assert corrected.metadata == channel.metadata + + np.testing.assert_array_equal( + channel.data, + original, + ) From 5eb52dab70379dc18d84f461b8d3b762a85b6101 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:27:48 -0400 Subject: [PATCH 34/82] feat(background): add structured P-spline analysis --- src/spmkit/core/analysis/background.py | 80 ++++++++++++++++++++++++++ tests/core/test_spline_background.py | 63 ++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 5a4bc23..03a2f52 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -32,6 +32,7 @@ "rolling_ball", "median", "polynomial", + "spline", ] @@ -1407,6 +1408,85 @@ def analyze_polynomial_background( ) +def analyze_spline_background( + channel: SPMChannel, + *, + n_basis_x: int = 12, + n_basis_y: int = 12, + degree_x: int = 3, + degree_y: int = 3, + penalty_order_x: int = 2, + penalty_order_y: int = 2, + smoothing_x: float = 1.0, + smoothing_y: float = 1.0, + mask: np.ndarray | None = None, + mask_mode: Literal["ignore", "include", "exclude"] = "ignore", + weights: np.ndarray | None = None, +) -> BackgroundResult: + """Estimate and subtract a P-spline background using one fit.""" + fit = _fit_spline_background( + channel, + n_basis_x=n_basis_x, + n_basis_y=n_basis_y, + degree_x=degree_x, + degree_y=degree_y, + penalty_order_x=penalty_order_x, + penalty_order_y=penalty_order_y, + smoothing_x=smoothing_x, + smoothing_y=smoothing_y, + mask=mask, + mask_mode=mask_mode, + weights=weights, + ) + + background = channel.with_data( + np.array( + fit.model, + dtype=float, + copy=True, + order="C", + ) + ) + + return _build_background_result( + channel, + background, + method="spline", + parameters={ + "n_basis_x": int(fit.coefficients.shape[1]), + "n_basis_y": int(fit.coefficients.shape[0]), + "degree_x": int(fit.degree_x), + "degree_y": int(fit.degree_y), + "penalty_order_x": int(fit.penalty_order_x), + "penalty_order_y": int(fit.penalty_order_y), + "smoothing_x": float(fit.smoothing_x), + "smoothing_y": float(fit.smoothing_y), + "mask_mode": mask_mode, + "mask_provided": mask is not None, + "weights_provided": weights is not None, + "coordinates": "physical_pixel_centres_normalized_0_1", + "diagnostics": { + "selected_points": int(fit.selected_points), + "total_points": int(fit.total_points), + "solver_stop_code": int(fit.solver_stop_code), + "solver_iterations": int(fit.solver_iterations), + "augmented_residual_norm": float(fit.augmented_residual_norm), + "normal_residual_norm": float(fit.normal_residual_norm), + "operator_norm": float(fit.operator_norm), + "condition_estimate": float(fit.condition_estimate), + "coefficient_norm": float(fit.coefficient_norm), + "weighted_data_residual_norm": float(fit.weighted_data_residual_norm), + "penalty_x_norm": float(fit.penalty_x_norm), + "penalty_y_norm": float(fit.penalty_y_norm), + "x_min": float(fit.x_min), + "x_max": float(fit.x_max), + "y_min": float(fit.y_min), + "y_max": float(fit.y_max), + }, + }, + ) + + def analyze_median_background( channel: SPMChannel, radius_pixels: int, diff --git a/tests/core/test_spline_background.py b/tests/core/test_spline_background.py index aa45fde..9366027 100644 --- a/tests/core/test_spline_background.py +++ b/tests/core/test_spline_background.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from unittest.mock import patch + import numpy as np import pytest from scipy.interpolate import BSpline @@ -12,7 +15,9 @@ fit_pspline_surface, ) from spmkit.core.analysis.background import ( + BackgroundResult, _fit_spline_background, + analyze_spline_background, estimate_spline_background, remove_spline_background, ) @@ -351,3 +356,61 @@ def test_remove_preserves_context_without_mutation() -> None: channel.data, original, ) + + +def test_analyze_returns_serializable_structured_result() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + + result = analyze_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + smoothing_x=2.0, + smoothing_y=3.0, + ) + + assert isinstance(result, BackgroundResult) + assert result.method == "spline" + + np.testing.assert_allclose( + result.corrected.data, + channel.data - result.background.data, + rtol=0.0, + atol=1e-12, + ) + + assert result.parameters["n_basis_x"] == 6 + assert result.parameters["n_basis_y"] == 6 + assert result.parameters["smoothing_x"] == 2.0 + assert result.parameters["smoothing_y"] == 3.0 + assert result.parameters["mask_provided"] is False + assert result.parameters["weights_provided"] is False + + diagnostics = result.parameters["diagnostics"] + + assert isinstance(diagnostics, dict) + assert diagnostics["selected_points"] == data.size + assert diagnostics["total_points"] == data.size + assert diagnostics["solver_iterations"] >= 0 + assert diagnostics["condition_estimate"] >= 0.0 + + json.dumps(result.to_dict()) + + +def test_analyze_performs_exactly_one_fit() -> None: + data = _zero_penalty_surface() + channel = _channel(data) + + with patch( + "spmkit.core.analysis.background._fit_spline_background", + wraps=_fit_spline_background, + ) as fit_mock: + result = analyze_spline_background( + channel, + n_basis_x=6, + n_basis_y=6, + ) + + assert fit_mock.call_count == 1 + assert result.method == "spline" From 7556e9b9bb15aa7431093513c8be01fe5428b98b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:31:15 -0400 Subject: [PATCH 35/82] feat(analysis): export P-spline background API --- src/spmkit/core/analysis/__init__.py | 6 ++++++ tests/core/test_spline_background.py | 16 ++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index c1e0532..a79358f 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -22,16 +22,19 @@ analyze_polynomial_background, analyze_rolling_ball_background, analyze_sphere_revolution_background, + analyze_spline_background, estimate_arc_revolution_background, estimate_median_background, estimate_polynomial_background, estimate_rolling_ball_background, estimate_sphere_revolution_background, + estimate_spline_background, remove_arc_revolution_background, remove_median_background, remove_polynomial_background, remove_rolling_ball_background, remove_sphere_revolution_background, + remove_spline_background, ) from spmkit.core.analysis.forcecurve import ForceCurveFit from spmkit.core.analysis.forcevolume import VolumeResult, analyze_volume @@ -62,16 +65,19 @@ "analyze_polynomial_background", "analyze_rolling_ball_background", "analyze_sphere_revolution_background", + "analyze_spline_background", "estimate_arc_revolution_background", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", "estimate_sphere_revolution_background", + "estimate_spline_background", "remove_arc_revolution_background", "remove_median_background", "remove_polynomial_background", "remove_rolling_ball_background", "remove_sphere_revolution_background", + "remove_spline_background", "calibration", "leveling", "roughness", diff --git a/tests/core/test_spline_background.py b/tests/core/test_spline_background.py index 9366027..37267a0 100644 --- a/tests/core/test_spline_background.py +++ b/tests/core/test_spline_background.py @@ -301,12 +301,16 @@ def test_output_preserves_context_without_mutation() -> None: assert channel.metadata == original_metadata -def test_estimator_is_not_public_yet() -> None: - assert not hasattr( - analysis, - "estimate_spline_background", - ) - assert "estimate_spline_background" not in getattr(analysis, "__all__", ()) +def test_spline_background_api_is_public() -> None: + assert analysis.estimate_spline_background is estimate_spline_background + assert analysis.remove_spline_background is remove_spline_background + assert analysis.analyze_spline_background is analyze_spline_background + + exported = getattr(analysis, "__all__", ()) + + assert "estimate_spline_background" in exported + assert "remove_spline_background" in exported + assert "analyze_spline_background" in exported def test_remove_matches_input_minus_estimate() -> None: From 7610d693b097222eee7e989e0f0773504e333f53 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:40:37 -0400 Subject: [PATCH 36/82] test(validation): cross-check P-spline reconstruction with FITPACK --- tests/core/test_pspline_fitpack_validation.py | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/core/test_pspline_fitpack_validation.py diff --git a/tests/core/test_pspline_fitpack_validation.py b/tests/core/test_pspline_fitpack_validation.py new file mode 100644 index 0000000..816e682 --- /dev/null +++ b/tests/core/test_pspline_fitpack_validation.py @@ -0,0 +1,181 @@ +"""Independent FITPACK checks for exact P-spline reconstruction.""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.interpolate import ( + BSpline, + LSQBivariateSpline, +) + +from spmkit.core.analysis._pspline import ( + _open_uniform_knots, + fit_pspline_surface, +) + + +def _validation_problem() -> tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, +]: + degree = 3 + n_basis_x = 7 + n_basis_y = 6 + + x = np.linspace(0.0, 1.0, 19) + y = np.linspace(0.0, 1.0, 17) + + knots_x = _open_uniform_knots( + n_basis_x, + degree, + ) + knots_y = _open_uniform_knots( + n_basis_y, + degree, + ) + + basis_x = BSpline.design_matrix( + x, + knots_x, + degree, + ).toarray() + basis_y = BSpline.design_matrix( + y, + knots_y, + degree, + ).toarray() + + x_index = np.arange( + n_basis_x, + dtype=float, + )[None, :] + y_index = np.arange( + n_basis_y, + dtype=float, + )[:, None] + + coefficients = 1.7 + 0.31 * x_index - 0.23 * y_index + 0.047 * x_index * y_index + + surface = basis_y @ coefficients @ basis_x.T + + return ( + x, + y, + knots_x, + knots_y, + np.asarray(surface, dtype=float), + ) + + +def _fitpack_model( + x: np.ndarray, + y: np.ndarray, + knots_x: np.ndarray, + knots_y: np.ndarray, + data: np.ndarray, + selection: np.ndarray, +) -> np.ndarray: + degree = 3 + xx, yy = np.meshgrid( + x, + y, + indexing="xy", + ) + + interior_x = knots_x[degree + 1 : -(degree + 1)] + interior_y = knots_y[degree + 1 : -(degree + 1)] + + spline = LSQBivariateSpline( + xx[selection], + yy[selection], + data[selection], + interior_x, + interior_y, + kx=degree, + ky=degree, + ) + + return np.asarray( + spline.ev( + xx.ravel(order="C"), + yy.ravel(order="C"), + ).reshape(data.shape), + dtype=float, + order="C", + ) + + +@pytest.mark.parametrize( + "exclude_feature", + [False, True], + ids=[ + "complete_surface", + "excluded_feature", + ], +) +def test_fitpack_recovers_penalty_null_surface( + exclude_feature: bool, +) -> None: + x, y, knots_x, knots_y, expected = _validation_problem() + + observed = expected.copy() + selection = np.ones( + expected.shape, + dtype=bool, + ) + + if exclude_feature: + observed[8, 9] += 100.0 + selection[8, 9] = False + + fit = fit_pspline_surface( + observed, + x=x, + y=y, + mask=selection, + n_basis_x=7, + n_basis_y=6, + degree_x=3, + degree_y=3, + penalty_order_x=2, + penalty_order_y=2, + smoothing_x=3.0, + smoothing_y=7.0, + atol=1e-14, + btol=1e-14, + ) + + fitpack = _fitpack_model( + x, + y, + knots_x, + knots_y, + observed, + selection, + ) + + np.testing.assert_allclose( + fit.model, + expected, + rtol=0.0, + atol=1e-9, + ) + np.testing.assert_allclose( + fitpack, + expected, + rtol=0.0, + atol=1e-9, + ) + np.testing.assert_allclose( + fit.model, + fitpack, + rtol=0.0, + atol=1e-9, + ) + + assert fit.penalty_x_norm < 1e-9 + assert fit.penalty_y_norm < 1e-9 From 00b46a520d1fc08e8701ca534d970edb348960ac Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:12:05 -0400 Subject: [PATCH 37/82] feat(analysis): add Gwyddion-compatible height distribution --- src/spmkit/core/analysis/_flatten_base.py | 87 +++++++++++++++++++++++ tests/core/test_flatten_base_core.py | 55 ++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/spmkit/core/analysis/_flatten_base.py create mode 100644 tests/core/test_flatten_base_core.py diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py new file mode 100644 index 0000000..c4d76c1 --- /dev/null +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -0,0 +1,87 @@ +"""Pure numerical building blocks for automated flat-base levelling.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class HeightDistribution: + """Height-distribution data compatible with Gwyddion's convention.""" + + centers: np.ndarray + density: np.ndarray + bin_width: float + minimum: float + maximum: float + sample_count: int + + +def _gwyddion_height_distribution(data: np.ndarray) -> HeightDistribution: + """Return a Gwyddion-compatible automatic height distribution.""" + array = np.asarray(data) + + if np.iscomplexobj(array): + raise TypeError("height distribution requires real-valued data") + if array.ndim != 2: + raise ValueError("height distribution requires a two-dimensional array") + if array.size == 0: + raise ValueError("height distribution requires at least one value") + + try: + values = np.asarray(array, dtype=float) + except (TypeError, ValueError) as exc: + raise TypeError("height distribution requires numeric data") from exc + + if not np.all(np.isfinite(values)): + raise ValueError("height distribution requires finite data") + + sample_count = int(values.size) + bin_count = max( + 2, + int(np.floor(3.49 * np.cbrt(sample_count) + 0.5)), + ) + + minimum = float(np.min(values)) + maximum = float(np.max(values)) + density = np.zeros(bin_count, dtype=float) + + if minimum == maximum: + histogram_range = abs(maximum) if minimum != 0.0 else 1.0 + bin_width = histogram_range / bin_count + centers = (np.arange(bin_count, dtype=float) + 0.5) * bin_width + density[0] = bin_count / histogram_range + else: + histogram_range = maximum - minimum + bin_width = histogram_range / bin_count + centers = minimum + (np.arange(bin_count, dtype=float) + 0.5) * bin_width + + flat_values = values.ravel() + indices = np.floor( + (flat_values - minimum) * bin_count / histogram_range + ).astype(np.intp) + + indices[flat_values == maximum] = bin_count - 1 + valid = (indices >= 0) & (indices < bin_count) + + counts = np.bincount( + indices[valid], + minlength=bin_count, + ).astype(float) + + counted = int(np.count_nonzero(valid)) + density = counts * bin_count / (histogram_range * max(counted, 1)) + + centers.setflags(write=False) + density.setflags(write=False) + + return HeightDistribution( + centers=centers, + density=density, + bin_width=float(bin_width), + minimum=minimum, + maximum=maximum, + sample_count=sample_count, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py new file mode 100644 index 0000000..911f5b4 --- /dev/null +++ b/tests/core/test_flatten_base_core.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis._flatten_base import _gwyddion_height_distribution + + +def test_height_distribution_matches_nonconstant_gwyddion_contract() -> None: + data = np.array( + [ + [0.0, 0.2, 0.8, 1.5], + [2.1, 3.1, 3.7, 4.0], + ], + dtype=float, + ) + original = data.copy() + + result = _gwyddion_height_distribution(data) + + expected_counts = np.array([2, 1, 1, 1, 0, 1, 2]) + expected_width = 4.0 / 7.0 + expected_centers = (np.arange(7, dtype=float) + 0.5) * expected_width + expected_density = expected_counts * 7.0 / (4.0 * data.size) + + np.testing.assert_allclose(result.centers, expected_centers) + np.testing.assert_allclose(result.density, expected_density) + assert result.bin_width == pytest.approx(expected_width) + assert result.minimum == 0.0 + assert result.maximum == 4.0 + assert result.sample_count == data.size + assert np.sum(result.density) * result.bin_width == pytest.approx(1.0) + + np.testing.assert_array_equal(data, original) + assert not result.centers.flags.writeable + assert not result.density.flags.writeable + + +def test_height_distribution_preserves_gwyddion_constant_field_convention() -> None: + data = np.full((3, 3), 5.0) + + result = _gwyddion_height_distribution(data) + + expected_width = 5.0 / 7.0 + expected_centers = (np.arange(7, dtype=float) + 0.5) * expected_width + expected_density = np.zeros(7) + expected_density[0] = 7.0 / 5.0 + + np.testing.assert_allclose(result.centers, expected_centers) + np.testing.assert_allclose(result.density, expected_density) + assert result.bin_width == pytest.approx(expected_width) + assert result.minimum == 5.0 + assert result.maximum == 5.0 + assert result.sample_count == 9 + assert np.sum(result.density) * result.bin_width == pytest.approx(1.0) From 9893001751c6de0ccbf7b76c6fe41e8a470210b5 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:16:24 -0400 Subject: [PATCH 38/82] feat(analysis): add base peak window selection --- src/spmkit/core/analysis/_flatten_base.py | 92 +++++++++++++++++++++++ tests/core/test_flatten_base_core.py | 54 ++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index c4d76c1..3558959 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -85,3 +85,95 @@ def _gwyddion_height_distribution(data: np.ndarray) -> HeightDistribution: maximum=maximum, sample_count=sample_count, ) + + +@dataclass(frozen=True) +class BasePeakWindow: + """Histogram window and initial parameters for base-peak fitting.""" + + centers: np.ndarray + density: np.ndarray + peak_index: int + start_index: int + stop_index: int + initial_mean: float + initial_offset: float + initial_amplitude: float + initial_width: float + + +def _select_base_peak_window( + distribution: HeightDistribution, +) -> BasePeakWindow: + """Select Gwyddion's local histogram window around the dominant peak.""" + centers = np.asarray(distribution.centers, dtype=float) + density = np.asarray(distribution.density, dtype=float) + + if centers.ndim != 1 or density.ndim != 1: + raise ValueError("base peak estimation requires one-dimensional histogram data") + if centers.size != density.size: + raise ValueError("base peak estimation requires matching centers and density") + if centers.size < 7: + raise ValueError( + "base peak estimation requires at least seven histogram bins" + ) + if not np.all(np.isfinite(centers)) or not np.all(np.isfinite(density)): + raise ValueError("base peak estimation requires finite histogram data") + if not np.isfinite(distribution.bin_width) or distribution.bin_width <= 0.0: + raise ValueError("base peak estimation requires a positive bin width") + + peak_index = int(np.argmax(density)) + peak_height = float(density[peak_index]) + + if peak_height <= 0.0: + raise ValueError("base peak estimation requires a positive histogram peak") + + threshold = 0.3 * peak_height + + start_index = peak_index + while start_index > 0: + if density[start_index] < threshold: + break + start_index -= 1 + + end_index = peak_index + last_index = density.size - 1 + while end_index < last_index: + if density[end_index] < threshold: + break + end_index += 1 + + sample_count = end_index + 1 - start_index + while sample_count < 7: + if start_index > 0: + start_index -= 1 + if end_index < last_index: + end_index += 1 + sample_count = end_index + 1 - start_index + + stop_index = end_index + 1 + selected_centers = np.array( + centers[start_index:stop_index], + dtype=float, + copy=True, + ) + selected_density = np.array( + density[start_index:stop_index], + dtype=float, + copy=True, + ) + + selected_centers.setflags(write=False) + selected_density.setflags(write=False) + + return BasePeakWindow( + centers=selected_centers, + density=selected_density, + peak_index=peak_index, + start_index=start_index, + stop_index=stop_index, + initial_mean=float(centers[peak_index]), + initial_offset=0.0, + initial_amplitude=peak_height, + initial_width=0.3 * sample_count * float(distribution.bin_width), + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 911f5b4..053e492 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -3,7 +3,11 @@ import numpy as np import pytest -from spmkit.core.analysis._flatten_base import _gwyddion_height_distribution +from spmkit.core.analysis._flatten_base import ( + HeightDistribution, + _gwyddion_height_distribution, + _select_base_peak_window, +) def test_height_distribution_matches_nonconstant_gwyddion_contract() -> None: @@ -53,3 +57,51 @@ def test_height_distribution_preserves_gwyddion_constant_field_convention() -> N assert result.maximum == 5.0 assert result.sample_count == 9 assert np.sum(result.density) * result.bin_width == pytest.approx(1.0) + + + +def test_base_peak_window_matches_gwyddion_selection_rules() -> None: + centers = np.arange(9, dtype=float) + 0.5 + density = np.array( + [0.1, 0.2, 1.0, 1.0, 0.29, 0.1, 0.0, 0.0, 0.0], + dtype=float, + ) + distribution = HeightDistribution( + centers=centers, + density=density, + bin_width=1.0, + minimum=0.0, + maximum=9.0, + sample_count=100, + ) + + result = _select_base_peak_window(distribution) + + assert result.peak_index == 2 + assert result.start_index == 0 + assert result.stop_index == 7 + np.testing.assert_array_equal(result.centers, centers[:7]) + np.testing.assert_array_equal(result.density, density[:7]) + assert result.initial_mean == pytest.approx(2.5) + assert result.initial_offset == 0.0 + assert result.initial_amplitude == pytest.approx(1.0) + assert result.initial_width == pytest.approx(2.1) + assert not result.centers.flags.writeable + assert not result.density.flags.writeable + + +def test_base_peak_window_rejects_fewer_than_seven_bins() -> None: + distribution = HeightDistribution( + centers=np.arange(6, dtype=float) + 0.5, + density=np.ones(6), + bin_width=1.0, + minimum=0.0, + maximum=6.0, + sample_count=8, + ) + + with pytest.raises( + ValueError, + match="base peak estimation requires at least seven histogram bins", + ): + _select_base_peak_window(distribution) From 37aa82572ff9d9fe71018b2c184a586f9e3e6c6d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:34:15 -0400 Subject: [PATCH 39/82] feat(analysis): add cross-validated base peak fitting --- src/spmkit/core/analysis/_flatten_base.py | 183 ++++++++++++++++++++++ tests/core/test_flatten_base_core.py | 113 +++++++++++++ 2 files changed, 296 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 3558959..b558193 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import numpy as np +from scipy.optimize import least_squares @dataclass(frozen=True) @@ -177,3 +178,185 @@ def _select_base_peak_window( initial_amplitude=peak_height, initial_width=0.3 * sample_count * float(distribution.bin_width), ) + + +@dataclass(frozen=True) +class BasePeakFit: + """Result and identifiability diagnostics for a Gaussian base peak.""" + + mean: float + rms: float + offset: float + amplitude: float + width: float + residual_norm: float + solver_success: bool + covariance_available: bool + evaluations: int + jacobian_rank: int + condition_estimate: float + + @property + def success(self) -> bool: + """Whether the fitted peak is both converged and identifiable.""" + return self.solver_success and self.covariance_available + + +def _fit_base_peak(window: BasePeakWindow) -> BasePeakFit: + """Fit Gwyddion's Gaussian parameterization to a selected peak window.""" + centers = np.asarray(window.centers, dtype=float) + density = np.asarray(window.density, dtype=float) + + if centers.ndim != 1 or density.ndim != 1: + raise ValueError("base peak fitting requires one-dimensional data") + if centers.size != density.size: + raise ValueError("base peak fitting requires matching centers and density") + if centers.size < 4: + raise ValueError("base peak fitting requires at least four samples") + if not np.all(np.isfinite(centers)) or not np.all(np.isfinite(density)): + raise ValueError("base peak fitting requires finite data") + + initial_width = abs(float(window.initial_width)) + initial = np.array( + [ + window.initial_mean, + window.initial_offset, + window.initial_amplitude, + initial_width, + ], + dtype=float, + ) + + if not np.all(np.isfinite(initial)): + raise ValueError("base peak fitting requires finite initial parameters") + if initial_width == 0.0: + raise ValueError("base peak fitting requires a non-zero initial width") + + coordinate_span = max(float(np.ptp(centers)), 1.0) + width_floor = np.finfo(float).eps * coordinate_span + + def gaussian_components( + parameters: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, float]: + mean, _, _, width = parameters + safe_width = float(width) + + if abs(safe_width) < width_floor: + safe_width = np.copysign( + width_floor, + safe_width if safe_width != 0.0 else 1.0, + ) + + delta = centers - mean + scaled = delta / safe_width + exponential = np.exp(-np.square(scaled)) + return exponential, scaled, safe_width + + def residuals(parameters: np.ndarray) -> np.ndarray: + _, offset, amplitude, _ = parameters + exponential, _, _ = gaussian_components(parameters) + return offset + amplitude * exponential - density + + def jacobian(parameters: np.ndarray) -> np.ndarray: + _, _, amplitude, _ = parameters + exponential, scaled, safe_width = gaussian_components(parameters) + + return np.column_stack( + ( + 2.0 * amplitude * exponential * scaled / safe_width, + np.ones_like(centers), + exponential, + 2.0 * amplitude * exponential * np.square(scaled) / safe_width, + ) + ) + + def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: + singular_values = np.linalg.svd(matrix, compute_uv=False) + + if singular_values.size == 0 or singular_values[0] == 0.0: + return 0, float("inf") + + tolerance = ( + np.finfo(float).eps + * max(matrix.shape) + * singular_values[0] + ) + rank = int(np.count_nonzero(singular_values > tolerance)) + + if rank < 4 or singular_values[-1] <= tolerance: + return rank, float("inf") + + return rank, float(singular_values[0] / singular_values[-1]) + + density_scale = max(float(np.max(np.abs(density))), 1.0) + constant_tolerance = 32.0 * np.finfo(float).eps * density_scale + + if float(np.ptp(density)) <= constant_tolerance: + parameters = np.array( + [ + window.initial_mean, + float(np.mean(density)), + 0.0, + initial_width, + ], + dtype=float, + ) + jacobian_rank, condition_estimate = rank_and_condition( + jacobian(parameters) + ) + + return BasePeakFit( + mean=float(parameters[0]), + rms=initial_width / np.sqrt(2.0), + offset=float(parameters[1]), + amplitude=0.0, + width=initial_width, + residual_norm=float(np.linalg.norm(residuals(parameters))), + solver_success=False, + covariance_available=False, + evaluations=0, + jacobian_rank=jacobian_rank, + condition_estimate=condition_estimate, + ) + + solution = least_squares( + residuals, + initial, + jac=jacobian, + method="lm", + ftol=1e-12, + xtol=1e-12, + gtol=1e-12, + max_nfev=2000, + ) + + parameters = np.asarray(solution.x, dtype=float) + width = abs(float(parameters[3])) + jacobian_rank, condition_estimate = rank_and_condition( + np.asarray(solution.jac, dtype=float) + ) + + solver_success = bool( + solution.success + and np.all(np.isfinite(parameters)) + and np.all(np.isfinite(solution.fun)) + ) + covariance_available = bool( + solver_success + and jacobian_rank == 4 + and width > width_floor + ) + + return BasePeakFit( + mean=float(parameters[0]), + rms=width / np.sqrt(2.0), + offset=float(parameters[1]), + amplitude=float(parameters[2]), + width=width, + residual_norm=float(np.linalg.norm(solution.fun)), + solver_success=solver_success, + covariance_available=covariance_available, + evaluations=int(solution.nfev), + jacobian_rank=jacobian_rank, + condition_estimate=condition_estimate, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 053e492..87e4209 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -4,7 +4,9 @@ import pytest from spmkit.core.analysis._flatten_base import ( + BasePeakWindow, HeightDistribution, + _fit_base_peak, _gwyddion_height_distribution, _select_base_peak_window, ) @@ -105,3 +107,114 @@ def test_base_peak_window_rejects_fewer_than_seven_bins() -> None: match="base peak estimation requires at least seven histogram bins", ): _select_base_peak_window(distribution) + + +def test_base_peak_fit_recovers_exact_gaussian() -> None: + centers = np.linspace(-3.0, 3.0, 17) + expected_mean = 0.35 + expected_offset = 0.18 + expected_amplitude = 2.4 + expected_width = 1.1 + + density = expected_offset + expected_amplitude * np.exp( + -np.square((centers - expected_mean) / expected_width) + ) + peak_index = int(np.argmax(density)) + + window = BasePeakWindow( + centers=centers, + density=density, + peak_index=peak_index, + start_index=0, + stop_index=centers.size, + initial_mean=float(centers[peak_index]), + initial_offset=0.0, + initial_amplitude=float(density[peak_index]), + initial_width=1.8, + ) + + result = _fit_base_peak(window) + + assert result.solver_success + assert result.covariance_available + assert result.success + assert result.mean == pytest.approx(expected_mean, abs=1e-8) + assert result.offset == pytest.approx(expected_offset, abs=1e-8) + assert result.amplitude == pytest.approx(expected_amplitude, abs=1e-8) + assert result.width == pytest.approx(expected_width, abs=1e-8) + assert result.rms == pytest.approx(expected_width / np.sqrt(2.0), abs=1e-8) + assert result.residual_norm < 1e-9 + assert result.evaluations > 0 + assert result.jacobian_rank == 4 + assert np.isfinite(result.condition_estimate) + + +def test_base_peak_fit_marks_constant_density_as_unidentifiable() -> None: + centers = np.linspace(-3.0, 3.0, 7) + density = np.ones_like(centers) + + window = BasePeakWindow( + centers=centers, + density=density, + peak_index=0, + start_index=0, + stop_index=centers.size, + initial_mean=float(centers[0]), + initial_offset=0.0, + initial_amplitude=1.0, + initial_width=2.1, + ) + + result = _fit_base_peak(window) + + assert not result.covariance_available + assert not result.success + assert result.jacobian_rank < 4 + + +def test_base_peak_fit_matches_gwyddion_271_reference() -> None: + """Cross-check a perturbed Gaussian against a direct Gwyddion 2.71 probe.""" + centers = -3.0 + 0.375 * np.arange(17, dtype=float) + density = ( + 0.18 + + 2.4 * np.exp(-np.square((centers - 0.35) / 1.1)) + + 0.015 * np.sin(1.7 * centers) + ) + peak_index = int(np.argmax(density)) + + window = BasePeakWindow( + centers=centers, + density=density, + peak_index=peak_index, + start_index=0, + stop_index=centers.size, + initial_mean=float(centers[peak_index]), + initial_offset=0.0, + initial_amplitude=float(density[peak_index]), + initial_width=1.8, + ) + + result = _fit_base_peak(window) + + # Frozen from a direct libgwyddion 2.71 C reference probe. + assert result.success + assert result.mean == pytest.approx( + 0.35624774459072917, + abs=5e-10, + ) + assert result.rms == pytest.approx( + 0.77337116119210381, + abs=5e-10, + ) + assert result.offset == pytest.approx( + 0.18099383510469755, + abs=5e-10, + ) + assert result.amplitude == pytest.approx( + 2.4105144489572057, + abs=5e-10, + ) + assert result.width == pytest.approx( + 1.0937119849061023, + abs=5e-10, + ) From 0e679f4d1cefdea7177b3e8a0bb9dc58c2d70d28 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:37:04 -0400 Subject: [PATCH 40/82] feat(analysis): compose base peak estimation --- src/spmkit/core/analysis/_flatten_base.py | 37 ++++++++++ tests/core/test_flatten_base_core.py | 86 +++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index b558193..3e97e28 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -360,3 +360,40 @@ def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: jacobian_rank=jacobian_rank, condition_estimate=condition_estimate, ) + + +@dataclass(frozen=True) +class BasePeakEstimate: + """Complete base-peak estimate with intermediate numerical evidence.""" + + distribution: HeightDistribution + window: BasePeakWindow + fit: BasePeakFit + + @property + def success(self) -> bool: + """Whether the Gaussian base peak is identifiable.""" + return self.fit.success + + @property + def mean(self) -> float: + """Fitted base-peak position.""" + return self.fit.mean + + @property + def rms(self) -> float: + """Fitted base-peak RMS width.""" + return self.fit.rms + + +def _estimate_base_peak(data: np.ndarray) -> BasePeakEstimate: + """Estimate the dominant base peak from a two-dimensional field.""" + distribution = _gwyddion_height_distribution(data) + window = _select_base_peak_window(distribution) + fit = _fit_base_peak(window) + + return BasePeakEstimate( + distribution=distribution, + window=window, + fit=fit, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 87e4209..fb48238 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -3,7 +3,9 @@ import numpy as np import pytest +import spmkit.core.analysis._flatten_base as flatten_base_core from spmkit.core.analysis._flatten_base import ( + BasePeakFit, BasePeakWindow, HeightDistribution, _fit_base_peak, @@ -218,3 +220,87 @@ def test_base_peak_fit_matches_gwyddion_271_reference() -> None: 1.0937119849061023, abs=5e-10, ) + + +def test_estimate_base_peak_composes_verified_stages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.arange(64, dtype=float).reshape(8, 8) + original = data.copy() + + distribution = HeightDistribution( + centers=np.arange(7, dtype=float) + 0.5, + density=np.array([0.1, 0.3, 1.0, 0.4, 0.2, 0.1, 0.0]), + bin_width=1.0, + minimum=0.0, + maximum=7.0, + sample_count=data.size, + ) + window = BasePeakWindow( + centers=distribution.centers, + density=distribution.density, + peak_index=2, + start_index=0, + stop_index=7, + initial_mean=2.5, + initial_offset=0.0, + initial_amplitude=1.0, + initial_width=2.1, + ) + fit = BasePeakFit( + mean=2.45, + rms=0.4, + offset=0.01, + amplitude=0.99, + width=0.4 * np.sqrt(2.0), + residual_norm=1e-8, + solver_success=True, + covariance_available=True, + evaluations=12, + jacobian_rank=4, + condition_estimate=8.0, + ) + + calls: list[str] = [] + + def fake_distribution(received: np.ndarray) -> HeightDistribution: + assert received is data + calls.append("distribution") + return distribution + + def fake_window(received: HeightDistribution) -> BasePeakWindow: + assert received is distribution + calls.append("window") + return window + + def fake_fit(received: BasePeakWindow) -> BasePeakFit: + assert received is window + calls.append("fit") + return fit + + monkeypatch.setattr( + flatten_base_core, + "_gwyddion_height_distribution", + fake_distribution, + ) + monkeypatch.setattr( + flatten_base_core, + "_select_base_peak_window", + fake_window, + ) + monkeypatch.setattr( + flatten_base_core, + "_fit_base_peak", + fake_fit, + ) + + result = flatten_base_core._estimate_base_peak(data) + + assert calls == ["distribution", "window", "fit"] + assert result.distribution is distribution + assert result.window is window + assert result.fit is fit + assert result.success + assert result.mean == fit.mean + assert result.rms == fit.rms + np.testing.assert_array_equal(data, original) From 115b56521fd22652da0e15671441a628db3947cd Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:44:27 -0400 Subject: [PATCH 41/82] feat(analysis): add cross-validated facet plane estimation --- src/spmkit/core/analysis/_flatten_base.py | 126 ++++++++++++++++++++ tests/core/test_flatten_base_core.py | 136 ++++++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 3e97e28..f711475 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -397,3 +397,129 @@ def _estimate_base_peak(data: np.ndarray) -> BasePeakEstimate: window=window, fit=fit, ) + + +@dataclass(frozen=True) +class FacetPlaneEstimate: + """Dominant-plane estimate using Gwyddion's facet weighting.""" + + intercept: float + x_coefficient: float + y_coefficient: float + physical_slope_x: float + physical_slope_y: float + slope_scale_squared: float + cell_count: int + weight_sum: float + degenerate: bool + + +def _estimate_gwyddion_facet_plane( + data: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, +) -> FacetPlaneEstimate: + """Estimate one dominant-plane correction without modifying the field.""" + array = np.asarray(data) + + if np.issubdtype(array.dtype, np.bool_): + raise TypeError("facet-plane estimation requires real-valued data") + if np.iscomplexobj(array): + raise TypeError("facet-plane estimation requires real-valued data") + if array.ndim != 2: + raise ValueError("facet-plane estimation requires a two-dimensional array") + if array.shape[0] < 2 or array.shape[1] < 2: + raise ValueError("facet-plane estimation requires at least one pixel cell") + + try: + values = np.asarray(array, dtype=float) + except (TypeError, ValueError) as exc: + raise TypeError("facet-plane estimation requires numeric data") from exc + + if not np.all(np.isfinite(values)): + raise ValueError("facet-plane estimation requires finite data") + + def positive_pixel_size(value: float, *, name: str) -> float: + if isinstance(value, (bool, np.bool_)) or np.iscomplexobj(value): + raise TypeError(f"facet-plane estimation requires {name} to be real") + + try: + scalar = float(value) + except (TypeError, ValueError) as exc: + raise TypeError( + f"facet-plane estimation requires {name} to be real" + ) from exc + + if not np.isfinite(scalar) or scalar <= 0.0: + raise ValueError( + f"facet-plane estimation requires {name} to be positive" + ) + + return scalar + + dx = positive_pixel_size(pixel_size_x, name="pixel_size_x") + dy = positive_pixel_size(pixel_size_y, name="pixel_size_y") + + x_slopes = ( + values[1:, 1:] + + values[:-1, 1:] + - values[1:, :-1] + - values[:-1, :-1] + ) / (2.0 * dx) + + y_slopes = ( + values[1:, :-1] + + values[1:, 1:] + - values[:-1, :-1] + - values[:-1, 1:] + ) / (2.0 * dy) + + if not np.all(np.isfinite(x_slopes)) or not np.all(np.isfinite(y_slopes)): + raise ValueError("facet-plane estimation produced non-finite slopes") + + squared_slopes = np.square(x_slopes) + np.square(y_slopes) + cell_count = int(squared_slopes.size) + slope_scale_squared = float(np.mean(squared_slopes) / 20.0) + + if slope_scale_squared == 0.0: + return FacetPlaneEstimate( + intercept=0.0, + x_coefficient=0.0, + y_coefficient=0.0, + physical_slope_x=0.0, + physical_slope_y=0.0, + slope_scale_squared=0.0, + cell_count=cell_count, + weight_sum=float(cell_count), + degenerate=True, + ) + + weights = np.exp(-squared_slopes / slope_scale_squared) + weight_sum = float(np.sum(weights)) + + if not np.isfinite(weight_sum) or weight_sum <= 0.0: + raise ValueError("facet-plane estimation produced invalid weights") + + physical_slope_x = float(np.sum(x_slopes * weights) / weight_sum) + physical_slope_y = float(np.sum(y_slopes * weights) / weight_sum) + + x_coefficient = physical_slope_x * dx + y_coefficient = physical_slope_y * dy + rows, columns = values.shape + intercept = -0.5 * ( + x_coefficient * columns + + y_coefficient * rows + ) + + return FacetPlaneEstimate( + intercept=float(intercept), + x_coefficient=float(x_coefficient), + y_coefficient=float(y_coefficient), + physical_slope_x=physical_slope_x, + physical_slope_y=physical_slope_y, + slope_scale_squared=slope_scale_squared, + cell_count=cell_count, + weight_sum=weight_sum, + degenerate=False, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index fb48238..554924d 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -304,3 +304,139 @@ def fake_fit(received: BasePeakWindow) -> BasePeakFit: assert result.mean == fit.mean assert result.rms == fit.rms np.testing.assert_array_equal(data, original) + + +def test_gwyddion_facet_plane_recovers_exact_physical_tilt() -> None: + rows = 4 + columns = 5 + pixel_size_x = 2.0 + pixel_size_y = 0.5 + expected_physical_x = 0.3 + expected_physical_y = -0.2 + + x = np.arange(columns, dtype=float) * pixel_size_x + y = np.arange(rows, dtype=float) * pixel_size_y + xx, yy = np.meshgrid(x, y) + + data = 7.0 + expected_physical_x * xx + expected_physical_y * yy + original = data.copy() + + result = flatten_base_core._estimate_gwyddion_facet_plane( + data, + pixel_size_x=pixel_size_x, + pixel_size_y=pixel_size_y, + ) + + expected_x_coefficient = expected_physical_x * pixel_size_x + expected_y_coefficient = expected_physical_y * pixel_size_y + expected_scale_squared = ( + expected_physical_x**2 + expected_physical_y**2 + ) / 20.0 + expected_intercept = -0.5 * ( + expected_x_coefficient * columns + + expected_y_coefficient * rows + ) + expected_cells = (rows - 1) * (columns - 1) + + assert not result.degenerate + assert result.cell_count == expected_cells + assert result.physical_slope_x == pytest.approx(expected_physical_x) + assert result.physical_slope_y == pytest.approx(expected_physical_y) + assert result.x_coefficient == pytest.approx(expected_x_coefficient) + assert result.y_coefficient == pytest.approx(expected_y_coefficient) + assert result.intercept == pytest.approx(expected_intercept) + assert result.slope_scale_squared == pytest.approx( + expected_scale_squared + ) + assert result.weight_sum == pytest.approx( + expected_cells * np.exp(-20.0) + ) + + np.testing.assert_array_equal(data, original) + + +def test_gwyddion_facet_plane_handles_flat_field_without_nan() -> None: + data = np.full((4, 5), 3.2) + + result = flatten_base_core._estimate_gwyddion_facet_plane( + data, + pixel_size_x=0.4, + pixel_size_y=0.7, + ) + + assert result.degenerate + assert result.cell_count == 12 + assert result.intercept == 0.0 + assert result.x_coefficient == 0.0 + assert result.y_coefficient == 0.0 + assert result.physical_slope_x == 0.0 + assert result.physical_slope_y == 0.0 + assert result.slope_scale_squared == 0.0 + assert result.weight_sum == pytest.approx(12.0) + + +def test_gwyddion_facet_plane_matches_gwyddion_271_reference() -> None: + """Cross-check the facet estimator against direct libgwyddion 2.71.""" + rows = 6 + columns = 7 + pixel_size_x = 2.0 + pixel_size_y = 0.5 + + data = np.empty((rows, columns), dtype=float) + + for row in range(rows): + for column in range(columns): + x = column * pixel_size_x + y = row * pixel_size_y + value = ( + 7.0 + + 0.3 * x + - 0.2 * y + + 0.04 * np.sin(0.7 * column + 0.3 * row) + ) + + if row == 1 and column == 2: + value += 4.0 + if row == 3 and column == 5: + value += 2.5 + + data[row, column] = value + + result = flatten_base_core._estimate_gwyddion_facet_plane( + data, + pixel_size_x=pixel_size_x, + pixel_size_y=pixel_size_y, + ) + + # Frozen from a direct gwy_data_field_fit_facet_plane() probe. + assert not result.degenerate + assert result.intercept == pytest.approx( + -1.7454698947303242, + abs=5e-13, + ) + assert result.x_coefficient == pytest.approx( + 0.58850087792355377, + abs=5e-13, + ) + assert result.y_coefficient == pytest.approx( + -0.10476105933403794, + abs=5e-13, + ) + assert result.physical_slope_x == pytest.approx( + 0.29425043896177688, + abs=5e-13, + ) + assert result.physical_slope_y == pytest.approx( + -0.20952211866807588, + abs=5e-13, + ) + + assert result.cell_count == 30 + assert result.slope_scale_squared == pytest.approx( + 0.16422579481110028, + abs=5e-14, + ) + assert result.weight_sum == pytest.approx( + 9.9247467971063745, + abs=5e-13, + ) From b268ad8d4e668cc252debe8b2e5fd398e204c451 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:48:51 -0400 Subject: [PATCH 42/82] feat(analysis): add Flatten Base facet stage --- src/spmkit/core/analysis/_flatten_base.py | 122 +++++++++++ tests/core/test_flatten_base_core.py | 252 ++++++++++++++++++++++ 2 files changed, 374 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index f711475..5515fc2 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -523,3 +523,125 @@ def positive_pixel_size(value: float, *, name: str) -> float: weight_sum=weight_sum, degenerate=False, ) + + +@dataclass(frozen=True) +class FacetStageIteration: + """One facet correction and the base peak estimated afterwards.""" + + index: int + plane: FacetPlaneEstimate + peak: BasePeakEstimate + + +@dataclass(frozen=True) +class FacetStageResult: + """Result and evidence from the five-step Flatten Base facet stage.""" + + corrected: np.ndarray + background: np.ndarray + initial_peak: BasePeakEstimate + iterations: tuple[FacetStageIteration, ...] + termination: str + + @property + def completed_iterations(self) -> int: + """Number of facet planes actually subtracted.""" + return len(self.iterations) + + +def _run_flatten_base_facet_stage( + data: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, +) -> FacetStageResult: + """Run the facet-levelling stage used by Gwyddion Flatten Base.""" + array = np.asarray(data) + + if np.issubdtype(array.dtype, np.bool_) or np.iscomplexobj(array): + raise TypeError("flatten-base facet stage requires real-valued data") + if array.ndim != 2: + raise ValueError( + "flatten-base facet stage requires a two-dimensional array" + ) + if array.shape[0] < 2 or array.shape[1] < 2: + raise ValueError( + "flatten-base facet stage requires at least one pixel cell" + ) + + try: + working = np.array(array, dtype=float, copy=True) + except (TypeError, ValueError) as exc: + raise TypeError( + "flatten-base facet stage requires numeric data" + ) from exc + + if not np.all(np.isfinite(working)): + raise ValueError("flatten-base facet stage requires finite data") + + background = np.zeros_like(working) + initial_peak = _estimate_base_peak(working) + + rows, columns = working.shape + column_indices = np.arange(columns, dtype=float) + row_indices = np.arange(rows, dtype=float) + xx, yy = np.meshgrid(column_indices, row_indices) + + iterations: list[FacetStageIteration] = [] + termination = "maximum_iterations" + + for index in range(5): + plane = _estimate_gwyddion_facet_plane( + working, + pixel_size_x=pixel_size_x, + pixel_size_y=pixel_size_y, + ) + + if plane.degenerate: + termination = "degenerate_plane" + break + + plane_surface = ( + plane.intercept + + plane.x_coefficient * xx + + plane.y_coefficient * yy + ) + + if not np.all(np.isfinite(plane_surface)): + raise ValueError( + "flatten-base facet stage produced a non-finite plane" + ) + + working -= plane_surface + background += plane_surface + + peak = _estimate_base_peak(working) + iterations.append( + FacetStageIteration( + index=index, + plane=plane, + peak=peak, + ) + ) + + if not peak.success: + termination = "peak_failure" + break + + corrected = np.array(working, dtype=float, copy=True) + accumulated_background = np.array( + background, + dtype=float, + copy=True, + ) + corrected.setflags(write=False) + accumulated_background.setflags(write=False) + + return FacetStageResult( + corrected=corrected, + background=accumulated_background, + initial_peak=initial_peak, + iterations=tuple(iterations), + termination=termination, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 554924d..6d6363d 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -440,3 +440,255 @@ def test_gwyddion_facet_plane_matches_gwyddion_271_reference() -> None: 9.9247467971063745, abs=5e-13, ) + + +def test_flatten_base_facet_stage_runs_exactly_five_iterations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 4 + columns = 5 + data = np.arange(rows * columns, dtype=float).reshape(rows, columns) + original = data.copy() + + plane = flatten_base_core.FacetPlaneEstimate( + intercept=0.4, + x_coefficient=0.2, + y_coefficient=-0.1, + physical_slope_x=0.1, + physical_slope_y=-0.2, + slope_scale_squared=0.03, + cell_count=(rows - 1) * (columns - 1), + weight_sum=6.0, + degenerate=False, + ) + + class SuccessfulPeak: + success = True + + peak = SuccessfulPeak() + facet_inputs: list[np.ndarray] = [] + peak_inputs: list[np.ndarray] = [] + + def fake_facet( + received: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, + ) -> flatten_base_core.FacetPlaneEstimate: + assert pixel_size_x == 2.0 + assert pixel_size_y == 0.5 + facet_inputs.append(received.copy()) + return plane + + def fake_peak(received: np.ndarray) -> SuccessfulPeak: + peak_inputs.append(received.copy()) + return peak + + monkeypatch.setattr( + flatten_base_core, + "_estimate_gwyddion_facet_plane", + fake_facet, + ) + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_peak, + ) + + result = flatten_base_core._run_flatten_base_facet_stage( + data, + pixel_size_x=2.0, + pixel_size_y=0.5, + ) + + column_indices = np.arange(columns, dtype=float) + row_indices = np.arange(rows, dtype=float) + xx, yy = np.meshgrid(column_indices, row_indices) + single_plane = ( + plane.intercept + + plane.x_coefficient * xx + + plane.y_coefficient * yy + ) + + np.testing.assert_allclose( + result.background, + 5.0 * single_plane, + ) + np.testing.assert_allclose( + result.corrected, + data - 5.0 * single_plane, + ) + + assert len(facet_inputs) == 5 + assert len(peak_inputs) == 6 + assert result.initial_peak is peak + assert result.completed_iterations == 5 + assert len(result.iterations) == 5 + assert result.termination == "maximum_iterations" + + for index, iteration in enumerate(result.iterations): + assert iteration.index == index + assert iteration.plane is plane + assert iteration.peak is peak + + np.testing.assert_array_equal(data, original) + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable + + +def test_flatten_base_facet_stage_stops_before_degenerate_plane( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.arange(20, dtype=float).reshape(4, 5) + original = data.copy() + + class SuccessfulPeak: + success = True + + peak = SuccessfulPeak() + degenerate_plane = flatten_base_core.FacetPlaneEstimate( + intercept=0.0, + x_coefficient=0.0, + y_coefficient=0.0, + physical_slope_x=0.0, + physical_slope_y=0.0, + slope_scale_squared=0.0, + cell_count=12, + weight_sum=12.0, + degenerate=True, + ) + + peak_calls: list[np.ndarray] = [] + facet_calls: list[np.ndarray] = [] + + def fake_peak(received: np.ndarray) -> SuccessfulPeak: + peak_calls.append(received.copy()) + return peak + + def fake_facet( + received: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, + ) -> flatten_base_core.FacetPlaneEstimate: + assert pixel_size_x == 1.0 + assert pixel_size_y == 1.0 + facet_calls.append(received.copy()) + return degenerate_plane + + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_peak, + ) + monkeypatch.setattr( + flatten_base_core, + "_estimate_gwyddion_facet_plane", + fake_facet, + ) + + result = flatten_base_core._run_flatten_base_facet_stage( + data, + pixel_size_x=1.0, + pixel_size_y=1.0, + ) + + assert result.initial_peak is peak + assert result.termination == "degenerate_plane" + assert result.completed_iterations == 0 + assert result.iterations == () + assert len(peak_calls) == 1 + assert len(facet_calls) == 1 + + np.testing.assert_array_equal(result.corrected, data) + np.testing.assert_array_equal( + result.background, + np.zeros_like(data), + ) + np.testing.assert_array_equal(data, original) + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable + + +def test_flatten_base_facet_stage_keeps_correction_before_peak_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 4 + columns = 5 + data = np.arange(rows * columns, dtype=float).reshape(rows, columns) + + class Peak: + def __init__(self, success: bool) -> None: + self.success = success + + initial_peak = Peak(success=True) + failed_peak = Peak(success=False) + peak_results = [initial_peak, failed_peak] + + plane = flatten_base_core.FacetPlaneEstimate( + intercept=0.3, + x_coefficient=0.15, + y_coefficient=-0.05, + physical_slope_x=0.075, + physical_slope_y=-0.1, + slope_scale_squared=0.02, + cell_count=12, + weight_sum=7.0, + degenerate=False, + ) + + facet_calls = 0 + + def fake_peak(received: np.ndarray) -> Peak: + del received + return peak_results.pop(0) + + def fake_facet( + received: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, + ) -> flatten_base_core.FacetPlaneEstimate: + nonlocal facet_calls + del received, pixel_size_x, pixel_size_y + facet_calls += 1 + return plane + + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_peak, + ) + monkeypatch.setattr( + flatten_base_core, + "_estimate_gwyddion_facet_plane", + fake_facet, + ) + + result = flatten_base_core._run_flatten_base_facet_stage( + data, + pixel_size_x=2.0, + pixel_size_y=0.5, + ) + + xx, yy = np.meshgrid( + np.arange(columns, dtype=float), + np.arange(rows, dtype=float), + ) + expected_plane = ( + plane.intercept + + plane.x_coefficient * xx + + plane.y_coefficient * yy + ) + + assert facet_calls == 1 + assert peak_results == [] + assert result.initial_peak is initial_peak + assert result.termination == "peak_failure" + assert result.completed_iterations == 1 + assert result.iterations[0].index == 0 + assert result.iterations[0].plane is plane + assert result.iterations[0].peak is failed_peak + + np.testing.assert_allclose(result.background, expected_plane) + np.testing.assert_allclose(result.corrected, data - expected_plane) From 954a7b8b5d650679df583c7627f5d7cb15584bf2 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:57:57 -0400 Subject: [PATCH 43/82] refactor(analysis): extract polynomial surface solver --- src/spmkit/core/analysis/leveling.py | 91 ++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index d16aa54..6ce0536 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -655,6 +655,72 @@ def three_point_level( return channel.with_data(data - plane) +def _fit_polynomial_surface_data( + data: np.ndarray, + *, + powers: tuple[tuple[int, int], ...], + selection: np.ndarray, + operation: str, +) -> tuple[np.ndarray, np.ndarray, int, np.ndarray]: + """Fit and evaluate polynomial terms on normalized pixel coordinates.""" + values = np.asarray(data, dtype=float) + selected_points = np.asarray(selection, dtype=bool) + + if values.ndim != 2: + raise ValueError(f"{operation} requires a two-dimensional array") + if selected_points.shape != values.shape: + raise ValueError(f"{operation} requires selection to match data shape") + if not powers: + raise ValueError(f"{operation} requires at least one polynomial term") + + selected_count = int(np.count_nonzero(selected_points)) + if selected_count < len(powers): + raise ValueError( + f"{operation} requires at least {len(powers)} selected points" + ) + + rows, columns = values.shape + x_coordinates = ( + np.linspace(-1.0, 1.0, columns) + if columns > 1 + else np.zeros(columns) + ) + y_coordinates = ( + np.linspace(-1.0, 1.0, rows) + if rows > 1 + else np.zeros(rows) + ) + xx, yy = np.meshgrid(x_coordinates, y_coordinates) + + terms = [ + (xx**x_power) * (yy**y_power) + for x_power, y_power in powers + ] + design = np.column_stack([term.ravel() for term in terms]) + selected = selected_points.ravel() + + coefficients, _, rank, singular_values = np.linalg.lstsq( + design[selected], + values.ravel()[selected], + rcond=None, + ) + + if rank < len(powers): + raise ValueError( + f"{operation} selected points do not define " + "a unique polynomial background" + ) + + background = (design @ coefficients).reshape(values.shape) + + return ( + background, + coefficients, + int(rank), + singular_values, + ) + + def _estimate_polynomial_background_data( channel: SPMChannel, *, @@ -722,28 +788,13 @@ def _estimate_polynomial_background_data( minimum_points=len(powers), ) - rows, columns = data.shape - - x_coordinates = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(columns) - y_coordinates = np.linspace(-1.0, 1.0, rows) if rows > 1 else np.zeros(rows) - xx, yy = np.meshgrid(x_coordinates, y_coordinates) - - terms = [(xx**x_power) * (yy**y_power) for x_power, y_power in powers] - design = np.column_stack([term.ravel() for term in terms]) - selected = selection.ravel() - - coefficients, _, rank, _ = np.linalg.lstsq( - design[selected], - data.ravel()[selected], - rcond=None, + background, _, _, _ = _fit_polynomial_surface_data( + data, + powers=tuple(powers), + selection=selection, + operation="polynomial_background", ) - if rank < len(powers): - raise ValueError( - "polynomial_background selected points do not define a unique polynomial background" - ) - - background = (design @ coefficients).reshape(data.shape) return background From 280b971f8189d026d5de7fa4507e1f9887bd5d8b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:02:05 -0400 Subject: [PATCH 44/82] feat(analysis): add Flatten Base mask growth --- src/spmkit/core/analysis/_flatten_base.py | 48 +++++++++ tests/core/test_flatten_base_core.py | 116 ++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 5515fc2..ed04750 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import numpy as np +from scipy.ndimage import binary_dilation from scipy.optimize import least_squares @@ -645,3 +646,50 @@ def _run_flatten_base_facet_stage( iterations=tuple(iterations), termination=termination, ) + + +def _grow_mask_conn4( + mask: np.ndarray, + *, + radius: int, +) -> np.ndarray: + """Grow a binary mask by an inclusive four-connectivity distance.""" + values = np.asarray(mask) + + if values.ndim != 2: + raise ValueError("conn4 mask growth requires a two-dimensional mask") + if not np.issubdtype(values.dtype, np.bool_): + raise TypeError("conn4 mask growth requires a boolean mask") + if isinstance(radius, (bool, np.bool_)) or not isinstance( + radius, + (int, np.integer), + ): + raise TypeError("conn4 mask growth requires an integer radius") + + radius_value = int(radius) + + if radius_value < 0: + raise ValueError("conn4 mask growth requires a non-negative radius") + + seeds = np.array(values, dtype=bool, copy=True) + + if radius_value == 0 or not np.any(seeds): + return seeds + + connectivity = np.array( + [ + [False, True, False], + [True, True, True ], + [False, True, False], + ], + dtype=bool, + ) + + grown = binary_dilation( + seeds, + structure=connectivity, + iterations=radius_value, + border_value=0, + ) + + return np.asarray(grown, dtype=bool) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 6d6363d..59d9176 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -692,3 +692,119 @@ def fake_facet( np.testing.assert_allclose(result.background, expected_plane) np.testing.assert_allclose(result.corrected, data - expected_plane) + + +def test_grow_mask_conn4_forms_inclusive_city_block_diamond() -> None: + mask = np.zeros((5, 5), dtype=bool) + mask[2, 2] = True + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=2, + ) + + expected = np.array( + [ + [False, False, True, False, False], + [False, True, True, True, False], + [True, True, True, True, True ], + [False, True, True, True, False], + [False, False, True, False, False], + ], + dtype=bool, + ) + + np.testing.assert_array_equal(observed, expected) + + +def test_grow_mask_conn4_crops_at_image_boundaries() -> None: + mask = np.zeros((5, 5), dtype=bool) + mask[0, 0] = True + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=2, + ) + + expected = np.array( + [ + [True, True, True, False, False], + [True, True, False, False, False], + [True, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + dtype=bool, + ) + + np.testing.assert_array_equal(observed, expected) + + +def test_grow_mask_conn4_allows_regions_to_merge() -> None: + mask = np.zeros((5, 5), dtype=bool) + mask[2, 1] = True + mask[2, 3] = True + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=1, + ) + + expected = np.array( + [ + [False, False, False, False, False], + [False, True, False, True, False], + [True, True, True, True, True ], + [False, True, False, True, False], + [False, False, False, False, False], + ], + dtype=bool, + ) + + np.testing.assert_array_equal(observed, expected) + + +def test_grow_mask_conn4_zero_radius_returns_independent_copy() -> None: + mask = np.zeros((4, 5), dtype=bool) + mask[1, 3] = True + original = mask.copy() + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=0, + ) + + np.testing.assert_array_equal(observed, original) + np.testing.assert_array_equal(mask, original) + assert not np.shares_memory(observed, mask) + + observed[0, 0] = True + assert not mask[0, 0] + + +def test_grow_mask_conn4_preserves_empty_mask() -> None: + mask = np.zeros((4, 5), dtype=bool) + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=3, + ) + + np.testing.assert_array_equal(observed, mask) + assert not np.any(observed) + assert not np.shares_memory(observed, mask) + + +def test_grow_mask_conn4_does_not_mutate_seed_mask() -> None: + mask = np.zeros((5, 5), dtype=bool) + mask[2, 2] = True + original = mask.copy() + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=2, + ) + + np.testing.assert_array_equal(mask, original) + assert np.count_nonzero(observed) == 13 + assert not np.shares_memory(observed, mask) From 129d13f60d8c24688b4cae400f763fbc25803a1f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:14:55 -0400 Subject: [PATCH 45/82] feat(analysis): add Flatten Base automatic mask --- src/spmkit/core/analysis/_flatten_base.py | 101 ++++++++++++++++++++++ tests/core/test_flatten_base_core.py | 98 +++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index ed04750..0afd9a7 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -693,3 +693,104 @@ def _grow_mask_conn4( ) return np.asarray(grown, dtype=bool) + + +@dataclass(frozen=True) +class FlattenBaseMask: + """Automatic positive-feature mask for one polynomial stage.""" + + degree: int + threshold: float + growth_radius: int + raw: np.ndarray + grown: np.ndarray + raw_count: int + grown_count: int + + +def _build_flatten_base_mask( + data: np.ndarray, + *, + peak: BasePeakEstimate, + degree: int, +) -> FlattenBaseMask: + """Build the automatic exclusion mask used by Flatten Base.""" + values = np.asarray(data) + + if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): + raise TypeError("Flatten Base masking requires real-valued data") + if values.ndim != 2: + raise ValueError( + "Flatten Base masking requires a two-dimensional array" + ) + if isinstance(degree, (bool, np.bool_)) or not isinstance( + degree, + (int, np.integer), + ): + raise TypeError("Flatten Base masking requires an integer degree") + + degree_value = int(degree) + + if degree_value < 0: + raise ValueError( + "Flatten Base masking requires a non-negative degree" + ) + if not peak.success: + raise ValueError( + "Flatten Base masking requires a successful base-peak estimate" + ) + + try: + numeric = np.asarray(values, dtype=float) + except (TypeError, ValueError) as exc: + raise TypeError( + "Flatten Base masking requires numeric data" + ) from exc + + if not np.all(np.isfinite(numeric)): + raise ValueError("Flatten Base masking requires finite data") + + mean = float(peak.mean) + rms = float(peak.rms) + + if not np.isfinite(mean) or not np.isfinite(rms): + raise ValueError( + "Flatten Base masking requires finite peak parameters" + ) + if rms < 0.0: + raise ValueError( + "Flatten Base masking requires non-negative peak RMS" + ) + + threshold = mean + 3.0 * rms + growth_radius = 1 + degree_value // 2 + + raw = np.array( + numeric > threshold, + dtype=bool, + copy=True, + ) + grown = np.array( + _grow_mask_conn4( + raw, + radius=growth_radius, + ), + dtype=bool, + copy=True, + ) + + raw_count = int(np.count_nonzero(raw)) + grown_count = int(np.count_nonzero(grown)) + + raw.setflags(write=False) + grown.setflags(write=False) + + return FlattenBaseMask( + degree=degree_value, + threshold=float(threshold), + growth_radius=growth_radius, + raw=raw, + grown=grown, + raw_count=raw_count, + grown_count=grown_count, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 59d9176..7a96ec4 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -808,3 +808,101 @@ def test_grow_mask_conn4_does_not_mutate_seed_mask() -> None: np.testing.assert_array_equal(mask, original) assert np.count_nonzero(observed) == 13 assert not np.shares_memory(observed, mask) + + +def test_flatten_base_mask_uses_strict_threshold_and_degree_radius( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.array( + [ + [6.0, 7.0, 8.0, 2.0], + [9.0, 1.0, 7.0, 10.0], + [0.0, 5.0, 3.0, 7.0], + ], + dtype=float, + ) + original = data.copy() + + class Peak: + success = True + mean = 1.0 + rms = 2.0 + + captured: dict[str, object] = {} + + def fake_grow( + mask: np.ndarray, + *, + radius: int, + ) -> np.ndarray: + captured["mask"] = mask.copy() + captured["radius"] = radius + + grown = mask.copy() + grown[0, 0] = True + return grown + + monkeypatch.setattr( + flatten_base_core, + "_grow_mask_conn4", + fake_grow, + ) + + result = flatten_base_core._build_flatten_base_mask( + data, + peak=Peak(), + degree=2, + ) + + expected_raw = data > 7.0 + expected_grown = expected_raw.copy() + expected_grown[0, 0] = True + + assert result.degree == 2 + assert result.threshold == 7.0 + assert result.growth_radius == 2 + assert captured["radius"] == 2 + + np.testing.assert_array_equal(captured["mask"], expected_raw) + np.testing.assert_array_equal(result.raw, expected_raw) + np.testing.assert_array_equal(result.grown, expected_grown) + + assert result.raw_count == 3 + assert result.grown_count == 4 + assert not result.raw.flags.writeable + assert not result.grown.flags.writeable + + np.testing.assert_array_equal(data, original) + + +def test_flatten_base_mask_integrates_threshold_and_conn4_growth() -> None: + data = np.zeros((7, 7), dtype=float) + data[3, 3] = 4.0 + data[0, 0] = 3.0 + + class Peak: + success = True + mean = 0.0 + rms = 1.0 + + result = flatten_base_core._build_flatten_base_mask( + data, + peak=Peak(), + degree=5, + ) + + expected_raw = np.zeros((7, 7), dtype=bool) + expected_raw[3, 3] = True + + yy, xx = np.mgrid[0:7, 0:7] + expected_grown = ( + np.abs(yy - 3) + np.abs(xx - 3) + ) <= 3 + + assert result.threshold == 3.0 + assert result.growth_radius == 3 + assert result.raw_count == 1 + assert result.grown_count == 25 + + np.testing.assert_array_equal(result.raw, expected_raw) + np.testing.assert_array_equal(result.grown, expected_grown) From 62d0d59e5d86aa73023c7e664646d24e95e3a746 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:45:32 -0400 Subject: [PATCH 46/82] feat(analysis): add cross-validated Flatten Base polynomial iteration --- src/spmkit/core/analysis/_flatten_base.py | 234 ++++++++++- tests/core/test_flatten_base_core.py | 459 +++++++++++++++++++++- 2 files changed, 665 insertions(+), 28 deletions(-) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 0afd9a7..4168488 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -5,7 +5,6 @@ from dataclasses import dataclass import numpy as np -from scipy.ndimage import binary_dilation from scipy.optimize import least_squares @@ -653,7 +652,13 @@ def _grow_mask_conn4( *, radius: int, ) -> np.ndarray: - """Grow a binary mask by an inclusive four-connectivity distance.""" + """Reproduce Gwyddion 2.71 CONN4 mask growth. + + This intentionally follows ``gwy_data_field_grains_grow()`` with + ``from_border=FALSE``, including its special image-border behaviour. + It is therefore not equivalent to ordinary city-block dilation when + grains are absent from, or touch, the field boundary. + """ values = np.asarray(mask) if values.ndim != 2: @@ -670,29 +675,100 @@ def _grow_mask_conn4( if radius_value < 0: raise ValueError("conn4 mask growth requires a non-negative radius") + if values.shape[0] == 0 or values.shape[1] == 0: + raise ValueError("conn4 mask growth requires a non-empty mask") seeds = np.array(values, dtype=bool, copy=True) - if radius_value == 0 or not np.any(seeds): + # Gwyddion returns immediately for growth amounts below 0.5. + if radius_value == 0: return seeds - connectivity = np.array( - [ - [False, True, False], - [True, True, True ], - [False, True, False], - ], - dtype=bool, - ) + rows, columns = seeds.shape + unreachable = int(np.iinfo(np.uint32).max) - grown = binary_dilation( + # grains_grow() duplicates and inverts the original mask before the + # distance transform. Consequently original mask pixels are zeros, + # while the surrounding region starts as G_MAXUINT. + distances = np.where( seeds, - structure=connectivity, - iterations=radius_value, - border_value=0, - ) + 0, + unreachable, + ).astype(np.int64, copy=False) + + queue: list[tuple[int, int]] = [] + + # init_erosion_4(..., from_border=FALSE) scans only interior pixels. + for row in range(1, rows - 1): + for column in range(1, columns - 1): + if distances[row, column] != unreachable: + continue + + if ( + distances[row - 1, column] == 0 + or distances[row, column - 1] == 0 + or distances[row, column + 1] == 0 + or distances[row + 1, column] == 0 + ): + distances[row, column] = 1 + queue.append((row, column)) + + distance = 1 + + while queue: + next_queue: list[tuple[int, int]] = [] + next_distance = distance + 1 + + for row, column in queue: + neighbours = ( + (row - 1, column), + (row, column - 1), + (row, column + 1), + (row + 1, column), + ) + + for neighbour_row, neighbour_column in neighbours: + if not ( + 0 <= neighbour_row < rows + and 0 <= neighbour_column < columns + ): + continue + + if ( + distances[neighbour_row, neighbour_column] + != unreachable + ): + continue + + distances[neighbour_row, neighbour_column] = next_distance + next_queue.append( + (neighbour_row, neighbour_column) + ) + + if not next_queue: + break - return np.asarray(grown, dtype=bool) + queue = next_queue + distance = next_distance + + # Gwyddion's post-pass gives distance 1 to border pixels that were + # never reached by the interior erosion queues. + top_unreached = distances[0, :] == unreachable + distances[0, top_unreached] = 1 + + bottom_unreached = distances[-1, :] == unreachable + distances[-1, bottom_unreached] = 1 + + left_unreached = distances[:, 0] == unreachable + distances[left_unreached, 0] = 1 + + right_unreached = distances[:, -1] == unreachable + distances[right_unreached, -1] = 1 + + grown = seeds.copy() + grown[distances <= radius_value] = True + + return grown @dataclass(frozen=True) @@ -794,3 +870,127 @@ def _build_flatten_base_mask( raw_count=raw_count, grown_count=grown_count, ) + + + +@dataclass(frozen=True) +class FlattenBasePolynomialIteration: + """Evidence from one masked polynomial correction.""" + + degree: int + powers: tuple[tuple[int, int], ...] + mask: FlattenBaseMask + selected_count: int + coefficients: np.ndarray + rank: int + singular_values: np.ndarray + background: np.ndarray + corrected: np.ndarray + peak: BasePeakEstimate + + +def _run_flatten_base_polynomial_iteration( + data: np.ndarray, + *, + peak: BasePeakEstimate, + degree: int, +) -> FlattenBasePolynomialIteration: + """Run one masked polynomial correction used by Flatten Base.""" + automatic_mask = _build_flatten_base_mask( + data, + peak=peak, + degree=degree, + ) + degree_value = automatic_mask.degree + + powers = tuple( + (x_power, y_power) + for x_power in range(degree_value + 1) + for y_power in range(degree_value + 1 - x_power) + ) + + selection = np.logical_not(automatic_mask.grown) + selected_count = int(np.count_nonzero(selection)) + + from spmkit.core.analysis.leveling import ( + _fit_polynomial_surface_data, + ) + + ( + fitted_background, + fitted_coefficients, + rank, + fitted_singular_values, + ) = _fit_polynomial_surface_data( + data, + powers=powers, + selection=selection, + operation=f"Flatten Base degree {degree_value}", + ) + + values = np.asarray(data, dtype=float) + background = np.array( + fitted_background, + dtype=float, + copy=True, + ) + coefficients = np.array( + fitted_coefficients, + dtype=float, + copy=True, + ) + singular_values = np.array( + fitted_singular_values, + dtype=float, + copy=True, + ) + + if background.shape != values.shape: + raise ValueError( + "Flatten Base polynomial fit returned an invalid background shape" + ) + if coefficients.ndim != 1 or coefficients.size != len(powers): + raise ValueError( + "Flatten Base polynomial fit returned invalid coefficients" + ) + if singular_values.ndim != 1: + raise ValueError( + "Flatten Base polynomial fit returned invalid singular values" + ) + if not np.all(np.isfinite(background)): + raise ValueError( + "Flatten Base polynomial fit returned a non-finite background" + ) + if not np.all(np.isfinite(coefficients)): + raise ValueError( + "Flatten Base polynomial fit returned non-finite coefficients" + ) + if not np.all(np.isfinite(singular_values)): + raise ValueError( + "Flatten Base polynomial fit returned non-finite singular values" + ) + + corrected = np.array( + values - background, + dtype=float, + copy=True, + ) + updated_peak = _estimate_base_peak(corrected) + + coefficients.setflags(write=False) + singular_values.setflags(write=False) + background.setflags(write=False) + corrected.setflags(write=False) + + return FlattenBasePolynomialIteration( + degree=degree_value, + powers=powers, + mask=automatic_mask, + selected_count=selected_count, + coefficients=coefficients, + rank=int(rank), + singular_values=singular_values, + background=background, + corrected=corrected, + peak=updated_peak, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 7a96ec4..49f9441 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -717,7 +717,7 @@ def test_grow_mask_conn4_forms_inclusive_city_block_diamond() -> None: np.testing.assert_array_equal(observed, expected) -def test_grow_mask_conn4_crops_at_image_boundaries() -> None: +def test_grow_mask_conn4_matches_gwyddion_corner_handling() -> None: mask = np.zeros((5, 5), dtype=bool) mask[0, 0] = True @@ -728,19 +728,20 @@ def test_grow_mask_conn4_crops_at_image_boundaries() -> None: expected = np.array( [ - [True, True, True, False, False], - [True, True, False, False, False], - [True, False, False, False, False], - [False, False, False, False, False], - [False, False, False, False, False], + [True, True, True, True, True], + [True, False, False, False, True], + [True, False, False, False, True], + [True, False, False, False, True], + [True, True, True, True, True], ], dtype=bool, ) np.testing.assert_array_equal(observed, expected) + assert np.count_nonzero(observed) == 16 -def test_grow_mask_conn4_allows_regions_to_merge() -> None: +def test_grow_mask_conn4_matches_gwyddion_interior_merge_reference() -> None: mask = np.zeros((5, 5), dtype=bool) mask[2, 1] = True mask[2, 3] = True @@ -754,7 +755,7 @@ def test_grow_mask_conn4_allows_regions_to_merge() -> None: [ [False, False, False, False, False], [False, True, False, True, False], - [True, True, True, True, True ], + [False, True, True, True, False], [False, True, False, True, False], [False, False, False, False, False], ], @@ -762,6 +763,7 @@ def test_grow_mask_conn4_allows_regions_to_merge() -> None: ) np.testing.assert_array_equal(observed, expected) + assert np.count_nonzero(observed) == 7 def test_grow_mask_conn4_zero_radius_returns_independent_copy() -> None: @@ -782,7 +784,7 @@ def test_grow_mask_conn4_zero_radius_returns_independent_copy() -> None: assert not mask[0, 0] -def test_grow_mask_conn4_preserves_empty_mask() -> None: +def test_grow_mask_conn4_matches_gwyddion_empty_mask_handling() -> None: mask = np.zeros((4, 5), dtype=bool) observed = flatten_base_core._grow_mask_conn4( @@ -790,8 +792,18 @@ def test_grow_mask_conn4_preserves_empty_mask() -> None: radius=3, ) - np.testing.assert_array_equal(observed, mask) - assert not np.any(observed) + expected = np.array( + [ + [True, True, True, True, True], + [True, False, False, False, True], + [True, False, False, False, True], + [True, True, True, True, True], + ], + dtype=bool, + ) + + np.testing.assert_array_equal(observed, expected) + assert np.count_nonzero(observed) == 14 assert not np.shares_memory(observed, mask) @@ -906,3 +918,428 @@ class Peak: np.testing.assert_array_equal(result.raw, expected_raw) np.testing.assert_array_equal(result.grown, expected_grown) + + +def test_flatten_base_polynomial_iteration_composes_mask_fit_and_peak( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 4 + columns = 5 + data = np.arange(rows * columns, dtype=float).reshape(rows, columns) + original = data.copy() + + raw_mask = np.zeros_like(data, dtype=bool) + raw_mask[1, 2] = True + + grown_mask = raw_mask.copy() + grown_mask[1, 1:4] = True + grown_mask[0, 2] = True + grown_mask[2, 2] = True + + raw_mask.setflags(write=False) + grown_mask.setflags(write=False) + + automatic_mask = flatten_base_core.FlattenBaseMask( + degree=2, + threshold=7.0, + growth_radius=2, + raw=raw_mask, + grown=grown_mask, + raw_count=1, + grown_count=5, + ) + + class InitialPeak: + success = True + mean = 1.0 + rms = 2.0 + + class UpdatedPeak: + success = True + mean = 0.2 + rms = 0.4 + + initial_peak = InitialPeak() + updated_peak = UpdatedPeak() + + expected_background = np.full_like(data, 1.25) + coefficients = np.arange(6, dtype=float) + singular_values = np.linspace(6.0, 1.0, 6) + + captured: dict[str, object] = {} + + def fake_mask( + received: np.ndarray, + *, + peak: InitialPeak, + degree: int, + ) -> flatten_base_core.FlattenBaseMask: + np.testing.assert_array_equal(received, data) + assert peak is initial_peak + assert degree == 2 + return automatic_mask + + def fake_fit( + received: np.ndarray, + *, + powers: tuple[tuple[int, int], ...], + selection: np.ndarray, + operation: str, + ) -> tuple[np.ndarray, np.ndarray, int, np.ndarray]: + np.testing.assert_array_equal(received, data) + + captured["powers"] = powers + captured["selection"] = selection.copy() + captured["operation"] = operation + + return ( + expected_background.copy(), + coefficients.copy(), + 6, + singular_values.copy(), + ) + + def fake_peak(received: np.ndarray) -> UpdatedPeak: + np.testing.assert_allclose( + received, + data - expected_background, + ) + return updated_peak + + monkeypatch.setattr( + flatten_base_core, + "_build_flatten_base_mask", + fake_mask, + ) + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_peak, + ) + + import spmkit.core.analysis.leveling as leveling + + monkeypatch.setattr( + leveling, + "_fit_polynomial_surface_data", + fake_fit, + ) + + result = flatten_base_core._run_flatten_base_polynomial_iteration( + data, + peak=initial_peak, + degree=2, + ) + + expected_powers = ( + (0, 0), + (0, 1), + (0, 2), + (1, 0), + (1, 1), + (2, 0), + ) + + assert captured["powers"] == expected_powers + assert captured["operation"] == "Flatten Base degree 2" + np.testing.assert_array_equal( + captured["selection"], + ~grown_mask, + ) + + assert result.degree == 2 + assert result.powers == expected_powers + assert result.mask is automatic_mask + assert result.selected_count == data.size - 5 + assert result.rank == 6 + assert result.peak is updated_peak + + np.testing.assert_array_equal(result.coefficients, coefficients) + np.testing.assert_array_equal( + result.singular_values, + singular_values, + ) + np.testing.assert_allclose( + result.background, + expected_background, + ) + np.testing.assert_allclose( + result.corrected, + data - expected_background, + ) + + assert not result.coefficients.flags.writeable + assert not result.singular_values.flags.writeable + assert not result.background.flags.writeable + assert not result.corrected.flags.writeable + + np.testing.assert_array_equal(data, original) + + +def test_flatten_base_polynomial_iteration_recovers_exact_surface( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 11 + columns = 11 + x = np.linspace(-1.0, 1.0, columns) + y = np.linspace(-1.0, 1.0, rows) + xx, yy = np.meshgrid(x, y) + + expected_background = ( + 0.10 + + 0.05 * xx + - 0.04 * yy + + 0.03 * xx * yy + + 0.02 * xx**2 + - 0.01 * yy**2 + ) + + data = expected_background.copy() + data[5, 5] += 5.0 + original = data.copy() + + class InitialPeak: + success = True + mean = 0.0 + rms = 0.2 + + class UpdatedPeak: + success = True + + updated_peak = UpdatedPeak() + + def fake_updated_peak(received: np.ndarray) -> UpdatedPeak: + expected_corrected = np.zeros_like(data) + expected_corrected[5, 5] = 5.0 + np.testing.assert_allclose( + received, + expected_corrected, + atol=2e-13, + ) + return updated_peak + + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_updated_peak, + ) + + result = flatten_base_core._run_flatten_base_polynomial_iteration( + data, + peak=InitialPeak(), + degree=2, + ) + + expected_coefficients = np.array( + [ + 0.10, + -0.04, + -0.01, + 0.05, + 0.03, + 0.02, + ], + dtype=float, + ) + expected_corrected = np.zeros_like(data) + expected_corrected[5, 5] = 5.0 + + assert result.powers == ( + (0, 0), + (0, 1), + (0, 2), + (1, 0), + (1, 1), + (2, 0), + ) + assert result.mask.raw_count == 1 + assert result.mask.grown_count == 13 + assert result.selected_count == data.size - 13 + assert result.rank == 6 + assert result.peak is updated_peak + + np.testing.assert_allclose( + result.coefficients, + expected_coefficients, + atol=2e-13, + ) + np.testing.assert_allclose( + result.background, + expected_background, + atol=2e-13, + ) + np.testing.assert_allclose( + result.corrected, + expected_corrected, + atol=2e-13, + ) + np.testing.assert_array_equal(data, original) + + + +def test_grow_mask_conn4_matches_gwyddion_271_right_edge_reference() -> None: + mask = np.zeros((8, 9), dtype=bool) + mask[2, 4] = True + mask[5, 7] = True + + observed = flatten_base_core._grow_mask_conn4( + mask, + radius=2, + ) + + frozen = ( + "000010000" + "000111000" + "001111100" + "000111010" + "000010111" + "000001110" + "000000111" + "000000010" + ) + expected = np.array( + [value == "1" for value in frozen], + dtype=bool, + ).reshape(8, 9) + + np.testing.assert_array_equal(observed, expected) + assert np.count_nonzero(observed) == 24 + assert not observed[5, 8] + + +def test_flatten_base_polynomial_iteration_matches_gwyddion_271( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 8 + columns = 9 + data = np.empty((rows, columns), dtype=float) + + for row in range(rows): + y = 2.0 * row / (rows - 1.0) - 1.0 + + for column in range(columns): + x = 2.0 * column / (columns - 1.0) - 1.0 + value = ( + 0.72 + + 0.18 * x + - 0.11 * y + + 0.07 * x * y + + 0.035 * x**2 + - 0.02 * y**2 + + 0.025 * np.sin(0.9 * column + 0.4 * row) + ) + + if row == 2 and column == 4: + value += 1.5 + if row == 5 and column == 7: + value += 1.0 + + data[row, column] = value + + original = data.copy() + + class InitialPeak: + success = True + mean = 0.75 + rms = 0.10 + + class UpdatedPeak: + success = True + + updated_peak = UpdatedPeak() + + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + lambda received: updated_peak, + ) + + result = flatten_base_core._run_flatten_base_polynomial_iteration( + data, + peak=InitialPeak(), + degree=2, + ) + + raw_frozen = ( + "000000000" + "000000000" + "000010000" + "000000000" + "000000000" + "000000010" + "000000000" + "000000000" + ) + grown_frozen = ( + "000010000" + "000111000" + "001111100" + "000111010" + "000010111" + "000001110" + "000000111" + "000000010" + ) + + expected_raw = np.array( + [value == "1" for value in raw_frozen], + dtype=bool, + ).reshape(rows, columns) + expected_grown = np.array( + [value == "1" for value in grown_frozen], + dtype=bool, + ).reshape(rows, columns) + + expected_coefficients = np.array( + [ + 0.71670865227920233, + -0.11459538697342461, + -0.024654671854024313, + 0.18007084915965604, + 0.0761606492072983, + 0.056803123388348246, + ], + dtype=float, + ) + + x = np.linspace(-1.0, 1.0, columns) + y = np.linspace(-1.0, 1.0, rows) + xx, yy = np.meshgrid(x, y) + + expected_background = ( + expected_coefficients[0] + + expected_coefficients[1] * yy + + expected_coefficients[2] * yy**2 + + expected_coefficients[3] * xx + + expected_coefficients[4] * xx * yy + + expected_coefficients[5] * xx**2 + ) + + assert result.degree == 2 + assert result.rank == 6 + assert result.selected_count == 48 + assert result.mask.raw_count == 2 + assert result.mask.grown_count == 24 + assert result.peak is updated_peak + + np.testing.assert_array_equal(result.mask.raw, expected_raw) + np.testing.assert_array_equal(result.mask.grown, expected_grown) + np.testing.assert_allclose( + result.coefficients, + expected_coefficients, + atol=5e-13, + rtol=0.0, + ) + np.testing.assert_allclose( + result.background, + expected_background, + atol=5e-13, + rtol=0.0, + ) + np.testing.assert_allclose( + result.corrected, + data - expected_background, + atol=5e-13, + rtol=0.0, + ) + np.testing.assert_array_equal(data, original) From ae8f2ee023e98a1549258d59b293fc9432dbd3f7 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:51:32 -0400 Subject: [PATCH 47/82] feat(analysis): add Flatten Base polynomial stage --- src/spmkit/core/analysis/_flatten_base.py | 126 ++++++++++++ tests/core/test_flatten_base_core.py | 227 ++++++++++++++++++++++ 2 files changed, 353 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 4168488..2c5e7ed 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -994,3 +994,129 @@ def _run_flatten_base_polynomial_iteration( corrected=corrected, peak=updated_peak, ) + + +@dataclass(frozen=True) +class FlattenBasePolynomialStage: + """Evidence from the complete degree 2–5 polynomial stage.""" + + corrected: np.ndarray + background: np.ndarray + initial_peak: BasePeakEstimate + iterations: tuple[FlattenBasePolynomialIteration, ...] + termination: str + + @property + def completed_degrees(self) -> tuple[int, ...]: + """Polynomial degrees successfully applied.""" + return tuple( + iteration.degree + for iteration in self.iterations + ) + + +def _run_flatten_base_polynomial_stage( + data: np.ndarray, + *, + peak: BasePeakEstimate, +) -> FlattenBasePolynomialStage: + """Run the degree 2, 3, 4 and 5 Flatten Base corrections.""" + values = np.asarray(data) + + if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): + raise TypeError( + "Flatten Base polynomial stage requires real-valued data" + ) + if values.ndim != 2: + raise ValueError( + "Flatten Base polynomial stage requires a two-dimensional array" + ) + + try: + working = np.array(values, dtype=float, copy=True) + except (TypeError, ValueError) as exc: + raise TypeError( + "Flatten Base polynomial stage requires numeric data" + ) from exc + + if not np.all(np.isfinite(working)): + raise ValueError( + "Flatten Base polynomial stage requires finite data" + ) + + accumulated_background = np.zeros_like(working) + iterations: list[FlattenBasePolynomialIteration] = [] + current_peak = peak + termination = "completed" + + for degree in (2, 3, 4, 5): + if not current_peak.success: + termination = "peak_failure" + break + + iteration = _run_flatten_base_polynomial_iteration( + working, + peak=current_peak, + degree=degree, + ) + + iteration_background = np.asarray( + iteration.background, + dtype=float, + ) + iteration_corrected = np.asarray( + iteration.corrected, + dtype=float, + ) + + if iteration_background.shape != working.shape: + raise ValueError( + "Flatten Base polynomial iteration returned " + "an invalid background shape" + ) + if iteration_corrected.shape != working.shape: + raise ValueError( + "Flatten Base polynomial iteration returned " + "an invalid corrected shape" + ) + if not np.all(np.isfinite(iteration_background)): + raise ValueError( + "Flatten Base polynomial iteration returned " + "a non-finite background" + ) + if not np.all(np.isfinite(iteration_corrected)): + raise ValueError( + "Flatten Base polynomial iteration returned " + "non-finite corrected data" + ) + + accumulated_background += iteration_background + working = np.array( + iteration_corrected, + dtype=float, + copy=True, + ) + iterations.append(iteration) + current_peak = iteration.peak + + if not current_peak.success: + termination = "peak_failure" + break + + corrected = np.array(working, dtype=float, copy=True) + background = np.array( + accumulated_background, + dtype=float, + copy=True, + ) + + corrected.setflags(write=False) + background.setflags(write=False) + + return FlattenBasePolynomialStage( + corrected=corrected, + background=background, + initial_peak=peak, + iterations=tuple(iterations), + termination=termination, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 49f9441..ed6bbe8 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -1343,3 +1343,230 @@ class UpdatedPeak: rtol=0.0, ) np.testing.assert_array_equal(data, original) + + +def test_flatten_base_polynomial_stage_runs_degrees_two_to_five( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = 4 + columns = 5 + data = np.arange(rows * columns, dtype=float).reshape(rows, columns) + original = data.copy() + + class Peak: + success = True + + def __init__(self, label: str) -> None: + self.label = label + + peaks = [Peak(f"peak-{index}") for index in range(5)] + calls: list[tuple[np.ndarray, Peak, int]] = [] + produced_iterations: list[ + flatten_base_core.FlattenBasePolynomialIteration + ] = [] + + def fake_iteration( + received: np.ndarray, + *, + peak: Peak, + degree: int, + ) -> flatten_base_core.FlattenBasePolynomialIteration: + expected_index = len(calls) + expected_degree = (2, 3, 4, 5)[expected_index] + + assert degree == expected_degree + assert peak is peaks[expected_index] + + calls.append((received.copy(), peak, degree)) + + background = np.full_like( + data, + float(degree), + ) + corrected = received - background + + background.setflags(write=False) + corrected.setflags(write=False) + + coefficients = np.zeros(1, dtype=float) + singular_values = np.ones(1, dtype=float) + coefficients.setflags(write=False) + singular_values.setflags(write=False) + + empty_mask = np.zeros_like(data, dtype=bool) + empty_mask.setflags(write=False) + + automatic_mask = flatten_base_core.FlattenBaseMask( + degree=degree, + threshold=0.0, + growth_radius=1 + degree // 2, + raw=empty_mask, + grown=empty_mask, + raw_count=0, + grown_count=0, + ) + + iteration = ( + flatten_base_core.FlattenBasePolynomialIteration( + degree=degree, + powers=((0, 0),), + mask=automatic_mask, + selected_count=data.size, + coefficients=coefficients, + rank=1, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=peaks[expected_index + 1], + ) + ) + produced_iterations.append(iteration) + return iteration + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_iteration", + fake_iteration, + ) + + result = flatten_base_core._run_flatten_base_polynomial_stage( + data, + peak=peaks[0], + ) + + expected_background = np.full_like( + data, + 2.0 + 3.0 + 4.0 + 5.0, + ) + + assert [call[2] for call in calls] == [2, 3, 4, 5] + assert result.initial_peak is peaks[0] + assert result.iterations == tuple(produced_iterations) + assert result.completed_degrees == (2, 3, 4, 5) + assert result.termination == "completed" + + for index, (received, received_peak, degree) in enumerate(calls): + expected_previous = sum((2, 3, 4, 5)[:index]) + np.testing.assert_allclose( + received, + data - expected_previous, + ) + assert received_peak is peaks[index] + assert degree == (2, 3, 4, 5)[index] + + np.testing.assert_allclose( + result.background, + expected_background, + ) + np.testing.assert_allclose( + result.corrected, + data - expected_background, + ) + + assert not result.background.flags.writeable + assert not result.corrected.flags.writeable + np.testing.assert_array_equal(data, original) + + +def test_flatten_base_polynomial_stage_stops_after_peak_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.arange(20, dtype=float).reshape(4, 5) + original = data.copy() + + class Peak: + def __init__(self, success: bool) -> None: + self.success = success + + initial_peak = Peak(success=True) + degree_two_peak = Peak(success=True) + failed_peak = Peak(success=False) + + calls: list[int] = [] + + def fake_iteration( + received: np.ndarray, + *, + peak: Peak, + degree: int, + ) -> flatten_base_core.FlattenBasePolynomialIteration: + calls.append(degree) + + if degree == 2: + assert peak is initial_peak + next_peak = degree_two_peak + elif degree == 3: + assert peak is degree_two_peak + next_peak = failed_peak + else: + raise AssertionError( + f"unexpected polynomial degree after failure: {degree}" + ) + + background = np.full_like(received, float(degree)) + corrected = received - background + + coefficients = np.zeros(1, dtype=float) + singular_values = np.ones(1, dtype=float) + empty_mask = np.zeros_like(received, dtype=bool) + + background.setflags(write=False) + corrected.setflags(write=False) + coefficients.setflags(write=False) + singular_values.setflags(write=False) + empty_mask.setflags(write=False) + + automatic_mask = flatten_base_core.FlattenBaseMask( + degree=degree, + threshold=0.0, + growth_radius=1 + degree // 2, + raw=empty_mask, + grown=empty_mask, + raw_count=0, + grown_count=0, + ) + + return flatten_base_core.FlattenBasePolynomialIteration( + degree=degree, + powers=((0, 0),), + mask=automatic_mask, + selected_count=received.size, + coefficients=coefficients, + rank=1, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=next_peak, + ) + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_iteration", + fake_iteration, + ) + + result = flatten_base_core._run_flatten_base_polynomial_stage( + data, + peak=initial_peak, + ) + + expected_background = np.full_like(data, 5.0) + + assert calls == [2, 3] + assert result.initial_peak is initial_peak + assert result.completed_degrees == (2, 3) + assert result.termination == "peak_failure" + assert result.iterations[-1].peak is failed_peak + + np.testing.assert_allclose( + result.background, + expected_background, + ) + np.testing.assert_allclose( + result.corrected, + data - expected_background, + ) + np.testing.assert_array_equal(data, original) + + assert not result.background.flags.writeable + assert not result.corrected.flags.writeable From 742e1c82a8bb2f633f1571a713472d3fd72e0808 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:01:36 -0400 Subject: [PATCH 48/82] fix(analysis): align Flatten Base polynomial control flow --- src/spmkit/core/analysis/_flatten_base.py | 96 +++++++-- tests/core/test_flatten_base_core.py | 244 ++++++++++++++++++++++ 2 files changed, 328 insertions(+), 12 deletions(-) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 2c5e7ed..55820b1 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -811,10 +811,6 @@ def _build_flatten_base_mask( raise ValueError( "Flatten Base masking requires a non-negative degree" ) - if not peak.success: - raise ValueError( - "Flatten Base masking requires a successful base-peak estimate" - ) try: numeric = np.asarray(values, dtype=float) @@ -879,7 +875,7 @@ class FlattenBasePolynomialIteration: degree: int powers: tuple[tuple[int, int], ...] - mask: FlattenBaseMask + mask: FlattenBaseMask | None selected_count: int coefficients: np.ndarray rank: int @@ -887,6 +883,7 @@ class FlattenBasePolynomialIteration: background: np.ndarray corrected: np.ndarray peak: BasePeakEstimate + applied: bool = True def _run_flatten_base_polynomial_iteration( @@ -896,10 +893,80 @@ def _run_flatten_base_polynomial_iteration( degree: int, ) -> FlattenBasePolynomialIteration: """Run one masked polynomial correction used by Flatten Base.""" + values = np.asarray(data) + + if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): + raise TypeError( + "Flatten Base polynomial iteration requires real-valued data" + ) + if values.ndim != 2: + raise ValueError( + "Flatten Base polynomial iteration requires " + "a two-dimensional array" + ) + if values.size == 0: + raise ValueError( + "Flatten Base polynomial iteration requires non-empty data" + ) + if isinstance(degree, (bool, np.bool_)) or not isinstance( + degree, + (int, np.integer), + ): + raise TypeError( + "Flatten Base polynomial iteration requires an integer degree" + ) + + degree_value = int(degree) + + if degree_value < 0: + raise ValueError( + "Flatten Base polynomial iteration requires " + "a non-negative degree" + ) + + try: + numeric = np.asarray(values, dtype=float) + except (TypeError, ValueError) as exc: + raise TypeError( + "Flatten Base polynomial iteration requires numeric data" + ) from exc + + if not np.all(np.isfinite(numeric)): + raise ValueError( + "Flatten Base polynomial iteration requires finite data" + ) + + if float(np.max(numeric)) <= float(np.min(numeric)): + background = np.zeros_like(numeric) + corrected = np.array(numeric, dtype=float, copy=True) + coefficients = np.empty(0, dtype=float) + singular_values = np.empty(0, dtype=float) + + updated_peak = _estimate_base_peak(corrected) + + background.setflags(write=False) + corrected.setflags(write=False) + coefficients.setflags(write=False) + singular_values.setflags(write=False) + + return FlattenBasePolynomialIteration( + degree=degree_value, + powers=(), + mask=None, + selected_count=0, + coefficients=coefficients, + rank=0, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=updated_peak, + applied=False, + ) + automatic_mask = _build_flatten_base_mask( - data, + numeric, peak=peak, - degree=degree, + degree=degree_value, ) degree_value = automatic_mask.degree @@ -1006,12 +1073,21 @@ class FlattenBasePolynomialStage: iterations: tuple[FlattenBasePolynomialIteration, ...] termination: str + @property + def attempted_degrees(self) -> tuple[int, ...]: + """Polynomial degrees attempted by the stage.""" + return tuple( + iteration.degree + for iteration in self.iterations + ) + @property def completed_degrees(self) -> tuple[int, ...]: - """Polynomial degrees successfully applied.""" + """Polynomial degrees that actually subtracted a background.""" return tuple( iteration.degree for iteration in self.iterations + if getattr(iteration, "applied", True) ) @@ -1050,10 +1126,6 @@ def _run_flatten_base_polynomial_stage( termination = "completed" for degree in (2, 3, 4, 5): - if not current_peak.success: - termination = "peak_failure" - break - iteration = _run_flatten_base_polynomial_iteration( working, peak=current_peak, diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index ed6bbe8..506759f 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -1570,3 +1570,247 @@ def fake_iteration( assert not result.background.flags.writeable assert not result.corrected.flags.writeable + + +def test_flatten_base_mask_uses_parameters_from_unsuccessful_peak() -> None: + data = np.zeros((5, 5), dtype=float) + data[2, 2] = 4.0 + + class Peak: + success = False + mean = 1.0 + rms = 0.5 + + result = flatten_base_core._build_flatten_base_mask( + data, + peak=Peak(), + degree=2, + ) + + assert result.threshold == 2.5 + assert result.raw_count == 1 + assert result.grown_count == 13 + assert result.raw[2, 2] + + +def test_polynomial_stage_runs_degree_two_with_failed_incoming_peak( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.arange(20, dtype=float).reshape(4, 5) + + class Peak: + def __init__(self, success: bool) -> None: + self.success = success + + incoming_peak = Peak(success=False) + failed_updated_peak = Peak(success=False) + calls: list[int] = [] + + class Iteration: + degree = 2 + background = np.ones_like(data) + corrected = data - 1.0 + peak = failed_updated_peak + + def fake_iteration( + received: np.ndarray, + *, + peak: Peak, + degree: int, + ) -> Iteration: + np.testing.assert_array_equal(received, data) + assert peak is incoming_peak + assert degree == 2 + calls.append(degree) + return Iteration() + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_iteration", + fake_iteration, + ) + + result = flatten_base_core._run_flatten_base_polynomial_stage( + data, + peak=incoming_peak, + ) + + assert calls == [2] + assert result.completed_degrees == (2,) + assert result.termination == "peak_failure" + + np.testing.assert_array_equal( + result.background, + np.ones_like(data), + ) + np.testing.assert_array_equal( + result.corrected, + data - 1.0, + ) + + +def test_polynomial_iteration_skips_constant_field_and_reestimates_peak( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.full((5, 5), 2.5, dtype=float) + original = data.copy() + + class IncomingPeak: + success = True + mean = 2.5 + rms = 0.0 + + class UpdatedPeak: + success = False + mean = 2.5 + rms = 0.0 + + incoming_peak = IncomingPeak() + updated_peak = UpdatedPeak() + peak_calls: list[np.ndarray] = [] + + def forbidden_mask(*args: object, **kwargs: object) -> None: + del args, kwargs + raise AssertionError( + "constant-field iteration must not construct a mask" + ) + + def forbidden_fit(*args: object, **kwargs: object) -> None: + del args, kwargs + raise AssertionError( + "constant-field iteration must not fit a polynomial" + ) + + def fake_peak(received: np.ndarray) -> UpdatedPeak: + peak_calls.append(received.copy()) + np.testing.assert_array_equal(received, data) + return updated_peak + + monkeypatch.setattr( + flatten_base_core, + "_build_flatten_base_mask", + forbidden_mask, + ) + monkeypatch.setattr( + flatten_base_core, + "_estimate_base_peak", + fake_peak, + ) + + import spmkit.core.analysis.leveling as leveling + + monkeypatch.setattr( + leveling, + "_fit_polynomial_surface_data", + forbidden_fit, + ) + + result = flatten_base_core._run_flatten_base_polynomial_iteration( + data, + peak=incoming_peak, + degree=2, + ) + + assert not result.applied + assert result.degree == 2 + assert result.powers == () + assert result.mask is None + assert result.selected_count == 0 + assert result.rank == 0 + assert result.coefficients.size == 0 + assert result.singular_values.size == 0 + assert result.peak is updated_peak + assert len(peak_calls) == 1 + + np.testing.assert_array_equal( + result.background, + np.zeros_like(data), + ) + np.testing.assert_array_equal(result.corrected, data) + np.testing.assert_array_equal(data, original) + + assert not result.coefficients.flags.writeable + assert not result.singular_values.flags.writeable + assert not result.background.flags.writeable + assert not result.corrected.flags.writeable + + +def test_polynomial_stage_records_unapplied_degree_before_peak_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.arange(20, dtype=float).reshape(4, 5) + original = data.copy() + + class Peak: + def __init__(self, success: bool) -> None: + self.success = success + + incoming_peak = Peak(success=True) + failed_peak = Peak(success=False) + + background = np.zeros_like(data) + corrected = data.copy() + coefficients = np.empty(0, dtype=float) + singular_values = np.empty(0, dtype=float) + + background.setflags(write=False) + corrected.setflags(write=False) + coefficients.setflags(write=False) + singular_values.setflags(write=False) + + skipped_iteration = ( + flatten_base_core.FlattenBasePolynomialIteration( + degree=2, + powers=(), + mask=None, + selected_count=0, + coefficients=coefficients, + rank=0, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=failed_peak, + applied=False, + ) + ) + + calls: list[int] = [] + + def fake_iteration( + received: np.ndarray, + *, + peak: Peak, + degree: int, + ) -> flatten_base_core.FlattenBasePolynomialIteration: + np.testing.assert_array_equal(received, data) + assert peak is incoming_peak + assert degree == 2 + calls.append(degree) + return skipped_iteration + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_iteration", + fake_iteration, + ) + + result = flatten_base_core._run_flatten_base_polynomial_stage( + data, + peak=incoming_peak, + ) + + assert calls == [2] + assert result.attempted_degrees == (2,) + assert result.completed_degrees == () + assert result.iterations == (skipped_iteration,) + assert result.termination == "peak_failure" + + np.testing.assert_array_equal( + result.background, + np.zeros_like(data), + ) + np.testing.assert_array_equal(result.corrected, data) + np.testing.assert_array_equal(data, original) + + assert not result.background.flags.writeable + assert not result.corrected.flags.writeable From 223aabe7a392ae22b795d2630442a855b5b39bdf Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:06:57 -0400 Subject: [PATCH 49/82] feat(analysis): compose complete Flatten Base pipeline --- src/spmkit/core/analysis/_flatten_base.py | 130 ++++++++ tests/core/test_flatten_base_core.py | 345 ++++++++++++++++++++++ 2 files changed, 475 insertions(+) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 55820b1..380f5fe 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -1192,3 +1192,133 @@ def _run_flatten_base_polynomial_stage( iterations=tuple(iterations), termination=termination, ) + + +@dataclass(frozen=True) +class FlattenBaseResult: + """Complete Flatten Base result and stage-level evidence.""" + + corrected: np.ndarray + background: np.ndarray + facet_stage: FacetStageResult + polynomial_stage: FlattenBasePolynomialStage + final_peak: BasePeakEstimate + mean_offset: float + minimum_offset: float + mean_centered: bool + + @property + def total_offset(self) -> float: + """Total constant offset subtracted after background leveling.""" + return self.mean_offset + self.minimum_offset + + +def _run_flatten_base( + data: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, +) -> FlattenBaseResult: + """Run the complete Gwyddion-compatible Flatten Base pipeline.""" + facet_stage = _run_flatten_base_facet_stage( + data, + pixel_size_x=pixel_size_x, + pixel_size_y=pixel_size_y, + ) + + if facet_stage.iterations: + polynomial_input_peak = facet_stage.iterations[-1].peak + else: + polynomial_input_peak = facet_stage.initial_peak + + polynomial_stage = _run_flatten_base_polynomial_stage( + facet_stage.corrected, + peak=polynomial_input_peak, + ) + + if polynomial_stage.iterations: + final_peak = polynomial_stage.iterations[-1].peak + else: + final_peak = polynomial_stage.initial_peak + + corrected = np.array( + polynomial_stage.corrected, + dtype=float, + copy=True, + ) + background = np.array( + facet_stage.background, + dtype=float, + copy=True, + ) + polynomial_background = np.asarray( + polynomial_stage.background, + dtype=float, + ) + + if background.shape != corrected.shape: + raise ValueError( + "Flatten Base facet stage returned incompatible shapes" + ) + if polynomial_background.shape != corrected.shape: + raise ValueError( + "Flatten Base polynomial stage returned " + "incompatible shapes" + ) + if corrected.size == 0: + raise ValueError( + "Flatten Base requires non-empty corrected data" + ) + if not np.all(np.isfinite(corrected)): + raise ValueError( + "Flatten Base polynomial stage returned " + "non-finite corrected data" + ) + if not np.all(np.isfinite(background)): + raise ValueError( + "Flatten Base facet stage returned " + "a non-finite background" + ) + if not np.all(np.isfinite(polynomial_background)): + raise ValueError( + "Flatten Base polynomial stage returned " + "a non-finite background" + ) + + background += polynomial_background + + mean_centered = bool(final_peak.success) + mean_offset = ( + float(final_peak.mean) + if mean_centered + else 0.0 + ) + + if mean_centered: + corrected -= mean_offset + background += mean_offset + + remaining_minimum = float(np.min(corrected)) + minimum_offset = ( + remaining_minimum + if remaining_minimum > 0.0 + else 0.0 + ) + + if minimum_offset > 0.0: + corrected -= minimum_offset + background += minimum_offset + + corrected.setflags(write=False) + background.setflags(write=False) + + return FlattenBaseResult( + corrected=corrected, + background=background, + facet_stage=facet_stage, + polynomial_stage=polynomial_stage, + final_peak=final_peak, + mean_offset=mean_offset, + minimum_offset=minimum_offset, + mean_centered=mean_centered, + ) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 506759f..71e1459 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -1814,3 +1814,348 @@ def fake_iteration( assert not result.background.flags.writeable assert not result.corrected.flags.writeable + + +def test_flatten_base_composes_stages_and_final_offsets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.array( + [ + [10.0, 11.0, 12.0], + [13.0, 14.0, 15.0], + [16.0, 17.0, 18.0], + ], + dtype=float, + ) + original = data.copy() + + class FacetPeak: + success = True + mean = 2.0 + rms = 0.5 + + class FinalPeak: + success = True + mean = 1.5 + rms = 0.2 + + facet_peak = FacetPeak() + final_peak = FinalPeak() + + facet_background = np.full_like(data, 2.0) + facet_corrected = data - facet_background + facet_background.setflags(write=False) + facet_corrected.setflags(write=False) + + facet_stage = flatten_base_core.FacetStageResult( + corrected=facet_corrected, + background=facet_background, + initial_peak=facet_peak, + iterations=(), + termination="degenerate_plane", + ) + + polynomial_background = np.full_like(data, 3.0) + polynomial_corrected = facet_corrected - polynomial_background + polynomial_background.setflags(write=False) + polynomial_corrected.setflags(write=False) + + class FinalIteration: + degree = 5 + peak = final_peak + applied = True + + polynomial_stage = flatten_base_core.FlattenBasePolynomialStage( + corrected=polynomial_corrected, + background=polynomial_background, + initial_peak=facet_peak, + iterations=(FinalIteration(),), + termination="completed", + ) + + calls: dict[str, object] = {} + + def fake_facet_stage( + received: np.ndarray, + *, + pixel_size_x: float, + pixel_size_y: float, + ) -> flatten_base_core.FacetStageResult: + np.testing.assert_array_equal(received, data) + assert pixel_size_x == 2.0 + assert pixel_size_y == 0.5 + calls["facet"] = True + return facet_stage + + def fake_polynomial_stage( + received: np.ndarray, + *, + peak: FacetPeak, + ) -> flatten_base_core.FlattenBasePolynomialStage: + np.testing.assert_array_equal(received, facet_corrected) + assert peak is facet_peak + calls["polynomial"] = True + return polynomial_stage + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_facet_stage", + fake_facet_stage, + ) + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_stage", + fake_polynomial_stage, + ) + + result = flatten_base_core._run_flatten_base( + data, + pixel_size_x=2.0, + pixel_size_y=0.5, + ) + + after_mean_centering = polynomial_corrected - final_peak.mean + expected_minimum_offset = float(np.min(after_mean_centering)) + expected_corrected = ( + after_mean_centering + - expected_minimum_offset + ) + expected_background = ( + facet_background + + polynomial_background + + final_peak.mean + + expected_minimum_offset + ) + + assert calls == { + "facet": True, + "polynomial": True, + } + assert result.facet_stage is facet_stage + assert result.polynomial_stage is polynomial_stage + assert result.final_peak is final_peak + assert result.mean_centered + assert result.mean_offset == 1.5 + assert result.minimum_offset == 3.5 + assert result.total_offset == 5.0 + + np.testing.assert_allclose( + result.corrected, + expected_corrected, + ) + np.testing.assert_allclose( + result.background, + expected_background, + ) + np.testing.assert_allclose( + result.corrected + result.background, + data, + ) + np.testing.assert_array_equal(data, original) + + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable + + +def test_flatten_base_skips_mean_after_failed_final_peak() -> None: + data = np.array( + [ + [8.0, 9.0], + [10.0, 11.0], + ], + dtype=float, + ) + original = data.copy() + + class FacetPeak: + success = True + mean = 1.0 + rms = 0.25 + + class FailedPeak: + success = False + mean = 999.0 + rms = 0.5 + + facet_peak = FacetPeak() + failed_peak = FailedPeak() + + facet_background = np.full_like(data, 1.0) + facet_corrected = data - facet_background + + facet_stage = flatten_base_core.FacetStageResult( + corrected=facet_corrected, + background=facet_background, + initial_peak=facet_peak, + iterations=(), + termination="completed", + ) + + polynomial_background = np.full_like(data, 2.0) + polynomial_corrected = facet_corrected - polynomial_background + + class FinalIteration: + degree = 2 + peak = failed_peak + applied = True + + polynomial_stage = flatten_base_core.FlattenBasePolynomialStage( + corrected=polynomial_corrected, + background=polynomial_background, + initial_peak=facet_peak, + iterations=(FinalIteration(),), + termination="peak_failure", + ) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_facet_stage", + lambda *args, **kwargs: facet_stage, + ) + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_stage", + lambda *args, **kwargs: polynomial_stage, + ) + + result = flatten_base_core._run_flatten_base( + data, + pixel_size_x=1.0, + pixel_size_y=1.0, + ) + + expected_minimum_offset = 5.0 + expected_corrected = polynomial_corrected - expected_minimum_offset + expected_background = ( + facet_background + + polynomial_background + + expected_minimum_offset + ) + + assert result.final_peak is failed_peak + assert not result.mean_centered + assert result.mean_offset == 0.0 + assert result.minimum_offset == expected_minimum_offset + assert result.total_offset == expected_minimum_offset + + np.testing.assert_array_equal( + result.corrected, + expected_corrected, + ) + np.testing.assert_array_equal( + result.background, + expected_background, + ) + np.testing.assert_array_equal( + result.corrected + result.background, + data, + ) + np.testing.assert_array_equal(data, original) + + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable + + +def test_flatten_base_preserves_nonpositive_minimum_after_mean_centering( + monkeypatch: pytest.MonkeyPatch, +) -> None: + data = np.array( + [ + [2.0, 3.0], + [4.0, 5.0], + ], + dtype=float, + ) + original = data.copy() + + class FacetPeak: + success = True + mean = 0.0 + rms = 0.25 + + class FinalPeak: + success = True + mean = 0.5 + rms = 0.2 + + facet_peak = FacetPeak() + final_peak = FinalPeak() + + facet_background = np.full_like(data, 1.0) + facet_corrected = data - facet_background + + facet_stage = flatten_base_core.FacetStageResult( + corrected=facet_corrected, + background=facet_background, + initial_peak=facet_peak, + iterations=(), + termination="completed", + ) + + polynomial_background = np.full_like(data, 2.0) + polynomial_corrected = ( + facet_corrected + - polynomial_background + ) + + class FinalIteration: + degree = 5 + peak = final_peak + applied = True + + polynomial_stage = flatten_base_core.FlattenBasePolynomialStage( + corrected=polynomial_corrected, + background=polynomial_background, + initial_peak=facet_peak, + iterations=(FinalIteration(),), + termination="completed", + ) + + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_facet_stage", + lambda *args, **kwargs: facet_stage, + ) + monkeypatch.setattr( + flatten_base_core, + "_run_flatten_base_polynomial_stage", + lambda *args, **kwargs: polynomial_stage, + ) + + result = flatten_base_core._run_flatten_base( + data, + pixel_size_x=1.0, + pixel_size_y=1.0, + ) + + expected_corrected = polynomial_corrected - final_peak.mean + expected_background = ( + facet_background + + polynomial_background + + final_peak.mean + ) + + assert result.final_peak is final_peak + assert result.mean_centered + assert result.mean_offset == 0.5 + assert result.minimum_offset == 0.0 + assert result.total_offset == 0.5 + assert float(np.min(result.corrected)) == -1.5 + + np.testing.assert_allclose( + result.corrected, + expected_corrected, + ) + np.testing.assert_allclose( + result.background, + expected_background, + ) + np.testing.assert_allclose( + result.corrected + result.background, + data, + ) + np.testing.assert_array_equal(data, original) + + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable From af4db87f9dc62a79fb839e5d23e8dff0ed965471 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:18:10 -0400 Subject: [PATCH 50/82] feat(analysis): match Gwyddion Flatten Base fitting semantics --- src/spmkit/core/analysis/_flatten_base.py | 676 +++++++++++++++++- tests/core/test_flatten_base_core.py | 158 ++++ .../gwyddion_2_71_end_to_end.json | 50 ++ .../flatten_base/gwyddion_2_71_end_to_end.npz | Bin 0 -> 11915 bytes .../test_flatten_base_vs_gwyddion.py | 98 +++ 5 files changed, 943 insertions(+), 39 deletions(-) create mode 100644 tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json create mode 100644 tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.npz create mode 100644 tests/validation/test_flatten_base_vs_gwyddion.py diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 380f5fe..c203ff9 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -5,7 +5,6 @@ from dataclasses import dataclass import numpy as np -from scipy.optimize import least_squares @dataclass(frozen=True) @@ -202,6 +201,630 @@ def success(self) -> bool: return self.solver_success and self.covariance_available +def _packed_lower_index(row: int, column: int) -> int: + """Index a row-packed lower-triangular symmetric matrix.""" + return row * (row + 1) // 2 + column + + +def _gwyddion_cholesky_decompose( + dimension: int, + packed: np.ndarray, +) -> bool: + """Decompose a packed SPD matrix using Gwyddion's loop order.""" + for diagonal in range(dimension): + value = float( + packed[_packed_lower_index(diagonal, diagonal)] + ) + + for index in range(diagonal): + factor = float( + packed[_packed_lower_index(diagonal, index)] + ) + value -= factor * factor + + if value <= 0.0: + return False + + root = float(np.sqrt(value)) + packed[_packed_lower_index(diagonal, diagonal)] = root + + for row in range(diagonal + 1, dimension): + value = float( + packed[_packed_lower_index(row, diagonal)] + ) + + for index in range(diagonal): + value -= ( + float( + packed[ + _packed_lower_index(diagonal, index) + ] + ) + * float( + packed[ + _packed_lower_index(row, index) + ] + ) + ) + + packed[_packed_lower_index(row, diagonal)] = ( + value / root + ) + + return True + + +def _gwyddion_cholesky_solve( + dimension: int, + decomposition: np.ndarray, + right_hand_side: np.ndarray, +) -> None: + """Solve an SPD system using Gwyddion's substitution order.""" + for row in range(dimension): + for column in range(row): + right_hand_side[row] -= ( + decomposition[ + _packed_lower_index(row, column) + ] + * right_hand_side[column] + ) + + right_hand_side[row] /= decomposition[ + _packed_lower_index(row, row) + ] + + for row in range(dimension - 1, -1, -1): + for column in range(row + 1, dimension): + right_hand_side[row] -= ( + decomposition[ + _packed_lower_index(column, row) + ] + * right_hand_side[column] + ) + + right_hand_side[row] /= decomposition[ + _packed_lower_index(row, row) + ] + + +def _gwyddion_cholesky_invert( + dimension: int, + packed: np.ndarray, +) -> bool: + """Invert a packed SPD matrix using Gwyddion's algorithm.""" + temporary = np.empty(dimension, dtype=float) + packed_offset = 0 + + for pivot in range(dimension - 1, -1, -1): + scale = float(packed[0]) + + if scale <= 0.0: + return False + + row_end = 0 + + for row in range(dimension - 1): + packed_offset = row_end + 1 + row_end += row + 2 + element = float(packed[packed_offset]) + + temporary[row] = -element / scale + + if row >= pivot: + temporary[row] = -temporary[row] + + for index in range(packed_offset, row_end): + packed[index - (row + 1)] = ( + packed[index + 1] + + element + * temporary[index - packed_offset] + ) + + packed[row_end] = 1.0 / scale + + for row in range(dimension - 1): + packed[packed_offset + row] = temporary[row] + + return True + + +def _fit_base_peak_gwyddion_lm( + window: BasePeakWindow, +) -> BasePeakFit: + """Fit a Gaussian using Gwyddion's nonlinear-fit semantics.""" + centers = np.asarray(window.centers, dtype=float) + density = np.asarray(window.density, dtype=float) + + if centers.ndim != 1 or density.ndim != 1: + raise ValueError( + "base peak fitting requires one-dimensional data" + ) + if centers.size != density.size: + raise ValueError( + "base peak fitting requires matching centers and density" + ) + if centers.size < 4: + raise ValueError( + "base peak fitting requires at least four samples" + ) + if ( + not np.all(np.isfinite(centers)) + or not np.all(np.isfinite(density)) + ): + raise ValueError( + "base peak fitting requires finite data" + ) + + parameters = np.array( + [ + window.initial_mean, + window.initial_offset, + window.initial_amplitude, + window.initial_width, + ], + dtype=float, + ) + + if not np.all(np.isfinite(parameters)): + raise ValueError( + "base peak fitting requires finite initial parameters" + ) + if parameters[3] == 0.0: + raise ValueError( + "base peak fitting requires a non-zero initial width" + ) + + parameter_count = 4 + packed_size = parameter_count * (parameter_count + 1) // 2 + finite_limit = np.finfo(float).max + + damping = 1.0e-4 + damping_decrease = 0.4 + damping_increase = 10.0 + damping_zero_replacement = 1.0e-6 + convergence_tolerance = 1.0e-16 + derivative_scale = 1.0e-5 + maximum_iterations = 100 + maximum_unimproved = 12 + + evaluations = 0 + + def gaussian_value( + coordinate: float, + current: np.ndarray, + ) -> tuple[float, bool]: + nonlocal evaluations + evaluations += 1 + + width = float(current[3]) + + if width == 0.0: + return 0.0, False + + scaled = ( + float(coordinate) - float(current[0]) + ) / width + + with np.errstate( + over="ignore", + invalid="ignore", + ): + value = ( + float(current[2]) + * float(np.exp(-(scaled * scaled))) + + float(current[1]) + ) + + return value, True + + def calculate_residuals( + current: np.ndarray, + ) -> tuple[np.ndarray, float, bool]: + residuals = np.empty(centers.size, dtype=float) + residual_sum = 0.0 + + for index in range(centers.size): + value, valid = gaussian_value( + float(centers[index]), + current, + ) + + if not valid: + return residuals, -1.0, False + + residual = value - float(density[index]) + residuals[index] = residual + residual_sum += residual * residual + + if not np.isfinite(residual_sum): + return residuals, -1.0, False + + return residuals, residual_sum, True + + def calculate_derivatives( + coordinate: float, + current: np.ndarray, + ) -> tuple[np.ndarray, bool]: + derivatives = np.empty(parameter_count, dtype=float) + perturbed = current.copy() + + for parameter_index in range(parameter_count): + step = ( + abs(float(perturbed[parameter_index])) + * derivative_scale + ) + + if step == 0.0: + step = derivative_scale + + perturbed[parameter_index] -= step + left, valid = gaussian_value( + coordinate, + perturbed, + ) + + if not valid: + return derivatives, False + + perturbed[parameter_index] += 2.0 * step + right, valid = gaussian_value( + coordinate, + perturbed, + ) + + if not valid: + return derivatives, False + + derivatives[parameter_index] = ( + (right - left) / (2.0 * step) + ) + perturbed[parameter_index] = current[ + parameter_index + ] + + return derivatives, True + + def rank_and_condition( + current: np.ndarray, + ) -> tuple[int, float]: + jacobian = np.empty( + (centers.size, parameter_count), + dtype=float, + ) + + for index in range(centers.size): + derivatives, valid = calculate_derivatives( + float(centers[index]), + current, + ) + + if not valid: + return 0, float("inf") + + jacobian[index, :] = derivatives + + singular_values = np.linalg.svd( + jacobian, + compute_uv=False, + ) + + if ( + singular_values.size == 0 + or singular_values[0] == 0.0 + ): + return 0, float("inf") + + tolerance = ( + np.finfo(float).eps + * max(jacobian.shape) + * singular_values[0] + ) + rank = int( + np.count_nonzero( + singular_values > tolerance + ) + ) + + if ( + rank < parameter_count + or singular_values[-1] <= tolerance + ): + return rank, float("inf") + + condition = float( + singular_values[0] / singular_values[-1] + ) + return rank, condition + + residuals, residual_sum_new, evaluation_valid = ( + calculate_residuals(parameters) + ) + + if not evaluation_valid: + width = abs(float(parameters[3])) + + return BasePeakFit( + mean=float(parameters[0]), + rms=width / np.sqrt(2.0), + offset=float(parameters[1]), + amplitude=float(parameters[2]), + width=width, + residual_norm=float("inf"), + solver_success=False, + covariance_available=False, + evaluations=evaluations, + jacobian_rank=0, + condition_estimate=float("inf"), + ) + + best_parameters = parameters.copy() + residual_sum_best = finite_limit + + gradient = np.empty(parameter_count, dtype=float) + normal = np.empty(packed_size, dtype=float) + saved_normal: np.ndarray | None = None + saved_parameters: np.ndarray | None = None + + iteration = 0 + unimproved = 0 + finished = False + + while True: + if unimproved == 0: + damping *= damping_decrease + residual_sum_best = residual_sum_new + best_parameters = parameters.copy() + + gradient.fill(0.0) + normal.fill(0.0) + + for sample_index in range(centers.size): + derivatives, valid = calculate_derivatives( + float(centers[sample_index]), + parameters, + ) + + if not valid: + evaluation_valid = False + residual_sum_best = -1.0 + break + + for row in range(parameter_count): + gradient[row] += ( + derivatives[row] + * residuals[sample_index] + ) + + packed_row = row * (row + 1) // 2 + + for column in range(row + 1): + normal[packed_row + column] += ( + derivatives[row] + * derivatives[column] + ) + + if not evaluation_valid: + break + + saved_normal = normal.copy() + saved_parameters = parameters.copy() + + if saved_normal is None or saved_parameters is None: + evaluation_valid = False + residual_sum_best = -1.0 + break + + positive_definite = False + first_pass = True + + while ( + not positive_definite + and np.isfinite(damping) + ): + if not first_pass: + normal[:] = saved_normal + else: + first_pass = False + + step = -gradient.copy() + + for parameter_index in range(parameter_count): + diagonal = ( + parameter_index + * (parameter_index + 3) + // 2 + ) + + if saved_normal[diagonal] == 0.0: + normal[diagonal] = damping + else: + normal[diagonal] = ( + saved_normal[diagonal] + * (1.0 + damping) + ) + + positive_definite = ( + _gwyddion_cholesky_decompose( + parameter_count, + normal, + ) + ) + + if not positive_definite: + damping *= damping_increase + + if damping == 0.0: + damping = damping_zero_replacement + + if not np.isfinite(damping): + evaluation_valid = False + residual_sum_best = -1.0 + break + + _gwyddion_cholesky_solve( + parameter_count, + normal, + step, + ) + + parameters = saved_parameters + step + + unchanged = 0 + + for parameter_index in range(parameter_count): + if ( + abs( + float(parameters[parameter_index]) + - float( + saved_parameters[parameter_index] + ) + ) + == 0.0 + ): + unchanged += 1 + + if unchanged == parameter_count: + break + + ( + residuals, + residual_sum_new, + evaluation_valid, + ) = calculate_residuals(parameters) + + if not evaluation_valid: + residual_sum_best = -1.0 + break + + if ( + residual_sum_new == 0.0 + or ( + iteration > 2 + and abs( + ( + residual_sum_best + - residual_sum_new + ) + / residual_sum_best + ) + < convergence_tolerance + ) + ): + finished = True + + if residual_sum_new >= residual_sum_best: + damping *= damping_increase + + if damping == 0.0: + damping = damping_zero_replacement + + unimproved += 1 + else: + unimproved = 0 + + if unimproved >= maximum_unimproved: + break + + iteration += 1 + + if iteration >= maximum_iterations: + break + + if finished: + break + + parameters = best_parameters.copy() + solver_evaluations = evaluations + + covariance_available = False + + if evaluation_valid and saved_normal is not None: + original_normal = saved_normal.copy() + covariance = saved_normal.copy() + + for parameter_index in range(parameter_count): + diagonal = ( + parameter_index + * (parameter_index + 3) + // 2 + ) + + if original_normal[diagonal] == 0.0: + covariance[diagonal] = 1.0 + + covariance_available = ( + _gwyddion_cholesky_invert( + parameter_count, + covariance, + ) + ) + + if not covariance_available: + covariance = original_normal.copy() + + for parameter_index in range(parameter_count): + diagonal = ( + parameter_index + * (parameter_index + 3) + // 2 + ) + + if original_normal[diagonal] == 0.0: + covariance[diagonal] = 1.0 + + covariance[diagonal] *= 1.0001 + + covariance_available = ( + _gwyddion_cholesky_invert( + parameter_count, + covariance, + ) + ) + + covariance_available = bool( + covariance_available + and np.all(np.isfinite(covariance)) + ) + + finite_parameters = bool( + np.all(np.isfinite(parameters)) + ) + + if not finite_parameters: + covariance_available = False + + jacobian_rank, condition_estimate = ( + rank_and_condition(parameters) + ) + + width = abs(float(parameters[3])) + solver_success = bool( + covariance_available + and finite_parameters + and residual_sum_best >= 0.0 + ) + + residual_norm = ( + float(np.sqrt(residual_sum_best)) + if residual_sum_best >= 0.0 + else float("inf") + ) + + return BasePeakFit( + mean=float(parameters[0]), + rms=width / np.sqrt(2.0), + offset=float(parameters[1]), + amplitude=float(parameters[2]), + width=width, + residual_norm=residual_norm, + solver_success=solver_success, + covariance_available=covariance_available, + evaluations=solver_evaluations, + jacobian_rank=jacobian_rank, + condition_estimate=condition_estimate, + ) + + def _fit_base_peak(window: BasePeakWindow) -> BasePeakFit: """Fit Gwyddion's Gaussian parameterization to a selected peak window.""" centers = np.asarray(window.centers, dtype=float) @@ -319,46 +942,20 @@ def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: condition_estimate=condition_estimate, ) - solution = least_squares( - residuals, - initial, - jac=jacobian, - method="lm", - ftol=1e-12, - xtol=1e-12, - gtol=1e-12, - max_nfev=2000, - ) - - parameters = np.asarray(solution.x, dtype=float) - width = abs(float(parameters[3])) - jacobian_rank, condition_estimate = rank_and_condition( - np.asarray(solution.jac, dtype=float) - ) - - solver_success = bool( - solution.success - and np.all(np.isfinite(parameters)) - and np.all(np.isfinite(solution.fun)) - ) - covariance_available = bool( - solver_success - and jacobian_rank == 4 - and width > width_floor + normalized_window = BasePeakWindow( + centers=window.centers, + density=window.density, + peak_index=window.peak_index, + start_index=window.start_index, + stop_index=window.stop_index, + initial_mean=window.initial_mean, + initial_offset=window.initial_offset, + initial_amplitude=window.initial_amplitude, + initial_width=initial_width, ) - return BasePeakFit( - mean=float(parameters[0]), - rms=width / np.sqrt(2.0), - offset=float(parameters[1]), - amplitude=float(parameters[2]), - width=width, - residual_norm=float(np.linalg.norm(solution.fun)), - solver_success=solver_success, - covariance_available=covariance_available, - evaluations=int(solution.nfev), - jacobian_rank=jacobian_rank, - condition_estimate=condition_estimate, + return _fit_base_peak_gwyddion_lm( + normalized_window ) @@ -386,6 +983,7 @@ def rms(self) -> float: return self.fit.rms + def _estimate_base_peak(data: np.ndarray) -> BasePeakEstimate: """Estimate the dominant base peak from a two-dimensional field.""" distribution = _gwyddion_height_distribution(data) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index 71e1459..c0a625e 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -2159,3 +2159,161 @@ class FinalIteration: assert not result.corrected.flags.writeable assert not result.background.flags.writeable + + +def test_gwyddion_lm_reproduces_edge_peak_solution() -> None: + centers = np.array( + [ + 0.075611686318400137, + 0.26307892775469505, + 0.45054616919099005, + 0.638013410627285, + 0.82548065206358001, + 1.0129478934998748, + 1.2004151349361698, + ], + dtype=float, + ) + density = np.array( + [ + 4.514677658785919, + 0.18058710635143677, + 0.13891315873187443, + 0.076402237302530943, + 0.0, + 0.083347895239124656, + 0.041673947619562328, + ], + dtype=float, + ) + + original_centers = centers.copy() + original_density = density.copy() + + window = flatten_base_core.BasePeakWindow( + centers=centers, + density=density, + peak_index=0, + start_index=0, + stop_index=7, + initial_mean=0.075611686318400137, + initial_offset=0.0, + initial_amplitude=4.514677658785919, + initial_width=0.39368120701621939, + ) + + result = flatten_base_core._fit_base_peak_gwyddion_lm( + window + ) + + assert result.solver_success + assert result.covariance_available + assert result.jacobian_rank == 4 + assert np.isfinite(result.condition_estimate) + + assert result.mean == pytest.approx( + -0.38015369096654944, + abs=5e-10, + rel=0.0, + ) + assert result.offset == pytest.approx( + 0.067658206848585964, + abs=5e-10, + rel=0.0, + ) + assert result.amplitude == pytest.approx( + 178.54320058289358, + abs=5e-7, + rel=0.0, + ) + assert result.width == pytest.approx( + 0.23717840912131738, + abs=5e-10, + rel=0.0, + ) + assert result.rms == pytest.approx( + 0.1677104614407208, + abs=5e-10, + rel=0.0, + ) + + np.testing.assert_array_equal( + centers, + original_centers, + ) + np.testing.assert_array_equal( + density, + original_density, + ) + + +def test_gwyddion_packed_cholesky_matches_dense_reference() -> None: + matrix = np.array( + [ + [7.0, 1.2, 0.4, -0.3], + [1.2, 5.0, 0.8, 0.2], + [0.4, 0.8, 4.0, 0.6], + [-0.3, 0.2, 0.6, 3.0], + ], + dtype=float, + ) + right_hand_side = np.array( + [1.0, -2.0, 0.5, 3.0], + dtype=float, + ) + + packed = np.array( + [ + matrix[row, column] + for row in range(matrix.shape[0]) + for column in range(row + 1) + ], + dtype=float, + ) + + decomposition = packed.copy() + + assert flatten_base_core._gwyddion_cholesky_decompose( + 4, + decomposition, + ) + + solution = right_hand_side.copy() + + flatten_base_core._gwyddion_cholesky_solve( + 4, + decomposition, + solution, + ) + + np.testing.assert_allclose( + solution, + np.linalg.solve(matrix, right_hand_side), + atol=5e-15, + rtol=5e-15, + ) + + inverse = packed.copy() + + assert flatten_base_core._gwyddion_cholesky_invert( + 4, + inverse, + ) + + expected_inverse = np.linalg.inv(matrix) + + expected_packed_inverse = np.array( + [ + expected_inverse[row, column] + for row in range(matrix.shape[0]) + for column in range(row + 1) + ], + dtype=float, + ) + + np.testing.assert_allclose( + inverse, + expected_packed_inverse, + atol=5e-15, + rtol=5e-15, + ) diff --git a/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json b/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json new file mode 100644 index 0000000..68a09d0 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json @@ -0,0 +1,50 @@ +{ + "acceptance": { + "corrected_max_abs_error": 5e-13, + "final_peak_mean_abs_error": 5e-10, + "final_peak_rms_abs_error": 5e-10 + }, + "artifacts": { + "corrected_canonical_sha256": "0cdfcb4315ed0bb231890e80cb00845d63f4264be2b7dcdb4f9ed9230f2d003d", + "input_canonical_sha256": "b323b21a248a4882b324b189c9198eb140138bf729a3f483d27887f1b51db802", + "npz_filename": "gwyddion_2_71_end_to_end.npz", + "npz_sha256": "e92fe35ceaeaf166457efacb72a310ed5e012608946a604bdffba6e9df13d058" + }, + "expected_control_flow": { + "facet_iterations": 5, + "final_peak_success": true, + "polynomial_applied": 4, + "polynomial_attempted": 4, + "polynomial_degrees": [ + 2, + 3, + 4, + 5 + ] + }, + "expected_result": { + "corrected_maximum": 6.0000618910655525, + "corrected_minimum": 0.0, + "final_peak_mean": -0.38253115055779424, + "final_peak_rms": 0.16615659800631805 + }, + "field": { + "dtype": "float64", + "height_unit": "unspecified synthetic units", + "pixel_size_x": 0.8, + "pixel_size_y": 1.3, + "shape": [ + 24, + 32 + ] + }, + "fixture_id": "gwyddion-2.71-flatten-base-end-to-end", + "reference": { + "operation": "Flatten Base", + "probe_output_sha256": "51ead932b37381b8c0cdd4b0df6b4f9e8962cf638c1cfaad96d0b178118a70a0", + "probe_source": ".reference/gwyddion-2.71/flatten-base-parity/flatten_base_end_to_end_probe.c", + "software": "Gwyddion", + "version": "2.71" + }, + "schema_version": 1 +} diff --git a/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.npz b/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.npz new file mode 100644 index 0000000000000000000000000000000000000000..a88893d328185b15e5c52719a0ac31cd0535fa09 GIT binary patch literal 11915 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 KLNfnn^?v|i9< None: + metadata = json.loads(_METADATA_PATH.read_text()) + npz_path = _FIXTURE_DIR / metadata["artifacts"]["npz_filename"] + + assert hashlib.sha256(npz_path.read_bytes()).hexdigest() == ( + metadata["artifacts"]["npz_sha256"] + ) + + with np.load(npz_path) as fixture: + input_field = np.asarray( + fixture["input"], + dtype=float, + ) + expected_corrected = np.asarray( + fixture["corrected"], + dtype=float, + ) + + original_input = input_field.copy() + field = metadata["field"] + expected_flow = metadata["expected_control_flow"] + expected_result = metadata["expected_result"] + acceptance = metadata["acceptance"] + + result = _flatten_base._run_flatten_base( + input_field, + pixel_size_x=float(field["pixel_size_x"]), + pixel_size_y=float(field["pixel_size_y"]), + ) + + attempted_degrees = tuple(iteration.degree for iteration in result.polynomial_stage.iterations) + applied_degrees = tuple( + iteration.degree for iteration in result.polynomial_stage.iterations if iteration.applied + ) + expected_degrees = tuple(expected_flow["polynomial_degrees"]) + + assert len(result.facet_stage.iterations) == (expected_flow["facet_iterations"]) + assert attempted_degrees == expected_degrees + assert applied_degrees == expected_degrees + assert result.final_peak.success is (expected_flow["final_peak_success"]) + + assert result.final_peak.mean == pytest.approx( + expected_result["final_peak_mean"], + abs=acceptance["final_peak_mean_abs_error"], + rel=0.0, + ) + assert result.final_peak.rms == pytest.approx( + expected_result["final_peak_rms"], + abs=acceptance["final_peak_rms_abs_error"], + rel=0.0, + ) + + np.testing.assert_allclose( + result.corrected, + expected_corrected, + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + + assert float(np.min(result.corrected)) == pytest.approx( + expected_result["corrected_minimum"], + abs=acceptance["corrected_max_abs_error"], + rel=0.0, + ) + assert float(np.max(result.corrected)) == pytest.approx( + expected_result["corrected_maximum"], + abs=acceptance["corrected_max_abs_error"], + rel=0.0, + ) + + np.testing.assert_array_equal( + input_field, + original_input, + ) + np.testing.assert_allclose( + result.corrected + result.background, + input_field, + atol=5e-14, + rtol=0.0, + ) + + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable From 6b7ee83afb0bc7cf54caccfeb9b2996e7069ab9b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:04:23 -0400 Subject: [PATCH 51/82] docs(science): record Flatten Base Gwyddion cross-validation --- docs/scientific-status.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/scientific-status.md b/docs/scientific-status.md index a25f010..2cf5e0e 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -35,6 +35,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Sa, Sq, Sz on public experimental GWY matrices | `core.analysis.roughness` | 12 cases, 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the shared-matrix algorithm track | Gwyddion 2.71 | Parser/end-to-end observations are separate: 10 equivalences and 2 preserved channel-count differences | | Limited Nanoscope III `.spm` images | `core.io.bruker_spm` | Six demonstrated files; 18/18 Sa/Sq/Sz comparisons within tolerance and zero reported pixel delta | NUMERICALLY_VERIFIED | Gwyddion 2.71 | `ACCIDENTAL_PRE_FREEZE_UNBLINDING`; partial variants only, no blind holdout or general Bruker support | | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | +| Gwyddion Flatten Base end-to-end trajectory | `core.analysis._flatten_base` | Focal LM and packed-Cholesky verification plus a frozen Gwyddion 2.71 end-to-end fixture; matching facet/polynomial control flow and corrected-field maximum absolute difference `1.465494e-14` | CROSS_VALIDATED | Gwyddion 2.71 executable | Internal Core path and one frozen end-to-end trajectory plus focused numerical cases; no universal equivalence claim across datasets, parameter regimes, platforms or Gwyddion versions | | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | @@ -54,6 +55,7 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Public experimental GWY pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/real_data_roughness_pilot_v0.1_summary.json) - [Nanoscope `.spm` pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/nanoscope_spm_parser_pilot_v0.1_summary.json) - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) +- [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) ## Test-count policy From 960bc7f3e681cafff6cbb21e20148457c9d71259 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:20:04 -0400 Subject: [PATCH 52/82] feat(analysis): add Gwyddion arc kernel primitives --- .../core/analysis/_gwyddion_arc_revolution.py | 133 +++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 218 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_arc_revolution.py create mode 100644 tests/core/test_gwyddion_arc_revolution_kernel.py diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py new file mode 100644 index 0000000..dc4c552 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -0,0 +1,133 @@ +"""Numerical kernels compatible with Gwyddion's Revolve Arc operation. + +This module implements the numerical semantics of the Gwyddion 2.71 +``arc-revolve`` process independently in NumPy. It intentionally remains +separate from SPMKit's physical arc-revolution estimator because the two +operations use different radius, scaling, and boundary conventions. +""" + +from __future__ import annotations + +import math + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] + + +def _gwyddion_round_positive(value: object) -> int: + """Round a finite non-negative scalar using ``floor(value + 0.5)``. + + Gwyddion's ``GWY_ROUND`` macro does not use bankers' rounding. In + particular, ``2.5`` becomes ``3`` and ``4.5`` becomes ``5``. + """ + value_data = np.asarray(value) + + if ( + value_data.ndim != 0 + or not np.issubdtype(value_data.dtype, np.number) + or np.iscomplexobj(value_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError("Gwyddion rounding requires a real scalar") + + numeric_value = float(value_data.item()) + + if not math.isfinite(numeric_value): + raise ValueError("Gwyddion rounding requires a finite scalar") + + if numeric_value < 0.0: + raise ValueError("Gwyddion rounding requires a non-negative scalar") + + return math.floor(numeric_value + 0.5) + + +def _make_gwyddion_arc( + radius: object, + maxres: object, +) -> FloatArray: + """Return the dimensionless arc generated by Gwyddion 2.71. + + Parameters + ---------- + radius: + Arc radius in samples, following Gwyddion's pixel-based convention. + maxres: + Maximum available profile resolution. The generated half-width is + ``GWY_ROUND(min(radius, maxres))``. + + Returns + ------- + numpy.ndarray + Read-only, symmetric ``float64`` arc with an odd number of samples. + + Notes + ----- + The very-flat-arc polynomial is reproduced with the same operation order + as the reference C implementation. Values whose normalized offset + exceeds one are clipped to one, matching Gwyddion's explicit branch. + """ + radius_data = np.asarray(radius) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(radius, (bool, np.bool_)) + ): + raise TypeError("Gwyddion arc radius must be a positive real scalar") + + radius_value = float(radius_data.item()) + + if not math.isfinite(radius_value): + raise ValueError("Gwyddion arc radius must be finite") + + if radius_value <= 0.0: + raise ValueError("Gwyddion arc radius must be positive") + + maxres_data = np.asarray(maxres) + + if ( + maxres_data.ndim != 0 + or not np.issubdtype(maxres_data.dtype, np.integer) + or isinstance(maxres, (bool, np.bool_)) + ): + raise TypeError("Gwyddion arc maxres must be a positive integer") + + maxres_value = int(maxres_data.item()) + + if maxres_value <= 0: + raise ValueError("Gwyddion arc maxres must be positive") + + size = _gwyddion_round_positive(min(radius_value, maxres_value)) + arc = np.empty(2 * size + 1, dtype=np.float64) + use_flat_arc_expansion = radius_value / 8.0 > maxres_value + + for offset in range(size + 1): + normalized_offset = offset / radius_value + + if use_flat_arc_expansion: + squared_offset = normalized_offset * normalized_offset + height = ( + squared_offset + / 2.0 + * ( + 1.0 + + squared_offset + / 4.0 + * (1.0 + squared_offset / 2.0) + ) + ) + elif normalized_offset > 1.0: + height = 1.0 + else: + height = 1.0 - math.sqrt( + 1.0 - normalized_offset * normalized_offset + ) + + arc[size + offset] = height + arc[size - offset] = height + + arc.setflags(write=False) + return arc diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py new file mode 100644 index 0000000..258df24 --- /dev/null +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -0,0 +1,218 @@ +"""Tests for the Gwyddion-compatible Revolve Arc numerical kernel.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_round_positive, + _make_gwyddion_arc, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0.0, 0), + (1.49, 1), + (1.5, 2), + (2.49, 2), + (2.5, 3), + (3.5, 4), + (4.5, 5), + (7.999999999999, 8), + (8.0, 8), + ], +) +def test_gwyddion_round_uses_half_up_semantics( + value: float, + expected: int, +) -> None: + assert _gwyddion_round_positive(value) == expected + + +@pytest.mark.parametrize( + "value", + [True, "2.5", [2.5], 1.0 + 1.0j], +) +def test_gwyddion_round_rejects_non_real_scalars(value: object) -> None: + with pytest.raises(TypeError): + _gwyddion_round_positive(value) + + +@pytest.mark.parametrize( + "value", + [-1.0, np.nan, np.inf, -np.inf], +) +def test_gwyddion_round_rejects_invalid_values(value: float) -> None: + with pytest.raises(ValueError): + _gwyddion_round_positive(value) + + +@pytest.mark.parametrize( + ("radius", "maxres", "expected"), + [ + ( + 1.0, + 7, + np.array( + [ + 1.0, + 0.0, + 1.0, + ] + ), + ), + ( + 2.49, + 7, + np.array( + [ + 0.40430786866297319, + 0.084187639942432058, + 0.0, + 0.084187639942432058, + 0.40430786866297319, + ] + ), + ), + ( + 2.5, + 7, + np.array( + [ + 1.0, + 0.40000000000000013, + 0.083484861008832012, + 0.0, + 0.083484861008832012, + 0.40000000000000013, + 1.0, + ] + ), + ), + ( + 4.0, + 16, + np.array( + [ + 1.0, + 0.33856217223385232, + 0.1339745962155614, + 0.031754163448145745, + 0.0, + 0.031754163448145745, + 0.1339745962155614, + 0.33856217223385232, + 1.0, + ] + ), + ), + ( + 20.0, + 3, + np.array( + [ + 0.011314003335740508, + 0.0050125628933800348, + 0.0012507822280910519, + 0.0, + 0.0012507822280910519, + 0.0050125628933800348, + 0.011314003335740508, + ] + ), + ), + ( + 1000.0, + 7, + np.array( + [ + 2.4500300132353065e-05, + 1.8000162002915999e-05, + 1.2500078125976563e-05, + 8.000032000256001e-06, + 4.5000101250455631e-06, + 2.0000020000040001e-06, + 5.0000012500006248e-07, + 0.0, + 5.0000012500006248e-07, + 2.0000020000040001e-06, + 4.5000101250455631e-06, + 8.000032000256001e-06, + 1.2500078125976563e-05, + 1.8000162002915999e-05, + 2.4500300132353065e-05, + ] + ), + ), + ], +) +def test_make_arc_matches_gwyddion_2_71_reference( + radius: float, + maxres: int, + expected: np.ndarray, +) -> None: + result = _make_gwyddion_arc(radius, maxres) + + np.testing.assert_allclose( + result, + expected, + atol=5e-16, + rtol=0.0, + ) + + +def test_half_integer_radius_changes_arc_resolution() -> None: + below_half = _make_gwyddion_arc(2.49, 7) + half_up = _make_gwyddion_arc(2.5, 7) + + assert below_half.shape == (5,) + assert half_up.shape == (7,) + + +def test_arc_is_symmetric_centered_float64_and_read_only() -> None: + arc = _make_gwyddion_arc(4.0, 16) + + assert arc.dtype == np.float64 + assert arc.shape == (9,) + assert arc[arc.size // 2] == 0.0 + np.testing.assert_array_equal(arc, arc[::-1]) + assert not arc.flags.writeable + + +@pytest.mark.parametrize( + "radius", + [0.0, -1.0, np.nan, np.inf, -np.inf], +) +def test_make_arc_rejects_invalid_radius(radius: float) -> None: + with pytest.raises(ValueError): + _make_gwyddion_arc(radius, 7) + + +@pytest.mark.parametrize( + "radius", + [True, "2.5", [2.5], 1.0 + 1.0j], +) +def test_make_arc_rejects_non_real_radius(radius: object) -> None: + with pytest.raises(TypeError): + _make_gwyddion_arc(radius, 7) + + +@pytest.mark.parametrize( + "maxres", + [0, -1], +) +def test_make_arc_rejects_non_positive_resolution(maxres: int) -> None: + with pytest.raises(ValueError): + _make_gwyddion_arc(2.5, maxres) + + +@pytest.mark.parametrize( + "maxres", + [True, 3.5, "7", [7]], +) +def test_make_arc_rejects_non_integer_resolution(maxres: object) -> None: + with pytest.raises(TypeError): + _make_gwyddion_arc(2.5, maxres) From 84b22bb9aedd9f54836d0fed8b152358f47b32fa Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:52:19 -0400 Subject: [PATCH 53/82] feat(analysis): implement Gwyddion horizontal arc kernel --- .../core/analysis/_gwyddion_arc_revolution.py | 284 +++++++++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 292 ++++++++++++++++++ 2 files changed, 576 insertions(+) diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index dc4c552..2e1ec4d 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -131,3 +131,287 @@ def _make_gwyddion_arc( arc.setflags(write=False) return arc + + +def _gwyddion_population_rms(data: np.ndarray) -> float: + """Return Gwyddion's population RMS with respect to the global mean. + + The mean and squared deviations are accumulated sequentially in C order, + matching ``gwy_data_field_get_rms()``. The divisor is the complete sample + count, equivalent to ``ddof=0``. + """ + data_array = np.asarray(data) + + if data_array.size == 0: + raise ValueError("Gwyddion RMS requires non-empty data") + + if not np.issubdtype(data_array.dtype, np.number) or np.iscomplexobj(data_array): + raise TypeError("Gwyddion RMS requires real numeric data") + + if not np.all(np.isfinite(data_array)): + raise ValueError("Gwyddion RMS requires finite data") + + flattened = np.asarray( + data_array, + dtype=np.float64, + ).ravel(order="C") + + total = 0.0 + for value in flattened: + total += float(value) + + mean = total / flattened.size + + squared_deviation_sum = 0.0 + for value in flattened: + deviation = float(value) - mean + squared_deviation_sum += deviation * deviation + + return math.sqrt(squared_deviation_sum / flattened.size) + + +def _moving_sums( + row: np.ndarray, + size: object, +) -> tuple[FloatArray, FloatArray]: + """Return Gwyddion-compatible moving sums and squared sums. + + ``size`` is Gwyddion's historical moving-window parameter. For ordinary + sizes the operation is equivalent to an asymmetric truncated window. + When the window becomes comparable to the complete row, the reference + enters its distinct ``Moving a whale`` control-flow branch; this behaviour + is reproduced explicitly rather than replaced by a conventional window. + + Notes + ----- + Gwyddion 2.71 accesses memory before the output buffer for certain + one-sample and out-of-domain combinations. Such undefined combinations + are rejected here. The horizontal kernel handles a one-sample processing + axis explicitly as identity. + """ + row_array = np.asarray(row) + + if row_array.ndim != 1: + raise ValueError("Gwyddion moving sums require a one-dimensional row") + + if row_array.size == 0: + raise ValueError("Gwyddion moving sums require a non-empty row") + + if not np.issubdtype(row_array.dtype, np.number) or np.iscomplexobj(row_array): + raise TypeError("Gwyddion moving sums require real numeric data") + + if not np.all(np.isfinite(row_array)): + raise ValueError("Gwyddion moving sums require finite data") + + size_data = np.asarray(size) + + if ( + size_data.ndim != 0 + or not np.issubdtype(size_data.dtype, np.integer) + or isinstance(size, (bool, np.bool_)) + ): + raise TypeError("Gwyddion moving-sum size must be a non-negative integer") + + size_value = int(size_data.item()) + + if size_value < 0: + raise ValueError("Gwyddion moving-sum size must be non-negative") + + values = np.asarray( + row_array, + dtype=np.float64, + ) + resolution = values.size + + sums = np.zeros(resolution, dtype=np.float64) + squared_sums = np.zeros(resolution, dtype=np.float64) + + left_half = size_value // 2 + right_half = 0 if size_value == 0 else (size_value - 1) // 2 + + # Exact historical shortcut. It is unreachable from make_arc() because + # the generated half-width never exceeds the processed resolution. + if right_half >= resolution: + first_value = float(values[0]) + sums.fill(first_value) + squared_sums.fill(first_value * first_value) + return sums, squared_sums + + phase_3b_start = resolution - 1 - right_half + if phase_3b_start <= 0 and phase_3b_start <= left_half: + raise ValueError( + "Gwyddion 2.71 moving sums are undefined for this " + "resolution and window size" + ) + + # Phase 1: fill the first output element. + for index in range(right_half + 1): + value = float(values[index]) + sums[0] += value + squared_sums[0] += value * value + + # Phase 2: gather new values without dropping old ones. + phase_2_end = min( + left_half, + resolution - 1 - right_half, + ) + for index in range(1, phase_2_end + 1): + value = float(values[index + right_half]) + sums[index] = sums[index - 1] + value + squared_sums[index] = squared_sums[index - 1] + value * value + + # Phase 3a: move a complete window. + for index in range( + left_half + 1, + resolution - right_half, + ): + entering = float(values[index + right_half]) + leaving = float(values[index - left_half - 1]) + + sums[index] = sums[index - 1] + entering - leaving + squared_sums[index] = ( + squared_sums[index - 1] + + entering * entering + - leaving * leaving + ) + + # Phase 3b: a window larger than the available interior remains fixed. + for index in range( + phase_3b_start, + left_half + 1, + ): + sums[index] = sums[index - 1] + squared_sums[index] = squared_sums[index - 1] + + # Phase 4: lose values without gathering new ones. + for index in range( + max(left_half + 1, resolution - right_half), + resolution, + ): + leaving = float(values[index - left_half - 1]) + sums[index] = sums[index - 1] - leaving + squared_sums[index] = squared_sums[index - 1] - leaving * leaving + + return sums, squared_sums + + +def _gwyddion_arc_horizontal( + data: np.ndarray, + radius: object, +) -> FloatArray: + """Estimate a horizontal background using Gwyddion 2.71 semantics. + + The algorithm uses a global population RMS to scale the dimensionless arc, + clips downward protrusions against a local ``mean - 2.5*rms`` envelope, + and finds the minimum touching position using truncated edge support. + + A processing axis containing one sample is defined as identity. Gwyddion + 2.71 performs an out-of-bounds read for this degenerate geometry, so no + stable external numerical result exists to reproduce. + """ + data_array = np.asarray(data) + + if data_array.ndim != 2: + raise ValueError("Gwyddion horizontal arc requires two-dimensional data") + + if data_array.size == 0: + raise ValueError("Gwyddion horizontal arc requires non-empty data") + + if not np.issubdtype(data_array.dtype, np.number) or np.iscomplexobj(data_array): + raise TypeError("Gwyddion horizontal arc requires real numeric data") + + if not np.all(np.isfinite(data_array)): + raise ValueError("Gwyddion horizontal arc requires finite data") + + values = np.array( + data_array, + dtype=np.float64, + copy=True, + order="C", + ) + row_count, column_count = values.shape + + # Defined SPMKit behaviour for a reference implementation defect. + if column_count == 1: + values.setflags(write=False) + return values + + rms = _gwyddion_population_rms(values) + scale = rms / math.sqrt(2.0 / 3.0 - math.pi / 16.0) + + arc = _make_gwyddion_arc( + radius, + column_count, + ) + scaled_arc = np.asarray( + arc * -scale, + dtype=np.float64, + ) + half_width = scaled_arc.size // 2 + + weights, _ = _moving_sums( + np.ones(column_count, dtype=np.float64), + half_width, + ) + + background = np.empty_like(values) + clipped_row = np.empty(column_count, dtype=np.float64) + + for row_index in range(row_count): + source_row = values[row_index] + local_sums, local_squared_sums = _moving_sums( + source_row, + half_width, + ) + + for column_index in range(column_count): + local_mean = local_sums[column_index] / weights[column_index] + local_variance = ( + local_squared_sums[column_index] / weights[column_index] + - local_mean * local_mean + ) + + local_rms = ( + float("nan") + if local_variance < 0.0 + else math.sqrt(local_variance) + ) + + lower_envelope = local_mean - 2.5 * local_rms + source_value = float(source_row[column_index]) + + # Preserve the argument ordering of GLib's MAX(a, b) macro. + clipped_row[column_index] = ( + source_value + if source_value > lower_envelope + else lower_envelope + ) + + for column_index in range(column_count): + first_offset = max( + 0, + column_index - half_width, + ) - column_index + final_offset = min( + column_index + half_width, + column_count - 1, + ) - column_index + + minimum = math.inf + + for offset in range( + first_offset, + final_offset + 1, + ): + candidate = ( + -scaled_arc[half_width + offset] + + clipped_row[column_index + offset] + ) + + if candidate < minimum: + minimum = float(candidate) + + background[row_index, column_index] = minimum + + background.setflags(write=False) + return background diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py index 258df24..c62027f 100644 --- a/tests/core/test_gwyddion_arc_revolution_kernel.py +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -216,3 +216,295 @@ def test_make_arc_rejects_non_positive_resolution(maxres: int) -> None: def test_make_arc_rejects_non_integer_resolution(maxres: object) -> None: with pytest.raises(TypeError): _make_gwyddion_arc(2.5, maxres) + + +def _asymmetric_reference_field() -> np.ndarray: + data = np.empty((5, 7), dtype=float) + + for row in range(5): + for column in range(7): + value = ( + 2.0 + + 0.12 * column + - 0.07 * row + + 0.015 * column * row + + 0.03 * np.sin(0.9 * column + 0.4 * row) + ) + + if row == 1 and column == 4: + value += 3.5 + if row == 3 and column == 2: + value -= 4.0 + if row == 4 and column == 6: + value += 1.2 + + data[row, column] = value + + return data + + +def _independent_truncated_moving_sums( + row: np.ndarray, + size: int, +) -> tuple[np.ndarray, np.ndarray]: + left_half = size // 2 + right_half = 0 if size == 0 else (size - 1) // 2 + + sums = np.empty(row.size, dtype=float) + squared_sums = np.empty(row.size, dtype=float) + + for index in range(row.size): + start = max(0, index - left_half) + stop = min(row.size, index + right_half + 1) + window = row[start:stop] + + sums[index] = sum(float(value) for value in window) + squared_sums[index] = sum(float(value) ** 2 for value in window) + + return sums, squared_sums + + +def test_population_rms_matches_gwyddion_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_population_rms, + ) + + data = _asymmetric_reference_field() + rms = _gwyddion_population_rms(data) + scale = rms / np.sqrt(2.0 / 3.0 - np.pi / 16.0) + + assert scale == pytest.approx( + 1.485841488666283, + abs=2e-15, + rel=0.0, + ) + + +@pytest.mark.parametrize("size", range(0, 6)) +def test_moving_sums_match_independent_truncated_oracle(size: int) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + expected_sum, expected_sum2 = _independent_truncated_moving_sums( + row, + size, + ) + + result_sum, result_sum2 = _moving_sums( + row, + size, + ) + + np.testing.assert_array_equal(result_sum, expected_sum) + np.testing.assert_array_equal(result_sum2, expected_sum2) + + +@pytest.mark.parametrize( + ("size", "expected_sum", "expected_sum2"), + [ + ( + 6, + np.array([3.0, 11.0, 10.0, 10.0, 9.0, 11.0]), + np.array([21.0, 85.0, 86.0, 86.0, 85.0, 81.0]), + ), + ( + 7, + np.array([11.0, 10.0, 10.0, 10.0, 9.0, 11.0]), + np.array([85.0, 86.0, 86.0, 86.0, 85.0, 81.0]), + ), + ], +) +def test_moving_sums_match_gwyddion_whale_branch( + size: int, + expected_sum: np.ndarray, + expected_sum2: np.ndarray, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + + result_sum, result_sum2 = _moving_sums( + row, + size, + ) + + np.testing.assert_array_equal(result_sum, expected_sum) + np.testing.assert_array_equal(result_sum2, expected_sum2) + + +def test_moving_sums_reproduce_reference_large_window_shortcut() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + result_sum, result_sum2 = _moving_sums(row, 13) + + np.testing.assert_array_equal( + result_sum, + np.ones(6), + ) + np.testing.assert_array_equal( + result_sum2, + np.ones(6), + ) + + +def test_moving_sums_reject_reference_undefined_memory_case() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + with pytest.raises( + ValueError, + match="undefined", + ): + _moving_sums( + np.array([4.0]), + 1, + ) + + +def test_horizontal_kernel_matches_asymmetric_gwyddion_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + expected = np.array( + [ + [2.0, 2.1240452701624606, 2.2675450774512851, + 2.3728213964070148, 2.4667243867011543, + 2.570674096470047, 2.6947193666325076], + [1.9416825502692594, 2.0657278204317202, + 2.2179520157249768, 2.3362474198729988, + 2.4602926900354594, 2.5755264216212703, + 2.6995716917837309], + [1.8815206827269855, 2.0055659528894463, + 2.1637952144760346, 2.299476503169311, + 2.4235217733317715, 2.5554972079457752, + 2.7090772468957436], + [-1.2814298042916905, -1.7517211295957433, + -1.8757663997582039, -1.7517211295957433, + -1.2814298042916905, -0.38992491109192096, + 2.7225247038845315], + [1.7499872080912451, 1.8740324782537057, + 2.0419994344855796, 2.1963790371016558, + 2.3565602920599771, 2.5375416304908565, + 2.738580395034298], + ] + ) + + result = _gwyddion_arc_horizontal( + _asymmetric_reference_field(), + 2.5, + ) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_preserves_constant_field() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.full((2, 5), 3.25) + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(result, data) + + +def test_horizontal_kernel_matches_single_row_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array([[0.0, 1.0, -2.0, 4.0, 1.0]]) + expected = np.array( + [[ + 0.0, + -1.2800008456684795, + -2.0, + -1.2800008456684795, + 0.82747338686665239, + ]] + ) + + result = _gwyddion_arc_horizontal(data, 1.5) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_matches_large_radius_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array( + [ + [0.0, 1.0, 4.0, 2.0, -1.0, 3.0, 0.0], + [2.0, 2.5, 1.0, 0.0, -2.0, 1.0, 4.0], + ] + ) + expected = np.array( + [ + [ + -0.99997994598602924, + -0.9999887196368823, + -0.99999498651154795, + -0.99999874662882704, + -1.0, + -0.99999874662882704, + -0.99999498651154795, + ], + [ + -1.9999799459860292, + -1.9999887196368822, + -1.9999949865115478, + -1.9999987466288269, + -2.0, + -1.9999987466288269, + -1.9999949865115478, + ], + ] + ) + + result = _gwyddion_arc_horizontal(data, 1000.0) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_defines_one_sample_axis_as_identity() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array([[0.0], [-1.0], [2.0], [5.0]]) + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(result, data) + + +def test_horizontal_kernel_does_not_mutate_input_and_returns_read_only() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = _asymmetric_reference_field() + original = data.copy() + + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(data, original) + assert result.dtype == np.float64 + assert not result.flags.writeable From c0e4fc1d3ed24d9970b2e4f6781fb2552d9527c8 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:30 -0400 Subject: [PATCH 54/82] feat(analysis): validate Gwyddion arc direction semantics --- .../core/analysis/_gwyddion_arc_revolution.py | 107 ++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 191 +++++++++++++++++ .../gwyddion_2_71_directional.json | 102 +++++++++ .../gwyddion_2_71_directional.npz | Bin 0 -> 5634 bytes .../test_arc_revolution_vs_gwyddion.py | 200 ++++++++++++++++++ 5 files changed, 600 insertions(+) create mode 100644 tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json create mode 100644 tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz create mode 100644 tests/validation/test_arc_revolution_vs_gwyddion.py diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index 2e1ec4d..713ab66 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -9,11 +9,13 @@ from __future__ import annotations import math +from typing import Literal import numpy as np from numpy.typing import NDArray FloatArray = NDArray[np.float64] +GwyddionArcDirection = Literal["horizontal", "vertical", "both"] def _gwyddion_round_positive(value: object) -> int: @@ -415,3 +417,108 @@ def _gwyddion_arc_horizontal( background.setflags(write=False) return background + + +def _readonly_float_array(values: np.ndarray) -> FloatArray: + """Return an independent C-contiguous read-only ``float64`` array.""" + result = np.array( + values, + dtype=np.float64, + copy=True, + order="C", + ) + result.setflags(write=False) + return result + + +def _gwyddion_arc_background( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Compose a complete Gwyddion-compatible Revolve Arc background. + + ``"vertical"`` is implemented by transposing the field, applying the + horizontal kernel, and transposing the result back. ``"both"`` applies + horizontal first and vertical second, matching Gwyddion 2.71. + + Inversion follows the mathematically consistent dual ``-B(-data)``. + Gwyddion 2.71 computes this background correctly for all directions, + including its defective horizontal-inverted wrapper route. + """ + if not isinstance(direction, str): + raise TypeError("Gwyddion arc direction must be a string") + + if direction not in ("horizontal", "vertical", "both"): + raise ValueError( + "Gwyddion arc direction must be one of " + "'horizontal', 'vertical', or 'both'" + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError("Gwyddion arc inverted must be a boolean") + + inverted_value = bool(inverted) + source = np.asarray(data) + + working = ( + -np.asarray(source, dtype=np.float64) + if inverted_value + else source + ) + + if direction == "horizontal": + background = _gwyddion_arc_horizontal( + working, + radius, + ) + elif direction == "vertical": + background = _gwyddion_arc_horizontal( + np.asarray(working).T, + radius, + ).T + else: + horizontal = _gwyddion_arc_horizontal( + working, + radius, + ) + background = _gwyddion_arc_horizontal( + horizontal.T, + radius, + ).T + + if inverted_value: + background = -background + + return _readonly_float_array(background) + + +def _gwyddion_arc_corrected( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Subtract a Gwyddion-compatible arc background from an input field. + + Unlike the Gwyddion 2.71 module wrapper, this function also returns a + scientifically consistent result for ``direction="horizontal"`` with + ``inverted=True``. The reference computes the background correctly in + that route but returns before populating its corrected result field. + """ + background = _gwyddion_arc_background( + data, + radius, + direction=direction, + inverted=inverted, + ) + + corrected = ( + np.asarray(data, dtype=np.float64) + - background + ) + + return _readonly_float_array(corrected) diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py index c62027f..0414c52 100644 --- a/tests/core/test_gwyddion_arc_revolution_kernel.py +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -508,3 +508,194 @@ def test_horizontal_kernel_does_not_mutate_input_and_returns_read_only() -> None np.testing.assert_array_equal(data, original) assert result.dtype == np.float64 assert not result.flags.writeable + + +@pytest.mark.parametrize( + ("direction", "inverted"), + [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), + ], +) +def test_directional_background_matches_explicit_composition( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_horizontal, + ) + + data = _asymmetric_reference_field() + working = -data if inverted else data + + if direction == "horizontal": + expected = _gwyddion_arc_horizontal( + working, + 2.5, + ) + elif direction == "vertical": + expected = _gwyddion_arc_horizontal( + working.T, + 2.5, + ).T + else: + horizontal = _gwyddion_arc_horizontal( + working, + 2.5, + ) + expected = _gwyddion_arc_horizontal( + horizontal.T, + 2.5, + ).T + + if inverted: + expected = -expected + + result = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(result, expected) + + +@pytest.mark.parametrize( + ("direction", "inverted"), + [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), + ], +) +def test_directional_corrected_reconstructs_input( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, + ) + + data = _asymmetric_reference_field() + original = data.copy() + + background = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + corrected = _gwyddion_arc_corrected( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(data, original) + np.testing.assert_allclose( + corrected + background, + data, + atol=5e-15, + rtol=0.0, + ) + + assert background.dtype == np.float64 + assert corrected.dtype == np.float64 + assert background.flags.c_contiguous + assert corrected.flags.c_contiguous + assert not background.flags.writeable + assert not corrected.flags.writeable + + +@pytest.mark.parametrize( + "direction", + ["horizontal", "vertical", "both"], +) +@pytest.mark.parametrize( + "inverted", + [False, True], +) +def test_directional_one_by_one_field_is_identity( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, + ) + + data = np.array([[4.25]]) + + background = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + corrected = _gwyddion_arc_corrected( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(background, data) + np.testing.assert_array_equal( + corrected, + np.zeros_like(data), + ) + + +@pytest.mark.parametrize( + "direction", + ["diagonal", "", 1], +) +def test_directional_background_rejects_invalid_direction( + direction: object, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + ) + + expected_exception = ( + TypeError + if not isinstance(direction, str) + else ValueError + ) + + with pytest.raises(expected_exception): + _gwyddion_arc_background( + np.ones((2, 3)), + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "inverted", + [0, 1, "yes", None], +) +def test_directional_background_rejects_non_boolean_inversion( + inverted: object, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + ) + + with pytest.raises(TypeError): + _gwyddion_arc_background( + np.ones((2, 3)), + 2.5, + inverted=inverted, # type: ignore[arg-type] + ) diff --git a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json new file mode 100644 index 0000000..f32a35a --- /dev/null +++ b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json @@ -0,0 +1,102 @@ +{ + "acceptance": { + "background_max_abs_error": 5e-14, + "corrected_max_abs_error": 5e-14, + "reconstruction_max_abs_error": 5e-14 + }, + "artifacts": { + "array_canonical_sha256": { + "background_both_inverted": "3adb462bfcba10d75dd4c0416424db686959c392b3885dff8824ab0097d1f55b", + "background_both_normal": "931b156e5c8c58c24642e89573105531263d4534d795a8809edfccf8d2b6c163", + "background_horizontal_inverted": "9ffd9fc84037d3230be9a4bf3a353534833e57a186abfe2e759e8a9cb15b1b55", + "background_horizontal_normal": "a4e795a383f8e495edbca5b3b6a2fd903dd6739f9515169178a749ab86cab327", + "background_vertical_inverted": "df9f61f8ed98f63d8ec20905ecb56009a7f47945bda5d4805dd055a331fd9f56", + "background_vertical_normal": "6d4d08a08c1752aca685a452d1ec143d555471905c1bdcda48bbeba940b7cc30", + "corrected_both_inverted": "aff71f2fa45bb29b35c6c4cc0412263d0095732b222376f2291f3604b676a4cc", + "corrected_both_normal": "88d4553c6b95637decc3cc7841c56cd0bec556801c4712b09767b343f108953d", + "corrected_horizontal_inverted": "abb1ce32e2a6e68e5f7175306edc51de4cd23bccbc697df68b5fa5721853ab0b", + "corrected_horizontal_normal": "35cc88b2c8e2e5aa060d4446c459e8cda6c8e6eb9c1a00f4a7209422b24c6a60", + "corrected_vertical_inverted": "25249844f021891d31b2acea23d9ca256ed64a7c81c15999fd1fcf444075ee46", + "corrected_vertical_normal": "a3251acde29315ee05883a47ed53f0730f859239d6efc3a297303234d47baf83", + "input": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd" + }, + "npz_filename": "gwyddion_2_71_directional.npz", + "npz_sha256": "50b263b8add97950ba1ef882f96d5ee3bc35001908c47a1dbb20b9428bc3bc5e" + }, + "cases": { + "both_inverted": { + "corrected_reference_valid": true, + "direction": "both", + "inverted": true + }, + "both_normal": { + "corrected_reference_valid": true, + "direction": "both", + "inverted": false + }, + "horizontal_inverted": { + "corrected_reference_valid": false, + "direction": "horizontal", + "inverted": true, + "known_reference_defect": "Gwyddion 2.71 returns before populating the corrected result after computing and sign-restoring the background." + }, + "horizontal_normal": { + "corrected_reference_valid": true, + "direction": "horizontal", + "inverted": false + }, + "vertical_inverted": { + "corrected_reference_valid": true, + "direction": "vertical", + "inverted": true + }, + "vertical_normal": { + "corrected_reference_valid": true, + "direction": "vertical", + "inverted": false + } + }, + "field": { + "dtype": "float64", + "height_unit": "unspecified synthetic units", + "shape": [ + 5, + 7 + ], + "xreal": 5.6, + "yreal": 6.5 + }, + "fixture_id": "gwyddion-2.71-arc-revolution-directional", + "known_reference_defects": { + "horizontal_inverted_corrected_result": { + "classification": "KNOWN_REFERENCE_DEFECT", + "reference_result_untouched": true, + "sentinel": 123456789.0, + "spmkit_policy": "Preserve the validated background but return the scientifically consistent corrected field." + }, + "single_sample_processing_axis": { + "classification": "KNOWN_REFERENCE_DEFECT", + "reference_behavior": "The historical moving-sums control flow can read before its output buffer.", + "spmkit_policy": "Define a one-sample processing axis as identity." + } + }, + "parameters": { + "composition": { + "both": "horizontal followed by vertical", + "inverted": "negate input, process, negate background", + "vertical": "transpose, horizontal, transpose back" + }, + "radius_px": 2.5 + }, + "reference": { + "operation": "Revolve Arc", + "probe_output_sha256": "1f8ee0535ac3b0d93e3b330ec4f96b39436e4da1853f5d3ce9ba45e1f2d0eca3", + "probe_source": ".reference/gwyddion-2.71/arc-revolve-parity/arc_revolve_behavior_probe.c", + "probe_source_sha256": "27e92376d7955f134a6d76091775dc28fe2e1ba8246936b27e2e924d3ba765f4", + "software": "Gwyddion", + "source_file": ".reference/gwyddion-2.71/source/modules/process/arc-revolve.c", + "source_sha256": "afb19a2382b0abb46595fa3dabc126ade50ec31c91ec9c96ea2284f42d0a67ac", + "version": "2.71" + }, + "schema_version": 1 +} diff --git a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz new file mode 100644 index 0000000000000000000000000000000000000000..b7f5d817e21a464158e125e47651708482b8e453 GIT binary patch literal 5634 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 RaBN}xRWfdftRV2;e*vDRMK}Ne literal 0 HcmV?d00001 diff --git a/tests/validation/test_arc_revolution_vs_gwyddion.py b/tests/validation/test_arc_revolution_vs_gwyddion.py new file mode 100644 index 0000000..6d60b83 --- /dev/null +++ b/tests/validation/test_arc_revolution_vs_gwyddion.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, +) + +_FIXTURE_DIR = ( + Path(__file__).resolve().parent + / "fixtures" + / "gwyddion" + / "arc_revolution" +) +_METADATA_PATH = _FIXTURE_DIR / "gwyddion_2_71_directional.json" +_METADATA = json.loads(_METADATA_PATH.read_text(encoding="utf-8")) +_CASE_NAMES = tuple(_METADATA["cases"]) + + +def _canonical_array_sha256(array: np.ndarray) -> str: + canonical = np.ascontiguousarray( + array, + dtype=np.float64, + ) + + digest = hashlib.sha256() + digest.update(str(canonical.dtype).encode("ascii")) + digest.update(b"\0") + digest.update( + ",".join(str(value) for value in canonical.shape).encode("ascii") + ) + digest.update(b"\0") + digest.update(canonical.tobytes(order="C")) + return digest.hexdigest() + + +def _load_fixture() -> dict[str, np.ndarray]: + npz_path = ( + _FIXTURE_DIR + / _METADATA["artifacts"]["npz_filename"] + ) + + assert hashlib.sha256(npz_path.read_bytes()).hexdigest() == ( + _METADATA["artifacts"]["npz_sha256"] + ) + + with np.load(npz_path) as fixture: + arrays = { + name: np.asarray( + fixture[name], + dtype=np.float64, + ) + for name in fixture.files + } + + expected_hashes = _METADATA["artifacts"][ + "array_canonical_sha256" + ] + + assert set(arrays) == set(expected_hashes) + + for name, array in arrays.items(): + assert _canonical_array_sha256(array) == expected_hashes[name] + + return arrays + + +@pytest.mark.parametrize("case_name", _CASE_NAMES) +def test_arc_background_matches_gwyddion_2_71( + case_name: str, +) -> None: + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + original_input = input_field.copy() + + result = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + result, + fixture[f"background_{case_name}"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_array_equal( + input_field, + original_input, + ) + + assert result.dtype == np.float64 + assert result.flags.c_contiguous + assert not result.flags.writeable + + +@pytest.mark.parametrize( + "case_name", + [ + name + for name, case in _METADATA["cases"].items() + if case["corrected_reference_valid"] + ], +) +def test_arc_corrected_matches_valid_gwyddion_2_71_results( + case_name: str, +) -> None: + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + + background = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + corrected = _gwyddion_arc_corrected( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + corrected, + fixture[f"corrected_{case_name}"], + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_allclose( + corrected + background, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + + assert not corrected.flags.writeable + + +def test_horizontal_inverted_reference_defect_is_preserved_and_repaired() -> None: + fixture = _load_fixture() + defect = _METADATA["known_reference_defects"][ + "horizontal_inverted_corrected_result" + ] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + reference_corrected = fixture[ + "corrected_horizontal_inverted" + ] + + assert defect["classification"] == "KNOWN_REFERENCE_DEFECT" + assert defect["reference_result_untouched"] is True + assert np.all( + reference_corrected == defect["sentinel"] + ) + + background = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction="horizontal", + inverted=True, + ) + corrected = _gwyddion_arc_corrected( + input_field, + _METADATA["parameters"]["radius_px"], + direction="horizontal", + inverted=True, + ) + + np.testing.assert_allclose( + background, + fixture["background_horizontal_inverted"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_allclose( + corrected + background, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + + assert not np.any(corrected == defect["sentinel"]) + assert not background.flags.writeable + assert not corrected.flags.writeable From 38d0955ced027c010159ec0bc96e162ed43c8ff4 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:19:57 -0400 Subject: [PATCH 55/82] docs(science): specify Gwyddion arc compatibility --- .../GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md diff --git a/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md b/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md new file mode 100644 index 0000000..3e6c6b3 --- /dev/null +++ b/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md @@ -0,0 +1,443 @@ +# Gwyddion 2.71 Revolve Arc Compatibility Specification + +**Specification ID:** `spmkit-gwyddion-arc-revolution-v1`
+**Status:** Normative pre-implementation contract
+**Reference:** Gwyddion 2.71
+**Base branch:** `feat/gwyddion-leveling-parity`
+**Base commit:** `c0e4fc1d3ed24d9970b2e4f6781fb2552d9527c8`
+**Frozen fixture:** `gwyddion-2.71-arc-revolution-directional`
+**Scoped maturity:** `LEVEL 3 — CROSS_VALIDATED` + +This claim applies only to the source, probes, fixtures, routes, parameter +cases and tolerances declared here. It is not a universal-equivalence claim. + +## 1. Purpose + +This specification binds together the mathematics, exact reference control +flow, external probes, numerical fixtures, public API, tests, provenance and +scientific claims for SPMKit's Gwyddion-compatible Revolve Arc operation. + +The implementation is judged against the evidence and this specification. +Neither the algorithm, tests nor specification may be silently altered merely +to obtain passing tests. + +Every discrepancy must first be classified as an implementation defect, test +defect, oracle defect, reference defect, specification defect, +unsupported-domain case, or floating-point/platform effect. + +## 2. Scientific identity and scope + +The operation is classified as a: + +> **Gwyddion 2.71-compatible, data-adaptive arc-envelope background +> estimator.** + +It is not represented as: + +- SPMKit's physical arc-revolution estimator; +- a classical morphological opening; +- an exact frequency cutoff; +- probe deconvolution; +- tip estimation; +- specimen-surface reconstruction; +- metrologically certified correction. + +SPMKit's existing physical Arc Revolution remains an independent algorithm. +It uses a physical radius in metres, lateral pixel spacing and explicit border +policies. The compatibility operation uses radius in samples and reproduces +the declared Gwyddion 2.71 semantics. + +## 3. Evidence hierarchy + +Conflicts are resolved in this order: + +1. Exact Gwyddion 2.71 source. +2. C probes compiled against Gwyddion 2.71. +3. Frozen JSON/NPZ numerical fixtures. +4. Official Gwyddion user documentation. +5. Mathematical and SPM-domain literature. +6. Bibliographic and general software context. + +Literature defines terminology and conceptual boundaries. It does not +override observed behaviour of the frozen executable reference. + +### 3.1 Executable evidence + +| Artifact | SHA-256 | +|---|---| +| Gwyddion 2.71 `arc-revolve.c` | `afb19a2382b0abb46595fa3dabc126ade50ec31c91ec9c96ea2284f42d0a67ac` | +| Behaviour-probe source | `27e92376d7955f134a6d76091775dc28fe2e1ba8246936b27e2e924d3ba765f4` | +| Behaviour-probe output | `1f8ee0535ac3b0d93e3b330ec4f96b39436e4da1853f5d3ce9ba45e1f2d0eca3` | +| Directional fixture metadata | `5e037b33e04d2c95420c3e71acbf7b4bc46b8723163bfc11363e6ac083005cd2` | +| Directional fixture NPZ | `50b263b8add97950ba1ef882f96d5ee3bc35001908c47a1dbb20b9428bc3bc5e` | + +### 3.2 Literature evidence + +| Source | Role | SHA-256 | +|---|---|---| +| Gwyddion levelling guide | Declared user semantics | `002b1af784a1f5c441c21bbf55b670c335d5ed53eb0f6e02ab894d4911178ade` | +| Heijmans, 1995 | Mathematical morphology context | `085cede6c5cce62e214d14eb9ef624db5902f8ac512ec9626761afeedafa41eb` | +| Villarrubia, 1997 | SPM geometry and reconstruction boundary | `d50c845edf53bb6713dc8c3d72fdded1db6ba44906bbac0ee9d830eaad0dbae9` | +| Nečas–Klapetek DOI CSL | Bibliographic identity | `5f6ec95fd7eb68ec66aa8eeeaeee4284d1232e8a85cfbccb48e5d11ca20f448e` | + +The Nečas–Klapetek full-text PDF is contextual and optional. Its absence does +not weaken executable numerical provenance. Dynamic publisher HTML is +explicitly non-normative. + +## 4. Mathematical boundary + +Mathematical morphology provides an algebraic and geometric framework for +non-linear image transformations. Villarrubia applies dilation and erosion to +SPM image simulation, surface reconstruction and tip estimation. + +These sources explain why geometric envelopes matter in SPM. They do not +prove that Gwyddion Revolve Arc is a classical morphological opening. + +The reference operator is data-adaptive because: + +- arc amplitude depends on the global RMS; +- local mean-minus-RMS clipping modifies the working profile; +- moving sums contain historical reference-specific control flow. + +Idempotence, anti-extensivity, increasingness and other morphology axioms +must not be claimed without separate proof for this exact adaptive operator. + +## 5. Public input contract + +The public operation accepts a real, finite, non-empty, two-dimensional +channel. + +- `radius_px` is a finite real scalar. +- Boolean, complex, NaN and infinite radii are rejected. +- `1.0 <= radius_px <= 1000.0`. +- Default `radius_px` is `20.0`. +- Direction is `horizontal`, `vertical` or `both`. +- `inverted` is strictly boolean. +- Masks and non-finite field values are unsupported. +- Z units are preserved and need not be geometric lengths. +- Physical lateral ranges do not affect the numerical output. + +## 6. Global scale + +For all N field samples in C-order, + + mu = (1/N) sum_i f_i + +and + + sigma = sqrt((1/N) sum_i (f_i - mu)^2). + +This is population RMS. The arc scale is + + q = sigma / sqrt(2/3 - pi/16). + +Accumulation order is part of numerical compatibility. + +## 7. Discrete arc + +For radius r and processing-axis resolution n, + + s = floor(min(r, n) + 1/2). + +This is Gwyddion positive half-up rounding, not bankers' rounding. + +For k from -s through s, with u = abs(k)/r, + + phi_r(k) = + u^2/2 * (1 + u^2/4 * (1 + u^2/2)) when r/8 > n + 1 when u > 1 + 1 - sqrt(1 - u^2) otherwise + +and + + a_r(k) = q * phi_r(k). + +Branch order and floating-point operation order are normative. + +## 8. Historical moving sums + +Local statistics reproduce Gwyddion 2.71 `moving_sums()` control flow. + +Ordinary regimes agree with asymmetric truncated windows. When window size +becomes comparable to the profile, the historical `Moving a whale` branch is +normative even where it differs from a conventional window oracle. + +The oversized shortcut is retained in the private primitive for source +fidelity, although it is unreachable through normal valid arc geometry. + +## 9. Local clipping and horizontal envelope + +At each position j, let m_j and s_j be the local mean and population RMS +produced by the historical moving-sum route. + + g_j = max(f_j, m_j - 2.5*s_j). + +No undocumented variance clamp is introduced. + +The horizontal background is + + H_r(f)_j = min_k (g_(j+k) + a_r(k)), + +using only offsets that remain within the profile. Edges use truncated +support. There is no padding mode and no public border parameter. + +## 10. Directional composition + +For a field F: + + B_horizontal(F) = H_r(F) + + B_vertical(F) = transpose(H_r(transpose(F))) + + B_both(F) = + transpose(H_r(transpose(H_r(F)))) + +`both` is horizontal followed by vertical. It is ordered and is not +represented as a commutative isotropic two-dimensional operator. + +## 11. Inversion and corrected field + +The inverted background is + + B_inverted(F) = -B(-F). + +The corrected field is always + + C(F) = F - B(F). + +This reconstruction identity applies to all six direction/inversion routes. + +## 12. Known reference defects + +### 12.1 Horizontal plus inverted corrected result + +Gwyddion 2.71 computes and restores the background correctly, then returns +before writing the corrected field. + +Classification: `KNOWN_REFERENCE_DEFECT`. + +SPMKit policy: + +- preserve the externally validated background; +- return `input - background`; +- test reconstruction explicitly; +- disclose the repaired divergence; +- never claim reproduction of the defective corrected output. + +### 12.2 One-sample processing axis + +The historical moving-sum branch can read before its output buffer. + +Classification: `KNOWN_REFERENCE_DEFECT`. + +SPMKit policy: + +- do not freeze process-memory-dependent output; +- define a one-sample processed axis as identity; +- test the safe definition explicitly. + +### 12.3 Dynamic publisher HTML + +Immediate downloads produced different generated byte streams. + +Classification: `NON_NORMATIVE_DYNAMIC_CAPTURE`. + +SPMKit policy: + +- use DOI CSL as canonical bibliographic identity; +- treat local full text as optional context; +- retain dynamic HTML only as provenance; +- never make its byte hash an implementation requirement. + +## 13. Public API + + estimate_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> SPMChannel + + remove_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> SPMChannel + + analyze_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> BackgroundResult + +The existing physical functions remain unchanged. + +## 14. Single authoritative numerical route + +The adapter exposes one internal route: + + _gwyddion_arc_result( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection, + inverted: bool, + ) -> tuple[FloatArray, FloatArray] + +It computes the background once and derives corrected data from that exact +background. + +- Estimate selects background. +- Remove selects corrected. +- Analyze wraps both. +- No public route recomputes the kernel. +- No alternative correction path can drift. + +## 15. Channel and structured-result contract + +Returned channels preserve: + +- name; +- unit; +- x and y ranges; +- acquisition direction; +- group; +- an independent metadata copy. + +Kernel arrays are: + +- `float64`; +- C-contiguous; +- independent of the input buffer; +- read-only; +- non-mutating. + +Structured results use: + + method = "gwyddion_arc_revolution" + +and record effective runtime parameters only: + + { + "radius_px": 20.0, + "direction": "horizontal", + "inverted": False, + } + +Reference versions, hashes, defects, tolerances and maturity remain in +validation provenance rather than runtime parameters. + +## 16. Verification model + +### 16.1 Source-semantic tests + +- half-up rounding; +- exact arc branch boundaries; +- population RMS; +- ordinary moving windows; +- `Moving a whale`; +- oversized shortcut; +- constant fields; +- single rows; +- large radii; +- one-sample safe definition; +- validation; +- non-mutation; +- immutability. + +### 16.2 External validation + +Frozen fixture: `gwyddion-2.71-arc-revolution-directional`. + +The campaign validates: + +- one asymmetric 5 by 7 field; +- radius 2.5; +- six background routes; +- five valid corrected routes; +- horizontal-inverted background; +- untouched defect sentinel; +- repaired SPMKit reconstruction; +- directional composition; +- artifact and array hashes. + +### 16.3 Metamorphic properties + +Where mathematically supported, test: + + B(F + c) = B(F) + c + C(F + c) = C(F) + B(alpha*F) = alpha*B(F), alpha > 0 + B_inverted(F) = -B(-F) + B_vertical(F) = transpose(B_horizontal(transpose(F))) + C(F) + B(F) = F + +Classical morphology axioms are not requirements without independent proof +for this adaptive operator. + +## 17. Scientific claim + +Supported claim: + +> SPMKit's declared Gwyddion-compatible Revolve Arc path is +> `LEVEL 3 — CROSS_VALIDATED` against Gwyddion 2.71 for the frozen kernels, +> fixture, six background routes, five valid corrected routes, focal cases +> and declared tolerances. + +This does not establish: + +- universal equivalence; +- equivalence with other Gwyddion versions; +- physical truth of the estimated background; +- specimen-surface recovery; +- tip deconvolution or reconstruction; +- metrological traceability; +- performance equivalence; +- support for masks or non-finite values. + +## 18. Change control + +Explicit specification review is required for changes to: + +- arithmetic or accumulation order; +- radius semantics; +- edge handling; +- local clipping; +- direction order; +- inversion; +- one-sample policy; +- public defaults or limits; +- runtime metadata; +- fixtures; +- tolerances; +- scientific claims. + +Tests may reveal a specification defect. They may not silently redefine the +specification. + +Source, probes, implementation, tests and specification must be reconciled +and classified before changing the algorithm. + +## 19. References + +1. Gwyddion developers, Data Levelling and Background Subtraction, frozen + official user documentation. +2. H. J. A. M. Heijmans, Mathematical Morphology: A Modern Approach in Image + Processing Based on Algebra and Geometry, SIAM Review 37(1), 1–36, 1995. + DOI: 10.1137/1037001. +3. J. S. Villarrubia, Algorithms for Scanned Probe Microscope Image + Simulation, Surface Reconstruction, and Tip Estimation, Journal of + Research of NIST 102(4), 425–454, 1997. + DOI: 10.6028/jres.102.030. +4. D. Nečas and P. Klapetek, Gwyddion: an open-source software for SPM data + analysis, Central European Journal of Physics 10(1), 181–188, 2012. + DOI: 10.2478/s11534-011-0096-2. +5. Masaryk University institutional publication record: + https://www.muni.cz/en/research/publications/966983 +6. Gwyddion project publication record: + https://gwyddion.net/publications/ From c693fc97e94a5829f57c8b2acf8f522f4f05fb4f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:53 -0400 Subject: [PATCH 56/82] feat(analysis): expose Gwyddion arc background API --- docs/scientific-status.md | 1 + docs/validation/index.md | 1 + src/spmkit/core/analysis/__init__.py | 8 + .../core/analysis/_gwyddion_arc_revolution.py | 42 +- src/spmkit/core/analysis/background.py | 188 +++++- ...test_gwyddion_arc_revolution_background.py | 567 ++++++++++++++++++ .../test_arc_revolution_vs_gwyddion.py | 87 +++ 7 files changed, 882 insertions(+), 12 deletions(-) create mode 100644 tests/core/test_gwyddion_arc_revolution_background.py diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 2cf5e0e..3d4c042 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -37,6 +37,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | | Gwyddion Flatten Base end-to-end trajectory | `core.analysis._flatten_base` | Focal LM and packed-Cholesky verification plus a frozen Gwyddion 2.71 end-to-end fixture; matching facet/polynomial control flow and corrected-field maximum absolute difference `1.465494e-14` | CROSS_VALIDATED | Gwyddion 2.71 executable | Internal Core path and one frozen end-to-end trajectory plus focused numerical cases; no universal equivalence claim across datasets, parameter regimes, platforms or Gwyddion versions | | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | +| Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | diff --git a/docs/validation/index.md b/docs/validation/index.md index 09f1190..39dd4ea 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -36,6 +36,7 @@ references, tolerances, outputs, hashes, and limitations. |---|---|---:|---|---| | Gwyddion roughness 48 v0.1 | Sa, Sq, Sz on 48 canonical synthetic matrices | 144/144 within tolerance | CROSS_VALIDATED | No preprocessing; shared matrices; not physical validation | | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | +| Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index a79358f..9b14aa8 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -17,19 +17,23 @@ ) from spmkit.core.analysis.background import ( BackgroundResult, + GwyddionArcDirection, analyze_arc_revolution_background, + analyze_gwyddion_arc_revolution_background, analyze_median_background, analyze_polynomial_background, analyze_rolling_ball_background, analyze_sphere_revolution_background, analyze_spline_background, estimate_arc_revolution_background, + estimate_gwyddion_arc_revolution_background, estimate_median_background, estimate_polynomial_background, estimate_rolling_ball_background, estimate_sphere_revolution_background, estimate_spline_background, remove_arc_revolution_background, + remove_gwyddion_arc_revolution_background, remove_median_background, remove_polynomial_background, remove_rolling_ball_background, @@ -60,19 +64,23 @@ __all__ = [ "background", "BackgroundResult", + "GwyddionArcDirection", "analyze_arc_revolution_background", + "analyze_gwyddion_arc_revolution_background", "analyze_median_background", "analyze_polynomial_background", "analyze_rolling_ball_background", "analyze_sphere_revolution_background", "analyze_spline_background", "estimate_arc_revolution_background", + "estimate_gwyddion_arc_revolution_background", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", "estimate_sphere_revolution_background", "estimate_spline_background", "remove_arc_revolution_background", + "remove_gwyddion_arc_revolution_background", "remove_median_background", "remove_polynomial_background", "remove_rolling_ball_background", diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index 713ab66..2f8206f 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -495,19 +495,18 @@ def _gwyddion_arc_background( return _readonly_float_array(background) -def _gwyddion_arc_corrected( +def _gwyddion_arc_result( data: np.ndarray, radius: object, *, direction: GwyddionArcDirection = "horizontal", inverted: bool = False, -) -> FloatArray: - """Subtract a Gwyddion-compatible arc background from an input field. +) -> tuple[FloatArray, FloatArray]: + """Return background and corrected fields through one numerical route. - Unlike the Gwyddion 2.71 module wrapper, this function also returns a - scientifically consistent result for ``direction="horizontal"`` with - ``inverted=True``. The reference computes the background correctly in - that route but returns before populating its corrected result field. + The background is computed exactly once. The corrected field is then + defined by the reconstruction identity ``corrected = input - background``. + Both arrays are independent, C-contiguous, ``float64`` and read-only. """ background = _gwyddion_arc_background( data, @@ -515,10 +514,31 @@ def _gwyddion_arc_corrected( direction=direction, inverted=inverted, ) + corrected = _readonly_float_array( + np.asarray(data, dtype=np.float64) - background + ) + + return background, corrected - corrected = ( - np.asarray(data, dtype=np.float64) - - background + +def _gwyddion_arc_corrected( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Return the corrected field from the authoritative result route. + + Unlike the defective Gwyddion 2.71 horizontal-inverted wrapper, this + function always returns the scientifically consistent ``input-background`` + result while preserving the externally validated background. + """ + _, corrected = _gwyddion_arc_result( + data, + radius, + direction=direction, + inverted=inverted, ) - return _readonly_float_array(corrected) + return corrected diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 03a2f52..70e9376 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -8,11 +8,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from typing import Literal, cast import numpy as np from scipy.ndimage import generic_filter, grey_erosion, grey_opening +from spmkit.core.analysis._gwyddion_arc_revolution import ( + GwyddionArcDirection, + _gwyddion_arc_result, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -28,6 +32,7 @@ ArcBorder = Literal["nearest", "reflect"] BackgroundMethod = Literal[ "arc_revolution", + "gwyddion_arc_revolution", "sphere_revolution", "rolling_ball", "median", @@ -97,6 +102,40 @@ def _validated_channel_data( return data +def _validated_gwyddion_radius_px( + radius_px: object, + *, + operation: str, +) -> float: + """Validate the public Gwyddion radius measured in samples.""" + radius_data = np.asarray(radius_px) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(radius_px, (bool, np.bool_)) + ): + raise TypeError( + f"{operation} requires radius_px to be a real scalar" + ) + + value = float(radius_data.item()) + + if not np.isfinite(value): + raise ValueError( + f"{operation} requires radius_px to be finite" + ) + + if not 1.0 <= value <= 1000.0: + raise ValueError( + f"{operation} requires radius_px to be between " + "1.0 and 1000.0 inclusive" + ) + + return value + + def _positive_radius( radius: object, *, @@ -451,6 +490,119 @@ def remove_arc_revolution_background( return channel.with_data(corrected) +def _gwyddion_arc_channels( + channel: SPMChannel, + radius_px: object, + *, + direction: object, + inverted: object, + operation: str, +) -> tuple[ + SPMChannel, + SPMChannel, + float, + GwyddionArcDirection, + bool, +]: + """Validate one request and return background and corrected channels.""" + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _validated_gwyddion_radius_px( + radius_px, + operation=operation, + ) + direction_value = cast( + GwyddionArcDirection, + _validated_choice( + direction, + name="direction", + allowed=("horizontal", "vertical", "both"), + operation=operation, + ), + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError( + f"{operation} requires inverted to be a boolean" + ) + + inverted_value = bool(inverted) + + background_data, corrected_data = _gwyddion_arc_result( + data, + radius_value, + direction=direction_value, + inverted=inverted_value, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + radius_value, + direction_value, + inverted_value, + ) + + +def estimate_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Revolve Arc background. + + Parameters + ---------- + channel: + Real, finite, non-empty two-dimensional channel. The Z unit is + preserved and need not represent geometric length. + radius_px: + Arc radius in samples. The public Gwyddion-compatible range is + inclusive from 1.0 through 1000.0. + direction: + ``"horizontal"`` processes rows, ``"vertical"`` processes columns, + and ``"both"`` applies horizontal followed by vertical. + inverted: + Apply the exact dual ``-B(-data)``. + + Notes + ----- + This is a data-adaptive compatibility estimator, not SPMKit's physical + arc-revolution model, tip deconvolution, or surface reconstruction. + Edges use the truncated support of Gwyddion 2.71; there is no border mode. + """ + background, _, _, _, _ = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="estimate_gwyddion_arc_revolution_background", + ) + return background + + +def remove_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> SPMChannel: + """Subtract a Gwyddion 2.71-compatible Revolve Arc background.""" + _, corrected, _, _, _ = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="remove_gwyddion_arc_revolution_background", + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -1316,6 +1468,40 @@ def analyze_arc_revolution_background( ) +def analyze_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> BackgroundResult: + """Estimate and subtract a compatible Revolve Arc background once.""" + ( + background, + corrected, + radius_value, + direction_value, + inverted_value, + ) = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="analyze_gwyddion_arc_revolution_background", + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_arc_revolution", + parameters={ + "radius_px": radius_value, + "direction": direction_value, + "inverted": inverted_value, + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_arc_revolution_background.py b/tests/core/test_gwyddion_arc_revolution_background.py new file mode 100644 index 0000000..08ab76a --- /dev/null +++ b/tests/core/test_gwyddion_arc_revolution_background.py @@ -0,0 +1,567 @@ +"""Public-contract tests for Gwyddion-compatible Revolve Arc.""" + +from __future__ import annotations + +import inspect +from typing import get_args + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.background as background_module +from spmkit.core.analysis import ( + BackgroundResult, + GwyddionArcDirection, + analyze_gwyddion_arc_revolution_background, + estimate_gwyddion_arc_revolution_background, + remove_gwyddion_arc_revolution_background, +) +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_result, +) +from spmkit.core.models import SPMChannel + +_ROUTES = [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), +] + + +def _field() -> np.ndarray: + return np.array( + [ + [2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5], + [1.5, 1.75, 2.0, 2.25, 6.0, 2.75, 3.0], + [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5], + [0.5, 0.75, 1.0, -2.0, 1.5, 1.75, 2.0], + [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5], + ], + dtype=np.float64, + ) + + +def _channel( + data: np.ndarray | None = None, + *, + unit: str = "V", + x_range: float = 8.0e-6, + y_range: float = 5.0e-6, +) -> SPMChannel: + return SPMChannel( + name="Synthetic CPD", + data=_field() if data is None else data, + unit=unit, + x_range=x_range, + y_range=y_range, + direction="backward", + group="Validation group", + metadata={ + "source": "frozen synthetic contract", + "operator": "public-adapter-test", + }, + ) + + +def _assert_context_preserved( + source: SPMChannel, + result: SPMChannel, +) -> None: + assert result.name == source.name + assert result.unit == source.unit + assert result.x_range == source.x_range + assert result.y_range == source.y_range + assert result.direction == source.direction + assert result.group == source.group + assert result.metadata == source.metadata + assert result.metadata is not source.metadata + + +def _assert_array_contract(data: np.ndarray) -> None: + assert data.dtype == np.float64 + assert data.flags.c_contiguous + assert not data.flags.writeable + + +def _roundoff_bound(expected: np.ndarray) -> float: + scale = max( + 1.0, + float(np.max(np.abs(expected))), + ) + return 512.0 * np.finfo(np.float64).eps * scale + + +def test_public_exports_and_defaults_are_stable() -> None: + expected_names = { + "GwyddionArcDirection", + "estimate_gwyddion_arc_revolution_background", + "remove_gwyddion_arc_revolution_background", + "analyze_gwyddion_arc_revolution_background", + } + + assert expected_names <= set(analysis.__all__) + + for name in expected_names: + assert getattr(analysis, name) is not None + + assert set(get_args(GwyddionArcDirection)) == { + "horizontal", + "vertical", + "both", + } + + for function in ( + estimate_gwyddion_arc_revolution_background, + remove_gwyddion_arc_revolution_background, + analyze_gwyddion_arc_revolution_background, + ): + signature = inspect.signature(function) + assert signature.parameters["radius_px"].default == 20.0 + assert signature.parameters["direction"].default == "horizontal" + assert signature.parameters["inverted"].default is False + + +@pytest.mark.parametrize(("direction", "inverted"), _ROUTES) +def test_public_family_matches_authoritative_private_result( + direction: str, + inverted: bool, +) -> None: + channel = _channel() + original_data = channel.data.copy() + original_metadata = dict(channel.metadata) + + expected_background, expected_corrected = _gwyddion_arc_result( + channel.data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + estimated = estimate_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + removed = remove_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + analyzed = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + assert isinstance(analyzed, BackgroundResult) + + np.testing.assert_array_equal( + estimated.data, + expected_background, + ) + np.testing.assert_array_equal( + removed.data, + expected_corrected, + ) + np.testing.assert_array_equal( + analyzed.background.data, + expected_background, + ) + np.testing.assert_array_equal( + analyzed.corrected.data, + expected_corrected, + ) + np.testing.assert_array_equal( + analyzed.corrected.data + analyzed.background.data, + channel.data, + ) + + assert analyzed.method == "gwyddion_arc_revolution" + assert analyzed.parameters == { + "radius_px": 2.5, + "direction": direction, + "inverted": inverted, + } + + for result_channel in ( + estimated, + removed, + analyzed.background, + analyzed.corrected, + ): + _assert_context_preserved(channel, result_channel) + _assert_array_contract(result_channel.data) + + np.testing.assert_array_equal(channel.data, original_data) + assert channel.metadata == original_metadata + + payload = analyzed.to_dict() + assert payload["method"] == "gwyddion_arc_revolution" + assert payload["parameters"] == analyzed.parameters + assert payload["background"]["shape"] == [5, 7] # type: ignore[index] + assert payload["corrected"]["unit"] == "V" # type: ignore[index] + + +def test_analyze_executes_single_authoritative_result_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + call_count = 0 + original = background_module._gwyddion_arc_result + + def counted_result(*args: object, **kwargs: object) -> object: + nonlocal call_count + call_count += 1 + return original(*args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr( + background_module, + "_gwyddion_arc_result", + counted_result, + ) + + result = analyze_gwyddion_arc_revolution_background( + _channel(), + 2.5, + direction="both", + inverted=True, + ) + + assert isinstance(result, BackgroundResult) + assert call_count == 1 + + +@pytest.mark.parametrize(("direction", "inverted"), _ROUTES) +def test_metamorphic_translation_and_positive_scale( + direction: str, + inverted: bool, +) -> None: + data = _field() + shift = 16.0 + scale = 8.0 + + base_channel = _channel(data) + shifted_channel = _channel(data + shift) + scaled_channel = _channel(data * scale) + + base_background = estimate_gwyddion_arc_revolution_background( + base_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + shifted_background = estimate_gwyddion_arc_revolution_background( + shifted_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + scaled_background = estimate_gwyddion_arc_revolution_background( + scaled_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + + base_corrected = remove_gwyddion_arc_revolution_background( + base_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + shifted_corrected = remove_gwyddion_arc_revolution_background( + shifted_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + scaled_corrected = remove_gwyddion_arc_revolution_background( + scaled_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + + expected_shifted_background = base_background + shift + expected_scaled_background = base_background * scale + expected_scaled_corrected = base_corrected * scale + + np.testing.assert_allclose( + shifted_background, + expected_shifted_background, + atol=_roundoff_bound(expected_shifted_background), + rtol=0.0, + ) + np.testing.assert_allclose( + shifted_corrected, + base_corrected, + atol=_roundoff_bound(base_corrected), + rtol=0.0, + ) + np.testing.assert_allclose( + scaled_background, + expected_scaled_background, + atol=_roundoff_bound(expected_scaled_background), + rtol=0.0, + ) + np.testing.assert_allclose( + scaled_corrected, + expected_scaled_corrected, + atol=_roundoff_bound(expected_scaled_corrected), + rtol=0.0, + ) + + +def test_units_and_lateral_ranges_are_numerically_irrelevant() -> None: + voltage = _channel( + unit="V", + x_range=1.0e-9, + y_range=2.0e-9, + ) + phase = _channel( + unit="deg", + x_range=0.25, + y_range=12.0, + ) + + voltage_result = analyze_gwyddion_arc_revolution_background( + voltage, + 2.5, + direction="both", + ) + phase_result = analyze_gwyddion_arc_revolution_background( + phase, + 2.5, + direction="both", + ) + + np.testing.assert_array_equal( + voltage_result.background.data, + phase_result.background.data, + ) + np.testing.assert_array_equal( + voltage_result.corrected.data, + phase_result.corrected.data, + ) + + assert voltage_result.background.unit == "V" + assert voltage_result.corrected.unit == "V" + assert phase_result.background.unit == "deg" + assert phase_result.corrected.unit == "deg" + + +@pytest.mark.parametrize("radius_px", [1.0, 1000.0, np.float64(20.0)]) +def test_public_radius_boundaries_are_accepted( + radius_px: object, +) -> None: + result = analyze_gwyddion_arc_revolution_background( + _channel(), + radius_px, # type: ignore[arg-type] + ) + + assert result.parameters["radius_px"] == float(radius_px) + + +@pytest.mark.parametrize( + "radius_px", + [ + 0.0, + -1.0, + 0.999999, + 1000.000001, + np.nan, + np.inf, + -np.inf, + ], +) +def test_invalid_radius_values_are_rejected( + radius_px: float, +) -> None: + with pytest.raises(ValueError): + estimate_gwyddion_arc_revolution_background( + _channel(), + radius_px, + ) + + +@pytest.mark.parametrize( + "radius_px", + [ + True, + "20", + 20.0 + 0.0j, + [20.0], + np.array([20.0]), + ], +) +def test_invalid_radius_types_are_rejected( + radius_px: object, +) -> None: + with pytest.raises(TypeError): + estimate_gwyddion_arc_revolution_background( + _channel(), + radius_px, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "direction", + ["diagonal", "", 1, None], +) +def test_invalid_direction_is_rejected( + direction: object, +) -> None: + expected_exception = ( + TypeError + if not isinstance(direction, str) + else ValueError + ) + + with pytest.raises(expected_exception): + estimate_gwyddion_arc_revolution_background( + _channel(), + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "inverted", + [0, 1, "yes", None], +) +def test_non_boolean_inversion_is_rejected( + inverted: object, +) -> None: + with pytest.raises(TypeError): + estimate_gwyddion_arc_revolution_background( + _channel(), + 2.5, + inverted=inverted, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[1.0, np.nan]]), + np.array([[1.0, np.inf]]), + np.array([[1.0 + 0.0j, 2.0 + 0.0j]]), + np.array([1.0, 2.0]), + np.empty((0, 3)), + ], +) +def test_invalid_channel_data_is_rejected( + data: np.ndarray, +) -> None: + expected_exception = ( + TypeError + if np.iscomplexobj(data) + else ValueError + ) + + with pytest.raises(expected_exception): + estimate_gwyddion_arc_revolution_background( + _channel(data), + 2.5, + ) + + +@pytest.mark.parametrize( + ("shape", "direction"), + [ + ((1, 1), "horizontal"), + ((5, 1), "horizontal"), + ((1, 5), "vertical"), + ((1, 1), "both"), + ], +) +def test_single_sample_processing_axis_has_safe_identity_semantics( + shape: tuple[int, int], + direction: str, +) -> None: + data = np.arange( + shape[0] * shape[1], + dtype=np.float64, + ).reshape(shape) + channel = _channel(data) + + result = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + np.testing.assert_array_equal( + result.background.data, + data, + ) + np.testing.assert_array_equal( + result.corrected.data, + np.zeros_like(data), + ) + + +@pytest.mark.parametrize( + ("shape", "direction"), + [ + ((1, 5), "horizontal"), + ((5, 1), "vertical"), + ], +) +def test_singleton_orthogonal_axis_does_not_force_identity( + shape: tuple[int, int], + direction: str, +) -> None: + """A singleton orthogonal dimension is not a singleton processed axis.""" + data = np.arange( + shape[0] * shape[1], + dtype=np.float64, + ).reshape(shape) + channel = _channel(data) + + result = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + assert not np.array_equal( + result.background.data, + data, + ) + np.testing.assert_array_equal( + result.corrected.data + result.background.data, + data, + ) + + +def test_public_result_is_deterministic() -> None: + channel = _channel() + + first = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction="both", + inverted=True, + ) + second = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction="both", + inverted=True, + ) + + np.testing.assert_array_equal( + first.background.data, + second.background.data, + ) + np.testing.assert_array_equal( + first.corrected.data, + second.corrected.data, + ) diff --git a/tests/validation/test_arc_revolution_vs_gwyddion.py b/tests/validation/test_arc_revolution_vs_gwyddion.py index 6d60b83..85cf51a 100644 --- a/tests/validation/test_arc_revolution_vs_gwyddion.py +++ b/tests/validation/test_arc_revolution_vs_gwyddion.py @@ -198,3 +198,90 @@ def test_horizontal_inverted_reference_defect_is_preserved_and_repaired() -> Non assert not np.any(corrected == defect["sentinel"]) assert not background.flags.writeable assert not corrected.flags.writeable + + +@pytest.mark.parametrize("case_name", _CASE_NAMES) +def test_public_arc_result_matches_gwyddion_2_71( + case_name: str, +) -> None: + from spmkit.core.analysis import ( + analyze_gwyddion_arc_revolution_background, + ) + from spmkit.core.models import SPMChannel + + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + field = _METADATA["field"] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + original_input = input_field.copy() + + channel = SPMChannel( + name="Gwyddion 2.71 frozen Revolve Arc field", + data=input_field, + unit="V", + x_range=float(field["xreal"]), + y_range=float(field["yreal"]), + direction="forward", + group="external-validation", + metadata={ + "fixture_id": _METADATA["fixture_id"], + "reference": "Gwyddion 2.71", + }, + ) + + result = analyze_gwyddion_arc_revolution_background( + channel, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + result.background.data, + fixture[f"background_{case_name}"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + + if case["corrected_reference_valid"]: + np.testing.assert_allclose( + result.corrected.data, + fixture[f"corrected_{case_name}"], + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + else: + defect = _METADATA["known_reference_defects"][ + "horizontal_inverted_corrected_result" + ] + assert np.all( + fixture[f"corrected_{case_name}"] + == defect["sentinel"] + ) + assert not np.any( + result.corrected.data == defect["sentinel"] + ) + + np.testing.assert_allclose( + result.corrected.data + result.background.data, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_array_equal( + input_field, + original_input, + ) + + assert result.method == "gwyddion_arc_revolution" + assert result.parameters == { + "radius_px": 2.5, + "direction": case["direction"], + "inverted": case["inverted"], + } + assert result.background.unit == "V" + assert result.corrected.unit == "V" + assert not result.background.data.flags.writeable + assert not result.corrected.data.flags.writeable From c466e7a8c15c2a9deb60453dad241ace29ab517e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:04 -0400 Subject: [PATCH 57/82] feat(analysis): match Gwyddion Sphere Revolution semantics --- docs/api.md | 48 ++ ...WYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md | 486 ++++++++++++++++++ docs/scientific-status.md | 14 + docs/validation/index.md | 1 + src/spmkit/core/analysis/__init__.py | 6 + .../analysis/_gwyddion_sphere_revolution.py | 300 +++++++++++ src/spmkit/core/analysis/background.py | 126 +++++ ...t_gwyddion_sphere_revolution_background.py | 371 +++++++++++++ .../sphere_revolution_reference.json | 485 +++++++++++++++++ .../sphere_revolution_reference.npz | Bin 0 -> 39566 bytes .../test_sphere_revolution_vs_gwyddion.py | 306 +++++++++++ 11 files changed, 2143 insertions(+) create mode 100644 docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md create mode 100644 src/spmkit/core/analysis/_gwyddion_sphere_revolution.py create mode 100644 tests/core/test_gwyddion_sphere_revolution_background.py create mode 100644 tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json create mode 100644 tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz create mode 100644 tests/validation/test_sphere_revolution_vs_gwyddion.py diff --git a/docs/api.md b/docs/api.md index 8091b5d..c32afb1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -178,6 +178,54 @@ This implementation is LEVEL 1 — SOFTWARE_VERIFIED through synthetic tests and independent test-local two-dimensional oracles for both supported border policies. Numerical equivalence with Gwyddion has not been established. +## Gwyddion-compatible Sphere-revolution background + +SPM-Kit provides data-adaptive background estimation compatible with Gwyddion 2.71's +Revolve Sphere module: + +```python +from spmkit.core.analysis import ( + analyze_gwyddion_sphere_revolution_background, + estimate_gwyddion_sphere_revolution_background, + remove_gwyddion_sphere_revolution_background, +) + +background = estimate_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) + +corrected = remove_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) + +result = analyze_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) +``` + +`radius_px` is expressed in samples (array-index units). The public Gwyddion-compatible +range is inclusive from 1.0 through 1000.0. `channel` must be a real, finite, non-empty +`SPMChannel`. + +`inverted=False` executes Gwyddion 2.71's normal Sphere Revolution route. `inverted=True` +applies the exact dual `-B(-data)` for background estimation. To avoid the internal crash +occurring in Gwyddion 2.71's C module when `inverted=True`, the corrected channel uses +the safe deliberate divergence `corrected = original - background`, guaranteeing exact +reconstruction of the original channel data. + +`analyze_gwyddion_sphere_revolution_background` returns a `BackgroundResult` with +`method="gwyddion_sphere_revolution"` and `parameters={"radius_px": float(radius_px), "inverted": bool(inverted)}`. + +This estimator is distinct from SPMKit's physical sphere-revolution model +(`estimate_sphere_revolution_background`), which operates with physical metric radii in metres, +circular footprints in physical coordinates, and explicit physical border policies. + ## KPFM statistics ```python diff --git a/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md new file mode 100644 index 0000000..60a7635 --- /dev/null +++ b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md @@ -0,0 +1,486 @@ +# Gwyddion 2.71 Sphere Revolution Compatibility Specification + +## 1. Status and scope + +This document establishes the normative design specification for SPMKit's compatibility with Gwyddion 2.71's 2D Revolve Sphere background leveling operation. + +Current Status: Normative Design Specification (Implementation Pending). + +The primary objective is to reproduce the exact, observable numerical semantics of Gwyddion 2.71's ``sphere-revolve`` module within SPMKit. + +Scope boundaries: +- Direct external reference scope (direct external reference): The normal route (`inverted=False`). +- Derived external reference scope (derived external reference): Inverted background evaluated via the exact mathematical dual `-B(-F)` using normal reference executions on negated inputs. +- Safe deliberate divergence scope (safe deliberate divergence): Inverted corrected field evaluated as `F - B_inv(F)` to guarantee complete reconstruction identity without inheriting upstream memory corruption bugs. +- Universal equivalence is explicitly excluded. Parity claims apply strictly to the frozen test suite and external validation fixtures. + +| Capability | Evidence class | Planned SPMKit behavior | +|---|---|---| +| Normal background | Direct external reference | Reproduce Gwyddion 2.71 numerical outputs | +| Normal corrected | Direct external reference | Reproduce Gwyddion 2.71 numerical outputs | +| Inverted reference wrapper | Frozen reference defect | Excluded due to upstream memory corruption crash | +| Inverted background | Derived external reference | Reproduce dual `-B_normal(-F)` using negated inputs | +| Inverted corrected | Safe deliberate divergence | Evaluate `F - B_inv(F)` to ensure input reconstruction | +| Physical Sphere Revolution | Independent physical model | Preserved completely intact without modification | + +## 2. Separation from physical Sphere Revolution + +SPMKit currently provides `estimate_sphere_revolution_background`, which models a physical 2D spherical contact tip over real surface topographies using physical SI units (metres), anisotropic lateral pixel dimensions (`dx`, `dy`), morphological opening operations, and physical border modes (`nearest`, `reflect`). + +The Gwyddion 2.71 compatibility operation defined herein uses dimensionless pixel-sample radii (`radius_px`), data-adaptive RMS scaling (`q`), and historical C-array index truncation. + +> The two operations remain separate because their parameter semantics, geometry, scaling, and evidence contracts are not equivalent. + +The existing physical Sphere Revolution implementation (`estimate_sphere_revolution_background`, `remove_sphere_revolution_background`, `analyze_sphere_revolution_background`) and its test suite shall remain completely intact and unmodified. + +## 3. Proposed public API + +The public API for Gwyddion Sphere Revolution compatibility shall provide three functions in `spmkit.core.analysis.background`: + +```python +estimate_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel +``` + +```python +remove_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel +``` + +```python +analyze_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> BackgroundResult +``` + +Method string string: + +`gwyddion_sphere_revolution` + +Parameters dictionary: + +```python +{ + "radius_px": float(radius_px), + "inverted": bool(inverted), +} +``` + +Public imports will be exported in `spmkit.core.analysis` and `spmkit` upon completion of Block D of the implementation sequence. + +## 4. Input and parameter contract + +The input channel and parameters must adhere to the following contract: +- Input channel data must be a two-dimensional, finite, real, non-empty `float64` array. +- All internal numerical calculations must use IEEE-754 `numpy.float64` double precision. +- `radius_px` must be a real, finite scalar in the inclusive range `1.0 <= radius_px <= 1000.0`. +- Boolean values passed as `radius_px` must be rejected with `TypeError`, matching SPMKit's `_validated_gwyddion_radius_px` contract. +- `inverted` must be a boolean. Non-boolean values (e.g. integers or strings) must be rejected with `TypeError`. +- `radius_px` represents a sample count along pixel grid axes and is completely independent of physical lateral metadata (`xreal`, `yreal`, `dx`, `dy`). +- Input channels and arrays must never be mutated in place. +- Private array outputs must be C-contiguous `numpy.float64` arrays with `flags.writeable = False`. +- Public SPMChannel metadata, Z units, and spatial context must be preserved using `channel.with_data(...)`. + +## 5. Global normalization + +Let $F$ be a 2D data field of dimensions $y_{\mathrm{res}} \times x_{\mathrm{res}}$ containing $N = y_{\mathrm{res}} \cdot x_{\mathrm{res}}$ samples. + +Global mean $\bar F$ is calculated using a serial C-order sum over double-precision values: + +$$ +\bar F = \frac{1}{N} \sum_{p=0}^{N-1} F_p +$$ + +Global population RMS is calculated in a second serial C-order pass: + +$$ +\operatorname{RMS}(F) = \sqrt{\frac{1}{N} \sum_{p=0}^{N-1} (F_p - \bar F)^2} +$$ + +The global scaling parameter $q$ is defined as: + +$$ +q = \frac{\operatorname{RMS}(F)}{\sqrt{5/6}} +$$ + +Key requirements: +- The divisor $N$ uses the full population count. +- Calculation requires two explicit serial passes to preserve exact accumulation order. +- A constant input field yields $\operatorname{RMS}(F) = 0.0$ and $q = 0.0$. +- `np.std()` must not be used as the normative definition due to variance in accumulation order. + +## 6. Discrete sphere construction + +The integer sphere radius $s$ in samples, kernel size $n$, and local filter half-width $k$ are derived via `GWY_ROUND` (`floor(val + 0.5)`): + +$$ +s = \left\lfloor \min(r, x_{\mathrm{res}}) + 0.5 \right\rfloor +$$ + +$$ +n = 2s + 1 +$$ + +$$ +k = \left\lfloor \frac{s}{2} \right\rfloor +$$ + +For indices $i, j \in [0, s]$, normalized coordinate offsets are defined as: + +$$ +u = \frac{i}{r}, \qquad v = \frac{j}{r}, \qquad \rho^2 = u^2 + v^2 +$$ + +The dimensionless sphere height $z$ is evaluated via the normal branch when $r / 8 \le x_{\mathrm{res}}$: + +$$ +z = \begin{cases} 1 - \sqrt{1 - \rho^2}, & \rho^2 \le 1 \\ 2, & \rho^2 > 1 \end{cases} +$$ + +When $r / 8 > x_{\mathrm{res}}$, the very-flat branch polynomial is evaluated: + +$$ +z = \frac{\rho^2}{2} \left[ 1 + \frac{\rho^2}{4} \left( 1 + \frac{\rho^2}{2} \right) \right] +$$ + +The scaled sphere kernel $S$ is obtained by quadrant-symmetric assignment and scaling: + +$$ +S = -q z +$$ + +Key requirements: +- Quadrant symmetry assigns identical $z$ to $(s-i, s-j)$, $(s-i, s+j)$, $(s+i, s-j)$, and $(s+i, s+j)$. +- The parameter $x_{\mathrm{res}}$ is the sole dimension passed to Gwyddion's `make_sphere`. +- On non-square rectangular grids ($x_{\mathrm{res}} \ne y_{\mathrm{res}}$), this creates an intentional asymmetry under matrix transposition. +- This historical asymmetry is required for exact Gwyddion 2.71 compatibility and must not be "corrected" by using $\min(x_{\mathrm{res}}, y_{\mathrm{res}})$. + +## 7. Local mean and RMS semantics + +For filter size $k = s // 2 > 0$, local moving windows use asymmetric negative and positive extensions: + +$$ +k_- = (k - 1) // 2, \qquad k_+ = k // 2 +$$ + +For each pixel $(r, c)$, the window bounds are truncated at image borders: + +$$ +r_{\mathrm{start}} = \max(0, r - k_-), \qquad r_{\mathrm{stop}} = \min(y_{\mathrm{res}} - 1, r + k_+) +$$ + +$$ +c_{\mathrm{start}} = \max(0, c - k_-), \qquad c_{\mathrm{stop}} = \min(x_{\mathrm{res}} - 1, c + k_+) +$$ + +Properties: +- Truncated window support at boundaries without zero-padding. +- Window sums are normalized by the effective pixel count in the window. +- Odd $k$ yields a symmetric centered window; even $k$ places one extra sample to the right and bottom. + +Local mean $\mu_{\mathrm{local}}$ is the window arithmetic mean. + +Local RMS $\sigma_{\mathrm{local}}$ for $k > 1$ is calculated as: + +$$ +\sigma_{\mathrm{local}} = \sqrt{\max\left(E[F^2] - E[F]^2, 0\right)} +$$ + +Special filter size semantics: +- When $k = 0$: $\mu_{\mathrm{local}} = F$ (unfiltered copy) and $\sigma_{\mathrm{local}} = F$ (unfiltered copy). +- When $k = 1$: $\mu_{\mathrm{local}} = F$ (unfiltered copy) and $\sigma_{\mathrm{local}} = 0.0$. +- SPMKit reproduces these exact numerical outputs. +- SPMKit does not emit Gwyddion's `GwyProcess-CRITICAL` GLib diagnostic warnings. + +Note: The independent Python oracle uses direct window summation loops, while Gwyddion uses 1D rolling sums. Both yield identical floating-point results within sub-ULP rounding tolerances. + +## 8. Outlier-trimmed field + +The outlier-trimmed field $T$ is computed element-by-element as: + +$$ +T = \max\left(F, \mu_{\mathrm{local}} - 2.5 \sigma_{\mathrm{local}}\right) +$$ + +For $k = 0$, where $\mu_{\mathrm{local}} = F$ and $\sigma_{\mathrm{local}} = F$, this simplifies to: + +$$ +T = \max(F, -1.5 F) +$$ + +$T$ represents an intermediate trimmed field and is not the final background. + +## 9. Two-dimensional envelope + +The background $B_{ij}$ at pixel $(i, j)$ is extracted as the lower envelope of $T$ relative to the scaled sphere kernel $S$: + +$$ +B_{ij} = \min_{\substack{a \in [-s, s], b \in [-s, s] \\ (i+a, j+b) \text{ valid} \\ S_{s+a, s+b} \ge -q}} \left[ T_{i+a, j+b} - S_{s+a, s+b} \right] +$$ + +Since $S = -q z$, this is equivalent to: + +$$ +B_{ij} = \min \left[ T_{i+a, j+b} + q z_{s+a, s+b} \right] +$$ + +Key requirements: +- Full 2D minimization loop over valid kernel offsets. +- Support points with $S < -q$ are excluded from minimization. +- Truncated boundary support without padding. +- Normal corrected field: $C = F - B$. + +## 10. Radius-one historical semantics + +Executable probe evidence confirms: +- For $r = 1.0$, $s = 1$, $n = 3$, and $k = 0$. +- Gwyddion 2.71 emits two GLib critical diagnostic warnings (`size > 0` assertion failures) because $k = 0$. +- The Gwyddion execution continues normally, returning exit code 0, finite outputs, and exact reconstruction. +- SPMKit preserves the exact numerical output ($T = \max(F, -1.5F)$ passed through the 2D envelope) while executing cleanly without diagnostics. + +## 11. Constant-field q=0 semantics + +For constant input fields ($F_{ij} = c$): +- Global RMS is $0.0$, yielding $q = 0.0$. +- The scaled sphere kernel $S$ consists entirely of zeros. +- The condition $S \ge -q$ ($0.0 \ge 0.0$) holds for all kernel positions. +- Background $B_{ij} = c$ and corrected $C_{ij} = 0.0$. +- All outputs are finite and exhibit exact zero error. + +## 12. Inverted reference execution defect + +### EXECUTABLE_CONFIRMED_REFERENCE_DEFECT + +Executable campaign evidence across 15 inverted reference cases confirms: +- In Gwyddion 2.71's `sphere-revolve.c`, when `inverted=TRUE`, line 320 executes `gwy_object_unref(field); field = invfield;`. +- At line 328, `gwy_data_field_subtract_fields(args->field, field)` uses `args->field` which was not reassigned to `invfield`. +- 15 out of 15 inverted normal cases crashed with exit code 139 (SIGSEGV). +- 15 out of 15 inverted ASan cases crashed with exit code 134 (SIGABRT). +- Probe output recorded `execute_started=1` but `execute_returned=0`. +- The crash occurs inside `gwy_data_field_check_compatibility()` due to a read of unallocated memory during final subtraction. +- ASan output did not emit the literal string `heap-use-after-free`, so that specific string is not used as an exact diagnostic tag. +- Gwyddion 2.71 provides no valid reference output for `inverted=TRUE`. +- The reference `inverted=TRUE` C wrapper cannot serve as a valid numerical oracle. + +## 13. Safe inverted semantics in SPMKit + +To provide mathematically sound inversion without inheriting reference crashes, SPMKit defines safe inverted semantics: + +Inverted Background Dual: + +$$ +B_{\mathrm{inv}}(F) = -B_{\mathrm{normal}}(-F) +$$ + +Inverted Corrected Field: + +$$ +C_{\mathrm{inv}}(F) = F - B_{\mathrm{inv}}(F) +$$ + +Evidence hierarchy: +- Direct external reference: Normal route (`inverted=False`). +- Derived external reference: Inverted background $B_{\mathrm{inv}}(F) = -B_{\mathrm{normal}}(-F)$, verified by running Gwyddion's normal C kernel on 10 explicitly negated inputs (`input_negation_max_abs = 0.0`, $q$ difference $= 0.0$, dual reconstruction max abs error $= 8.88 \times 10^{-16}$). +- Safe deliberate divergence: Inverted corrected field $C_{\mathrm{inv}}(F) = F - B_{\mathrm{inv}}(F)$, ensuring exact reconstruction identity without undefined behavior. + +## 14. Independent oracle evidence + +An independent Python oracle (`/tmp/spmkit_gwyddion_sphere_oracle.py`) evaluated 20 valid cases (10 original normal, 10 negated normal): +- Implemented in pure Python 3 and NumPy without SciPy or SPMKit imports. +- Evaluated using direct 2D window loops. +- Max $q$ absolute error: `0.0`. +- Max background absolute error: `4.4408920985006262e-16`. +- Max background ULP error: 2 ULP. +- Max corrected absolute error: `8.8817841970012523e-16`. +- Max reconstruction error: `8.8817841970012523e-16`. +- All outputs 100% finite. +- Large raw corrected ULP (`4377498837804122113`) resulted from comparing `-4.44e-16` against positive zero `0.0`. +- Raw ULP near zero is not an acceptance criterion. + +## 15. Acceptance criteria + +Numerical acceptance criteria for external validation fixtures: + +```text +background_max_abs_error = 5e-14 +corrected_max_abs_error = 5e-14 +reconstruction_max_abs_error = 5e-14 +rtol = 0 +``` + +Explanation: +- Provides a safety margin above the observed maximum numerical discrepancy (`8.88e-16`). +- Matches the tolerance scale established for Gwyddion Revolve Arc compatibility. +- Applies absolute comparison (`atol = 5e-14`, `rtol = 0`). +- ULP distance is logged as a diagnostic metric only. +- Applies to frozen external validation fixtures and does not constitute universal equivalence. + +## 16. Planned implementation architecture + +Implementation will add one private module and modify two existing files: +- New file: `src/spmkit/core/analysis/_gwyddion_sphere_revolution.py` +- Modify: `src/spmkit/core/analysis/background.py` +- Modify: `src/spmkit/core/analysis/__init__.py` + +Private API signatures in `_gwyddion_sphere_revolution.py`: + +```python +_gwyddion_sphere_background( + data: FloatArray, + radius: float, +) -> FloatArray +``` + +```python +_gwyddion_sphere_result( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> tuple[FloatArray, FloatArray] +``` + +```python +_gwyddion_sphere_corrected( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> FloatArray +``` + +Architecture rules: +- `_gwyddion_sphere_background` computes authoritative normal background. +- `_gwyddion_sphere_result` centralizes inversion dual and corrected calculation. +- `_gwyddion_sphere_corrected` delegates to `_gwyddion_sphere_result`. +- Public adapters delegate to `_gwyddion_sphere_result`. +- No duplication of algorithm logic. +- No sharing of private helper functions with Arc Revolution in this phase. +- Existing physical Sphere Revolution code remains completely untouched. + +## 17. Required tests + +Required test matrix: + +### Private/core tests (`tests/core/test_gwyddion_sphere_revolution_background.py`) +- `test_default_radius_is_20` +- `test_radius_boundary_1_accepted` +- `test_radius_boundary_1000_accepted` +- `test_invalid_nonfinite_radius_rejected` +- `test_invalid_range_radius_rejected` +- `test_bool_radius_rejected` +- `test_float64_conversion` +- `test_c_contiguous_output` +- `test_readonly_array_output` +- `test_input_array_not_mutated` +- `test_constant_field_zero_corrected` +- `test_radius_one_semantics` +- `test_very_flat_branch_execution` +- `test_rectangular_asymmetry` +- `test_safe_inversion_dual_identity` +- `test_reconstruction_identity` +- `test_private_result_agreement` + +### Public adapter tests (`tests/core/test_gwyddion_sphere_revolution_background.py`) +- `test_estimate_delegates_correctly` +- `test_remove_delegates_correctly` +- `test_analyze_returns_background_result` +- `test_method_string_is_gwyddion_sphere_revolution` +- `test_parameters_dict_contents` +- `test_channel_context_preservation` +- `test_physical_sphere_api_unchanged` +- `test_public_private_numerical_agreement` + +### External validation tests (`tests/validation/test_sphere_revolution_vs_gwyddion.py`) +- `test_gwyddion_sphere_direct_normal_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_direct_normal_corrected_matches_gwyddion_2_71` +- `test_gwyddion_sphere_negated_normal_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_derived_inverted_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_safe_inverted_corrected_matches_gwyddion_2_71` +- `test_gwyddion_sphere_reconstruction_identity` +- `test_gwyddion_sphere_reference_inverted_failure_evidence_is_documented` +- `test_gwyddion_sphere_public_result_matches_gwyddion_2_71` +- `test_gwyddion_sphere_fixture_hashes_are_stable` + +## 18. Fixture and provenance requirements + +Fixture location: +`tests/validation/fixtures/gwyddion/sphere_revolution/` + +Artifacts: +- `gwyddion_2_71_sphere.npz` (uncompressed NPZ containing input, reference background, reference corrected, and derived arrays). +- `gwyddion_2_71_sphere.json` (JSON metadata with SHA-256 hashes, canonical array hashes, source/probe/runner/oracle hashes, case metadata, acceptance tolerances, and reference execution status). + +Requirements: +- Distinguish direct normal cases, negated-normal cases, derived inverted arrays, and failed original inverted executions. +- Do not store dummy or synthetic arrays for crashing reference cases. + +## 19. Scientific claim boundaries + +Claim status before implementation: +- Design specified and normative contract established. +- External reference and independent oracle characterized. +- SPMKit implementation pending. + +Claim status after implementation and validation: +- Normal route: Level 3 CROSS_VALIDATED within external fixture. +- Inverted background: Derived external cross-validation (`-B_normal(-F)`). +- Inverted corrected: Safe deliberate divergence (`F - B_inv(F)`). +- Universal equivalence across arbitrary inputs or platforms is not claimed. +- Physical Sphere Revolution maintains its independent maturity and physical claims. + +## 20. Explicit non-goals + +The following non-goals are explicitly established: +- Do not replace or modify physical Sphere Revolution. +- Do not modify or patch upstream Gwyddion 2.71 C source code. +- Do not reproduce upstream C memory corruption crashes. +- Do not reproduce GLib warning messages. +- Do not use physical units (metres, nanometres) in Gwyddion compatibility APIs. +- Do not add mask support in this phase. +- Do not add border padding or extension policies. +- Do not add direction parameters (`direction` is for Arc, not Sphere). +- Do not expand radius range beyond `1.0 <= radius_px <= 1000.0`. +- Do not perform premature performance optimization before closing parity. +- Do not parallelize execution loops. +- Do not refactor Arc Revolution implementation. +- Do not claim universal numerical equivalence beyond frozen fixtures. + +## 21. Provenance ledger + +| Artifact | SHA-256 | Role | +|---|---|---| +| `sphere-revolve.c` | `4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9` | Gwyddion 2.71 reference C module source | +| `sphere_revolve_behavior_probe.c` | `97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188` | C probe source (schema_version 2, 35 cases) | +| `run_sphere_probe_campaign.sh` | `d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7` | Campaign runner script v2 | +| `sphere_oracle.py` | `f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b` | Independent Python oracle script | +| `precision_audit.py` | `58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4` | Floating-point precision audit script | +| `implementation_dossier.md` | `fd9e83b28027a130025528130f96d3e4f4f03bdc792830666861ec335efef3a2` | Implementation dossier report | + +## 22. Implementation sequence + +Execution order: +1. Block A: Create normative specification `docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md` (completed in this step). +2. Block B: Implement private numerical kernel `src/spmkit/core/analysis/_gwyddion_sphere_revolution.py`. +3. Block C: Implement private unit tests `tests/core/test_gwyddion_sphere_revolution_background.py`. +4. Block D: Implement public adapters in `src/spmkit/core/analysis/background.py` and `__init__.py`. +5. Block E: Implement public API unit tests in `tests/core/test_gwyddion_sphere_revolution_background.py`. +6. Block F: Create frozen external validation fixtures in `tests/validation/fixtures/gwyddion/sphere_revolution/`. +7. Block G: Implement external validation tests in `tests/validation/test_sphere_revolution_vs_gwyddion.py`. +8. Block H: Update documentation files (`docs/api.md`, `docs/scientific-status.md`, `docs/validation/index.md`). +9. Block I: Run focal regression test suite and static type checking. +10. Block J: Run global regression test suite (`pytest`). +11. Block K: Atomic git commit and push. + +Each block must be verified before proceeding to the next block. diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 3d4c042..fe9d638 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -39,6 +39,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | +| Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -57,6 +58,19 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Nanoscope `.spm` pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/nanoscope_spm_parser_pilot_v0.1_summary.json) - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) - [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) +- [Sphere Revolution Gwyddion 2.71 frozen fixture](https://github.com/kegouro/spmkit/blob/feat/gwyddion-leveling-parity/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json) + +### Gwyddion Sphere Revolution + +SPM-Kit's `gwyddion_sphere_revolution` implementation is maintained separately from physical sphere revolution. It reproduces Gwyddion 2.71's Revolve Sphere numerical semantics: +- **Campaign Scope:** 10 original surface matrices across WIDE_ASYMMETRIC, TALL_ASYMMETRIC, CONSTANT_ZERO_RMS, and SIGNED_MICRO_GRID families, plus 10 normal executions on explicitly negated inputs (20 valid external runs per build). +- **Inverted Route Failure Evidence:** 15/15 executions of `inverted=True` crash in Gwyddion 2.71's C module (exit code 139 in normal build, exit code 134 under ASan at `sphere-revolve.c:328 gwy_data_field_subtract_fields`). +- **Evidence Classes:** + - `normal` route: Direct external reference from Gwyddion 2.71 stdout. + - `inverted` background: Derived external cross-validation from `-B(-data)`. + - `inverted` corrected: Safe deliberate divergence (`original - background`) reconstructing original data without invoking Gwyddion's crashing subtract wrapper. +- **Independent Oracle & Tolerances:** Verified against an independent Python oracle (`atol=5e-14`, `rtol=0.0`). The maximum observed numerical discrepancy across all comparisons is `8.881784e-16` (well below `5e-14`). +- **Claim:** LEVEL 3 CROSS_VALIDATED within the frozen fixture scope. No universal equivalence or physical validation is claimed. Physical sphere revolution (`estimate_sphere_revolution_background`) maintains its independent software verification. ## Test-count policy diff --git a/docs/validation/index.md b/docs/validation/index.md index 39dd4ea..dd2f392 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -37,6 +37,7 @@ references, tolerances, outputs, hashes, and limitations. | Gwyddion roughness 48 v0.1 | Sa, Sq, Sz on 48 canonical synthetic matrices | 144/144 within tolerance | CROSS_VALIDATED | No preprocessing; shared matrices; not physical validation | | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | | Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | +| Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 9b14aa8..0231bc5 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -20,6 +20,7 @@ GwyddionArcDirection, analyze_arc_revolution_background, analyze_gwyddion_arc_revolution_background, + analyze_gwyddion_sphere_revolution_background, analyze_median_background, analyze_polynomial_background, analyze_rolling_ball_background, @@ -27,6 +28,7 @@ analyze_spline_background, estimate_arc_revolution_background, estimate_gwyddion_arc_revolution_background, + estimate_gwyddion_sphere_revolution_background, estimate_median_background, estimate_polynomial_background, estimate_rolling_ball_background, @@ -34,6 +36,7 @@ estimate_spline_background, remove_arc_revolution_background, remove_gwyddion_arc_revolution_background, + remove_gwyddion_sphere_revolution_background, remove_median_background, remove_polynomial_background, remove_rolling_ball_background, @@ -67,6 +70,7 @@ "GwyddionArcDirection", "analyze_arc_revolution_background", "analyze_gwyddion_arc_revolution_background", + "analyze_gwyddion_sphere_revolution_background", "analyze_median_background", "analyze_polynomial_background", "analyze_rolling_ball_background", @@ -74,6 +78,7 @@ "analyze_spline_background", "estimate_arc_revolution_background", "estimate_gwyddion_arc_revolution_background", + "estimate_gwyddion_sphere_revolution_background", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", @@ -81,6 +86,7 @@ "estimate_spline_background", "remove_arc_revolution_background", "remove_gwyddion_arc_revolution_background", + "remove_gwyddion_sphere_revolution_background", "remove_median_background", "remove_polynomial_background", "remove_rolling_ball_background", diff --git a/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py new file mode 100644 index 0000000..a54de27 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py @@ -0,0 +1,300 @@ +"""Numerical kernels compatible with Gwyddion's Revolve Sphere operation. + +This module implements the numerical semantics of the Gwyddion 2.71 +``sphere-revolve`` process independently in NumPy. It intentionally remains +separate from SPMKit's physical sphere-revolution estimator because the two +operations use different radius, scaling, and boundary conventions. +""" + +from __future__ import annotations + +import math +import sys + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] + + +def _validated_data_array(data: object, operation: str) -> FloatArray: + """Validate that input data is a real, finite, non-empty 2D array.""" + data_arr = np.asarray(data) + + if ( + data_arr.ndim != 2 + or data_arr.size == 0 + or not np.issubdtype(data_arr.dtype, np.number) + or np.iscomplexobj(data_arr) + or isinstance(data, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires data to be a real 2D array") + + float_arr = np.array(data_arr, dtype=np.float64, order="C", copy=True) + + if not np.all(np.isfinite(float_arr)): + raise ValueError(f"{operation} requires data to be finite") + + return float_arr + + +def _validated_radius(radius: object, operation: str) -> float: + """Validate that radius is a real scalar finite number between 1.0 and 1000.0.""" + radius_arr = np.asarray(radius) + + if ( + radius_arr.ndim != 0 + or not np.issubdtype(radius_arr.dtype, np.number) + or np.iscomplexobj(radius_arr) + or isinstance(radius, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires radius to be a real scalar") + + val = float(radius_arr.item()) + + if not math.isfinite(val): + raise ValueError(f"{operation} requires radius to be finite") + + if not 1.0 <= val <= 1000.0: + raise ValueError(f"{operation} requires radius to be between 1.0 and 1000.0 samples") + + return val + + +def _validated_inverted(inverted: object, operation: str) -> bool: + """Validate that inverted option is a boolean.""" + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError(f"{operation} requires inverted to be a boolean") + return bool(inverted) + + +def _readonly_float_array(values: NDArray[np.float64]) -> FloatArray: + """Return a C-contiguous, read-only float64 copy of the input array.""" + array = np.array(values, dtype=np.float64, order="C", copy=True) + array.setflags(write=False) + return array + + +def _gwyddion_sphere_background( + data: FloatArray, + radius: float, +) -> FloatArray: + """Calculate Gwyddion 2.71 Sphere Revolution background on 2D float64 data. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + + Returns + ------- + numpy.ndarray + Read-only C-contiguous float64 background matrix. + """ + array = _validated_data_array(data, "_gwyddion_sphere_background") + radius_val = _validated_radius(radius, "_gwyddion_sphere_background") + + yres, xres = array.shape + + # 1. Serial global mean (C-order) + total = 0.0 + for value in array.ravel(order="C"): + total += float(value) + mean = total / float(array.size) + + # 2. Serial global population RMS (C-order) + sum2 = 0.0 + for value in array.ravel(order="C"): + delta = float(value) - mean + sum2 += delta * delta + rms = math.sqrt(sum2 / float(array.size)) + + # 3. Global scaling parameter q + q = rms / math.sqrt(5.0 / 6.0) + + # 4. Discrete sphere dimensions + sphere_size = math.floor(min(radius_val, float(xres)) + 0.5) + sphere_resolution = 2 * sphere_size + 1 + local_filter_size = sphere_size // 2 + very_flat = (radius_val / 8.0) > float(xres) + + center = sphere_size + sphere_z = np.zeros((sphere_resolution, sphere_resolution), dtype=np.float64, order="C") + + # 5. Discrete sphere construction (quadrant loop) + for i in range(sphere_size + 1): + u = i / radius_val + for j in range(sphere_size + 1): + v = j / radius_val + r2 = u * u + v * v + if very_flat: + z = (r2 / 2.0) * (1.0 + (r2 / 4.0) * (1.0 + r2 / 2.0)) + else: + z = 2.0 if r2 > 1.0 else 1.0 - math.sqrt(1.0 - r2) + sphere_z[center - i, center - j] = z + sphere_z[center - i, center + j] = z + sphere_z[center + i, center - j] = z + sphere_z[center + i, center + j] = z + + # Correction 1: explicit scalar loop scaling + sphere_scaled = np.zeros( + (sphere_resolution, sphere_resolution), + dtype=np.float64, + order="C", + ) + for row in range(sphere_resolution): + for column in range(sphere_resolution): + sphere_scaled[row, column] = ( + -q * float(sphere_z[row, column]) + ) + + # 6. Direct local mean field + if local_filter_size == 0: + local_mean = np.array(array, dtype=np.float64, order="C", copy=True) + else: + neg_ext = (local_filter_size - 1) // 2 + pos_ext = local_filter_size // 2 + local_mean = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + r_start = max(0, r - neg_ext) + r_stop = min(yres - 1, r + pos_ext) + for c in range(xres): + c_start = max(0, c - neg_ext) + c_stop = min(xres - 1, c + pos_ext) + sum_val = 0.0 + count = 0 + for rr in range(r_start, r_stop + 1): + for cc in range(c_start, c_stop + 1): + sum_val += float(array[rr, cc]) + count += 1 + local_mean[r, c] = sum_val / float(count) + + # 7. Direct local RMS field + if local_filter_size == 0: + local_rms = np.array(array, dtype=np.float64, order="C", copy=True) + elif local_filter_size == 1: + local_rms = np.zeros((yres, xres), dtype=np.float64, order="C") + else: + neg_ext = (local_filter_size - 1) // 2 + pos_ext = local_filter_size // 2 + local_rms = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + r_start = max(0, r - neg_ext) + r_stop = min(yres - 1, r + pos_ext) + for c in range(xres): + c_start = max(0, c - neg_ext) + c_stop = min(xres - 1, c + pos_ext) + sum_val = 0.0 + sum_sq = 0.0 + count = 0 + for rr in range(r_start, r_stop + 1): + for cc in range(c_start, c_stop + 1): + val = float(array[rr, cc]) + sum_val += val + sum_sq += val * val + count += 1 + m = sum_val / float(count) + m_sq = sum_sq / float(count) + var = m_sq - m * m + if var < 0.0: + var = 0.0 + local_rms[r, c] = math.sqrt(var) + + # 8. Outlier-trimmed field T + trimmed = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + for c in range(xres): + thresh = local_mean[r, c] - 2.5 * local_rms[r, c] + val = float(array[r, c]) + trimmed[r, c] = thresh if thresh > val else val + + # 9. Two-dimensional lower envelope minimization + bg = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + for c in range(xres): + ifrom = max(0, r - sphere_size) - r + ito = min(r + sphere_size, yres - 1) - r + jfrom = max(0, c - sphere_size) - c + jto = min(c + sphere_size, xres - 1) - c + minimum = sys.float_info.max + for ii in range(ifrom, ito + 1): + for jj in range(jfrom, jto + 1): + sph_val = float(sphere_scaled[center + ii, center + jj]) + if sph_val >= -q: + data_val = float(trimmed[r + ii, c + jj]) + cand = data_val - sph_val + if cand < minimum: + minimum = cand + bg[r, c] = minimum + + return _readonly_float_array(bg) + + +def _gwyddion_sphere_result( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> tuple[FloatArray, FloatArray]: + """Calculate Gwyddion 2.71 Sphere Revolution (background, corrected) tuple. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + inverted: + Apply safe dual inversion ``-B(-data)``. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + Read-only float64 (background, corrected) array pair. + """ + array = _validated_data_array(data, "_gwyddion_sphere_result") + _validated_radius(radius, "_gwyddion_sphere_result") + inv_bool = _validated_inverted(inverted, "_gwyddion_sphere_result") + + if not inv_bool: + bg_arr = _gwyddion_sphere_background(array, radius) + bg_raw = np.asarray(bg_arr) + else: + negated = -array + neg_bg = _gwyddion_sphere_background(negated, radius) + bg_raw = -np.asarray(neg_bg) + + corr_raw = np.zeros(array.shape, dtype=np.float64, order="C") + yres, xres = array.shape + for r in range(yres): + for c in range(xres): + corr_raw[r, c] = float(array[r, c]) - float(bg_raw[r, c]) + + return _readonly_float_array(bg_raw), _readonly_float_array(corr_raw) + + +def _gwyddion_sphere_corrected( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> FloatArray: + """Calculate Gwyddion 2.71 Sphere Revolution corrected field. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + inverted: + Apply safe dual inversion ``-B(-data)``. + + Returns + ------- + numpy.ndarray + Read-only C-contiguous float64 corrected matrix. + """ + return _gwyddion_sphere_result(data, radius, inverted=inverted)[1] diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 70e9376..6dbbb8a 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -17,6 +17,9 @@ GwyddionArcDirection, _gwyddion_arc_result, ) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_result, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -33,6 +36,7 @@ BackgroundMethod = Literal[ "arc_revolution", "gwyddion_arc_revolution", + "gwyddion_sphere_revolution", "sphere_revolution", "rolling_ball", "median", @@ -603,6 +607,98 @@ def remove_gwyddion_arc_revolution_background( return corrected +def _gwyddion_sphere_channels( + channel: SPMChannel, + radius_px: object, + *, + inverted: object, + operation: str, +) -> tuple[ + SPMChannel, + SPMChannel, + float, + bool, +]: + """Validate one request and return background and corrected channels.""" + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _validated_gwyddion_radius_px( + radius_px, + operation=operation, + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError( + f"{operation} requires inverted to be a boolean" + ) + + inverted_value = bool(inverted) + + background_data, corrected_data = _gwyddion_sphere_result( + data, + radius_value, + inverted=inverted_value, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + radius_value, + inverted_value, + ) + + +def estimate_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Sphere Revolution background. + + Parameters + ---------- + channel: + Real, finite, non-empty two-dimensional channel. The Z unit is + preserved and need not represent geometric length. + radius_px: + Sphere radius in samples. The public Gwyddion-compatible range is + inclusive from 1.0 through 1000.0. + inverted: + Apply the exact dual ``-B(-data)``. + + Notes + ----- + This is a data-adaptive compatibility estimator, not SPMKit's physical + sphere-revolution model, tip deconvolution, or surface reconstruction. + """ + background, _, _, _ = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="estimate_gwyddion_sphere_revolution_background", + ) + return background + + +def remove_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel: + """Subtract a Gwyddion 2.71-compatible Sphere Revolution background.""" + _, corrected, _, _ = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="remove_gwyddion_sphere_revolution_background", + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -1502,6 +1598,36 @@ def analyze_gwyddion_arc_revolution_background( ) +def analyze_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> BackgroundResult: + """Estimate and subtract a compatible Sphere Revolution background once.""" + ( + background, + corrected, + radius_value, + inverted_value, + ) = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="analyze_gwyddion_sphere_revolution_background", + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_sphere_revolution", + parameters={ + "radius_px": radius_value, + "inverted": inverted_value, + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_sphere_revolution_background.py b/tests/core/test_gwyddion_sphere_revolution_background.py new file mode 100644 index 0000000..7c14895 --- /dev/null +++ b/tests/core/test_gwyddion_sphere_revolution_background.py @@ -0,0 +1,371 @@ +"""Tests for private Gwyddion 2.71 Sphere Revolution numerical kernel.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_sphere_revolution_background, + estimate_gwyddion_sphere_revolution_background, + remove_gwyddion_sphere_revolution_background, +) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_background, + _gwyddion_sphere_corrected, + _gwyddion_sphere_result, +) +from spmkit.core.models import SPMChannel + + +def test_gwyddion_sphere_rejects_non_2d_input() -> None: + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.array([1.0, 2.0, 3.0]), 5.0) + + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.ones((2, 2, 2)), 5.0) + + +def test_gwyddion_sphere_rejects_empty_dimensions() -> None: + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.zeros((0, 5)), 5.0) + + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.zeros((5, 0)), 5.0) + + +def test_gwyddion_sphere_rejects_boolean_radius() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(TypeError, match="radius to be a real scalar"): + _gwyddion_sphere_background(data, True) + + with pytest.raises(TypeError, match="radius to be a real scalar"): + _gwyddion_sphere_background(data, False) + + +def test_gwyddion_sphere_rejects_nonfinite_radius() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="radius to be finite"): + _gwyddion_sphere_background(data, float("nan")) + + with pytest.raises(ValueError, match="radius to be finite"): + _gwyddion_sphere_background(data, float("inf")) + + +def test_gwyddion_sphere_rejects_radius_below_one() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="between 1.0 and 1000.0 samples"): + _gwyddion_sphere_background(data, 0.9) + + +def test_gwyddion_sphere_rejects_radius_above_thousand() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="between 1.0 and 1000.0 samples"): + _gwyddion_sphere_background(data, 1000.1) + + +def test_gwyddion_sphere_accepts_radius_boundaries() -> None: + data = np.ones((5, 5), dtype=np.float64) + + bg_min = _gwyddion_sphere_background(data, 1.0) + assert bg_min.shape == (5, 5) + assert np.all(np.isfinite(bg_min)) + + bg_max = _gwyddion_sphere_background(data, 1000.0) + assert bg_max.shape == (5, 5) + assert np.all(np.isfinite(bg_max)) + + +def test_gwyddion_sphere_converts_input_to_float64() -> None: + data_int = np.array([[1, 2], [3, 4]], dtype=np.int32) + bg = _gwyddion_sphere_background(data_int, 2.0) # type: ignore[arg-type] + + assert bg.dtype == np.float64 + + data_f32 = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + bg32 = _gwyddion_sphere_background(data_f32, 2.0) # type: ignore[arg-type] + + assert bg32.dtype == np.float64 + + +def test_gwyddion_sphere_does_not_mutate_input() -> None: + data = np.array([[-4.0, -2.0, 0.0], [1.0, 3.0, 7.0], [-1.0, 2.0, 5.0]], dtype=np.float64) + data_copy = data.copy() + + _gwyddion_sphere_background(data, 1.0) + np.testing.assert_array_equal(data, data_copy) + + _gwyddion_sphere_result(data, 1.0, inverted=True) + np.testing.assert_array_equal(data, data_copy) + + +def test_gwyddion_sphere_background_is_c_contiguous_and_read_only() -> None: + data = np.ones((5, 5), dtype=np.float64) + bg = _gwyddion_sphere_background(data, 2.5) + + assert bg.flags.c_contiguous + assert not bg.flags.writeable + + with pytest.raises(ValueError, match="read-only"): + bg[0, 0] = 99.0 + + +def test_gwyddion_sphere_result_arrays_are_distinct_and_read_only() -> None: + data = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + bg, corr = _gwyddion_sphere_result(data, 2.0, inverted=False) + + assert bg is not corr + assert bg.base is not corr + assert bg.flags.c_contiguous and not bg.flags.writeable + assert corr.flags.c_contiguous and not corr.flags.writeable + + +def test_gwyddion_sphere_constant_field_returns_identity_background() -> None: + data = np.full((7, 7), 5.0, dtype=np.float64) + bg = _gwyddion_sphere_background(data, 3.0) + + np.testing.assert_allclose(bg, data, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_constant_field_corrected_is_zero() -> None: + data = np.full((7, 7), 5.0, dtype=np.float64) + bg, corr = _gwyddion_sphere_result(data, 3.0, inverted=False) + + np.testing.assert_allclose(corr, 0.0, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_radius_one_signed_field_matches_frozen_values() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + expected_bg = np.array( + [ + [4.5694174231575584, 3.0, 0.0], + [1.0, 3.0, 3.5694174231575579], + [1.5, 2.0, 5.0], + ], + dtype=np.float64, + ) + + expected_corr = np.array( + [ + [-8.5694174231575584, -5.0, 0.0], + [0.0, 0.0, 3.4305825768424421], + [-2.5, 0.0, 0.0], + ], + dtype=np.float64, + ) + + bg, corr = _gwyddion_sphere_result(data, 1.0, inverted=False) + + np.testing.assert_allclose(bg, expected_bg, atol=5e-14, rtol=0.0) + np.testing.assert_allclose(corr, expected_corr, atol=5e-14, rtol=0.0) + + +def test_gwyddion_sphere_very_flat_branch_returns_finite_values() -> None: + data = np.arange(25, dtype=np.float64).reshape((5, 5)) + bg = _gwyddion_sphere_background(data, 50.0) + + assert bg.shape == (5, 5) + assert np.all(np.isfinite(bg)) + + +def test_gwyddion_sphere_uses_xres_for_sphere_size() -> None: + # On non-square grid (5 rows, 10 cols), radius=8 => sphere_size = min(8, 10) = 8. + # On transposed grid (10 rows, 5 cols), radius=8 => sphere_size = min(8, 5) = 5. + data_asym = np.arange(50, dtype=np.float64).reshape((5, 10)) + bg_orig = _gwyddion_sphere_background(data_asym, 8.0) + + data_transposed = data_asym.T + bg_trans = _gwyddion_sphere_background(data_transposed, 8.0) + + # Verify that transposed background is valid and finite + assert bg_orig.shape == (5, 10) + assert bg_trans.shape == (10, 5) + assert np.all(np.isfinite(bg_orig)) + assert np.all(np.isfinite(bg_trans)) + + +def test_gwyddion_sphere_safe_inversion_duality() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + bg_inv, _ = _gwyddion_sphere_result(data, 2.5, inverted=True) + neg_bg = _gwyddion_sphere_background(-data, 2.5) + + np.testing.assert_allclose(bg_inv, -neg_bg, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_result_reconstructs_input() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + for inv in (False, True): + bg, corr = _gwyddion_sphere_result(data, 2.5, inverted=inv) + reconstruction = corr + bg + np.testing.assert_allclose(reconstruction, data, atol=5e-14, rtol=0.0) + + +def test_gwyddion_sphere_corrected_delegates_to_result() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + for inv in (False, True): + corr_direct = _gwyddion_sphere_corrected(data, 2.5, inverted=inv) + _, corr_result = _gwyddion_sphere_result(data, 2.5, inverted=inv) + np.testing.assert_allclose(corr_direct, corr_result, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_accepts_singleton_2d_fields() -> None: + shapes = [(1, 1), (1, 5), (5, 1)] + for shape in shapes: + data = np.arange(shape[0] * shape[1], dtype=np.float64).reshape(shape) + bg, corr = _gwyddion_sphere_result(data, 2.0, inverted=False) + + assert bg.shape == shape + assert corr.shape == shape + assert np.all(np.isfinite(bg)) + assert np.all(np.isfinite(corr)) + np.testing.assert_allclose(corr + bg, data, atol=5e-14, rtol=0.0) + + +def _channel() -> SPMChannel: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + return SPMChannel( + name="Test Sphere", + data=data, + unit="m", + x_range=8.0e-6, + y_range=5.0e-6, + metadata={"source": "test_gwyddion_sphere"}, + ) + + +def test_estimate_gwyddion_sphere_background_matches_private_result() -> None: + ch = _channel() + for inv in (False, True): + pub_bg = estimate_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + priv_bg, _ = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + + np.testing.assert_allclose(pub_bg.data, priv_bg, atol=0.0, rtol=0.0) + + +def test_remove_gwyddion_sphere_background_matches_private_result() -> None: + ch = _channel() + for inv in (False, True): + pub_corr = remove_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + _, priv_corr = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + + np.testing.assert_allclose(pub_corr.data, priv_corr, atol=0.0, rtol=0.0) + + +def test_analyze_gwyddion_sphere_background_returns_consistent_result() -> None: + ch = _channel() + for inv in (False, True): + res = analyze_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + + assert isinstance(res, BackgroundResult) + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": 2.5, "inverted": inv} + + priv_bg, priv_corr = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + np.testing.assert_allclose(res.background.data, priv_bg, atol=0.0, rtol=0.0) + np.testing.assert_allclose(res.corrected.data, priv_corr, atol=0.0, rtol=0.0) + np.testing.assert_allclose( + res.corrected.data + res.background.data, + ch.data, + atol=5e-14, + rtol=0.0, + ) + + +def test_gwyddion_sphere_public_method_and_parameters() -> None: + ch = _channel() + res = analyze_gwyddion_sphere_revolution_background(ch, 15.0, inverted=True) + + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": 15.0, "inverted": True} + + +def test_gwyddion_sphere_public_preserves_channel_context() -> None: + ch = _channel() + res = analyze_gwyddion_sphere_revolution_background(ch, 2.5) + + assert res.background.unit == ch.unit + assert res.background.x_range == ch.x_range + assert res.background.y_range == ch.y_range + assert res.background.metadata == ch.metadata + + assert res.corrected.unit == ch.unit + assert res.corrected.x_range == ch.x_range + assert res.corrected.y_range == ch.y_range + assert res.corrected.metadata == ch.metadata + + +def test_gwyddion_sphere_public_does_not_mutate_channel() -> None: + ch = _channel() + ch_data_copy = ch.data.copy() + + estimate_gwyddion_sphere_revolution_background(ch, 2.5) + remove_gwyddion_sphere_revolution_background(ch, 2.5) + analyze_gwyddion_sphere_revolution_background(ch, 2.5) + + np.testing.assert_array_equal(ch.data, ch_data_copy) + + +def test_gwyddion_sphere_public_exports_are_available() -> None: + import spmkit.core.analysis as analysis_mod + + assert hasattr(analysis_mod, "estimate_gwyddion_sphere_revolution_background") + assert hasattr(analysis_mod, "remove_gwyddion_sphere_revolution_background") + assert hasattr(analysis_mod, "analyze_gwyddion_sphere_revolution_background") + + assert "estimate_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + assert "remove_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + assert "analyze_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + + +def test_gwyddion_sphere_physical_api_remains_distinct() -> None: + import spmkit.core.analysis as analysis_mod + + phys_estimate = analysis_mod.estimate_sphere_revolution_background + gwy_estimate = analysis_mod.estimate_gwyddion_sphere_revolution_background + + assert phys_estimate is not gwy_estimate + diff --git a/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json new file mode 100644 index 0000000..b8b8a29 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json @@ -0,0 +1,485 @@ +{ + "schema_version": 2, + "reference_software": "Gwyddion", + "reference_version": "2.71", + "operation": "sphere-revolve", + "spmkit_method": "gwyddion_sphere_revolution", + "generated_at": "2026-08-02T01:59:23.826557+00:00", + "branch": "feat/gwyddion-leveling-parity", + "head": "c693fc97e94a5829f57c8b2acf8f522f4f05fb4f", + "case_order": [ + "wide_r1", + "wide_r2_5", + "wide_r4", + "wide_r1000", + "tall_r2_5", + "tall_r1000", + "constant_r1", + "constant_r2_5", + "signed_r1", + "signed_r2_5" + ], + "acceptance": { + "background_atol": 5e-14, + "corrected_atol": 5e-14, + "reconstruction_atol": 5e-14, + "rtol": 0.0 + }, + "evidence_classes": { + "normal_route": "direct external reference", + "inverted_background": "derived external reference", + "inverted_corrected": "safe deliberate divergence" + }, + "source_and_artifact_hashes": { + "sphere_revolve_c": "4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9", + "probe_c": "97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188", + "runner_sh": "d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7", + "oracle_py": "f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b", + "precision_audit_py": "58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4", + "normative_spec_md": "3db2e7ea0c965dfa9bcd803ade6d3dae2fc38b216a132c05df36d52a9256b1c8", + "kernel_py": "ef00ec0033de3966ff1ab7642fdba3f24e0d9030550e5c7995fb55318eb75a38", + "core_test_py": "d0a46f20d6260327f80d68ddae8bfa5360def162b6ca6db77511baf5df5e6ef5" + }, + "npz_sha256": "a0713ec37da9d8865717eaf29d876a3c616d0e3e374b217d40f4d02b30288256", + "canonical_array_hashes": { + "input__wide_r1": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r1": "119858aaa90729cac657572c2a188d6da3039a1d7e51b1c0a3715620cbc2609f", + "direct_corrected__wide_r1": "e3a6738890370f7564f8bc70bbf7ff92e1a3a79e4ce43df467b6c8e519b5377e", + "negated_input__wide_r1": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r1": "918eaa4348b9e202f52207444b63649ed3842c2b53246ba44515211fe7191df3", + "direct_negated_corrected__wide_r1": "63ba46649b09a9b0a878f7cae78e7a28888eaac9a8179dbc83a6c9f2ade96615", + "derived_inverted_background__wide_r1": "8c6002c171f33dda2eec6ddc3e9c32529d79db216a49d2e18120893a65b2c0e4", + "safe_inverted_corrected__wide_r1": "470527380b8f8d9a1b3eebc89ed6172859893dd6336292c6b7a2dbc71b43fbd3", + "input__wide_r2_5": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r2_5": "647cdbd3a59f8402b3a035cfc95b2761857fc6f1d12698873a1baf7baf12e781", + "direct_corrected__wide_r2_5": "a1c3a4dfa3af93867b8c09bab8863a360bdfe11261f489473778e6c287a2ba8a", + "negated_input__wide_r2_5": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r2_5": "5b127b1e0d3915b0c7be45a51184e3480434372569640f9619cc59f973a703c1", + "direct_negated_corrected__wide_r2_5": "67f689918a9bfe5e37dd6d5a31eb73f57189875378523fba37da20ab870ec6c9", + "derived_inverted_background__wide_r2_5": "8c097d290cc5f026a6166d1cd02e7969000c3b5ef02331f5d61a5f0020932e90", + "safe_inverted_corrected__wide_r2_5": "9e6037fe4d5b7fd93ab88b324afe82e884543338ba5f064824f3280028b37041", + "input__wide_r4": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r4": "b7cebb17fe54bd7d9db06cc430c14980e7e4d44d375aff041272d96b3690be94", + "direct_corrected__wide_r4": "30c5edf90cbf0cc9828d4b7de4cb05922f7c80fadd74c0fff7161a57c7a23632", + "negated_input__wide_r4": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r4": "56180d83a286b67f17aa60547a1c80a805ecd1131ef59080df41c474da3cda72", + "direct_negated_corrected__wide_r4": "696949157f2bb11c57057b3380759a2d10699e7fb3babf605e7ff95f8cd72853", + "derived_inverted_background__wide_r4": "d928831acd34aa4fd87bf48d37a8f3fc54bacab2ab2f0df696511537aadd60ed", + "safe_inverted_corrected__wide_r4": "71d6cfcfbcb00ac883c006bf3326bdc31a27af5f441441b1a8eb70db948e4bfb", + "input__wide_r1000": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r1000": "aaad7ed8d28a89d2c3ddcb0ea9a523cb2f587b81405ad24221b02bcdf0a3e218", + "direct_corrected__wide_r1000": "cbe37ea045576e23a8edb245b92aad4d7de176570ce5335bedd4733a373932a9", + "negated_input__wide_r1000": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r1000": "15d9fe68194a00adfa0e8b72754ca2296cd0323f18ecb0c1ebd59ff800a8bc83", + "direct_negated_corrected__wide_r1000": "5ba0ed1dec2f5572fc47664049f5833e39339789f7f6137fea936dc6d205dcc8", + "derived_inverted_background__wide_r1000": "2d7981d21aade5a65c7815be10c2862a6254cad412e117800abb4af3127d70ee", + "safe_inverted_corrected__wide_r1000": "2370363919119f1adbe2900a6584cea0f6c333e6dcd3063309442e7c79235812", + "input__tall_r2_5": "3a837baf13a1cb0bea03702753db0d2b667893ee9003d21b28a07bd1bbd3a132", + "direct_background__tall_r2_5": "be5c9715e05c682598b6f94a76142a644ffc437216286b6f6e8860d339350b7e", + "direct_corrected__tall_r2_5": "77e9b4e6cb3bfe1567f88c7c3f5cc25bc6314b305ded3dda19e1614dcd3bfb08", + "negated_input__tall_r2_5": "007e29d4098488b51ee87aa3a00786dce3919b58ebb5cedb6efb8dcc597862c9", + "direct_negated_background__tall_r2_5": "88abc8164c05d9a31c556a99aca0c681adbfc0626453d416b894cefdf7de8d0e", + "direct_negated_corrected__tall_r2_5": "cca9cf55cc415101c85a532f9e84b8cb701e559cbb304db2a161c0960806aa6f", + "derived_inverted_background__tall_r2_5": "b86fcbe1697bd5398350e385e8982c9cfb23e6e42b36276179e04f447ffe8830", + "safe_inverted_corrected__tall_r2_5": "8645ac63b1a9f4eb53931501ca68fab23717d9ae79132832498e4a77f8912e4c", + "input__tall_r1000": "3a837baf13a1cb0bea03702753db0d2b667893ee9003d21b28a07bd1bbd3a132", + "direct_background__tall_r1000": "a94c005361b3f0ea87f8e19ed26776ab79adbeed63dd0a279fcddf15dad42d56", + "direct_corrected__tall_r1000": "78d4da412c0679a8d0dbc8dfc5890f59f62160dc6f38a8c48246c5afd73eed77", + "negated_input__tall_r1000": "007e29d4098488b51ee87aa3a00786dce3919b58ebb5cedb6efb8dcc597862c9", + "direct_negated_background__tall_r1000": "194cd005221041d281c2a9540091b646eba4a4efc15d73775994c79b6fd283cf", + "direct_negated_corrected__tall_r1000": "7955d363e6d608bf21e52ba3e1dd486e2128823508285955ddba0619a2c95776", + "derived_inverted_background__tall_r1000": "f5c91313e76e58115530d065ac9ce1a5cb6d088b6a40c589acda6eedf3c20b2c", + "safe_inverted_corrected__tall_r1000": "739b3e3bccae514ab39ff603f36084af7acf211a6a0e1377d24ff15f3078aa41", + "input__constant_r1": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_background__constant_r1": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_corrected__constant_r1": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "negated_input__constant_r1": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_background__constant_r1": "c492bf11d8ec721daae6bf83d888a283715408712ba6e5f40edee693fa05c7d5", + "direct_negated_corrected__constant_r1": "acd6960f3e73d5c21ba808b9fbdbdd90fdc1bfc38c2177d435aae82c790f9a0b", + "derived_inverted_background__constant_r1": "a2c6c9c0b75b5c307d548aea99671f656eee00ac723849c09b90f8d652b36a4c", + "safe_inverted_corrected__constant_r1": "f1ec68565055de1c7e54a960fc7e8fdce650dc5afe8f5e91c581acc63fd04815", + "input__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_background__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "negated_input__constant_r2_5": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_background__constant_r2_5": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "derived_inverted_background__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "safe_inverted_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "input__signed_r1": "c5d49a60375fbb0f0bd983aa6e15f3b0ae4aca85c28a9e4c612611cd0a9e4eea", + "direct_background__signed_r1": "bc5b006f2703d632ddd05cb532e6369dd451a3010390c1c842844a4cbffaed87", + "direct_corrected__signed_r1": "8d30b75f1ad6cbb6d578018a2cb07253fd8a9781b9ea84e1c4e5b05e055ad69f", + "negated_input__signed_r1": "18c1108964b720fb0059c59f858ffe2ab057b5fa65122f8ffd00dd1704b53f90", + "direct_negated_background__signed_r1": "f87fe6aeb2a2349c236f7385d397521bc7b3b0685b435eef080c08db1bb5bbe2", + "direct_negated_corrected__signed_r1": "30f4c167e4db33122f486c327acb3afceb473a6cf95f7bedc0fa4709f113bfb7", + "derived_inverted_background__signed_r1": "fb8cea53a478af8ac307a4a7c4e096951aba0232ee5b3a26f15cb588895dacd0", + "safe_inverted_corrected__signed_r1": "25258b05b2dc1d72723fdc024709831ca4f1b583c8d5ffb84ee7da556e2b18c6", + "input__signed_r2_5": "c5d49a60375fbb0f0bd983aa6e15f3b0ae4aca85c28a9e4c612611cd0a9e4eea", + "direct_background__signed_r2_5": "a5dcc566f0ce17f4512757dfa6b20be5506d71d70f22a2bff313273663a84790", + "direct_corrected__signed_r2_5": "c710507228e274f51cddbeeda3a60aab800b10dad2f2b0f87383230b97c42503", + "negated_input__signed_r2_5": "18c1108964b720fb0059c59f858ffe2ab057b5fa65122f8ffd00dd1704b53f90", + "direct_negated_background__signed_r2_5": "3321dacf524ad9b13efaa2a952068d1dc4e9113507260411d166b50b6881328a", + "direct_negated_corrected__signed_r2_5": "1a82f3c4fea7ac5ba986af6de8820a2898814565524a960a8bdaa9c108d013be", + "derived_inverted_background__signed_r2_5": "0b642ee63eb39e0b3f2fae3bc7461237e3eafee3c971335edea8ca94bffe380e", + "safe_inverted_corrected__signed_r2_5": "9073b6b0d8a1cd520bf01fe4132957ad11421e9da1ae4c9ab44ed644d8352c61" + }, + "cases": { + "wide_r1": { + "pair_id": "wide_r1", + "original_case": "wide_r1_normal", + "negated_case": "wide_r1_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 1.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 8.881784197001252e-16 + }, + "wide_r2_5": { + "pair_id": "wide_r2_5", + "original_case": "wide_r2_5_normal", + "negated_case": "wide_r2_5_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 2.5, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "wide_r4": { + "pair_id": "wide_r4", + "original_case": "wide_r4_normal", + "negated_case": "wide_r4_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 4.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 4, + "sphere_resolution": 9, + "local_filter_size": 2, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "wide_r1000": { + "pair_id": "wide_r1000", + "original_case": "wide_r1000_normal", + "negated_case": "wide_r1000_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 1000.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 7, + "sphere_resolution": 15, + "local_filter_size": 3, + "very_flat": true, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "tall_r2_5": { + "pair_id": "tall_r2_5", + "original_case": "tall_r2_5_normal", + "negated_case": "tall_r2_5_negated_normal", + "family": "TALL_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 5, + "yres": 7, + "radius": 2.5, + "q_original": 1.0649069433390794, + "q_negated": 1.0649069433390794, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "tall_r1000": { + "pair_id": "tall_r1000", + "original_case": "tall_r1000_normal", + "negated_case": "tall_r1000_negated_normal", + "family": "TALL_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 5, + "yres": 7, + "radius": 1000.0, + "q_original": 1.0649069433390794, + "q_negated": 1.0649069433390794, + "sphere_size": 5, + "sphere_resolution": 11, + "local_filter_size": 2, + "very_flat": true, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "constant_r1": { + "pair_id": "constant_r1", + "original_case": "constant_r1_normal", + "negated_case": "constant_r1_negated_normal", + "family": "CONSTANT_ZERO_RMS", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 4, + "yres": 3, + "radius": 1.0, + "q_original": 0.0, + "q_negated": 0.0, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "constant_r2_5": { + "pair_id": "constant_r2_5", + "original_case": "constant_r2_5_normal", + "negated_case": "constant_r2_5_negated_normal", + "family": "CONSTANT_ZERO_RMS", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 4, + "yres": 3, + "radius": 2.5, + "q_original": 0.0, + "q_negated": 0.0, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "signed_r1": { + "pair_id": "signed_r1", + "original_case": "signed_r1_normal", + "negated_case": "signed_r1_negated_normal", + "family": "SIGNED_MICRO_GRID", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 3, + "yres": 3, + "radius": 1.0, + "q_original": 3.569417423157558, + "q_negated": 3.569417423157558, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "signed_r2_5": { + "pair_id": "signed_r2_5", + "original_case": "signed_r2_5_normal", + "negated_case": "signed_r2_5_negated_normal", + "family": "SIGNED_MICRO_GRID", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 3, + "yres": 3, + "radius": 2.5, + "q_original": 3.569417423157558, + "q_negated": 3.569417423157558, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + } + }, + "inverted_reference_failures": [ + { + "case": "wide_r1_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r2_5_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r4_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 4.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r1000_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1000.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r2_5_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r1000_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 1000.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r1_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r2_5_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "signed_r1_inverted", + "family": "SIGNED_MICRO_GRID", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "signed_r2_5_inverted", + "family": "SIGNED_MICRO_GRID", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r1_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r2_5_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r4_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 4.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r2_5_negated_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r1_negated_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + } + ] +} \ No newline at end of file diff --git a/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..132d9a97233e91d66bf115075a4d90d5f2c2e28c GIT binary patch literal 39566 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 literal 0 HcmV?d00001 diff --git a/tests/validation/test_sphere_revolution_vs_gwyddion.py b/tests/validation/test_sphere_revolution_vs_gwyddion.py new file mode 100644 index 0000000..603782b --- /dev/null +++ b/tests/validation/test_sphere_revolution_vs_gwyddion.py @@ -0,0 +1,306 @@ +"""Validation tests for SPMKit Gwyddion Sphere Revolution against frozen 2.71 fixtures.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_sphere_revolution_background, +) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_background, + _gwyddion_sphere_result, +) +from spmkit.core.models import SPMChannel + +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "gwyddion" / "sphere_revolution" +_NPZ_PATH = _FIXTURE_DIR / "sphere_revolution_reference.npz" +_JSON_PATH = _FIXTURE_DIR / "sphere_revolution_reference.json" + +with _JSON_PATH.open("r", encoding="utf-8") as _f: + _METADATA = json.load(_f) + +_ACCEPTANCE_ATOL = float(_METADATA["acceptance"]["background_atol"]) +_ACCEPTANCE_RTOL = float(_METADATA["acceptance"]["rtol"]) + + +def _canonical_array_sha256(array: np.ndarray) -> str: + canonical = np.ascontiguousarray(array, dtype=np.float64) + digest = hashlib.sha256() + digest.update(str(canonical.dtype).encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(value) for value in canonical.shape).encode("ascii")) + digest.update(b"\0") + digest.update(canonical.tobytes(order="C")) + return digest.hexdigest() + + +def test_sphere_fixture_metadata_and_artifact_hashes() -> None: + assert _METADATA["schema_version"] == 2 + assert _METADATA["reference_software"] == "Gwyddion" + assert _METADATA["reference_version"] == "2.71" + assert _METADATA["operation"] == "sphere-revolve" + assert _METADATA["spmkit_method"] == "gwyddion_sphere_revolution" + assert _METADATA["branch"] == "feat/gwyddion-leveling-parity" + assert _METADATA["head"] == "c693fc97e94a5829f57c8b2acf8f522f4f05fb4f" + assert len(_METADATA["case_order"]) == 10 + + npz_sha256 = hashlib.sha256(_NPZ_PATH.read_bytes()).hexdigest() + assert npz_sha256 == _METADATA["npz_sha256"] + + source_hashes = _METADATA["source_and_artifact_hashes"] + expected_sources = { + "sphere_revolve_c": "4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9", + "probe_c": "97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188", + "runner_sh": "d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7", + "oracle_py": "f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b", + "precision_audit_py": "58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4", + "normative_spec_md": "3db2e7ea0c965dfa9bcd803ade6d3dae2fc38b216a132c05df36d52a9256b1c8", + "kernel_py": "ef00ec0033de3966ff1ab7642fdba3f24e0d9030550e5c7995fb55318eb75a38", + "core_test_py": "d0a46f20d6260327f80d68ddae8bfa5360def162b6ca6db77511baf5df5e6ef5", + } + for k, v in expected_sources.items(): + assert source_hashes[k] == v + + +def test_sphere_fixture_canonical_array_hashes() -> None: + expected_hashes = _METADATA["canonical_array_hashes"] + npz_data = np.load(_NPZ_PATH) + + assert len(npz_data.files) == 80 + assert len(expected_hashes) == 80 + assert set(npz_data.files) == set(expected_hashes.keys()) + + for array_name in npz_data.files: + array = npz_data[array_name] + assert array.dtype == np.float64 + assert array.flags.c_contiguous + assert np.all(np.isfinite(array)) + assert _canonical_array_sha256(array) == expected_hashes[array_name] + + +def test_gwyddion_sphere_direct_normal_background() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_bg = npz_data[f"direct_background__{pair_id}"] + + inp_copy = inp.copy() + bg = _gwyddion_sphere_background(inp, radius) + + assert bg.dtype == np.float64 + assert bg.flags.c_contiguous + assert not bg.flags.writeable + np.testing.assert_array_equal(inp, inp_copy) + + np.testing.assert_allclose( + bg, + expected_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_direct_normal_corrected() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_corr = npz_data[f"direct_corrected__{pair_id}"] + + inp_copy = inp.copy() + _, corr = _gwyddion_sphere_result(inp, radius, inverted=False) + + assert corr.dtype == np.float64 + assert corr.flags.c_contiguous + assert not corr.flags.writeable + np.testing.assert_array_equal(inp, inp_copy) + + np.testing.assert_allclose( + corr, + expected_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_normal_on_negated_input() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + neg_inp = npz_data[f"negated_input__{pair_id}"] + expected_neg_bg = npz_data[f"direct_negated_background__{pair_id}"] + + neg_bg = _gwyddion_sphere_background(neg_inp, radius) + + np.testing.assert_allclose( + neg_bg, + expected_neg_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_derived_inverted_background() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_inv_bg = npz_data[f"derived_inverted_background__{pair_id}"] + + inv_bg, _ = _gwyddion_sphere_result(inp, radius, inverted=True) + + assert inv_bg.dtype == np.float64 + assert inv_bg.flags.c_contiguous + assert not inv_bg.flags.writeable + + np.testing.assert_allclose( + inv_bg, + expected_inv_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_safe_inverted_corrected() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_safe_corr = npz_data[f"safe_inverted_corrected__{pair_id}"] + + _, inv_corr = _gwyddion_sphere_result(inp, radius, inverted=True) + + assert inv_corr.dtype == np.float64 + assert inv_corr.flags.c_contiguous + assert not inv_corr.flags.writeable + + np.testing.assert_allclose( + inv_corr, + expected_safe_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_reconstruction() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + neg_inp = npz_data[f"negated_input__{pair_id}"] + + # 1. Normal route + bg, corr = _gwyddion_sphere_result(inp, radius, inverted=False) + np.testing.assert_allclose(corr + bg, inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + # 2. Negated normal route + neg_bg, neg_corr = _gwyddion_sphere_result(neg_inp, radius, inverted=False) + np.testing.assert_allclose(neg_corr + neg_bg, neg_inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + # 3. Inverted route + inv_bg, inv_corr = _gwyddion_sphere_result(inp, radius, inverted=True) + np.testing.assert_allclose(inv_corr + inv_bg, inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + +def test_gwyddion_sphere_frozen_inverted_failure_evidence() -> None: + inv_failures = _METADATA["inverted_reference_failures"] + + assert len(inv_failures) == 15 + + for failure in inv_failures: + assert failure["arrays_available"] is False + assert failure["execute_started"] is True + assert failure["execute_returned"] is False + assert failure["normal_exit_code"] != 0 + assert failure["asan_exit_code"] != 0 + + +def test_gwyddion_sphere_public_analyze_against_fixture() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + inp = npz_data[f"input__{pair_id}"] + + channel = SPMChannel( + name=f"Channel_{pair_id}", + data=inp, + unit="nm", + x_range=1.0e-6, + y_range=1.0e-6, + metadata={"pair_id": pair_id}, + ) + + for inv in (False, True): + res = analyze_gwyddion_sphere_revolution_background( + channel, + radius, + inverted=inv, + ) + + assert isinstance(res, BackgroundResult) + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": radius, "inverted": inv} + + assert res.background.unit == "nm" + assert res.background.x_range == 1.0e-6 + assert res.background.y_range == 1.0e-6 + assert res.background.metadata == {"pair_id": pair_id} + + assert res.corrected.unit == "nm" + assert res.corrected.x_range == 1.0e-6 + assert res.corrected.y_range == 1.0e-6 + assert res.corrected.metadata == {"pair_id": pair_id} + + if not inv: + expected_bg = npz_data[f"direct_background__{pair_id}"] + expected_corr = npz_data[f"direct_corrected__{pair_id}"] + else: + expected_bg = npz_data[f"derived_inverted_background__{pair_id}"] + expected_corr = npz_data[f"safe_inverted_corrected__{pair_id}"] + + np.testing.assert_allclose( + res.background.data, + expected_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + np.testing.assert_allclose( + res.corrected.data, + expected_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) From 2becd72f7fdbcfbeb2ece6b0febb0afe774257a1 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:48 -0400 Subject: [PATCH 58/82] style(tests): normalize Sphere Revolution test EOF --- tests/core/test_gwyddion_sphere_revolution_background.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/test_gwyddion_sphere_revolution_background.py b/tests/core/test_gwyddion_sphere_revolution_background.py index 7c14895..8482695 100644 --- a/tests/core/test_gwyddion_sphere_revolution_background.py +++ b/tests/core/test_gwyddion_sphere_revolution_background.py @@ -368,4 +368,3 @@ def test_gwyddion_sphere_physical_api_remains_distinct() -> None: gwy_estimate = analysis_mod.estimate_gwyddion_sphere_revolution_background assert phys_estimate is not gwy_estimate - From 818dbd3cbf7a2778050884bc4a8d08a7d88d376b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:10:06 -0400 Subject: [PATCH 59/82] test(validation): freeze Gwyddion Median Background evidence --- ...WYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md | 156 ++ .../median_background_reference.json | 1570 +++++++++++++++++ .../median_background_reference.npz | Bin 0 -> 45742 bytes ...est_median_background_fixture_integrity.py | 267 +++ 4 files changed, 1993 insertions(+) create mode 100644 docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/median_background/median_background_reference.json create mode 100644 tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz create mode 100644 tests/validation/test_median_background_fixture_integrity.py diff --git a/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md new file mode 100644 index 0000000..aace822 --- /dev/null +++ b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md @@ -0,0 +1,156 @@ +# Gwyddion Median Background Compatibility + +## 0. Status and normative scope + +**FREEZE_AUDIT_APPROVED** applies to frozen Gwyddion 2.71 Median Background evidence. +This is a normative design specification for the next mini-batch; no production code is +delivered here. Evidence is limited to the frozen 36-case campaign, represents both rank +filter backends, does not validate an SPMKit implementation, and does not exclude errors +outside the frozen domain. + +## 1. Reference identity + +**SOURCE_CONFIRMED** reference software is Gwyddion 2.71. The frozen module is +`.reference/gwyddion-2.71/source/modules/process/median-bg.c`, SHA-256 +`5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729`. +The manifest records the probe, runner, oracle, campaign, and fixture identities. + +## 2. User-visible operation + +`median_bg` estimates a local rank-filter background and returns corrected data by +subtracting that background from input. Its radius is a pixel-sample quantity, not a +physical lateral-unit quantity. + +## 3. Parameter contract + +**SOURCE_CONFIRMED** radius is an integer from 1 through 1024, with default 20. Future +compatibility APIs shall use `radius_px=20` and expose no configurable border, shape, or +rank parameter. Fixture-domain inputs are finite two-dimensional `float64`; mutation is +forbidden. + +## 4. Digital elliptical kernel + +**SOURCE_CONFIRMED** kernel resolution is `2*radius + 1`. The active region is an +inclusive digital ellipse over pixel centres: `kernel_index + 0.5` with squared ellipse +condition `<= radius_squared`. Offsets subtract `radius` and enumerate row-major. +Cardinalities for radii 1, 2, 3, 4, 20, and 1024 are 9, 21, 37, 69, 1313, and 3297401. + +## 5. Border extension + +**SOURCE_CONFIRMED** exterior handling is `GWY_EXTERIOR_BORDER_EXTEND`: an exterior +sample maps to the nearest valid edge pixel. No alternate border policy belongs here. + +## 6. Rank selection + +The rank is `kernel_active_count//2`. The direct reference path applies when active +count is at most 25; the radixtree reference path applies when active count exceeds 25. + +## 7. Background and corrected fields + +For input `F` and background `B`, corrected data are `C = F - B`. Frozen outputs are +finite, C-contiguous `float64` arrays with shapes equal to the corresponding input. + +## 8. Direct and radix-tree reference paths + +**EXECUTABLE_EXTERNAL_REFERENCE** covers direct radii 1 and 2, and radixtree radii 3, +4, 20, and 1024. A future implementation shall match observed fields without reproducing +Gwyddion's internal radixtree structure. + +## 9. External probe campaign + +The campaign contains 36 logical cases and 72 executions: 36 normal and 36 ASan. All +exit codes are zero; timeouts, GLib detections, and ASan detections are zero. Normal and +ASan stdout are byte-identical in all 36 pairs. Coverage includes wide, tall, constants, +signed fields, impulses, monotonic fields, singleton dimensions, oversized radii, edges, +and corners. + +## 10. Independent oracle + +**INDEPENDENT_ORACLE_CONFIRMED** is a Python and NumPy oracle with no SPMKit or SciPy +import, subprocess, or Gwyddion execution. It uses `numpy.partition` rather than the +internal selection path, calculates from metadata and `input_*` before loading reference +arrays, and selects no acceptance tolerance. All frozen background and corrected arrays +are bitwise equal to their reference counterparts. + +## 11. Frozen fixture + +The fixture has exactly 108 arrays: `input__`, `background__`, and +`corrected__` for each case. Background and corrected arrays are copied from the +approved external-reference arrays. Canonical array hashes use SHA-256 of `dtype.str`, a +NUL byte, comma-separated shape, a NUL byte, and C-order bytes. + +## 12. Future SPMKit API contract + +**FUTURE_IMPLEMENTATION_REQUIREMENT** reserves `estimate_gwyddion_median_background`, +`remove_gwyddion_median_background`, and `analyze_gwyddion_median_background`. Planned +parameters are `channel`, `radius_px=20`, and keyword-only parameters where applicable. +`BackgroundResult` shall use `gwyddion_median_background` and metadata `radius_px`, +`kernel_resolution`, `kernel_active_count`, `rank_index`, `rank_backend_reference`, +`border_policy="gwyddion_border_extend"`, and +`kernel_geometry="gwyddion_digital_ellipse"`. These are future requirements, not APIs +already present. + +## 13. Acceptance contract + +Background and corrected comparisons require bitwise exact `float64` equality. Output +shape must equal input; C-contiguity and finiteness are required for fixture inputs; input +mutation is forbidden. Reconstruction requires `input == background + corrected` with +absolute tolerance `1e-15` and relative tolerance `0`. No acceptance relaxation may be +introduced merely to satisfy tests. Any discrepancy must first adjudicate source, probe, +oracle, fixture, and implementation evidence. + +## 14. Evidence classification + +| Classification | Meaning | +|---|---| +| **SOURCE_CONFIRMED** | Frozen source establishes parameter, mask, border, rank, and subtraction semantics. | +| **EXECUTABLE_EXTERNAL_REFERENCE** | Normal and ASan Gwyddion 2.71 probe outputs. | +| **INDEPENDENT_ORACLE_CONFIRMED** | Independent NumPy oracle matches external arrays. | +| **FREEZE_AUDIT_APPROVED** | Audit approval within the frozen domain. | +| **FUTURE_IMPLEMENTATION_REQUIREMENT** | Requirement for later work, not delivered code. | +| **NON_CLAIM** | Boundary that must not be inferred from evidence. | +| **TOOLING_LIMITATION** | Non-blocking campaign tooling observation. | + +## 15. Explicit non-claims + +**NON_CLAIM:** the fixture does not establish behavior for matrices, radii, input +families, or Gwyddion paths outside the frozen campaign. It does not by itself establish +the behavior of a future SPMKit implementation or every Gwyddion feature. + +## 16. Known tooling limitations + +**TOOLING_LIMITATION:** runner compilation commands use `|| true`, so their stored status +does not preserve the original compiler exit. Its auxiliary parser also recognizes broad +`background_` and `corrected_` prefixes. These do not invalidate the campaign: both +binaries executed, all 72 processes returned, outputs were valid, stderr was empty, +normal and ASan stdout were byte-identical, and arrays and metrics were recalculated +independently. + +## 17. Scientific-integrity rule + +Tests judge algorithms against valid evidence; algorithms must not be deformed +merely to make tests pass. + +Any discrepancy shall be adjudicated before production changes. Acceptance shall not be +relaxed for convenience. Deliberate divergences and reference defects, if evidenced, +shall be preserved explicitly. Existing generic or physical median operations shall +remain separate from this compatibility operation. + +## 18. Required implementation workflow + +1. Preserve this fixture and manifest unchanged before implementation. +2. Compare candidate background and corrected fields against the fixture bitwise. +3. Check dtype, shape, C-order, finiteness, input immutability, and reconstruction. +4. Adjudicate a mismatch against source, probe, oracle, and fixture evidence before + modifying production behavior. + +## 19. Artifact inventory + +The manifest records the frozen module, probe source, runner, campaign summary, oracle +script, summary, source NPZ, provenance, report, log, and permanent NPZ fixture. `/tmp` +paths are ephemeral source artifacts whose identity is frozen by SHA-256. + +## 20. Freeze decision + +**FREEZE_AUDIT_APPROVED:** artifacts are suitable as external evidence and an independent +oracle within the explicit domain of this campaign. diff --git a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json new file mode 100644 index 0000000..aee5653 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json @@ -0,0 +1,1570 @@ +{ + "acceptance_contract": { + "background_comparison": "bitwise exact float64 equality", + "corrected_comparison": "bitwise exact float64 equality", + "future_discrepancy_rule": "any future discrepancy must first adjudicate source, probe, oracle, fixture and implementation evidence", + "input_mutation": "forbidden", + "no_acceptance_relaxation": "no acceptance relaxation may be introduced merely to satisfy tests", + "output_c_contiguous": "required", + "output_dtype": "float64", + "output_finiteness": "required for finite inputs in this fixture", + "output_shape": "identical to input", + "reconstruction": { + "absolute_tolerance": 1e-15, + "relation": "input == background + corrected", + "relative_tolerance": 0.0 + } + }, + "campaign": { + "asan_detections": 0, + "asan_executions": 36, + "asan_exit_zero": 36, + "glib_detections": 0, + "input_mutation_maximum": 0.0, + "normal_asan_stdout_identical": 36, + "normal_executions": 36, + "normal_exit_zero": 36, + "radius_inventory": { + "1": { + "active_count": 9, + "backend": "direct", + "rank": 4, + "resolution": 3 + }, + "1024": { + "active_count": 3297401, + "backend": "radixtree", + "rank": 1648700, + "resolution": 2049 + }, + "2": { + "active_count": 21, + "backend": "direct", + "rank": 10, + "resolution": 5 + }, + "20": { + "active_count": 1313, + "backend": "radixtree", + "rank": 656, + "resolution": 41 + }, + "3": { + "active_count": 37, + "backend": "radixtree", + "rank": 18, + "resolution": 7 + }, + "4": { + "active_count": 69, + "backend": "radixtree", + "rank": 34, + "resolution": 9 + } + }, + "reconstruction_maximum": 4.440892098500626e-16, + "timeouts": 0, + "total_executions": 72, + "total_logical_cases": 36 + }, + "capability": "gwyddion_median_background", + "cases": [ + { + "arrays": { + "background": "background__wide_r1", + "corrected": "corrected__wide_r1", + "input": "input__wide_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "ee5a77b1472039236347ef7ef98215d3663c51c273e4d94977355c7f77163170", + "canonical_hashes": { + "background": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "corrected": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "wide_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "ee5a77b1472039236347ef7ef98215d3663c51c273e4d94977355c7f77163170", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r2", + "corrected": "corrected__wide_r2", + "input": "input__wide_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "6aef0c2c556e148c225ee9015982cf227064b69300ba4f815a1b2f7586949c4f", + "canonical_hashes": { + "background": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "corrected": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "wide_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "6aef0c2c556e148c225ee9015982cf227064b69300ba4f815a1b2f7586949c4f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r3", + "corrected": "corrected__wide_r3", + "input": "input__wide_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "94e409e5857751215cc6f58e6ef30f04a844305e26832ceab763aa30b924f62e", + "canonical_hashes": { + "background": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "corrected": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "wide_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "94e409e5857751215cc6f58e6ef30f04a844305e26832ceab763aa30b924f62e", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r4", + "corrected": "corrected__wide_r4", + "input": "input__wide_r4" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "74f3b7a01c85452d0ebd6ea3bdf149e03f71c6759be46fc39672ed551925d380", + "canonical_hashes": { + "background": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "corrected": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 69, + "kernel_resolution": 9, + "name": "wide_r4", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "74f3b7a01c85452d0ebd6ea3bdf149e03f71c6759be46fc39672ed551925d380", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 4, + "rank_backend_reference": "radixtree", + "rank_index": 34, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r20", + "corrected": "corrected__wide_r20", + "input": "input__wide_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "e78cd9f92b7450558b91f79e1dd2f7ca956b88db081ee37c76920feee4893461", + "canonical_hashes": { + "background": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "corrected": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "wide_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "e78cd9f92b7450558b91f79e1dd2f7ca956b88db081ee37c76920feee4893461", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__tall_r1", + "corrected": "corrected__tall_r1", + "input": "input__tall_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "1c3eae268263c2942688421781a36b7d79601a7f15df2e22e148314b44310186", + "canonical_hashes": { + "background": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "corrected": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "tall_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "1c3eae268263c2942688421781a36b7d79601a7f15df2e22e148314b44310186", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r2", + "corrected": "corrected__tall_r2", + "input": "input__tall_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "c4fb1bab61dea587929df374eb586c78d2174f9b1164cb4d2f9609568cabb8e6", + "canonical_hashes": { + "background": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "corrected": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "tall_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "c4fb1bab61dea587929df374eb586c78d2174f9b1164cb4d2f9609568cabb8e6", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r3", + "corrected": "corrected__tall_r3", + "input": "input__tall_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "784348f9e52f7b9ae3ec2e43735ae912863acabba8b1aa1f031ed0c362424cab", + "canonical_hashes": { + "background": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "corrected": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "tall_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "784348f9e52f7b9ae3ec2e43735ae912863acabba8b1aa1f031ed0c362424cab", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r4", + "corrected": "corrected__tall_r4", + "input": "input__tall_r4" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "527a50e7e291c883fc7ef9bf8611f3ea66c8ae3ada97eecc0ba113427a5d10f4", + "canonical_hashes": { + "background": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "corrected": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 69, + "kernel_resolution": 9, + "name": "tall_r4", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "527a50e7e291c883fc7ef9bf8611f3ea66c8ae3ada97eecc0ba113427a5d10f4", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 4, + "rank_backend_reference": "radixtree", + "rank_index": 34, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r20", + "corrected": "corrected__tall_r20", + "input": "input__tall_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "36a7270e69665f030a293cd1aad36d64a2554295c5ea332da93b350fc3a0a783", + "canonical_hashes": { + "background": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "corrected": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "tall_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "36a7270e69665f030a293cd1aad36d64a2554295c5ea332da93b350fc3a0a783", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__constant_r1", + "corrected": "corrected__constant_r1", + "input": "input__constant_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "0f9e8a47b96cfeedbb1009af226a753adac5cbbc429aa6d47dc718ff1169ccbc", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "constant_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "0f9e8a47b96cfeedbb1009af226a753adac5cbbc429aa6d47dc718ff1169ccbc", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__constant_r3", + "corrected": "corrected__constant_r3", + "input": "input__constant_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "63faa4e94c8239e10abed8150465d259ce0fdc4199160d31e43f4c8eab57aa66", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "constant_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "63faa4e94c8239e10abed8150465d259ce0fdc4199160d31e43f4c8eab57aa66", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__constant_r20", + "corrected": "corrected__constant_r20", + "input": "input__constant_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "c22d1cf50a37185c37e73d2409e7072ffb13be2ea7cf2079f5316152041e8709", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "constant_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "c22d1cf50a37185c37e73d2409e7072ffb13be2ea7cf2079f5316152041e8709", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r1", + "corrected": "corrected__signed_r1", + "input": "input__signed_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5bbc8a33fdc2f4f65de201cca101313eef6c3ed46c8198b15e6da608db7eaaae", + "canonical_hashes": { + "background": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "corrected": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "signed_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5bbc8a33fdc2f4f65de201cca101313eef6c3ed46c8198b15e6da608db7eaaae", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r2", + "corrected": "corrected__signed_r2", + "input": "input__signed_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "676315256497615ea251205778866fe9240f7a03b9c7828e59cd2821c22966b0", + "canonical_hashes": { + "background": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "corrected": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "signed_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "676315256497615ea251205778866fe9240f7a03b9c7828e59cd2821c22966b0", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r3", + "corrected": "corrected__signed_r3", + "input": "input__signed_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "f2fa6d42a4520d70ee9d38fd51ef89a7792dc8b7ae8294da416b4bbffc673207", + "canonical_hashes": { + "background": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "corrected": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "signed_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "f2fa6d42a4520d70ee9d38fd51ef89a7792dc8b7ae8294da416b4bbffc673207", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r20", + "corrected": "corrected__signed_r20", + "input": "input__signed_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "685ac6941cde1c12699c60df73786bbb2d93d28b2021403ee239e371500eae93", + "canonical_hashes": { + "background": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "corrected": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "signed_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "685ac6941cde1c12699c60df73786bbb2d93d28b2021403ee239e371500eae93", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__singleton_1x1_r1", + "corrected": "corrected__singleton_1x1_r1", + "input": "input__singleton_1x1_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "9f23d5ab27921bcb281195f1aa4242f6d567822c58db2289c72e654bb91679ec", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_1x1_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "9f23d5ab27921bcb281195f1aa4242f6d567822c58db2289c72e654bb91679ec", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r3", + "corrected": "corrected__singleton_1x1_r3", + "input": "input__singleton_1x1_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5350c8cf313880fe9d9c7d66322447619c031d76e533245b670e0fce615ddf9f", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_1x1_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5350c8cf313880fe9d9c7d66322447619c031d76e533245b670e0fce615ddf9f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r20", + "corrected": "corrected__singleton_1x1_r20", + "input": "input__singleton_1x1_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "935a44ebec138342eba5fd54e8ae4356d3cc8a0f693b50da86735b13915b6998", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_1x1_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "935a44ebec138342eba5fd54e8ae4356d3cc8a0f693b50da86735b13915b6998", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r1024", + "corrected": "corrected__singleton_1x1_r1024", + "input": "input__singleton_1x1_r1024" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "a3aa53016ac652fa2c6658a0dc08a40178cfdcde8250cc9efdea16a7234aa200", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 3297401, + "kernel_resolution": 2049, + "name": "singleton_1x1_r1024", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "a3aa53016ac652fa2c6658a0dc08a40178cfdcde8250cc9efdea16a7234aa200", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1024, + "rank_backend_reference": "radixtree", + "rank_index": 1648700, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r1", + "corrected": "corrected__singleton_row_r1", + "input": "input__singleton_row_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "049d6a8c610eec979a4a849aafd127cd03441dc5b09dd9716637acc4833820bd", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_row_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "049d6a8c610eec979a4a849aafd127cd03441dc5b09dd9716637acc4833820bd", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r3", + "corrected": "corrected__singleton_row_r3", + "input": "input__singleton_row_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5cde1b61afe9b14f6c0b067c10bb88cdd9f571631297140dfad8bb139bdf6995", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_row_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5cde1b61afe9b14f6c0b067c10bb88cdd9f571631297140dfad8bb139bdf6995", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r20", + "corrected": "corrected__singleton_row_r20", + "input": "input__singleton_row_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "fcc806e053dd97ba2cd197a545e8cae79c89eebcc9fce56ea158883bccc0224f", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_row_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "fcc806e053dd97ba2cd197a545e8cae79c89eebcc9fce56ea158883bccc0224f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_column_r1", + "corrected": "corrected__singleton_column_r1", + "input": "input__singleton_column_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "69bda8da85155b03afe65486f742e6c9d5a5fe15ae7cd96f6bfa5a6aa80e1420", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_column_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "69bda8da85155b03afe65486f742e6c9d5a5fe15ae7cd96f6bfa5a6aa80e1420", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__singleton_column_r3", + "corrected": "corrected__singleton_column_r3", + "input": "input__singleton_column_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "b683797214f377eb700e8104b2c38a2138bdc34667228b70895d6fcbaa95330a", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_column_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "b683797214f377eb700e8104b2c38a2138bdc34667228b70895d6fcbaa95330a", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__singleton_column_r20", + "corrected": "corrected__singleton_column_r20", + "input": "input__singleton_column_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "df16cae389eb5a7f7a3e1b89247d564d94b0e62cde63259b781d235b8de22ed0", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_column_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "df16cae389eb5a7f7a3e1b89247d564d94b0e62cde63259b781d235b8de22ed0", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r1", + "corrected": "corrected__impulse_positive_r1", + "input": "input__impulse_positive_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "e6cae67ec4b60be3c67305e2bb4a8cd7ea9ebf2853d28865cafc9721e82008e2", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "impulse_positive_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "e6cae67ec4b60be3c67305e2bb4a8cd7ea9ebf2853d28865cafc9721e82008e2", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r2", + "corrected": "corrected__impulse_positive_r2", + "input": "input__impulse_positive_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "d54909e63485b8123a710f92495c51b333122ceb4c56b9581478c011b5031e3b", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "impulse_positive_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "d54909e63485b8123a710f92495c51b333122ceb4c56b9581478c011b5031e3b", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r3", + "corrected": "corrected__impulse_positive_r3", + "input": "input__impulse_positive_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "33503698dbc21880eec0a1c6b2cca79af69c06fc5142d84cdff725f613fb8cf2", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "impulse_positive_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "33503698dbc21880eec0a1c6b2cca79af69c06fc5142d84cdff725f613fb8cf2", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r1", + "corrected": "corrected__impulse_negative_r1", + "input": "input__impulse_negative_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "136cef3c87081f62d1a58ea34ba4cec53c3ec0c3a70e546c7781dabc03672900", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "impulse_negative_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "136cef3c87081f62d1a58ea34ba4cec53c3ec0c3a70e546c7781dabc03672900", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r2", + "corrected": "corrected__impulse_negative_r2", + "input": "input__impulse_negative_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "13cacbc9901651a60cee59fb99f6001b7cc1923cb19077175dc2ef900320eaef", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "impulse_negative_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "13cacbc9901651a60cee59fb99f6001b7cc1923cb19077175dc2ef900320eaef", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r3", + "corrected": "corrected__impulse_negative_r3", + "input": "input__impulse_negative_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "d82063e40817af9aea7bbdee7e0ae6c456dac52d238a4eb3aacacc7117d80fbe", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "impulse_negative_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "d82063e40817af9aea7bbdee7e0ae6c456dac52d238a4eb3aacacc7117d80fbe", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__monotonic_r1", + "corrected": "corrected__monotonic_r1", + "input": "input__monotonic_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "322b1a1a28cb3b1ca4be0790b3bf7ca57a92fa18b75530d222608d0c2e43fdff", + "canonical_hashes": { + "background": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "corrected": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "monotonic_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "322b1a1a28cb3b1ca4be0790b3bf7ca57a92fa18b75530d222608d0c2e43fdff", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + }, + { + "arrays": { + "background": "background__monotonic_r2", + "corrected": "corrected__monotonic_r2", + "input": "input__monotonic_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "f7b29e736c9e9e870da37aa5690ba0ef99d397ff87790d208aa6e4e0ec9720f1", + "canonical_hashes": { + "background": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "corrected": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "monotonic_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "f7b29e736c9e9e870da37aa5690ba0ef99d397ff87790d208aa6e4e0ec9720f1", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + }, + { + "arrays": { + "background": "background__monotonic_r3", + "corrected": "corrected__monotonic_r3", + "input": "input__monotonic_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "809f2c550b73dd1f9a7f963f2ebdde1c14c7524faf94b70b587d4dda49310350", + "canonical_hashes": { + "background": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "corrected": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "monotonic_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "809f2c550b73dd1f9a7f963f2ebdde1c14c7524faf94b70b587d4dda49310350", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + } + ], + "evidence_classification": { + "external_probe": "EXECUTABLE_EXTERNAL_REFERENCE", + "freeze_audit": "MEDIAN_BACKGROUND_ORACLE_FREEZE_APPROVED", + "independent_oracle": "INDEPENDENT_PYTHON_ORACLE", + "spmkit_implementation": "NOT_YET_IMPLEMENTED" + }, + "fixture": { + "all_finite": true, + "array_count": 108, + "arrays_per_case": 3, + "canonical_hash_algorithm": "SHA-256 of dtype.str, NUL, comma-separated shape, NUL, and C-order bytes", + "case_count": 36, + "compressed": false, + "dimension_count": 2, + "dtype": "float64", + "npz_relative_path": "tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz", + "npz_sha256": "a581893e44d8887939d2335bc7eda6650a301e78e4025a8c7aa77020db442b20", + "order": "C" + }, + "operation": "median_bg", + "oracle": { + "background_bitwise_exact_percentage_maximum": 100.0, + "background_bitwise_exact_percentage_minimum": 100.0, + "background_maximum_absolute_difference": 0.0, + "background_maximum_ulp": 0, + "canonical_source_array_hash_count": 180, + "canonical_source_array_hashes": { + "input__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__monotonic_r1": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__monotonic_r2": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__monotonic_r3": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__signed_r1": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r2": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r20": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r3": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__tall_r1": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r2": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r20": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r3": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r4": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__wide_r1": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r2": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r20": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r3": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r4": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "oracle_background__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__impulse_negative_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_negative_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_negative_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__monotonic_r1": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "oracle_background__monotonic_r2": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "oracle_background__monotonic_r3": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "oracle_background__signed_r1": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "oracle_background__signed_r2": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "oracle_background__signed_r20": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "oracle_background__signed_r3": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "oracle_background__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__tall_r1": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "oracle_background__tall_r2": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "oracle_background__tall_r20": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "oracle_background__tall_r3": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "oracle_background__tall_r4": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "oracle_background__wide_r1": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "oracle_background__wide_r2": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "oracle_background__wide_r20": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "oracle_background__wide_r3": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "oracle_background__wide_r4": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "oracle_corrected__constant_r1": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__constant_r20": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__constant_r3": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__monotonic_r1": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "oracle_corrected__monotonic_r2": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "oracle_corrected__monotonic_r3": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "oracle_corrected__signed_r1": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "oracle_corrected__signed_r2": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "oracle_corrected__signed_r20": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "oracle_corrected__signed_r3": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "oracle_corrected__singleton_1x1_r1": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r1024": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r20": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r3": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_column_r1": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_column_r20": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_column_r3": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_row_r1": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__singleton_row_r20": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__singleton_row_r3": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__tall_r1": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "oracle_corrected__tall_r2": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "oracle_corrected__tall_r20": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "oracle_corrected__tall_r3": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "oracle_corrected__tall_r4": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "oracle_corrected__wide_r1": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "oracle_corrected__wide_r2": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "oracle_corrected__wide_r20": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "oracle_corrected__wide_r3": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "oracle_corrected__wide_r4": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d", + "reference_background__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__impulse_negative_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_negative_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_negative_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__monotonic_r1": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "reference_background__monotonic_r2": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "reference_background__monotonic_r3": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "reference_background__signed_r1": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "reference_background__signed_r2": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "reference_background__signed_r20": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "reference_background__signed_r3": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "reference_background__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__tall_r1": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "reference_background__tall_r2": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "reference_background__tall_r20": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "reference_background__tall_r3": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "reference_background__tall_r4": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "reference_background__wide_r1": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "reference_background__wide_r2": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "reference_background__wide_r20": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "reference_background__wide_r3": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "reference_background__wide_r4": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "reference_corrected__constant_r1": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__constant_r20": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__constant_r3": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__monotonic_r1": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "reference_corrected__monotonic_r2": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "reference_corrected__monotonic_r3": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "reference_corrected__signed_r1": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "reference_corrected__signed_r2": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "reference_corrected__signed_r20": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "reference_corrected__signed_r3": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "reference_corrected__singleton_1x1_r1": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r1024": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r20": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r3": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_column_r1": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_column_r20": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_column_r3": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_row_r1": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__singleton_row_r20": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__singleton_row_r3": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__tall_r1": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "reference_corrected__tall_r2": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "reference_corrected__tall_r20": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "reference_corrected__tall_r3": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "reference_corrected__tall_r4": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "reference_corrected__wide_r1": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "reference_corrected__wide_r2": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "reference_corrected__wide_r20": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "reference_corrected__wide_r3": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "reference_corrected__wide_r4": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d" + }, + "cases": 36, + "corrected_bitwise_exact_percentage_maximum": 100.0, + "corrected_bitwise_exact_percentage_minimum": 100.0, + "corrected_maximum_absolute_difference": 0.0, + "corrected_maximum_ulp": 0, + "independence": { + "gwyddion_execution": false, + "numpy_partition_used_instead_of_internal_gwyddion_selection": true, + "reference_arrays_loaded_only_after_oracle_outputs_calculated": true, + "scipy_import": false, + "spmkit_import": false, + "subprocess_import": false, + "tolerance_selected": false + }, + "nonfinite_cases": 0, + "oracle_reconstruction_maximum": 4.440892098500626e-16, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "source_arrays": 180 + }, + "reference_software": { + "name": "Gwyddion", + "version": "2.71" + }, + "schema_version": 1, + "scope": { + "assertions": [ + "evidence is limited to the frozen 36-case campaign", + "both Gwyddion rank-filter backends are represented", + "no universal equivalence is claimed", + "no SPMKit implementation is validated by this fixture alone", + "errors outside the frozen domain are not excluded" + ] + }, + "semantics": { + "active_condition": "squared ellipse expression <= radius_squared", + "active_offsets": "centred by subtracting radius", + "corrected": "input - background", + "direct_backend_condition": "kernel_active_count <= 25", + "exterior": "GWY_EXTERIOR_BORDER_EXTEND", + "exterior_interpretation": "nearest valid edge pixel", + "input_mutation": false, + "kernel_geometry": "inclusive digital ellipse over pixel centres", + "kernel_resolution": "2*radius + 1", + "offset_order": "row-major", + "pixel_centre": "kernel_index + 0.5", + "radius_default": 20, + "radius_maximum": 1024, + "radius_minimum": 1, + "radius_type": "integer", + "radixtree_backend_condition": "kernel_active_count > 25", + "rank": "kernel_active_count//2" + }, + "source_artifacts": { + "campaign_summary": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_probe/campaign-summary.tsv", + "sha256": "7876d9cf3bc61375ecff5ca42c16789e11f4f0cc651f330ed7da57b36fc493b2" + }, + "median_bg_c": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/source/modules/process/median-bg.c", + "sha256": "5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729" + }, + "oracle_log": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle.log", + "sha256": "87eec8d8509d6d879ab41d748e9c21cc1ae428e6157131320c5fe7de0635d059" + }, + "oracle_provenance": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_provenance.json", + "sha256": "fe759bafbd7180394f3262da13e19275475ae569c3f79f879a51c6b8025e0d74" + }, + "oracle_report": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_report.md", + "sha256": "1a11f8caec6456de78feb9679e3fe0bec81e101058bfd73806d9953665b8dd31" + }, + "oracle_script": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle.py", + "sha256": "2696798b180fcce779bbded49131d106cdc8f159c30aa1df873008f04d66084b" + }, + "oracle_source_npz": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_arrays.npz", + "sha256": "d56117cb4bbfc182d9fdfb8a9f6d2b400b5d5d8c17e051075342387b98dacc09" + }, + "oracle_summary": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_summary.tsv", + "sha256": "0325066fd9a13cbc2b70d21473d2aeca783faa469a37d867fc6c11801c51b69f" + }, + "probe_c": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c", + "sha256": "8e1956a6dbc69afcf5244098bc930f529c733bdec2768909cb7372ccea260f10" + }, + "runner": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh", + "sha256": "8c1c42dba8fc8a36bb93d1a82e60257dca3ff5044a2987b452608f99115ab594" + } + } +} diff --git a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..3680a230f7f0c67c2b5c47be2cc24e9de802995f GIT binary patch literal 45742 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 QC#mLt2hCY_aG~(-zp;K^IRF3v literal 0 HcmV?d00001 diff --git a/tests/validation/test_median_background_fixture_integrity.py b/tests/validation/test_median_background_fixture_integrity.py new file mode 100644 index 0000000..8bbb917 --- /dev/null +++ b/tests/validation/test_median_background_fixture_integrity.py @@ -0,0 +1,267 @@ +"""Integrity checks for frozen Gwyddion 2.71 Median Background evidence.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "gwyddion" / "median_background" +_NPZ_PATH = _FIXTURE_DIR / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" +_EXPECTED_CASES = [ + "wide_r1", "wide_r2", "wide_r3", "wide_r4", "wide_r20", + "tall_r1", "tall_r2", "tall_r3", "tall_r4", "tall_r20", + "constant_r1", "constant_r3", "constant_r20", + "signed_r1", "signed_r2", "signed_r3", "signed_r20", + "singleton_1x1_r1", "singleton_1x1_r3", "singleton_1x1_r20", + "singleton_1x1_r1024", + "singleton_row_r1", "singleton_row_r3", "singleton_row_r20", + "singleton_column_r1", "singleton_column_r3", "singleton_column_r20", + "impulse_positive_r1", "impulse_positive_r2", "impulse_positive_r3", + "impulse_negative_r1", "impulse_negative_r2", "impulse_negative_r3", + "monotonic_r1", "monotonic_r2", "monotonic_r3", +] +_EXPECTED_RADIUS_INVENTORY = { + "1": {"active_count": 9, "backend": "direct", "rank": 4, "resolution": 3}, + "2": {"active_count": 21, "backend": "direct", "rank": 10, "resolution": 5}, + "3": {"active_count": 37, "backend": "radixtree", "rank": 18, "resolution": 7}, + "4": {"active_count": 69, "backend": "radixtree", "rank": 34, "resolution": 9}, + "20": {"active_count": 1313, "backend": "radixtree", "rank": 656, "resolution": 41}, + "1024": { + "active_count": 3297401, + "backend": "radixtree", + "rank": 1648700, + "resolution": 2049, + }, +} + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_array_hash(array: np.ndarray) -> str: + contiguous = np.ascontiguousarray(array) + digest = hashlib.sha256() + digest.update(contiguous.dtype.str.encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(value) for value in contiguous.shape).encode("ascii")) + digest.update(b"\0") + digest.update(contiguous.tobytes(order="C")) + return digest.hexdigest() + + +def _load_manifest() -> dict[str, object]: + with _MANIFEST_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _load_arrays_read_only() -> dict[str, np.ndarray]: + arrays: dict[str, np.ndarray] = {} + with np.load(_NPZ_PATH, allow_pickle=False) as archive: + for name in archive.files: + array = np.ascontiguousarray(archive[name]).copy(order="C") + array.setflags(write=False) + arrays[name] = array + return arrays + + +def _cases(manifest: dict[str, object]) -> list[dict[str, object]]: + return manifest["cases"] + + +def test_manifest_schema_and_identity() -> None: + manifest = _load_manifest() + + assert manifest["schema_version"] == 1 + assert manifest["capability"] == "gwyddion_median_background" + assert manifest["operation"] == "median_bg" + assert manifest["reference_software"] == {"name": "Gwyddion", "version": "2.71"} + assert manifest["fixture"]["case_count"] == 36 + assert manifest["oracle"]["canonical_source_array_hash_count"] == 180 + assert len(manifest["oracle"]["canonical_source_array_hashes"]) == 180 + assert "manifest_self_hash" not in manifest["fixture"] + + +def test_case_order_is_exact() -> None: + manifest = _load_manifest() + + assert [case["name"] for case in _cases(manifest)] == _EXPECTED_CASES + + +def test_fixture_exists_and_hash_matches_manifest() -> None: + manifest = _load_manifest() + + assert _NPZ_PATH.is_file() + assert _sha256_file(_NPZ_PATH) == manifest["fixture"]["npz_sha256"] + + +def test_fixture_contains_exactly_108_arrays() -> None: + arrays = _load_arrays_read_only() + + assert len(arrays) == 108 + + +def test_fixture_contains_exactly_three_arrays_per_case() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + names = set(case["arrays"].values()) + assert len(names) == 3 + assert names <= arrays.keys() + + +def test_fixture_array_names_are_exact() -> None: + arrays = _load_arrays_read_only() + expected_names = { + f"{role}__{case}" + for case in _EXPECTED_CASES + for role in ("input", "background", "corrected") + } + + assert set(arrays) == expected_names + + +def test_fixture_array_dtypes_are_float64() -> None: + arrays = _load_arrays_read_only() + + assert all(array.dtype == np.float64 for array in arrays.values()) + + +def test_fixture_arrays_are_two_dimensional() -> None: + arrays = _load_arrays_read_only() + + assert all(array.ndim == 2 for array in arrays.values()) + + +def test_fixture_arrays_are_c_contiguous() -> None: + arrays = _load_arrays_read_only() + + assert all(array.flags.c_contiguous for array in arrays.values()) + + +def test_fixture_arrays_are_finite() -> None: + arrays = _load_arrays_read_only() + + assert all(np.isfinite(array).all() for array in arrays.values()) + + +def test_fixture_shapes_match_manifest() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + expected_shape = tuple(case["shape"]) + for name in case["arrays"].values(): + assert arrays[name].shape == expected_shape + + +def test_fixture_canonical_hashes_match_manifest() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + for role, name in case["arrays"].items(): + assert _canonical_array_hash(arrays[name]) == case["canonical_hashes"][role] + + +def test_fixture_helper_returns_read_only_arrays() -> None: + arrays = _load_arrays_read_only() + + assert all(not array.flags.writeable for array in arrays.values()) + + +def test_background_and_corrected_shapes_match_input() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + assert background.shape == input_array.shape + assert corrected.shape == input_array.shape + + +def test_fixture_reconstruction_contract() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + reconstruction = manifest["acceptance_contract"]["reconstruction"] + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + np.testing.assert_allclose( + input_array, + background + corrected, + atol=reconstruction["absolute_tolerance"], + rtol=reconstruction["relative_tolerance"], + ) + + +def test_input_does_not_share_memory_with_outputs() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + assert not np.shares_memory(input_array, background) + assert not np.shares_memory(input_array, corrected) + + +def test_radius_and_backend_inventory_is_exact() -> None: + manifest = _load_manifest() + + assert manifest["campaign"]["radius_inventory"] == _EXPECTED_RADIUS_INVENTORY + assert {case["rank_backend_reference"] for case in _cases(manifest)} == { + "direct", + "radixtree", + } + + +def test_evidence_classification_is_exact() -> None: + manifest = _load_manifest() + + assert manifest["evidence_classification"] == { + "external_probe": "EXECUTABLE_EXTERNAL_REFERENCE", + "freeze_audit": "MEDIAN_BACKGROUND_ORACLE_FREEZE_APPROVED", + "independent_oracle": "INDEPENDENT_PYTHON_ORACLE", + "spmkit_implementation": "NOT_YET_IMPLEMENTED", + } + + +def test_acceptance_contract_is_exact() -> None: + manifest = _load_manifest() + contract = manifest["acceptance_contract"] + + assert contract["background_comparison"] == "bitwise exact float64 equality" + assert contract["corrected_comparison"] == "bitwise exact float64 equality" + assert contract["output_dtype"] == "float64" + assert contract["output_shape"] == "identical to input" + assert contract["output_c_contiguous"] == "required" + assert contract["output_finiteness"] == "required for finite inputs in this fixture" + assert contract["input_mutation"] == "forbidden" + assert contract["reconstruction"] == { + "absolute_tolerance": 1e-15, + "relation": "input == background + corrected", + "relative_tolerance": 0.0, + } + assert contract["no_acceptance_relaxation"] == ( + "no acceptance relaxation may be introduced merely to satisfy tests" + ) + + +def test_fixture_contains_no_oracle_or_reference_arrays() -> None: + arrays = _load_arrays_read_only() + + assert all(not name.startswith(("oracle_", "reference_")) for name in arrays) From a53c3bb5581233bae819c9f1a161fe153deb278a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:33:56 -0400 Subject: [PATCH 60/82] feat(analysis): add Gwyddion median background kernel --- .../core/analysis/_median_background.py | 177 ++++++++ ...test_gwyddion_median_background_private.py | 396 ++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 src/spmkit/core/analysis/_median_background.py create mode 100644 tests/core/test_gwyddion_median_background_private.py diff --git a/src/spmkit/core/analysis/_median_background.py b/src/spmkit/core/analysis/_median_background.py new file mode 100644 index 0000000..403e3ab --- /dev/null +++ b/src/spmkit/core/analysis/_median_background.py @@ -0,0 +1,177 @@ +"""Private numerical kernel for Gwyddion 2.71 Median Background. + +This module reproduces the frozen pixel-domain semantics independently with +NumPy. It deliberately has no public adapter: the future public API owns +channels, metadata, and result objects. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import lru_cache +from typing import Literal + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] +IntArray = NDArray[np.int_] +_GwyddionMedianBackgroundBackend = Literal["direct", "radixtree"] + + +@dataclass(frozen=True) +class _MedianBackgroundKernelSpec: + """Immutable discrete-kernel specification for Median Background.""" + + radius_px: int + kernel_resolution: int + kernel_active_count: int + rank_index: int + rank_backend_reference: _GwyddionMedianBackgroundBackend + + +def _validated_median_background_radius(radius_px: object) -> int: + """Return a Gwyddion Median Background radius in the frozen range.""" + if isinstance(radius_px, (bool, np.bool_)): + raise TypeError( + "Gwyddion Median Background radius_px must be a Python or NumPy " + "integer scalar; booleans are not valid" + ) + if not isinstance(radius_px, (int, np.integer)): + raise TypeError( + "Gwyddion Median Background radius_px must be a Python or NumPy " + "integer scalar; booleans are not valid" + ) + + radius = int(radius_px) + if not 1 <= radius <= 1024: + raise ValueError( + "Gwyddion Median Background radius_px must be in the inclusive " + "range 1..1024" + ) + + return radius + + +def _validated_median_background_data(data: object) -> FloatArray: + """Return a finite, non-empty, C-contiguous float64 copy of 2D data.""" + try: + source = np.asarray(data) + except (TypeError, ValueError) as exc: + raise TypeError("Gwyddion Median Background requires array-compatible data") from exc + + if source.ndim != 2: + raise ValueError("Gwyddion Median Background requires two-dimensional data") + if 0 in source.shape: + raise ValueError("Gwyddion Median Background requires non-empty dimensions") + if ( + not np.issubdtype(source.dtype, np.number) + or np.iscomplexobj(source) + or isinstance(data, (bool, np.bool_)) + ): + raise TypeError("Gwyddion Median Background requires real numeric data") + + values = np.array(source, dtype=np.float64, order="C", copy=True) + if not np.all(np.isfinite(values)): + raise ValueError("Gwyddion Median Background requires finite data") + + return values + + +@lru_cache(maxsize=8) +def _cached_median_background_active_offsets(radius_px: int) -> IntArray: + """Build read-only active offsets in the frozen row-major order.""" + diameter = 2 * radius_px + 1 + radius_square = diameter * diameter + + active_count = 0 + max_columns_by_row: list[int] = [] + for dr in range(-radius_px, radius_px + 1): + remaining = radius_square - 4 * dr * dr + max_abs_dc = math.isqrt(remaining // 4) + max_columns_by_row.append(max_abs_dc) + active_count += 2 * max_abs_dc + 1 + + offsets = np.empty((active_count, 2), dtype=np.int_, order="C") + position = 0 + for dr, max_abs_dc in zip( + range(-radius_px, radius_px + 1), max_columns_by_row, strict=True + ): + row_count = 2 * max_abs_dc + 1 + stop = position + row_count + offsets[position:stop, 0] = dr + offsets[position:stop, 1] = np.arange(-max_abs_dc, max_abs_dc + 1, dtype=np.int_) + position = stop + + if position != active_count: + raise RuntimeError("Gwyddion Median Background offset count is inconsistent") + if active_count % 2 != 1: + raise RuntimeError("Gwyddion Median Background active count must be odd") + if not np.array_equal(offsets[active_count // 2], np.array([0, 0], dtype=np.int_)): + raise RuntimeError("Gwyddion Median Background offsets must contain the centre") + if not offsets.flags.c_contiguous: + raise RuntimeError("Gwyddion Median Background offsets must be C-contiguous") + + offsets.setflags(write=False) + return offsets + + +def _median_background_active_offsets(radius_px: object) -> IntArray: + """Return cached active digital-ellipse offsets for ``radius_px``.""" + return _cached_median_background_active_offsets( + _validated_median_background_radius(radius_px) + ) + + +def _median_background_kernel_spec(radius_px: object) -> _MedianBackgroundKernelSpec: + """Construct the immutable Gwyddion Median Background kernel specification.""" + radius = _validated_median_background_radius(radius_px) + active_count = _cached_median_background_active_offsets(radius).shape[0] + backend: _GwyddionMedianBackgroundBackend = ( + "direct" if active_count <= 25 else "radixtree" + ) + + return _MedianBackgroundKernelSpec( + radius_px=radius, + kernel_resolution=2 * radius + 1, + kernel_active_count=active_count, + rank_index=active_count // 2, + rank_backend_reference=backend, + ) + + +def _gwyddion_median_background_result( + data: object, + radius_px: object, +) -> tuple[FloatArray, FloatArray, _MedianBackgroundKernelSpec]: + """Calculate frozen Gwyddion 2.71 Median Background fields. + + Exterior samples are clamped to the nearest valid edge pixel. The + selected rank is found independently with :func:`numpy.partition`; no + Gwyddion selection data structure is reproduced. + """ + values = _validated_median_background_data(data) + spec = _median_background_kernel_spec(radius_px) + offsets = _cached_median_background_active_offsets(spec.radius_px) + yres, xres = values.shape + + row_indices = np.clip( + np.arange(yres, dtype=np.int_)[:, np.newaxis] + offsets[np.newaxis, :, 0], + 0, + yres - 1, + ) + column_indices = np.clip( + np.arange(xres, dtype=np.int_)[:, np.newaxis] + offsets[np.newaxis, :, 1], + 0, + xres - 1, + ) + + background = np.empty(values.shape, dtype=np.float64, order="C") + for row in range(yres): + for column in range(xres): + samples = values[row_indices[row], column_indices[column]] + background[row, column] = np.partition(samples, spec.rank_index)[spec.rank_index] + + corrected = np.array(values - background, dtype=np.float64, order="C", copy=True) + return background, corrected, spec diff --git a/tests/core/test_gwyddion_median_background_private.py b/tests/core/test_gwyddion_median_background_private.py new file mode 100644 index 0000000..c0a62a0 --- /dev/null +++ b/tests/core/test_gwyddion_median_background_private.py @@ -0,0 +1,396 @@ +"""Focused tests for the private Gwyddion 2.71 Median Background kernel.""" + +from __future__ import annotations + +import json +from functools import cache, lru_cache +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._median_background import ( + _gwyddion_median_background_result, + _median_background_active_offsets, + _median_background_kernel_spec, +) + +_FIXTURE_DIR = ( + Path(__file__).parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "median_background" +) +_FIXTURE_PATH = _FIXTURE_DIR / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" +_KERNEL_INVENTORY = { + 1: (3, 9, 4, "direct"), + 2: (5, 21, 10, "direct"), + 3: (7, 37, 18, "radixtree"), + 4: (9, 69, 34, "radixtree"), + 20: (41, 1313, 656, "radixtree"), + 1024: (2049, 3297401, 1648700, "radixtree"), +} +_UINT64_MASK = (1 << 64) - 1 +_UINT64_SIGN = 1 << 63 + + +@lru_cache(maxsize=1) +def _fixture() -> tuple[dict[str, object], dict[str, np.ndarray]]: + """Load the frozen fixture only as test evidence.""" + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + arrays: dict[str, np.ndarray] = {} + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + for name in archive.files: + arrays[name] = np.array(archive[name], dtype=np.float64, order="C", copy=True) + return manifest, arrays + + +def _case(case_name: str) -> dict[str, object]: + manifest, _ = _fixture() + for case in manifest["cases"]: # type: ignore[index] + if case["name"] == case_name: + return case + raise AssertionError(f"frozen fixture case was not found: {case_name}") + + +@cache +def _result_for_case(case_name: str) -> tuple[np.ndarray, np.ndarray, object]: + case = _case(case_name) + _, arrays = _fixture() + keys = case["arrays"] + return _gwyddion_median_background_result( + arrays[keys["input"]], # type: ignore[index] + case["radius"], + ) + + +def _ordered_uint64(bits: int) -> int: + """Map IEEE-754 bits to an ordering suitable for a ULP distance.""" + if bits & _UINT64_SIGN: + return (-bits) & _UINT64_MASK + return bits | _UINT64_SIGN + + +def _assert_bitwise_equal( + case_name: str, + array_name: str, + expected: np.ndarray, + actual: np.ndarray, +) -> None: + expected_bits = expected.view(np.uint64) + actual_bits = actual.view(np.uint64) + mismatch = np.argwhere(expected_bits != actual_bits) + if mismatch.size == 0: + return + + row, column = (int(value) for value in mismatch[0]) + expected_bit = int(expected_bits[row, column]) + actual_bit = int(actual_bits[row, column]) + expected_value = float(expected[row, column]) + actual_value = float(actual[row, column]) + ulp_distance = abs(_ordered_uint64(expected_bit) - _ordered_uint64(actual_bit)) + raise AssertionError( + f"case={case_name} array={array_name} coordinate=({row}, {column}) " + f"expected={expected_value!r} actual={actual_value!r} " + f"expected_uint64={expected_bit} actual_uint64={actual_bit} " + f"absolute_difference={abs(expected_value - actual_value)!r} " + f"ulp_distance={ulp_distance}" + ) + + +def test_kernel_inventory_is_exact() -> None: + for radius, expected in _KERNEL_INVENTORY.items(): + specification = _median_background_kernel_spec(radius) + assert ( + specification.kernel_resolution, + specification.kernel_active_count, + specification.rank_index, + specification.rank_backend_reference, + ) == expected + + +def test_kernel_resolution_is_two_radius_plus_one() -> None: + for radius in _KERNEL_INVENTORY: + assert _median_background_kernel_spec(radius).kernel_resolution == 2 * radius + 1 + + +def test_kernel_active_count_is_odd() -> None: + for radius in _KERNEL_INVENTORY: + assert _median_background_kernel_spec(radius).kernel_active_count % 2 == 1 + + +def test_kernel_rank_is_half_the_active_count() -> None: + for radius in _KERNEL_INVENTORY: + specification = _median_background_kernel_spec(radius) + assert specification.rank_index == specification.kernel_active_count // 2 + + +def test_kernel_backend_reference_uses_frozen_threshold() -> None: + assert _median_background_kernel_spec(2).rank_backend_reference == "direct" + assert _median_background_kernel_spec(3).rank_backend_reference == "radixtree" + + +def test_offsets_are_in_row_major_order() -> None: + offsets = _median_background_active_offsets(20) + keys = offsets[:, 0] * 10000 + offsets[:, 1] + assert np.array_equal(keys, np.sort(keys)) + + +def test_offsets_obey_the_inclusive_integer_ellipse_condition() -> None: + radius = 20 + offsets = _median_background_active_offsets(radius) + left = 4 * (offsets[:, 0] * offsets[:, 0] + offsets[:, 1] * offsets[:, 1]) + assert np.all(left <= (2 * radius + 1) ** 2) + + +def test_offsets_exclude_immediately_exterior_integer_positions() -> None: + radius = 20 + offsets = _median_background_active_offsets(radius) + radius_square = (2 * radius + 1) ** 2 + for dr in range(-radius, radius + 1): + row_offsets = offsets[offsets[:, 0] == dr, 1] + next_dc = int(np.max(np.abs(row_offsets))) + 1 + assert 4 * (dr * dr + next_dc * next_dc) > radius_square + + +def test_offsets_contain_the_central_pixel() -> None: + offsets = _median_background_active_offsets(20) + assert np.any(np.all(offsets == np.array([0, 0]), axis=1)) + + +def test_offsets_are_c_contiguous_and_read_only() -> None: + offsets = _median_background_active_offsets(20) + assert offsets.flags.c_contiguous + assert not offsets.flags.writeable + + +def test_offset_cache_preserves_content() -> None: + first = _median_background_active_offsets(20) + second = _median_background_active_offsets(np.int64(20)) + assert first is second + assert np.array_equal(first, second) + + +@pytest.mark.parametrize( + "radius", + [True, 0, -1, 1025, 1.0, 1.5, "20"], +) +def test_radius_validation_rejects_values_outside_the_contract(radius: object) -> None: + expected_error = TypeError if isinstance(radius, (bool, float, str)) else ValueError + with pytest.raises(expected_error): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + ("radius", "expected"), + [ + (1, 1), + (1024, 1024), + (np.int8(2), 2), + (np.int64(20), 20), + (np.uint8(3), 3), + (np.uint64(1024), 1024), + ], +) +def test_radius_validation_accepts_python_and_numpy_integer_scalars( + radius: object, + expected: int, +) -> None: + specification = _median_background_kernel_spec(radius) + assert type(specification.radius_px) is int + assert specification.radius_px == expected + + +@pytest.mark.parametrize( + "radius", + [ + True, + False, + np.bool_(True), + np.bool_(False), + np.array(2, dtype=np.int64), + np.array(2, dtype=np.uint64), + np.array(True), + 2.0, + "2", + ], +) +def test_radius_validation_rejects_non_integer_scalars_with_type_error(radius: object) -> None: + with pytest.raises(TypeError, match="Python or NumPy integer scalar"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + "radius", + [0, -1, 1025, 10**100, -(10**100), np.uint64(1025)], +) +def test_radius_validation_rejects_all_out_of_range_integers_with_value_error( + radius: object, +) -> None: + with pytest.raises(ValueError, match=r"1\.\.1024"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize("radius", [True, False, np.bool_(True), np.bool_(False)]) +def test_radius_validation_explains_that_booleans_are_invalid(radius: object) -> None: + with pytest.raises(TypeError, match="booleans are not valid"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + "data, exception, message", + [ + (np.array(1.0), ValueError, "two-dimensional"), + (np.array([1.0, 2.0]), ValueError, "two-dimensional"), + (np.ones((1, 1, 1)), ValueError, "two-dimensional"), + (np.empty((0, 2)), ValueError, "non-empty"), + (np.array([[np.nan]]), ValueError, "finite"), + (np.array([[np.inf]]), ValueError, "finite"), + (np.array([[-np.inf]]), ValueError, "finite"), + ], +) +def test_input_validation_rejects_outside_the_frozen_domain( + data: np.ndarray, + exception: type[Exception], + message: str, +) -> None: + with pytest.raises(exception, match=message): + _gwyddion_median_background_result(data, 1) + + +def test_compatible_input_is_converted_to_float64() -> None: + background, corrected, _ = _gwyddion_median_background_result([[1, 2], [3, 4]], 1) + assert background.dtype == np.float64 + assert corrected.dtype == np.float64 + + +def test_input_is_not_mutated() -> None: + data = np.array([[1, -2, 3], [4, 5, -6]], dtype=np.float32) + before = data.copy() + _gwyddion_median_background_result(data, 2) + assert np.array_equal(data, before) + + +def test_outputs_do_not_share_memory_with_input_or_each_other() -> None: + data = np.arange(12, dtype=np.float64).reshape(3, 4) + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert not np.shares_memory(data, background) + assert not np.shares_memory(data, corrected) + assert not np.shares_memory(background, corrected) + + +def test_outputs_are_float64_two_dimensional_c_contiguous_and_finite() -> None: + background, corrected, _ = _gwyddion_median_background_result([[1, 2], [3, 4]], 1) + for output in (background, corrected): + assert output.dtype == np.float64 + assert output.ndim == 2 + assert output.flags.c_contiguous + assert np.all(np.isfinite(output)) + + +def test_output_shape_is_preserved() -> None: + data = np.arange(15, dtype=np.float64).reshape(3, 5) + background, corrected, _ = _gwyddion_median_background_result(data, 2) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_border_extension_clamps_to_the_nearest_edge() -> None: + data = np.array([[0.0, 10.0], [20.0, 30.0]]) + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert background[0, 0] == 10.0 + assert corrected[0, 0] == -10.0 + + +def test_constant_field_has_identity_background_and_zero_corrected() -> None: + data = np.full((4, 5), 7.25, dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert np.array_equal(background.view(np.uint64), data.view(np.uint64)) + assert np.array_equal(corrected.view(np.uint64), np.zeros_like(corrected).view(np.uint64)) + + +def test_signed_field_produces_finite_reconstructable_outputs() -> None: + data = np.array([[-4.0, -1.0, 2.0], [3.0, -5.0, 7.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 2) + assert np.all(np.isfinite(background)) + assert np.all(np.isfinite(corrected)) + np.testing.assert_allclose(data, background + corrected, atol=1e-15, rtol=0.0) + + +def test_positive_impulse_uses_the_rank_background() -> None: + data = np.zeros((5, 5), dtype=np.float64) + data[2, 2] = 100.0 + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert np.array_equal(background.view(np.uint64), np.zeros_like(background).view(np.uint64)) + assert corrected[2, 2] == 100.0 + + +def test_negative_impulse_uses_the_rank_background() -> None: + data = np.zeros((5, 5), dtype=np.float64) + data[2, 2] = -100.0 + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert np.array_equal(background.view(np.uint64), np.zeros_like(background).view(np.uint64)) + assert corrected[2, 2] == -100.0 + + +def test_singleton_one_by_one_field() -> None: + data = np.array([[3.5]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 1024) + assert np.array_equal(background.view(np.uint64), data.view(np.uint64)) + assert np.array_equal(corrected.view(np.uint64), np.zeros_like(corrected).view(np.uint64)) + + +def test_singleton_row_field() -> None: + data = np.array([[2.0, -1.0, 5.0, 0.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_singleton_column_field() -> None: + data = np.array([[2.0], [-1.0], [5.0], [0.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_all_fixture_backgrounds_are_bitwise_exact() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + background, _, _ = _result_for_case(name) + _assert_bitwise_equal(name, "background", arrays[case["arrays"]["background"]], background) + + +def test_all_fixture_corrected_fields_are_bitwise_exact() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + _, corrected, _ = _result_for_case(name) + _assert_bitwise_equal(name, "corrected", arrays[case["arrays"]["corrected"]], corrected) + + +def test_fixture_metadata_matches_the_private_kernel_specification() -> None: + manifest, _ = _fixture() + for case in manifest["cases"]: # type: ignore[index] + _, _, specification = _result_for_case(case["name"]) + assert specification.radius_px == case["radius"] + assert specification.kernel_resolution == case["kernel_resolution"] + assert specification.kernel_active_count == case["kernel_active_count"] + assert specification.rank_index == case["rank_index"] + assert specification.rank_backend_reference == case["rank_backend_reference"] + + +def test_fixture_results_obey_the_reconstruction_contract() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + background, corrected, _ = _result_for_case(name) + np.testing.assert_allclose( + arrays[case["arrays"]["input"]], + background + corrected, + atol=1e-15, + rtol=0.0, + ) From ed5c837b08659fe5a7aaa9ffa4315d832070b85f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:56:08 -0400 Subject: [PATCH 61/82] feat(analysis): expose Gwyddion median background API --- src/spmkit/core/analysis/__init__.py | 6 + src/spmkit/core/analysis/background.py | 148 +++++++- tests/core/test_gwyddion_median_background.py | 342 ++++++++++++++++++ 3 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 tests/core/test_gwyddion_median_background.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 0231bc5..d1bd414 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -20,6 +20,7 @@ GwyddionArcDirection, analyze_arc_revolution_background, analyze_gwyddion_arc_revolution_background, + analyze_gwyddion_median_background, analyze_gwyddion_sphere_revolution_background, analyze_median_background, analyze_polynomial_background, @@ -28,6 +29,7 @@ analyze_spline_background, estimate_arc_revolution_background, estimate_gwyddion_arc_revolution_background, + estimate_gwyddion_median_background, estimate_gwyddion_sphere_revolution_background, estimate_median_background, estimate_polynomial_background, @@ -36,6 +38,7 @@ estimate_spline_background, remove_arc_revolution_background, remove_gwyddion_arc_revolution_background, + remove_gwyddion_median_background, remove_gwyddion_sphere_revolution_background, remove_median_background, remove_polynomial_background, @@ -70,6 +73,7 @@ "GwyddionArcDirection", "analyze_arc_revolution_background", "analyze_gwyddion_arc_revolution_background", + "analyze_gwyddion_median_background", "analyze_gwyddion_sphere_revolution_background", "analyze_median_background", "analyze_polynomial_background", @@ -78,6 +82,7 @@ "analyze_spline_background", "estimate_arc_revolution_background", "estimate_gwyddion_arc_revolution_background", + "estimate_gwyddion_median_background", "estimate_gwyddion_sphere_revolution_background", "estimate_median_background", "estimate_polynomial_background", @@ -86,6 +91,7 @@ "estimate_spline_background", "remove_arc_revolution_background", "remove_gwyddion_arc_revolution_background", + "remove_gwyddion_median_background", "remove_gwyddion_sphere_revolution_background", "remove_median_background", "remove_polynomial_background", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 6dbbb8a..211e0a4 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -20,6 +20,10 @@ from spmkit.core.analysis._gwyddion_sphere_revolution import ( _gwyddion_sphere_result, ) +from spmkit.core.analysis._median_background import ( + _gwyddion_median_background_result, + _MedianBackgroundKernelSpec, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -36,6 +40,7 @@ BackgroundMethod = Literal[ "arc_revolution", "gwyddion_arc_revolution", + "gwyddion_median_background", "gwyddion_sphere_revolution", "sphere_revolution", "rolling_ball", @@ -418,11 +423,14 @@ def estimate_arc_revolution_background( operation=operation, ) - direction_value = _validated_choice( - direction, - name="direction", - allowed=("horizontal", "vertical", "both"), - operation=operation, + direction_value = cast( + ArcDirection, + _validated_choice( + direction, + name="direction", + allowed=("horizontal", "vertical", "both"), + operation=operation, + ), ) side_value = _validated_choice( side, @@ -430,11 +438,14 @@ def estimate_arc_revolution_background( allowed=("below", "above"), operation=operation, ) - border_value = _validated_choice( - border, - name="border", - allowed=("nearest", "reflect"), - operation=operation, + border_value = cast( + ArcBorder, + _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ), ) data_metres = length_values_to_metres( @@ -699,6 +710,73 @@ def remove_gwyddion_sphere_revolution_background( return corrected +def _gwyddion_median_background_channels( + channel: SPMChannel, + radius_px: object, +) -> tuple[ + SPMChannel, + SPMChannel, + _MedianBackgroundKernelSpec, +]: + """Calculate one Median Background result and preserve channel context.""" + background_data, corrected_data, kernel_spec = _gwyddion_median_background_result( + channel.data, + radius_px, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + kernel_spec, + ) + + +def estimate_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Median Background. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed by Gwyddion semantics. The input channel is not mutated; + finite two-dimensional input is required. + + Returns + ------- + SPMChannel + The estimated background with the input channel context preserved. + """ + background, _, _ = _gwyddion_median_background_channels( + channel, + radius_px, + ) + return background + + +def remove_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> SPMChannel: + """Return the corrected Gwyddion 2.71-compatible Median Background field. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed by Gwyddion semantics. The input channel is not mutated; + finite two-dimensional input is required. + + Returns + ------- + SPMChannel + The corrected field with the input channel context preserved. + """ + _, corrected, _ = _gwyddion_median_background_channels( + channel, + radius_px, + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -857,11 +935,14 @@ def estimate_sphere_revolution_background( allowed=("below", "above"), operation=operation, ) - border_value = _validated_choice( - border, - name="border", - allowed=("nearest", "reflect"), - operation=operation, + border_value = cast( + ArcBorder, + _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ), ) data_metres = length_values_to_metres( @@ -1628,6 +1709,43 @@ def analyze_gwyddion_sphere_revolution_background( ) +def analyze_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> BackgroundResult: + """Estimate and remove a Gwyddion 2.71-compatible Median Background. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed; border, shape, rank, and backend are not public options. The + input channel is not mutated and must contain finite two-dimensional data. + + Returns + ------- + BackgroundResult + The background, corrected field, method, and fixed kernel metadata. + """ + background, corrected, kernel_spec = _gwyddion_median_background_channels( + channel, + radius_px, + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_median_background", + parameters={ + "radius_px": kernel_spec.radius_px, + "kernel_resolution": kernel_spec.kernel_resolution, + "kernel_active_count": kernel_spec.kernel_active_count, + "rank_index": kernel_spec.rank_index, + "rank_backend_reference": kernel_spec.rank_backend_reference, + "border_policy": "gwyddion_border_extend", + "kernel_geometry": "gwyddion_digital_ellipse", + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_median_background.py b/tests/core/test_gwyddion_median_background.py new file mode 100644 index 0000000..a2dba01 --- /dev/null +++ b/tests/core/test_gwyddion_median_background.py @@ -0,0 +1,342 @@ +"""Public-contract tests for Gwyddion 2.71 Median Background.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from pathlib import Path + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.background as background_module +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_median_background, + estimate_gwyddion_median_background, + remove_gwyddion_median_background, +) +from spmkit.core.analysis._median_background import _gwyddion_median_background_result +from spmkit.core.models import SPMChannel + +_FIXTURE_DIRECTORY = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "median_background" +) +_FIXTURE_PATH = _FIXTURE_DIRECTORY / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIRECTORY / "median_background_reference.json" +_PUBLIC_OPERATIONS: tuple[Callable[[SPMChannel, object], object], ...] = ( + estimate_gwyddion_median_background, + remove_gwyddion_median_background, + analyze_gwyddion_median_background, +) + + +def _manifest() -> dict[str, object]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _cases() -> tuple[dict[str, object], ...]: + manifest = _manifest() + cases = manifest["cases"] + assert isinstance(cases, list) + return tuple(cases) + + +def _case(name: str) -> dict[str, object]: + return next(case for case in _cases() if case["name"] == name) + + +def _case_arrays(case: dict[str, object]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + arrays = case["arrays"] + assert isinstance(arrays, dict) + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + return ( + np.array(archive[arrays["input"]], dtype=np.float64, order="C", copy=True), + np.array(archive[arrays["background"]], dtype=np.float64, order="C", copy=True), + np.array(archive[arrays["corrected"]], dtype=np.float64, order="C", copy=True), + ) + + +def _channel(data: np.ndarray) -> SPMChannel: + return SPMChannel( + name="Median Background fixture", + data=data, + unit="V", + x_range=9.5e-6, + y_range=6.5e-6, + direction="backward", + group="Frozen external evidence", + metadata={ + "source": "gwyddion-2.71-median-background", + "context": {"campaign": "frozen"}, + }, + ) + + +def _ordered_bits(bits: int) -> int: + sign_bit = 1 << 63 + return ((~bits + 1) & ((1 << 64) - 1)) if bits & sign_bit else bits | sign_bit + + +def _assert_bitwise_equal( + actual: np.ndarray, + expected: np.ndarray, + *, + case: str, + operation: str, + array: str, +) -> None: + actual_bits = actual.view(np.uint64) + expected_bits = expected.view(np.uint64) + if np.array_equal(actual_bits, expected_bits): + return + + row, column = np.argwhere(actual_bits != expected_bits)[0] + actual_bit_value = int(actual_bits[row, column]) + expected_bit_value = int(expected_bits[row, column]) + ulp_distance = abs( + _ordered_bits(actual_bit_value) - _ordered_bits(expected_bit_value) + ) + pytest.fail( + f"case={case} operation={operation} array={array} " + f"coordinate=({row}, {column}) expected={expected[row, column]!r} " + f"actual={actual[row, column]!r} expected_uint64={expected_bit_value} " + f"actual_uint64={actual_bit_value} " + f"absolute_difference={abs(actual[row, column] - expected[row, column])!r} " + f"ulp_distance={ulp_distance}" + ) + + +def _expected_parameters(case: dict[str, object]) -> dict[str, object]: + return { + "radius_px": case["radius"], + "kernel_resolution": case["kernel_resolution"], + "kernel_active_count": case["kernel_active_count"], + "rank_index": case["rank_index"], + "rank_backend_reference": case["rank_backend_reference"], + "border_policy": "gwyddion_border_extend", + "kernel_geometry": "gwyddion_digital_ellipse", + } + + +def test_public_exports_and_signature_contract() -> None: + expected_names = { + "estimate_gwyddion_median_background", + "remove_gwyddion_median_background", + "analyze_gwyddion_median_background", + } + + assert expected_names <= set(analysis.__all__) + for name in expected_names: + assert getattr(analysis, name) is not None + + for operation in _PUBLIC_OPERATIONS: + signature = inspect.signature(operation) + assert list(signature.parameters) == ["channel", "radius_px"] + assert signature.parameters["radius_px"].default == 20 + assert signature.parameters["radius_px"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + + for private_name in ( + "_MedianBackgroundKernelSpec", + "_validated_median_background_radius", + "_median_background_active_offsets", + "_median_background_kernel_spec", + "_gwyddion_median_background_result", + ): + assert private_name not in analysis.__all__ + assert not hasattr(analysis, private_name) + + +@pytest.mark.parametrize( + "case_name", + ["wide_r1", "wide_r3", "wide_r20", "singleton_1x1_r1024"], +) +def test_analyze_reports_exact_frozen_metadata(case_name: str) -> None: + case = _case(case_name) + data, _, _ = _case_arrays(case) + + result = analyze_gwyddion_median_background( + _channel(data), + case["radius"], + ) + + assert isinstance(result, BackgroundResult) + assert result.method == "gwyddion_median_background" + assert result.parameters == _expected_parameters(case) + + +@pytest.mark.parametrize("case", _cases(), ids=lambda case: str(case["name"])) +def test_analyze_matches_all_frozen_cases_bitwise(case: dict[str, object]) -> None: + data, expected_background, expected_corrected = _case_arrays(case) + source = _channel(data) + original = source.data.copy() + + result = analyze_gwyddion_median_background(source, case["radius"]) + + _assert_bitwise_equal( + result.background.data, + expected_background, + case=str(case["name"]), + operation="analyze", + array="background", + ) + _assert_bitwise_equal( + result.corrected.data, + expected_corrected, + case=str(case["name"]), + operation="analyze", + array="corrected", + ) + assert result.parameters == _expected_parameters(case) + assert np.array_equal(source.data, original) + assert np.max(np.abs(source.data - (result.background.data + result.corrected.data))) <= 1e-15 + + +@pytest.mark.parametrize("case_name", ["wide_r1", "wide_r3"]) +def test_estimate_and_remove_match_private_and_fixture(case_name: str) -> None: + case = _case(case_name) + data, expected_background, expected_corrected = _case_arrays(case) + source = _channel(data) + private_background, private_corrected, _ = _gwyddion_median_background_result( + source.data, + case["radius"], + ) + + estimated = estimate_gwyddion_median_background(source, case["radius"]) + removed = remove_gwyddion_median_background(source, case["radius"]) + + for actual, expected, operation, array in ( + (estimated.data, private_background, "estimate", "background-private"), + (removed.data, private_corrected, "remove", "corrected-private"), + (estimated.data, expected_background, "estimate", "background-fixture"), + (removed.data, expected_corrected, "remove", "corrected-fixture"), + ): + _assert_bitwise_equal( + actual, + expected, + case=case_name, + operation=operation, + array=array, + ) + + +def test_context_array_contract_and_memory_independence_are_preserved() -> None: + case = _case("signed_r3") + data, _, _ = _case_arrays(case) + source = _channel(data) + result = analyze_gwyddion_median_background(source, case["radius"]) + + for output in (result.background, result.corrected): + assert output.name == source.name + assert output.unit == source.unit + assert output.x_range == source.x_range + assert output.y_range == source.y_range + assert output.direction == source.direction + assert output.group == source.group + assert output.metadata == source.metadata + assert output.metadata is not source.metadata + assert output.data.shape == source.data.shape + assert output.data.dtype == np.float64 + assert output.data.flags.c_contiguous + assert np.all(np.isfinite(output.data)) + assert not np.shares_memory(output.data, source.data) + + assert not np.shares_memory(result.background.data, result.corrected.data) + result.background.metadata["adapter"] = "background" + assert "adapter" not in source.metadata + assert "adapter" not in result.corrected.metadata + + +@pytest.mark.parametrize( + ("radius_px", "exception"), + [ + (True, TypeError), + (np.array(2, dtype=np.int64), TypeError), + (0, ValueError), + (1025, ValueError), + (10**100, ValueError), + ], +) +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +def test_invalid_radius_is_delegated_without_relaxation( + radius_px: object, + exception: type[Exception], + operation: Callable[[SPMChannel, object], object], +) -> None: + case = _case("wide_r1") + data, _, _ = _case_arrays(case) + + with pytest.raises(exception): + operation(_channel(data), radius_px) + + +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +@pytest.mark.parametrize("nonfinite", [np.nan, np.inf, -np.inf]) +def test_nonfinite_data_rejection_is_delegated( + operation: Callable[[SPMChannel, object], object], + nonfinite: float, +) -> None: + data = np.ones((2, 3), dtype=np.float64) + data[0, 1] = nonfinite + + with pytest.raises(ValueError, match="finite data"): + operation(_channel(data), 1) + + +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +def test_each_public_operation_invokes_private_kernel_once( + monkeypatch: pytest.MonkeyPatch, + operation: Callable[[SPMChannel, object], object], +) -> None: + case = _case("wide_r3") + data, _, _ = _case_arrays(case) + call_count = 0 + original = background_module._gwyddion_median_background_result + + def counted_result( + received_data: object, + received_radius_px: object, + ) -> tuple[np.ndarray, np.ndarray, object]: + nonlocal call_count + call_count += 1 + return original(received_data, received_radius_px) + + monkeypatch.setattr( + background_module, + "_gwyddion_median_background_result", + counted_result, + ) + + operation(_channel(data), case["radius"]) + + assert call_count == 1 + + +def test_default_radius_and_to_dict_are_publicly_stable() -> None: + case = _case("wide_r20") + data, expected_background, expected_corrected = _case_arrays(case) + + result = analyze_gwyddion_median_background(_channel(data)) + + _assert_bitwise_equal( + result.background.data, + expected_background, + case="wide_r20", + operation="analyze-default", + array="background", + ) + _assert_bitwise_equal( + result.corrected.data, + expected_corrected, + case="wide_r20", + operation="analyze-default", + array="corrected", + ) + assert result.parameters == _expected_parameters(case) + assert result.to_dict()["method"] == "gwyddion_median_background" From 7894560476a86e43a718b693d5bb3894e96803a5 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:01:42 -0400 Subject: [PATCH 62/82] docs(validation): close Gwyddion Median Background parity --- docs/api.md | 55 +++++++++++++++++++++++++++++++++++++++ docs/scientific-status.md | 53 +++++++++++++++++++++++++++++++++++++ docs/validation/index.md | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/docs/api.md b/docs/api.md index c32afb1..1b8dd97 100644 --- a/docs/api.md +++ b/docs/api.md @@ -226,6 +226,61 @@ This estimator is distinct from SPMKit's physical sphere-revolution model (`estimate_sphere_revolution_background`), which operates with physical metric radii in metres, circular footprints in physical coordinates, and explicit physical border policies. +## Gwyddion 2.71 Median Background + +SPM-Kit provides the frozen Gwyddion 2.71 Median Background semantics through three public +operations: + +- `estimate_gwyddion_median_background(channel, radius_px=20) -> SPMChannel` +- `remove_gwyddion_median_background(channel, radius_px=20) -> SPMChannel` +- `analyze_gwyddion_median_background(channel, radius_px=20) -> BackgroundResult` + +```python +from spmkit.core.analysis import ( + analyze_gwyddion_median_background, + estimate_gwyddion_median_background, + remove_gwyddion_median_background, +) + +background = estimate_gwyddion_median_background( + channel, + radius_px=20, +) + +corrected = remove_gwyddion_median_background( + channel, + radius_px=20, +) + +result = analyze_gwyddion_median_background( + channel, + radius_px=20, +) +print(result.method, result.parameters) +``` + +`radius_px` is an integer pixel radius with default `20` and inclusive range `1..1024`. +The kernel is Gwyddion's fixed inclusive digital ellipse and exterior samples use fixed +nearest-edge `gwyddion_border_extend`; the public API intentionally exposes no border, shape, +rank, or backend option. `estimate_gwyddion_median_background()` returns the background as an +`SPMChannel`, `remove_gwyddion_median_background()` returns `input - background` as an +`SPMChannel`, and `analyze_gwyddion_median_background()` returns a `BackgroundResult`. + +The result method is `"gwyddion_median_background"`. Its metadata records `radius_px`, +`kernel_resolution`, `kernel_active_count`, `rank_index`, `rank_backend_reference`, +`border_policy="gwyddion_border_extend"`, and +`kernel_geometry="gwyddion_digital_ellipse"`. `rank_backend_reference` identifies the +observed Gwyddion reference route, not an SPM-Kit backend. + +Inputs must be finite two-dimensional data; NaN and infinite values are rejected. The source +channel is not mutated, and output channels preserve its shape, units, ranges, direction, group, +and copied metadata according to the `SPMChannel` contract. The public implementation does not +require Gwyddion at runtime. + +This capability is CROSS_VALIDATED only within its frozen 36-case Gwyddion 2.71 campaign. Its +scope, frozen evidence, semantics, and non-claims are specified in the +[Gwyddion Median Background compatibility specification](design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md). + ## KPFM statistics ```python diff --git a/docs/scientific-status.md b/docs/scientific-status.md index fe9d638..0b075d3 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -40,6 +40,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | +| Gwyddion 2.71 Median Background | `core.analysis.background`, `core.analysis._median_background` | Frozen executable reference campaign: 36 logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, direct and radixtree reference paths; public background and corrected fields 36/36 bitwise exact, maximum absolute difference 0 and maximum ULP 0; input mutation maximum 0 and reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen 36-case campaign | Gwyddion 2.71 source, executable probe, independent Python oracle, frozen NPZ/JSON fixture | Finite two-dimensional inputs only; no universal equivalence, performance-equivalence, future-Gwyddion, all-radii, or all-matrices claim; `rank_backend_reference` describes Gwyddion, not an SPM-Kit backend | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -59,6 +60,7 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) - [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) - [Sphere Revolution Gwyddion 2.71 frozen fixture](https://github.com/kegouro/spmkit/blob/feat/gwyddion-leveling-parity/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json) +- [Median Background Gwyddion 2.71 frozen manifest](../tests/validation/fixtures/gwyddion/median_background/median_background_reference.json) ### Gwyddion Sphere Revolution @@ -72,6 +74,57 @@ SPM-Kit's `gwyddion_sphere_revolution` implementation is maintained separately f - **Independent Oracle & Tolerances:** Verified against an independent Python oracle (`atol=5e-14`, `rtol=0.0`). The maximum observed numerical discrepancy across all comparisons is `8.881784e-16` (well below `5e-14`). - **Claim:** LEVEL 3 CROSS_VALIDATED within the frozen fixture scope. No universal equivalence or physical validation is claimed. Physical sphere revolution (`estimate_sphere_revolution_background`) maintains its independent software verification. +### Gwyddion 2.71 Median Background + +**Claim:** `CROSS_VALIDATED` only for the frozen campaign. Gwyddion 2.71 is the executable +external reference; the campaign contains 36 logical cases and 72 executions (36 normal and +36 ASan) over radii 1, 2, 3, 4, 20, and 1024. Both Gwyddion reference paths are represented: +`direct` and `radixtree`. Public `estimate_gwyddion_median_background`, +`remove_gwyddion_median_background`, and `analyze_gwyddion_median_background` reproduce the +frozen background and corrected arrays bitwise in 36/36 cases: maximum absolute difference 0, +maximum ULP 0, input mutation maximum 0, and reconstruction maximum +`4.4408920985006262e-16`. + +The fixed semantics are the inclusive digital ellipse, `gwyddion_border_extend`, middle rank +`kernel_active_count//2`, and `corrected = input - background`. The public API preserves the +`SPMChannel` context and requires finite two-dimensional data. The private kernel is independent +of Gwyddion at runtime. The fixture and independent Python oracle remain frozen evidence outside +production. + +**Traceability:** + +```text +.reference/gwyddion-2.71/source/modules/process/median-bg.c + → .reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c + → .reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh + → independent Python oracle recorded by docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md + → tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz + → tests/validation/fixtures/gwyddion/median_background/median_background_reference.json + → src/spmkit/core/analysis/_median_background.py + → src/spmkit/core/analysis/background.py + → tests/core/test_gwyddion_median_background_private.py + → tests/core/test_gwyddion_median_background.py + → tests/validation/test_median_background_fixture_integrity.py + → docs/scientific-status.md +``` + +The evidence was frozen in `818dbd3` (freeze evidence), the private kernel in `a53c3bb`, and +the public API in `ed5c837`. The focal inventory is 20 fixture-integrity, 67 private Median +Background, and 72 public Median Background tests; the preceding combined focal run collected +442 tests. These are focal-campaign counts, not a project-wide total. + +**Non-claims:** no universal equivalence; no guarantee outside the 36 cases; no NaN or infinity +coverage; no reproduction of Gwyddion's internal radixtree; no performance-equivalence claim; +no claim for future Gwyddion versions; no claim for every radius or matrix; and no validation of +configurable border, shape, or rank parameters because the API exposes none. + +**Non-blocking tooling limitations:** the probe runner uses `|| true` during compilation and does +not retain the original compiler exit code; its auxiliary parser recognizes broad `background_` +and `corrected_` prefixes. The campaign remains valid because both binaries executed, 72 +processes returned, stderr was empty, normal and ASan stdout were byte-identical, outputs were +parsed and recalculated independently, oracle/reference results were bitwise exact, and the +fixture stores canonical hashes. + ## Test-count policy The collection total is measured with: diff --git a/docs/validation/index.md b/docs/validation/index.md index dd2f392..9ebe871 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -38,6 +38,7 @@ references, tolerances, outputs, hashes, and limitations. | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | | Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | | Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | +| Gwyddion Median Background 2.71 v1 | Local rank background on 36 frozen logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, and both direct/radixtree reference paths | Public background and corrected fields 36/36 bitwise exact; maximum absolute difference 0, maximum ULP 0, input mutation maximum 0, reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 only; finite inputs; no universal, performance, future-version, all-radii, or all-matrices claim; no public border/shape/rank configuration | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and @@ -50,6 +51,52 @@ The `.nid` path also provides byte-level inspection through `spmkit verify` and conversion, finiteness, and orientation rules. Integrity and parser traceability do not establish physical correctness. +### Gwyddion 2.71 Median Background + +The frozen campaign trace is: + +```text +Gwyddion source +→ external probe +→ independent Python oracle +→ frozen fixture +→ private SPMKit kernel +→ public API +→ public bitwise tests +→ scientific status +``` + +The concrete records are `.reference/gwyddion-2.71/source/modules/process/median-bg.c`, +`.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c`, +`.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh`, +`docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md`, +`tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz`, +`tests/validation/fixtures/gwyddion/median_background/median_background_reference.json`, +`src/spmkit/core/analysis/_median_background.py`, +`src/spmkit/core/analysis/background.py`, +`tests/core/test_gwyddion_median_background_private.py`, +`tests/core/test_gwyddion_median_background.py`, +`tests/validation/test_median_background_fixture_integrity.py`, and +`docs/scientific-status.md`. + +The chain was frozen by `818dbd3` (evidence), `a53c3bb` (private kernel), and `ed5c837` +(public API). The permanent fixture contains canonical hashes; the original oracle and its +ephemeral source artifacts are identified in the fixture manifest. The campaign's runner +limitations are non-blocking: `|| true` does not preserve an original compiler exit, and the +auxiliary parser accepts broad `background_` and `corrected_` prefixes. Both binaries still +executed; 72 processes returned with empty stderr; normal/ASan stdout was byte-identical; and +the parsed outputs, oracle/reference equality, and canonical hashes were independently checked. + +#### Focal test inventory + +- Fixture integrity: 20 tests. +- Private Median Background: 67 tests. +- Public Median Background: 72 tests. +- Combined focal campaign: 442 tests. + +These counts describe the frozen focal validation campaign for this capability. They are not +the global test total of the SPMKit project. + ## What remains open - redistributable multi-instrument fixtures for built-in and adapter readers; From 2ba366eb9f976c12d8cbfd7c57862206fdf4183c Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:33:29 -0400 Subject: [PATCH 63/82] test(validation): freeze Gwyddion flat-disc morphology evidence --- ...DION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md | 54 ++ .../flat_disc_morphology_reference.json | 751 ++++++++++++++++++ .../flat_disc_morphology_reference.npz | Bin 0 -> 515805 bytes ..._flat_disc_morphology_fixture_integrity.py | 39 + 4 files changed, 844 insertions(+) create mode 100644 docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json create mode 100644 tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz create mode 100644 tests/validation/test_flat_disc_morphology_fixture_integrity.py diff --git a/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md b/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md new file mode 100644 index 0000000..4dccdf7 --- /dev/null +++ b/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md @@ -0,0 +1,54 @@ +# Gwyddion Flat-Disc Morphology Compatibility + +## Status and scope + +`gwyddion_filter_flat_disc_morphology` records Gwyddion 2.71 Filter-tool +flat-disc Opening and Closing for finite, non-empty, full-field 2D data with +mask policy fixed to ignore. It is limited to the frozen 12-field campaign +and sizes 2, 3, 4, 5, 30 and 31. + +## Reference and parameters + +The reference is the audited installed Gwyddion 2.71 executable path through +`gwy_data_field_area_filter_min_max`. `size_px` is an integer in `2..31`; +the planned public default is 5. The kernel is a K by K digital ellipse, +where K equals `size_px`. + +## Numerical semantics + +The exterior policy is nearest valid edge pixel. Minimum/erosion uses the +unreflected RLE mask and anchor `(K-1)//2`; maximum/dilation uses the rotated, +row-sorted RLE mask and anchor `K//2`. Opening is dilation after erosion; +Closing is erosion after dilation. + +The executable hierarchy is not generic C-language tie semantics. The +audited Gwyddion 2.71 library (`libgwyprocess2.so.0.51.1`, SHA-256 +`5f5b53cb544068638d1a3be8d6703345e49d5626d3fa4791106ce11bc051d3d7`, +Build ID `04187a41d4102c827e2705bb867292ba77ae37f4`) recursively constructs +Each/Even row reductions. Its compiled MINSD/MAXSD composition sites select +the second operand on equal values, preserving signed zero. The later RLE +aggregation uses strict comparison and retains the earlier row-major segment. + +## Evidence and fixture + +The external canonical reference is SHA-256 +`907bd347cc8c213d1061b786b6efe5692c87ffd8be62db1f6de2bd9bc78acdbd` and +its provenance is `c3777cdcfdd868a705ef09c63a7548a5b1c0eb0b79d053e02ac2f45e9c97e5af`. +The independent oracle V2 is `bf4129fe4fd871dda3132d5457d45d0833d69e9acd5cf3bb765fc0f3a8d9792e`; +its executable reduction model is `43089668a7fe0c699093be440402c8b1b11b42dfea4eb720785241788306c543`. + +The fixture stores 12 inputs, 30 masks for sizes 2..31, 72 Opening outputs, +and 72 Closing outputs. It records kernels 30/30 and both operations 72/72 +bitwise exact, max absolute difference 0, max ULP 0, signed-zero mismatches 0, +and input mutation 0. + +The rejected uninitialised-kernel microprobe is invalid evidence: elliptic +fill writes active pixels only. Approved probes zero-initialize the kernel +before filling it. + +## Non-claims + +This is not universal equivalence. It excludes NaN, infinities, ROI, masks, +ASF, tip morphology, other Gwyddion versions/builds, and performance parity. +It records the audited executable path, not a claim that all C compilers lower +the source identically. diff --git a/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json new file mode 100644 index 0000000..2540a8d --- /dev/null +++ b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json @@ -0,0 +1,751 @@ +{ + "capability": "gwyddion_filter_flat_disc_morphology", + "cases": [ + { + "case_id": "singleton_1x1", + "input_key": "input__singleton_1x1", + "shape": [ + 1, + 1 + ], + "sizes": [ + { + "closing_key": "closing__singleton_1x1__2", + "opening_key": "opening__singleton_1x1__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_1x1__3", + "opening_key": "opening__singleton_1x1__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_1x1__4", + "opening_key": "opening__singleton_1x1__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_1x1__5", + "opening_key": "opening__singleton_1x1__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_1x1__30", + "opening_key": "opening__singleton_1x1__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_1x1__31", + "opening_key": "opening__singleton_1x1__31", + "size_px": 31 + } + ] + }, + { + "case_id": "singleton_row_1x7", + "input_key": "input__singleton_row_1x7", + "shape": [ + 1, + 7 + ], + "sizes": [ + { + "closing_key": "closing__singleton_row_1x7__2", + "opening_key": "opening__singleton_row_1x7__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_row_1x7__3", + "opening_key": "opening__singleton_row_1x7__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_row_1x7__4", + "opening_key": "opening__singleton_row_1x7__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_row_1x7__5", + "opening_key": "opening__singleton_row_1x7__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_row_1x7__30", + "opening_key": "opening__singleton_row_1x7__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_row_1x7__31", + "opening_key": "opening__singleton_row_1x7__31", + "size_px": 31 + } + ] + }, + { + "case_id": "singleton_column_7x1", + "input_key": "input__singleton_column_7x1", + "shape": [ + 7, + 1 + ], + "sizes": [ + { + "closing_key": "closing__singleton_column_7x1__2", + "opening_key": "opening__singleton_column_7x1__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_column_7x1__3", + "opening_key": "opening__singleton_column_7x1__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_column_7x1__4", + "opening_key": "opening__singleton_column_7x1__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_column_7x1__5", + "opening_key": "opening__singleton_column_7x1__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_column_7x1__30", + "opening_key": "opening__singleton_column_7x1__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_column_7x1__31", + "opening_key": "opening__singleton_column_7x1__31", + "size_px": 31 + } + ] + }, + { + "case_id": "wide_large_gradient", + "input_key": "input__wide_large_gradient", + "shape": [ + 35, + 39 + ], + "sizes": [ + { + "closing_key": "closing__wide_large_gradient__2", + "opening_key": "opening__wide_large_gradient__2", + "size_px": 2 + }, + { + "closing_key": "closing__wide_large_gradient__3", + "opening_key": "opening__wide_large_gradient__3", + "size_px": 3 + }, + { + "closing_key": "closing__wide_large_gradient__4", + "opening_key": "opening__wide_large_gradient__4", + "size_px": 4 + }, + { + "closing_key": "closing__wide_large_gradient__5", + "opening_key": "opening__wide_large_gradient__5", + "size_px": 5 + }, + { + "closing_key": "closing__wide_large_gradient__30", + "opening_key": "opening__wide_large_gradient__30", + "size_px": 30 + }, + { + "closing_key": "closing__wide_large_gradient__31", + "opening_key": "opening__wide_large_gradient__31", + "size_px": 31 + } + ] + }, + { + "case_id": "tall_large_gradient", + "input_key": "input__tall_large_gradient", + "shape": [ + 39, + 35 + ], + "sizes": [ + { + "closing_key": "closing__tall_large_gradient__2", + "opening_key": "opening__tall_large_gradient__2", + "size_px": 2 + }, + { + "closing_key": "closing__tall_large_gradient__3", + "opening_key": "opening__tall_large_gradient__3", + "size_px": 3 + }, + { + "closing_key": "closing__tall_large_gradient__4", + "opening_key": "opening__tall_large_gradient__4", + "size_px": 4 + }, + { + "closing_key": "closing__tall_large_gradient__5", + "opening_key": "opening__tall_large_gradient__5", + "size_px": 5 + }, + { + "closing_key": "closing__tall_large_gradient__30", + "opening_key": "opening__tall_large_gradient__30", + "size_px": 30 + }, + { + "closing_key": "closing__tall_large_gradient__31", + "opening_key": "opening__tall_large_gradient__31", + "size_px": 31 + } + ] + }, + { + "case_id": "constant_nonzero", + "input_key": "input__constant_nonzero", + "shape": [ + 4, + 6 + ], + "sizes": [ + { + "closing_key": "closing__constant_nonzero__2", + "opening_key": "opening__constant_nonzero__2", + "size_px": 2 + }, + { + "closing_key": "closing__constant_nonzero__3", + "opening_key": "opening__constant_nonzero__3", + "size_px": 3 + }, + { + "closing_key": "closing__constant_nonzero__4", + "opening_key": "opening__constant_nonzero__4", + "size_px": 4 + }, + { + "closing_key": "closing__constant_nonzero__5", + "opening_key": "opening__constant_nonzero__5", + "size_px": 5 + }, + { + "closing_key": "closing__constant_nonzero__30", + "opening_key": "opening__constant_nonzero__30", + "size_px": 30 + }, + { + "closing_key": "closing__constant_nonzero__31", + "opening_key": "opening__constant_nonzero__31", + "size_px": 31 + } + ] + }, + { + "case_id": "signed_monotonic", + "input_key": "input__signed_monotonic", + "shape": [ + 4, + 7 + ], + "sizes": [ + { + "closing_key": "closing__signed_monotonic__2", + "opening_key": "opening__signed_monotonic__2", + "size_px": 2 + }, + { + "closing_key": "closing__signed_monotonic__3", + "opening_key": "opening__signed_monotonic__3", + "size_px": 3 + }, + { + "closing_key": "closing__signed_monotonic__4", + "opening_key": "opening__signed_monotonic__4", + "size_px": 4 + }, + { + "closing_key": "closing__signed_monotonic__5", + "opening_key": "opening__signed_monotonic__5", + "size_px": 5 + }, + { + "closing_key": "closing__signed_monotonic__30", + "opening_key": "opening__signed_monotonic__30", + "size_px": 30 + }, + { + "closing_key": "closing__signed_monotonic__31", + "opening_key": "opening__signed_monotonic__31", + "size_px": 31 + } + ] + }, + { + "case_id": "checker_step", + "input_key": "input__checker_step", + "shape": [ + 6, + 8 + ], + "sizes": [ + { + "closing_key": "closing__checker_step__2", + "opening_key": "opening__checker_step__2", + "size_px": 2 + }, + { + "closing_key": "closing__checker_step__3", + "opening_key": "opening__checker_step__3", + "size_px": 3 + }, + { + "closing_key": "closing__checker_step__4", + "opening_key": "opening__checker_step__4", + "size_px": 4 + }, + { + "closing_key": "closing__checker_step__5", + "opening_key": "opening__checker_step__5", + "size_px": 5 + }, + { + "closing_key": "closing__checker_step__30", + "opening_key": "opening__checker_step__30", + "size_px": 30 + }, + { + "closing_key": "closing__checker_step__31", + "opening_key": "opening__checker_step__31", + "size_px": 31 + } + ] + }, + { + "case_id": "positive_impulse", + "input_key": "input__positive_impulse", + "shape": [ + 7, + 7 + ], + "sizes": [ + { + "closing_key": "closing__positive_impulse__2", + "opening_key": "opening__positive_impulse__2", + "size_px": 2 + }, + { + "closing_key": "closing__positive_impulse__3", + "opening_key": "opening__positive_impulse__3", + "size_px": 3 + }, + { + "closing_key": "closing__positive_impulse__4", + "opening_key": "opening__positive_impulse__4", + "size_px": 4 + }, + { + "closing_key": "closing__positive_impulse__5", + "opening_key": "opening__positive_impulse__5", + "size_px": 5 + }, + { + "closing_key": "closing__positive_impulse__30", + "opening_key": "opening__positive_impulse__30", + "size_px": 30 + }, + { + "closing_key": "closing__positive_impulse__31", + "opening_key": "opening__positive_impulse__31", + "size_px": 31 + } + ] + }, + { + "case_id": "negative_impulse", + "input_key": "input__negative_impulse", + "shape": [ + 7, + 7 + ], + "sizes": [ + { + "closing_key": "closing__negative_impulse__2", + "opening_key": "opening__negative_impulse__2", + "size_px": 2 + }, + { + "closing_key": "closing__negative_impulse__3", + "opening_key": "opening__negative_impulse__3", + "size_px": 3 + }, + { + "closing_key": "closing__negative_impulse__4", + "opening_key": "opening__negative_impulse__4", + "size_px": 4 + }, + { + "closing_key": "closing__negative_impulse__5", + "opening_key": "opening__negative_impulse__5", + "size_px": 5 + }, + { + "closing_key": "closing__negative_impulse__30", + "opening_key": "opening__negative_impulse__30", + "size_px": 30 + }, + { + "closing_key": "closing__negative_impulse__31", + "opening_key": "opening__negative_impulse__31", + "size_px": 31 + } + ] + }, + { + "case_id": "corner_edge_large_irregular", + "input_key": "input__corner_edge_large_irregular", + "shape": [ + 37, + 37 + ], + "sizes": [ + { + "closing_key": "closing__corner_edge_large_irregular__2", + "opening_key": "opening__corner_edge_large_irregular__2", + "size_px": 2 + }, + { + "closing_key": "closing__corner_edge_large_irregular__3", + "opening_key": "opening__corner_edge_large_irregular__3", + "size_px": 3 + }, + { + "closing_key": "closing__corner_edge_large_irregular__4", + "opening_key": "opening__corner_edge_large_irregular__4", + "size_px": 4 + }, + { + "closing_key": "closing__corner_edge_large_irregular__5", + "opening_key": "opening__corner_edge_large_irregular__5", + "size_px": 5 + }, + { + "closing_key": "closing__corner_edge_large_irregular__30", + "opening_key": "opening__corner_edge_large_irregular__30", + "size_px": 30 + }, + { + "closing_key": "closing__corner_edge_large_irregular__31", + "opening_key": "opening__corner_edge_large_irregular__31", + "size_px": 31 + } + ] + }, + { + "case_id": "plateau_signed_zero_irregular", + "input_key": "input__plateau_signed_zero_irregular", + "shape": [ + 6, + 6 + ], + "sizes": [ + { + "closing_key": "closing__plateau_signed_zero_irregular__2", + "opening_key": "opening__plateau_signed_zero_irregular__2", + "size_px": 2 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__3", + "opening_key": "opening__plateau_signed_zero_irregular__3", + "size_px": 3 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__4", + "opening_key": "opening__plateau_signed_zero_irregular__4", + "size_px": 4 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__5", + "opening_key": "opening__plateau_signed_zero_irregular__5", + "size_px": 5 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__30", + "opening_key": "opening__plateau_signed_zero_irregular__30", + "size_px": 30 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__31", + "opening_key": "opening__plateau_signed_zero_irregular__31", + "size_px": 31 + } + ] + } + ], + "display_name": "Gwyddion 2.71 Filter Flat-Disc Opening and Closing", + "evidence_roles": { + "external_gwyddion_outputs": "opening__* and closing__* arrays copied bitwise from canonical normal records", + "final_bitwise_agreement": "kernels 30/30; Opening 72/72; Closing 72/72", + "independently_derived_kernel_masks": "kernel__2 through kernel__31 derived from the frozen digital-ellipse rule", + "independently_regenerated_input_fields": "input__* arrays regenerated from frozen field provenance", + "oracle_outputs": "Oracle V2 agreed bitwise with every external operation output before fixture freeze" + }, + "external_evidence": { + "canonical_reference_sha256": "907bd347cc8c213d1061b786b6efe5692c87ffd8be62db1f6de2bd9bc78acdbd", + "provenance_sha256": "c3777cdcfdd868a705ef09c63a7548a5b1c0eb0b79d053e02ac2f45e9c97e5af" + }, + "fixture": { + "array_count": 186, + "array_hashes": { + "closing__checker_step__2": "549faf4f123fed4aa75090d49a29af404e5894caea1d1ba9accb136795607e66", + "closing__checker_step__3": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__checker_step__30": "c7605b54aec87be38b51b424273cbc38014c12be04f78d09b6816fc3ceeaa6d2", + "closing__checker_step__31": "c7605b54aec87be38b51b424273cbc38014c12be04f78d09b6816fc3ceeaa6d2", + "closing__checker_step__4": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__checker_step__5": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__constant_nonzero__2": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__3": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__30": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__31": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__4": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__5": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__corner_edge_large_irregular__2": "30d788eb30c28fdc14865cd57a223de101b525ccf8e0bd4c4ef0292f8165af16", + "closing__corner_edge_large_irregular__3": "d564d7850db6827ba3fbbed2fe07735aa5668259052b776c79473bb75fa8dde0", + "closing__corner_edge_large_irregular__30": "5350879dff7b92f4157021827ac241652b34decb362228c7c164be5d3a8fc694", + "closing__corner_edge_large_irregular__31": "76151275f43aecc801213fde0071e1cca128b0b491d5a31fef4c1981468d591c", + "closing__corner_edge_large_irregular__4": "aec763989f7c5c535d4b1e1a66054952284f5c271aee69f879244b8bdf33ec23", + "closing__corner_edge_large_irregular__5": "daa3ce3051807673a70435a81a84843614be3b09dc797cd7a2bbc3ba22d5f083", + "closing__negative_impulse__2": "33e796f5927b062c3093049847baa9a1747f871ef8ecc380e119d5ce7e0235ca", + "closing__negative_impulse__3": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__30": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__31": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__4": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__5": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__plateau_signed_zero_irregular__2": "35de89582f988c2c742c1cd318e43e36be7ac7c438255fb6a9b8d58a6a651509", + "closing__plateau_signed_zero_irregular__3": "8c4d247411130e8b14b12d00f3f83b398f4bf51d81dcbe44cbe2c0f372dfaff7", + "closing__plateau_signed_zero_irregular__30": "780be79a7e2f743626bcdc61491d302ab66ef7c65f911655083135fb9d698c16", + "closing__plateau_signed_zero_irregular__31": "780be79a7e2f743626bcdc61491d302ab66ef7c65f911655083135fb9d698c16", + "closing__plateau_signed_zero_irregular__4": "314a7ffcc66e2fd62d31f198bbe4232088b52204210ed70cc531d819ccb58c71", + "closing__plateau_signed_zero_irregular__5": "b6ca05886b04ffb4355809f7d12d038979484108822c7e548ad152df1511295d", + "closing__positive_impulse__2": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__3": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__30": "1bbb1f0181809223f299c464be224d16812e2096df00ff229dfec1c23edf4bb9", + "closing__positive_impulse__31": "1bbb1f0181809223f299c464be224d16812e2096df00ff229dfec1c23edf4bb9", + "closing__positive_impulse__4": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__5": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__signed_monotonic__2": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "closing__signed_monotonic__3": "03ee92fda9e395956ac96b8b05157de03757720086db9786d6b2bfce348e9a6f", + "closing__signed_monotonic__30": "8959adcac1341add2c49169d03667e6ff2052cd523bfd406e33ffbe281a4c9a8", + "closing__signed_monotonic__31": "8959adcac1341add2c49169d03667e6ff2052cd523bfd406e33ffbe281a4c9a8", + "closing__signed_monotonic__4": "b8ed900f335f6793043dff898a4c67152d9afcbab032597bfa5dcc8eef50be1e", + "closing__signed_monotonic__5": "f83360854be41f1e1a04d31bbb2e293153bb5c97db606b019c6126c5a1a990a1", + "closing__singleton_1x1__2": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__30": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__31": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__4": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__5": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_column_7x1__2": "3b8628be65941ecee0a1db198b5f74b886f46b3533ddaa3c4f4e863163ac760d", + "closing__singleton_column_7x1__3": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_column_7x1__30": "a6e67ec9314298513a44ebca1df1ed53e2cfe864c01726de0da46c4eca3b07f6", + "closing__singleton_column_7x1__31": "a6e67ec9314298513a44ebca1df1ed53e2cfe864c01726de0da46c4eca3b07f6", + "closing__singleton_column_7x1__4": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_column_7x1__5": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_row_1x7__2": "ec64dc8cdb866d776124e7b1ba4dc738dbd0ac7bf7125a57874f50f59e1fbe1f", + "closing__singleton_row_1x7__3": "5f0cf54986d51349a9e2222343234a6150c3b951f573ba216ce99177050d6ffa", + "closing__singleton_row_1x7__30": "8fb04fdba76775b3f1d4665e0d54af74642f533ef253f3c8b85cea4645d1cade", + "closing__singleton_row_1x7__31": "8fb04fdba76775b3f1d4665e0d54af74642f533ef253f3c8b85cea4645d1cade", + "closing__singleton_row_1x7__4": "ec64dc8cdb866d776124e7b1ba4dc738dbd0ac7bf7125a57874f50f59e1fbe1f", + "closing__singleton_row_1x7__5": "880142629b49de6c81553da4bd885ff4b528ef303d349c027acb5beec5f16d8e", + "closing__tall_large_gradient__2": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "closing__tall_large_gradient__3": "3802a4db20fab3c87830454b5f04c638248392ddc9067b2d373ae4bb82e96e81", + "closing__tall_large_gradient__30": "7e4fda56d5afaab125afddba11c2ff7409471e0d9951eb2310d75d2e5316f1ff", + "closing__tall_large_gradient__31": "c55d1ae466f23dbb6423c0eb6934c46d46389e3e34e2e5990ef39f2a684b7e66", + "closing__tall_large_gradient__4": "e9ad8eb63810e87e48ed7008fd30532dcce2926c0b76ce88289feda761d8192f", + "closing__tall_large_gradient__5": "188ebc037d1cd3607984fdf75c0b2b58cc42cef9684c32519f71a3a014648c11", + "closing__wide_large_gradient__2": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "closing__wide_large_gradient__3": "0740e5ae87db3729b88ecee4970d4d3c73f46da29643271ebb7d4abcbcdf3cfa", + "closing__wide_large_gradient__30": "fbd533fdb7ca5ceba54f0ca6f5c34769f1f7be80fc1a136bd92d7d9264034aad", + "closing__wide_large_gradient__31": "fc293e98538b12616575d6ab5653b18ff1f8a7a75b70c8c5477c24c0fa53fe28", + "closing__wide_large_gradient__4": "216dc6378a409bc61c6625b579bc59dfdd8526cf6de74b4acbb8866be3f03234", + "closing__wide_large_gradient__5": "56551dff49fcbc35ea761879e2c6c82f78b4ba98b2ff298b64a2ac78b128113d", + "input__checker_step": "10d0e45c0cee7e54cae872e70cc1f2da9abfa23110b94ea78554c593e27e3c68", + "input__constant_nonzero": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "input__corner_edge_large_irregular": "c65221e19a3f07e0f27701f0894284efa06e6f4170f81d3c7ad0e2fd5e99ea77", + "input__negative_impulse": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "input__plateau_signed_zero_irregular": "56345c343c12cf83f4b20665b0c47eae34a7816cd7345933f7474454c3eb36eb", + "input__positive_impulse": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "input__signed_monotonic": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "input__singleton_1x1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_column_7x1": "46ad2761807736956fb408501485f5da0d73240a4247578ef7458fb9aaa804f3", + "input__singleton_row_1x7": "7f069ded918cb1c64e81b0d3c624eb3255498176befd8502593e17e8684dc5ab", + "input__tall_large_gradient": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "input__wide_large_gradient": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "kernel__10": "5c70caa3ba2e6af3ed08232ed54240877a2ab2c9c60346f38401270b886f4f1d", + "kernel__11": "43d0d734008f7d8563be5e615d2ffb988dc524e009a4bd81d70bb37f8a40510a", + "kernel__12": "02f34f132d062512c439053764974fe4b2838578dc283311d93dde5f838456bb", + "kernel__13": "b2ff89cf2be45056773496e642aaab203ac62a140a6820ec91297625e37dd220", + "kernel__14": "bbea56dc715cffc1bfcd65a5798e99fb369b9d1ee0b516f144d7908547b0695f", + "kernel__15": "53acff94afe7b09554bedf0bc9dd309813d92c1ca96ee2c225c27ca6e1646207", + "kernel__16": "45fe81ab93f7e7d7cd747de2b832549c9b89bf6826fc34f99ad087604dc24d40", + "kernel__17": "07548696aea5a8f66ee5cc355873e8798576a34ba1247c66bb3ded63b46ff959", + "kernel__18": "6c9b729120fa04e0ef1cb168a84bd17b6bf06e00e87b00b1eba7587fcd0da3b1", + "kernel__19": "ecf1fe17f401651a4d364d6a12c4048d95cae3df742a3ba6df7047321009e4be", + "kernel__2": "d4179f7562080b20aa5758e8f77f7de2390b33886e4cddd798baafdf0bd8181d", + "kernel__20": "f1bdfd0eb3c7100d1cfabeb9a8c8bde5af05b676f9201c8861b6add2f7577306", + "kernel__21": "83c2e4242be54d74cdd01d4bf6971aa45b57c6d323b52095c79b0e97117341f4", + "kernel__22": "44b82fdaec19762a954fd5f6ca935e16a1cf3c2f7bf99c8e905687d842b81939", + "kernel__23": "8930b0c95e940f4bddbe48da7c82e7fc4c8ff8bc384f22f90b4ca8f1ac9ee95b", + "kernel__24": "7a107d522c9c0c5dd893d12a99d52f86bcf4e2c85555f42cfa8db7e5dfd1e1db", + "kernel__25": "1000540487c0356dfdc67fa37971459086ec0f3c3b387e177a7d42ad4883c61f", + "kernel__26": "dc1d314252a2bc35b31b85739ecb8bd4e4cae4afddc02537686cbc3cfdb461ef", + "kernel__27": "528ed618156c27d796f338720c0617792b33ae51239bf64e3baae6d37165256a", + "kernel__28": "74aa89788ebbb447a13d7bf330600731df45110aa5d78f06c1ccd14b0d941836", + "kernel__29": "a641959b84f07ca1d0da938841e9ac7ef6ae32513c0b825fea9970e94ad8c22c", + "kernel__3": "3ddc8773256fdade5c945f5bef0182bd29ff8cb0880f3850e5a27d37f797be20", + "kernel__30": "4060bc6edc617d162096c1a2440821e061f61193289c1a0068e99329e2848288", + "kernel__31": "04c7b79f82b1c0e0c68b3da94e33e9f8537cd555b4bc90ee602df85f064100e3", + "kernel__4": "a73d20c787bdf5392a6fd8edfbe76dec3938661cb51fca4f48398be9bbf8b49c", + "kernel__5": "9084089098214cf292025671cdb3caf7ed7fa23d0bab7284d719ba1a96af450b", + "kernel__6": "3d350c6e17e741516f2fd0beaad2bf6721ab953c4174ec96f08798d5c632f9a3", + "kernel__7": "8ba9c1acba082ff24ad22770b59996798b8916efc0d3e39b1c9ffd5be06a9787", + "kernel__8": "0af9a49e943bae75644c5cf347b5f18701c18e8cbd410372518f0b73c3d9ce91", + "kernel__9": "3eca03815aa18f289d2576af932c27e2d0d8d09a255675df01b9e52f6287748b", + "opening__checker_step__2": "0c07891198c9bb1dce2cafdab7033ab8cd71c7c8e6d99dc166433b14a72cd69d", + "opening__checker_step__3": "ae9e571893791b924efe61ccea35a121f28e19342e937764a7f30c885c4a5cea", + "opening__checker_step__30": "ab77e9652a2393ec9753e0657dd193dc63fc01fd52c729a3430fc2365da2cab7", + "opening__checker_step__31": "ab77e9652a2393ec9753e0657dd193dc63fc01fd52c729a3430fc2365da2cab7", + "opening__checker_step__4": "0c07891198c9bb1dce2cafdab7033ab8cd71c7c8e6d99dc166433b14a72cd69d", + "opening__checker_step__5": "ae9e571893791b924efe61ccea35a121f28e19342e937764a7f30c885c4a5cea", + "opening__constant_nonzero__2": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__3": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__30": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__31": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__4": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__5": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__corner_edge_large_irregular__2": "05c2b74c9feef6179fbefdcb3c424f932a94400db35230433ba29a4e3acf624f", + "opening__corner_edge_large_irregular__3": "d6317d7b4bb1b6c99b701fa88fe44524726e05bf49328c428a174c2df73193fc", + "opening__corner_edge_large_irregular__30": "c0bac360fc2a7942309334aa50783919aa46cc7580082cdacb61147128197293", + "opening__corner_edge_large_irregular__31": "545ac6253a65d37488d01371f13ddf8a87ba684073ee25ced462899e70097a2d", + "opening__corner_edge_large_irregular__4": "5d497cce0eff2fcfdac3955342462271914e322b5726e27f6ab7bec293022c9b", + "opening__corner_edge_large_irregular__5": "8177a29e983b3de0bfc2f90ba0337a64f37efd1c5cc9aff57d5b0640c777b260", + "opening__negative_impulse__2": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__3": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__30": "c14be15f646a1a7525df6a453588f7915272720df730751ccc2369e6640918bb", + "opening__negative_impulse__31": "c14be15f646a1a7525df6a453588f7915272720df730751ccc2369e6640918bb", + "opening__negative_impulse__4": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__5": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__plateau_signed_zero_irregular__2": "c1f17f225427e1d05943ec639e6a098532cdc59c604d6d5546e3a69046842d96", + "opening__plateau_signed_zero_irregular__3": "169f330642c47c2b5226f0d9a03ee5d7332e606ac5f86b4beee8d0e4250f9509", + "opening__plateau_signed_zero_irregular__30": "ca838e3d3ba38029761bf68da23a0136a73514928dc162b0733db65cf4452f8e", + "opening__plateau_signed_zero_irregular__31": "ca838e3d3ba38029761bf68da23a0136a73514928dc162b0733db65cf4452f8e", + "opening__plateau_signed_zero_irregular__4": "d786aa450bfe27248e22bb2dd4787b10261e2a475d125490bd783de8b31f13b4", + "opening__plateau_signed_zero_irregular__5": "049473bb0e3f3422c1b3c011d456356f70991b87b010444b799c4c0c0faa6e94", + "opening__positive_impulse__2": "335583a571361ab9904774510167583c48ab75f30813d80d6693e30ac69b6126", + "opening__positive_impulse__3": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__30": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__31": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__4": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__5": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__signed_monotonic__2": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "opening__signed_monotonic__3": "69dbaeab3157138b23bceeba6bf90063bbd9a85280660e57cb2217316afaf979", + "opening__signed_monotonic__30": "8d3e394a381329abe9629998aafe02e9fd773a30a46fbb8a9083f55276edee1e", + "opening__signed_monotonic__31": "8d3e394a381329abe9629998aafe02e9fd773a30a46fbb8a9083f55276edee1e", + "opening__signed_monotonic__4": "2dd5931263888cdec9ceb257cf6bbe509dfcde78999f74e62c27885502e126d3", + "opening__signed_monotonic__5": "cd704a6e56172f084f114a55967085576ca8aefc0a280e60213d207fae793c46", + "opening__singleton_1x1__2": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__30": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__31": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__4": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__5": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_column_7x1__2": "2bfc1127cd5e93cc78476d75d6d6a90e4640aa259bcd65010aba8b9730814d17", + "opening__singleton_column_7x1__3": "dbe9110f97777b6b19ad78c4d588f0dba77388aab20e34b15c5c6a19006dafa9", + "opening__singleton_column_7x1__30": "1520e3230762667c58530f3fc3ca081bf101ad1664c1c8bf3f7570cd1c72acd8", + "opening__singleton_column_7x1__31": "1520e3230762667c58530f3fc3ca081bf101ad1664c1c8bf3f7570cd1c72acd8", + "opening__singleton_column_7x1__4": "47cbefedcedc22d64d4fc36edfef2c8931d9db4de2c836281b9a85fb6878c530", + "opening__singleton_column_7x1__5": "9dd8b068fad65ebdccd934c8732464bd6428649b79add9b5c439cc6ccec1d3b0", + "opening__singleton_row_1x7__2": "3f056136013619ca2212d2b39b52746919a5d2bcc0e1b97a7e8a63e44734078c", + "opening__singleton_row_1x7__3": "a7a0701a59476221b8a4f64eefbaaf7c9be9b6f99c0cad06ace821064ca6f999", + "opening__singleton_row_1x7__30": "5676978c6bad3f9e0cf6a8569ed772ecff878f39ea67744c0450944d943840b1", + "opening__singleton_row_1x7__31": "5676978c6bad3f9e0cf6a8569ed772ecff878f39ea67744c0450944d943840b1", + "opening__singleton_row_1x7__4": "0573eb990c9972d8d0d72e341b525ae85a9119f442487c53fe94d78506ad72a9", + "opening__singleton_row_1x7__5": "0573eb990c9972d8d0d72e341b525ae85a9119f442487c53fe94d78506ad72a9", + "opening__tall_large_gradient__2": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "opening__tall_large_gradient__3": "1b7fa61d9665dc1d6e7a2c1d8f5cce9036936f654fdc49518b946bde730b4a44", + "opening__tall_large_gradient__30": "62f00af3fa414fc6117a257a2983e23c888f3bf448b9ef5887f9b289a44c88bd", + "opening__tall_large_gradient__31": "6c6913d38f16b6d30a9477134f81dcdc3d85806908757007ff32e37927e35fdf", + "opening__tall_large_gradient__4": "772e3cb4b2df37da6d478a349e461f9f8958a1643aa90e3df39f9395a56d262e", + "opening__tall_large_gradient__5": "068b5174c6d476fbcd8c7b47f3919ee9392c2f682c839c36804536ef7957f6bf", + "opening__wide_large_gradient__2": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "opening__wide_large_gradient__3": "46f729c6cc1f704814ff25d33110709dd7029122e26935302ee8d7371b1bb57d", + "opening__wide_large_gradient__30": "b732b91bf8c44a662fbdc8a3b6cd19579ca57abfc91832eb017f972b7c4e0373", + "opening__wide_large_gradient__31": "136e13a491d7ed38f86d4319cd243d97f562104de4ea8b13e7079483e0641e7e", + "opening__wide_large_gradient__4": "54e2941a7f7fdc2c356d4572eef880520bd3bf8dbb883d34660ba2036508c26b", + "opening__wide_large_gradient__5": "54c94ae164daa05701ff493558cf268c764797eadaaa9199dc34229b4d26378f" + }, + "hash_definition": "dtype.str, NUL, comma-separated shape, NUL, C-order bytes", + "npz_relative_path": "flat_disc_morphology_reference.npz", + "npz_sha256": "7cf0cbbd988376c361f9bc97c0c91cc8b0c3df2110596c2be6b1f92d0af787ef" + }, + "kernel_masks": { + "10": "kernel__10", + "11": "kernel__11", + "12": "kernel__12", + "13": "kernel__13", + "14": "kernel__14", + "15": "kernel__15", + "16": "kernel__16", + "17": "kernel__17", + "18": "kernel__18", + "19": "kernel__19", + "2": "kernel__2", + "20": "kernel__20", + "21": "kernel__21", + "22": "kernel__22", + "23": "kernel__23", + "24": "kernel__24", + "25": "kernel__25", + "26": "kernel__26", + "27": "kernel__27", + "28": "kernel__28", + "29": "kernel__29", + "3": "kernel__3", + "30": "kernel__30", + "31": "kernel__31", + "4": "kernel__4", + "5": "kernel__5", + "6": "kernel__6", + "7": "kernel__7", + "8": "kernel__8", + "9": "kernel__9" + }, + "metrics": { + "closing_bitwise_exact": "72/72", + "input_mutation": 0, + "kernels_bitwise_exact": "30/30", + "max_absolute_difference": 0.0, + "max_ulp_distance": 0, + "opening_bitwise_exact": "72/72", + "signed_zero_mismatches": 0 + }, + "oracle_evidence": { + "oracle_v2_sha256": "bf4129fe4fd871dda3132d5457d45d0833d69e9acd5cf3bb765fc0f3a8d9792e", + "reduction_model_sha256": "43089668a7fe0c699093be440402c8b1b11b42dfea4eb720785241788306c543", + "reduction_trace_manifest_sha256": "354117107e0e007c5187a4290049e340a980290aa47265057b4c4fed6621a52e" + }, + "schema_version": 1, + "scope": { + "finite_nonempty_2d": true, + "full_field": true, + "mask_policy": "ignore" + }, + "sizes_exercised": [ + 2, + 3, + 4, + 5, + 30, + 31 + ] +} diff --git a/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..debbe77e673df4a61d3c665df90007ca35cb4593 GIT binary patch literal 515805 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 literal 0 HcmV?d00001 diff --git a/tests/validation/test_flat_disc_morphology_fixture_integrity.py b/tests/validation/test_flat_disc_morphology_fixture_integrity.py new file mode 100644 index 0000000..ccfc455 --- /dev/null +++ b/tests/validation/test_flat_disc_morphology_fixture_integrity.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parent / "fixtures/gwyddion/flat_disc_morphology" + + +def _hash(array: np.ndarray) -> str: + value = np.ascontiguousarray(array) + digest = hashlib.sha256() + digest.update(value.dtype.str.encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(item) for item in value.shape).encode("ascii")) + digest.update(b"\0") + digest.update(value.tobytes(order="C")) + return digest.hexdigest() + + +def test_flat_disc_fixture_integrity() -> None: + manifest = json.loads((ROOT / "flat_disc_morphology_reference.json").read_text()) + assert manifest["schema_version"] == 1 + assert len(manifest["cases"]) == 12 + assert manifest["sizes_exercised"] == [2, 3, 4, 5, 30, 31] + assert len(manifest["kernel_masks"]) == 30 + assert manifest["metrics"]["opening_bitwise_exact"] == "72/72" + assert manifest["metrics"]["closing_bitwise_exact"] == "72/72" + with np.load(ROOT / "flat_disc_morphology_reference.npz", allow_pickle=False) as data: + assert len(data.files) == 186 + assert set(data.files) == set(manifest["fixture"]["array_hashes"]) + for name in data.files: + array = data[name] + assert array.ndim == 2 and array.flags.c_contiguous + assert np.isfinite(array).all() + assert _hash(array) == manifest["fixture"]["array_hashes"][name] + assert data["input__singleton_row_1x7"].view(np.uint64)[0, 0] == 1 << 63 From 05c5ae461dcddf63acd84e0efeb717bb701ed2ad Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:33:31 -0400 Subject: [PATCH 64/82] feat(analysis): add Gwyddion flat-disc morphology kernel --- .../_gwyddion_flat_disc_morphology.py | 355 ++++++++++++++++++ ...t_gwyddion_flat_disc_morphology_private.py | 113 ++++++ 2 files changed, 468 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py create mode 100644 tests/core/test_gwyddion_flat_disc_morphology_private.py diff --git a/src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py b/src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py new file mode 100644 index 0000000..b882315 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py @@ -0,0 +1,355 @@ +"""Diagnostic model of the Gwyddion min/max RLE reduction hierarchy. + +This is an executable-evidence model, not an oracle. It preserves the +precomputed ``Each``/``Even`` construction and every comparison site. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Literal + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +Kind = Literal["each", "even"] + + +@dataclass(frozen=True) +class _GwyddionFlatDiscKernelSpec: + size_px: int + kernel_resolution: int + kernel_active_count: int + + +@dataclass(frozen=True) +class _GwyddionFlatDiscMorphologyResult: + opening: NDArray[np.float64] + closing: NDArray[np.float64] + kernel: _GwyddionFlatDiscKernelSpec + + +def _validated_gwyddion_flat_disc_size(size_px: object) -> int: + if isinstance(size_px, (bool, np.bool_)) or not isinstance(size_px, (int, np.integer)): + raise TypeError("size_px must be a Python or NumPy integer scalar; booleans are invalid") + value = int(size_px) + if not 2 <= value <= 31: + raise ValueError("size_px must be in the inclusive range 2..31") + return value + + +def _validated_gwyddion_flat_disc_data(data: ArrayLike) -> NDArray[np.float64]: + field = np.asarray(data, dtype=np.float64) + if field.ndim != 2: + raise ValueError("data must be exactly two-dimensional") + if 0 in field.shape: + raise ValueError("data dimensions must be non-empty") + if not np.isfinite(field).all(): + raise ValueError("data values must all be finite") + return np.array(field, dtype=np.float64, order="C", copy=True) + + +@dataclass +class Requirement: + needed: bool = False + even_even: bool = False + even_odd: bool = False + sublen1: int = 0 + sublen2: int = 0 + + +@dataclass +class Plan: + each: dict[int, Requirement] = field(default_factory=dict) + even: dict[int, Requirement] = field(default_factory=dict) + + def requirement(self, kind: Kind, length: int) -> Requirement: + mapping = self.even if kind == "even" else self.each + return mapping.setdefault(length, Requirement()) + + +def _set_requirement( + plan: Plan, + kind: Kind, + length: int, + sublen1: int, + sublen2: int, + even_odd: bool, + even_even: bool, +) -> None: + item = plan.requirement(kind, length) + item.sublen1 = sublen1 + item.sublen2 = sublen2 + item.even_odd = even_odd + item.even_even = even_even + + +def _need(plan: Plan, kind: Kind, length: int) -> bool: + item = plan.requirement(kind, length) + if item.needed: + return True + item.needed = True + return False + + +def _build_requirement(plan: Plan, length: int, even: bool) -> None: + kind: Kind = "even" if even else "each" + if _need(plan, kind, length): + return + if even: + if length == 2: + _set_requirement(plan, kind, length, 1, 1, False, False) + _build_requirement(plan, 1, False) + elif length % 4 == 0: + _set_requirement(plan, kind, length, length // 2, length // 2, False, True) + _build_requirement(plan, length // 2, True) + else: + _set_requirement(plan, kind, length, length // 2 - 1, length // 2 + 1, False, True) + _build_requirement(plan, length // 2 - 1, True) + _build_requirement(plan, length // 2 + 1, True) + return + if length == 1: + return + if length % 2 == 0: + for left in range(1, length // 2 + 1): + right = length - left + if plan.requirement("each", left).needed and plan.requirement("each", right).needed: + _set_requirement(plan, kind, length, left, right, False, False) + return + _set_requirement(plan, kind, length, length // 2, length // 2, False, False) + _build_requirement(plan, length // 2, False) + return + possible = 0 + for left in range(1, length // 2 + 1): + right = length - left + if plan.requirement("each", left).needed and plan.requirement("each", right).needed: + _set_requirement(plan, kind, length, left, right, False, False) + return + if plan.requirement("even", left).needed and plan.requirement("each", right).needed: + _set_requirement(plan, kind, length, left, right, True, False) + return + if plan.requirement("each", left).needed and plan.requirement("even", right).needed: + _set_requirement(plan, kind, length, right, left, True, False) + return + if plan.requirement("each", left).needed: + possible = left + if possible: + _set_requirement(plan, kind, length, possible, length - possible, False, False) + _build_requirement(plan, length - possible, False) + elif length % 4 == 1: + _set_requirement(plan, kind, length, length // 2, length // 2 + 1, True, False) + _build_requirement(plan, length // 2, True) + _build_requirement(plan, length // 2 + 1, False) + else: + _set_requirement(plan, kind, length, length // 2 + 1, length // 2, True, False) + _build_requirement(plan, length // 2 + 1, True) + _build_requirement(plan, length // 2, False) + + +def _second_on_equal(left: np.uint64, right: np.uint64, maximum: bool) -> np.uint64: + left_float = left.view(np.float64) + right_float = right.view(np.float64) + if maximum: + return right if right_float >= left_float else left + return right if right_float <= left_float else left + + +def _first_on_equal(left: np.uint64, right: np.uint64, maximum: bool) -> np.uint64: + left_float = left.view(np.float64) + right_float = right.view(np.float64) + if maximum: + return right if right_float > left_float else left + return right if right_float < left_float else left + + +def _compose_each(left: np.ndarray, right: np.ndarray, a: int, b: int, maximum: bool) -> np.ndarray: + target = np.zeros_like(left) + for index in range(left.size - (a + b) + 1): + target[index] = _second_on_equal(left[index], right[index + a], maximum) + return target + + +def _compose_even(left: np.ndarray, right: np.ndarray, a: int, b: int, maximum: bool) -> np.ndarray: + target = np.zeros_like(left) + for index in range(0, left.size - (a + b) + 1, 2): + target[index] = _second_on_equal(left[index], right[index + a], maximum) + return target + + +def _compose_even_odd( + even: np.ndarray, odd: np.ndarray, even_len: int, odd_len: int, maximum: bool +) -> np.ndarray: + target = np.zeros_like(odd) + count = odd.size - (even_len + odd_len) + even_one, odd_one = 0, even_len + even_two, odd_two = odd_len + 1, 1 + index = 0 + while index + 1 <= count: + target[index] = _second_on_equal(even[even_one], odd[odd_one], maximum) + index += 1 + even_one += 2 + odd_one += 2 + target[index] = _second_on_equal(even[even_two], odd[odd_two], maximum) + index += 1 + even_two += 2 + odd_two += 2 + if index <= count: + target[index] = _second_on_equal(even[even_one], odd[odd_one], maximum) + index += 1 + if index <= count: + target[index] = _second_on_equal(even[even_two], odd[odd_two], maximum) + return target + + +def _row_precomputations( + values: np.ndarray, lengths: tuple[int, ...], maximum: bool +) -> dict[int, np.ndarray]: + plan = Plan() + for length in sorted(set(lengths)): + _build_requirement(plan, length, False) + each: dict[int, np.ndarray] = {1: values.copy()} + even: dict[int, np.ndarray] = {} + max_each = max(plan.each, default=1) + max_even = max(plan.even, default=0) + for length in range(2, max_each + 1): + requirement = plan.requirement("each", length) + if requirement.needed: + if requirement.even_odd: + each[length] = _compose_even_odd( + even[requirement.sublen1], + each[requirement.sublen2], + requirement.sublen1, + requirement.sublen2, + maximum, + ) + else: + each[length] = _compose_each( + each[requirement.sublen1], + each[requirement.sublen2], + requirement.sublen1, + requirement.sublen2, + maximum, + ) + if length <= max_even: + requirement = plan.requirement("even", length) + if requirement.needed: + if requirement.even_even: + even[length] = _compose_even( + even[requirement.sublen1], + even[requirement.sublen2], + requirement.sublen1, + requirement.sublen2, + maximum, + ) + else: + even[length] = _compose_even(each[1], each[1], 1, 1, maximum) + return each + + +@lru_cache(maxsize=30) +def _mask(size_px: int) -> np.ndarray: + mask = np.zeros((size_px, size_px), dtype=np.uint8) + half = size_px / 2.0 + for row in range(size_px): + factor = ((row + 0.5) / half) * (2.0 - ((row + 0.5) / half)) + if factor > 0.0: + first = max(0, int(np.ceil(half * (1.0 - np.sqrt(factor)) - 0.5))) + last = min(size_px - 1, int(np.floor(half * (1.0 + np.sqrt(factor)) - 0.5))) + mask[row, first : last + 1] = 1 + return mask + + +def _segments(size_px: int, maximum: bool) -> tuple[tuple[int, int, int], ...]: + mask = _mask(size_px) + if maximum: + mask = mask[::-1, ::-1] + result = [] + for row in range(size_px): + columns = np.flatnonzero(mask[row]) + if columns.size: + result.append((row, int(columns[0]), int(columns.size))) + return tuple(result) + + +def filter_field(field: np.ndarray, size_px: int, maximum: bool) -> np.ndarray: + field = np.ascontiguousarray(field, dtype=np.float64) + rows, columns = field.shape + segments = _segments(size_px, maximum) + lengths = tuple(segment[2] for segment in segments) + up = size_px // 2 if maximum else (size_px - 1) // 2 + left = size_px // 2 if maximum else (size_px - 1) // 2 + right = size_px - 1 - left + result = np.empty(field.shape, dtype=np.uint64) + field_bits = field.view(np.uint64) + for output_row in range(rows): + per_row = [] + for kernel_row in range(size_px): + source_row = min(max(output_row + kernel_row - up, 0), rows - 1) + extended = np.concatenate( + ( + np.repeat(field_bits[source_row, 0], left), + field_bits[source_row], + np.repeat(field_bits[source_row, -1], right), + ) + ) + per_row.append(_row_precomputations(extended, lengths, maximum)) + for output_column in range(columns): + value: np.uint64 | None = None + for kernel_row, kernel_column, length in segments: + candidate = per_row[kernel_row][length][output_column + kernel_column] + value = candidate if value is None else _first_on_equal(value, candidate, maximum) + assert value is not None + result[output_row, output_column] = value + return result.view(np.float64) + + +def opening(field: np.ndarray, size_px: int) -> np.ndarray: + return filter_field(filter_field(field, size_px, False), size_px, True) + + +def closing(field: np.ndarray, size_px: int) -> np.ndarray: + return filter_field(filter_field(field, size_px, True), size_px, False) + + +def _gwyddion_flat_disc_kernel(size_px: object) -> _GwyddionFlatDiscKernelSpec: + value = _validated_gwyddion_flat_disc_size(size_px) + return _GwyddionFlatDiscKernelSpec( + size_px=value, + kernel_resolution=value, + kernel_active_count=int(_mask(value).sum()), + ) + + +def _gwyddion_flat_disc_extremum( + data: ArrayLike, + size_px: object, + *, + maximum: bool, +) -> NDArray[np.float64]: + field = _validated_gwyddion_flat_disc_data(data) + value = _validated_gwyddion_flat_disc_size(size_px) + return np.array(filter_field(field, value, maximum), dtype=np.float64, order="C") + + +def _gwyddion_flat_disc_morphology_result( + data: ArrayLike, + size_px: object, +) -> _GwyddionFlatDiscMorphologyResult: + field = _validated_gwyddion_flat_disc_data(data) + kernel = _gwyddion_flat_disc_kernel(size_px) + opening = np.array( + filter_field(filter_field(field, kernel.size_px, False), kernel.size_px, True), + dtype=np.float64, + order="C", + ) + closing = np.array( + filter_field(filter_field(field, kernel.size_px, True), kernel.size_px, False), + dtype=np.float64, + order="C", + ) + return _GwyddionFlatDiscMorphologyResult( + opening=opening, + closing=closing, + kernel=kernel, + ) diff --git a/tests/core/test_gwyddion_flat_disc_morphology_private.py b/tests/core/test_gwyddion_flat_disc_morphology_private.py new file mode 100644 index 0000000..ac5a65f --- /dev/null +++ b/tests/core/test_gwyddion_flat_disc_morphology_private.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_flat_disc_morphology import ( + Plan, + _build_requirement, + _gwyddion_flat_disc_kernel, + _gwyddion_flat_disc_morphology_result, + _segments, + _validated_gwyddion_flat_disc_size, +) + +FIXTURE = Path(__file__).resolve().parents[1] / "validation/fixtures/gwyddion/flat_disc_morphology" + + +def test_kernel_inventory_and_validation() -> None: + assert [_gwyddion_flat_disc_kernel(size).kernel_active_count for size in range(2, 32)][0:4] == [ + 4, + 9, + 12, + 21, + ] + assert _gwyddion_flat_disc_kernel(30).kernel_active_count == 716 + assert _gwyddion_flat_disc_kernel(31).kernel_active_count == 749 + assert isinstance(_validated_gwyddion_flat_disc_size(np.uint8(2)), int) + for value in (True, np.array(2), 2.0, "2"): + with pytest.raises(TypeError): + _validated_gwyddion_flat_disc_size(value) + for value in (1, 32, 10**100): + with pytest.raises(ValueError): + _validated_gwyddion_flat_disc_size(value) + + +def test_frozen_opening_and_closing_are_bitwise_exact() -> None: + manifest = json.loads((FIXTURE / "flat_disc_morphology_reference.json").read_text()) + with np.load(FIXTURE / "flat_disc_morphology_reference.npz", allow_pickle=False) as archive: + for case in manifest["cases"]: + input_data = archive[case["input_key"]].copy(order="C") + before = input_data.copy(order="C") + for size in case["sizes"]: + result = _gwyddion_flat_disc_morphology_result(input_data, size["size_px"]) + for operation in ("opening", "closing"): + expected = archive[size[f"{operation}_key"]] + actual = getattr(result, operation) + assert np.array_equal(actual.view(np.uint64), expected.view(np.uint64)), ( + case["case_id"], + operation, + size["size_px"], + ) + assert actual.dtype == np.float64 and actual.flags.c_contiguous + assert not np.shares_memory(result.opening, result.closing) + assert np.array_equal(input_data.view(np.uint64), before.view(np.uint64)) + + +def test_input_contract() -> None: + for value in (np.array([]), np.array([1.0]), np.zeros((1, 1, 1)), np.array([[np.nan]])): + with pytest.raises(ValueError): + _gwyddion_flat_disc_morphology_result(value, 2) + + +def _requirement_signature(plan: Plan) -> tuple[tuple[object, ...], ...]: + rows: list[tuple[object, ...]] = [] + for kind, mapping in (("each", plan.each), ("even", plan.even)): + for length, requirement in sorted(mapping.items()): + rows.append( + ( + kind, + length, + requirement.needed, + requirement.sublen1, + requirement.sublen2, + requirement.even_odd, + requirement.even_even, + ) + ) + return tuple(rows) + + +def test_requirement_tree_is_complete_and_deterministic() -> None: + lengths = { + int(segment[2]) + for size_px in range(2, 32) + for segment in _segments(size_px, False) + } + first = Plan() + for length in sorted(lengths): + _build_requirement(first, length, False) + second = Plan() + for length in sorted(lengths): + _build_requirement(second, length, False) + + assert _requirement_signature(first) == _requirement_signature(second) + for mapping in (first.each, first.even): + for length, requirement in mapping.items(): + if not requirement.needed or length == 1: + continue + assert requirement.sublen1 > 0 + assert requirement.sublen2 > 0 + assert requirement.sublen1 + requirement.sublen2 == length + + +def test_singleton_and_thin_fields_execute_all_sizes() -> None: + for shape in ((1, 1), (1, 7), (7, 1)): + field = np.arange(np.prod(shape), dtype=np.float64).reshape(shape) + for size_px in range(2, 32): + result = _gwyddion_flat_disc_morphology_result(field, size_px) + assert result.opening.shape == shape + assert result.closing.shape == shape From 1b2d081f37f5394bc9c70d15904bd95225269a9f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:00:17 -0400 Subject: [PATCH 65/82] feat(analysis): expose Gwyddion flat-disc morphology API --- src/spmkit/core/analysis/__init__.py | 4 + src/spmkit/core/analysis/background.py | 54 +++++ .../test_gwyddion_flat_disc_morphology.py | 195 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 tests/core/test_gwyddion_flat_disc_morphology.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index d1bd414..d2fae55 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -36,6 +36,8 @@ estimate_rolling_ball_background, estimate_sphere_revolution_background, estimate_spline_background, + gwyddion_flat_disc_closing, + gwyddion_flat_disc_opening, remove_arc_revolution_background, remove_gwyddion_arc_revolution_background, remove_gwyddion_median_background, @@ -84,6 +86,8 @@ "estimate_gwyddion_arc_revolution_background", "estimate_gwyddion_median_background", "estimate_gwyddion_sphere_revolution_background", + "gwyddion_flat_disc_closing", + "gwyddion_flat_disc_opening", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 211e0a4..5dd3a9a 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -17,6 +17,9 @@ GwyddionArcDirection, _gwyddion_arc_result, ) +from spmkit.core.analysis._gwyddion_flat_disc_morphology import ( + _gwyddion_flat_disc_morphology_result, +) from spmkit.core.analysis._gwyddion_sphere_revolution import ( _gwyddion_sphere_result, ) @@ -777,6 +780,57 @@ def remove_gwyddion_median_background( return corrected +def _gwyddion_flat_disc_channels( + channel: SPMChannel, + size_px: object, +) -> tuple[SPMChannel, SPMChannel]: + """Calculate flat-disc opening and closing while preserving context.""" + result = _gwyddion_flat_disc_morphology_result(channel.data, size_px) + return channel.with_data(result.opening), channel.with_data(result.closing) + + +def gwyddion_flat_disc_opening( + channel: SPMChannel, + *, + size_px: object = 5, +) -> SPMChannel: + """Apply Gwyddion 2.71 Filter flat-disc Opening. + + ``size_px`` is the pixel width of the fixed digital-ellipse kernel, from + 2 through 31 inclusive, with default ``5``. Nearest-edge extension and + the executable Gwyddion even-size anchor are fixed. Finite, non-empty + two-dimensional data are required and the input channel is not mutated. + + Returns + ------- + SPMChannel + The opening field with the input channel context preserved. + """ + opening, _ = _gwyddion_flat_disc_channels(channel, size_px) + return opening + + +def gwyddion_flat_disc_closing( + channel: SPMChannel, + *, + size_px: object = 5, +) -> SPMChannel: + """Apply Gwyddion 2.71 Filter flat-disc Closing. + + ``size_px`` is the pixel width of the fixed digital-ellipse kernel, from + 2 through 31 inclusive, with default ``5``. Nearest-edge extension and + the executable Gwyddion even-size anchor are fixed. Finite, non-empty + two-dimensional data are required and the input channel is not mutated. + + Returns + ------- + SPMChannel + The closing field with the input channel context preserved. + """ + _, closing = _gwyddion_flat_disc_channels(channel, size_px) + return closing + + def _sphere_structure( *, radius: float, diff --git a/tests/core/test_gwyddion_flat_disc_morphology.py b/tests/core/test_gwyddion_flat_disc_morphology.py new file mode 100644 index 0000000..189c118 --- /dev/null +++ b/tests/core/test_gwyddion_flat_disc_morphology.py @@ -0,0 +1,195 @@ +"""Public-contract tests for Gwyddion 2.71 flat-disc morphology.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from pathlib import Path + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.background as background_module +from spmkit.core.analysis import ( + gwyddion_flat_disc_closing, + gwyddion_flat_disc_opening, +) +from spmkit.core.models import SPMChannel + +_FIXTURE_DIRECTORY = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "flat_disc_morphology" +) +_FIXTURE_PATH = _FIXTURE_DIRECTORY / "flat_disc_morphology_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIRECTORY / "flat_disc_morphology_reference.json" +_OPERATIONS: tuple[Callable[..., SPMChannel], ...] = ( + gwyddion_flat_disc_opening, + gwyddion_flat_disc_closing, +) + + +def _manifest() -> dict[str, object]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _channel(data: np.ndarray) -> SPMChannel: + return SPMChannel( + name="Flat-disc fixture", + data=data, + unit="V", + x_range=8.5e-6, + y_range=6.5e-6, + direction="backward", + group="Frozen morphology evidence", + metadata={"source": "gwyddion-2.71-flat-disc", "context": {"id": 7}}, + ) + + +def _ordered_uint64(bits: int) -> int: + sign_bit = 1 << 63 + return ((~bits + 1) & ((1 << 64) - 1)) if bits & sign_bit else bits | sign_bit + + +def _assert_bitwise_equal( + actual: np.ndarray, + expected: np.ndarray, + *, + case_id: str, + operation: str, +) -> None: + actual_bits = actual.view(np.uint64) + expected_bits = expected.view(np.uint64) + if np.array_equal(actual_bits, expected_bits): + return + + row, column = np.argwhere(actual_bits != expected_bits)[0] + actual_bits_value = int(actual_bits[row, column]) + expected_bits_value = int(expected_bits[row, column]) + ulp_distance = abs( + _ordered_uint64(actual_bits_value) - _ordered_uint64(expected_bits_value) + ) + pytest.fail( + f"case={case_id} operation={operation} coordinate=({row}, {column}) " + f"expected={expected[row, column]!r} actual={actual[row, column]!r} " + f"expected_uint64={expected_bits_value} actual_uint64={actual_bits_value} " + f"absolute_difference={abs(actual[row, column] - expected[row, column])!r} " + f"ulp_distance={ulp_distance}" + ) + + +def test_public_exports_signature_and_parameter_contract() -> None: + expected_names = {"gwyddion_flat_disc_opening", "gwyddion_flat_disc_closing"} + assert expected_names <= set(analysis.__all__) + for name in expected_names: + assert getattr(analysis, name) is not None + + for operation in _OPERATIONS: + signature = inspect.signature(operation) + assert list(signature.parameters) == ["channel", "size_px"] + assert signature.parameters["size_px"].default == 5 + assert signature.parameters["size_px"].kind is inspect.Parameter.KEYWORD_ONLY + assert "border" not in signature.parameters + assert "shape" not in signature.parameters + assert "rank" not in signature.parameters + assert "backend" not in signature.parameters + + for private_name in ( + "_GwyddionFlatDiscKernelSpec", + "_GwyddionFlatDiscMorphologyResult", + "_gwyddion_flat_disc_kernel", + "_gwyddion_flat_disc_extremum", + "_gwyddion_flat_disc_morphology_result", + ): + assert private_name not in analysis.__all__ + assert not hasattr(analysis, private_name) + + +@pytest.mark.parametrize("case", _manifest()["cases"], ids=lambda case: case["case_id"]) +def test_public_opening_and_closing_are_bitwise_exact(case: dict[str, object]) -> None: + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + source_data = np.array(archive[case["input_key"]], dtype=np.float64, order="C", copy=True) + original_bits = source_data.view(np.uint64).copy() + source = _channel(source_data) + for size_entry in case["sizes"]: + size_px = size_entry["size_px"] + opening = gwyddion_flat_disc_opening(source, size_px=size_px) + closing = gwyddion_flat_disc_closing(source, size_px=size_px) + _assert_bitwise_equal( + opening.data, + archive[size_entry["opening_key"]], + case_id=case["case_id"], + operation="opening", + ) + _assert_bitwise_equal( + closing.data, + archive[size_entry["closing_key"]], + case_id=case["case_id"], + operation="closing", + ) + for output in (opening, closing): + assert output.data.dtype == np.float64 + assert output.data.flags.c_contiguous + assert output.data.shape == source.data.shape + assert np.isfinite(output.data).all() + assert not np.shares_memory(output.data, source.data) + assert not np.shares_memory(opening.data, closing.data) + assert np.array_equal(source.data.view(np.uint64), original_bits) + + +def test_context_and_metadata_are_preserved_without_sharing() -> None: + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + source = _channel(np.array(archive["input__wide_large_gradient"], copy=True, order="C")) + opening = gwyddion_flat_disc_opening(source, size_px=3) + closing = gwyddion_flat_disc_closing(source, size_px=3) + for output in (opening, closing): + assert output.name == source.name + assert output.unit == source.unit + assert output.x_range == source.x_range + assert output.y_range == source.y_range + assert output.direction == source.direction + assert output.group == source.group + assert output.metadata == source.metadata + assert output.metadata is not source.metadata + opening.metadata["new_key"] = True + assert "new_key" not in source.metadata + assert "new_key" not in closing.metadata + + +@pytest.mark.parametrize("operation", _OPERATIONS) +@pytest.mark.parametrize("size_px", [True, np.array(2), 0, 32, 10**100, 2.0, "2"]) +def test_validation_is_delegated(operation: Callable[..., SPMChannel], size_px: object) -> None: + data = np.ones((3, 4), dtype=np.float64) + expected = TypeError if isinstance(size_px, (bool, np.ndarray, float, str)) else ValueError + with pytest.raises(expected): + operation(_channel(data), size_px=size_px) + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_nonfinite_data_is_rejected(operation: Callable[..., SPMChannel]) -> None: + data = np.ones((2, 3), dtype=np.float64) + data[0, 1] = np.nan + with pytest.raises(ValueError, match="finite"): + operation(_channel(data), size_px=3) + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_each_public_call_invokes_private_entry_once( + monkeypatch: pytest.MonkeyPatch, + operation: Callable[..., SPMChannel], +) -> None: + calls = 0 + original = background_module._gwyddion_flat_disc_morphology_result + + def counted_entry(data: object, size_px: object) -> object: + nonlocal calls + calls += 1 + return original(data, size_px) + + monkeypatch.setattr(background_module, "_gwyddion_flat_disc_morphology_result", counted_entry) + operation(_channel(np.arange(12, dtype=np.float64).reshape(3, 4)), size_px=4) + assert calls == 1 From c0de811fbc04974bc07e6d164c4428a251b25268 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:00:17 -0400 Subject: [PATCH 66/82] docs(validation): close Gwyddion flat-disc morphology parity --- docs/api.md | 26 ++++++++++++++++++++++++++ docs/scientific-status.md | 37 +++++++++++++++++++++++++++++++++++++ docs/validation/index.md | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/docs/api.md b/docs/api.md index 1b8dd97..779f1a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -281,6 +281,32 @@ This capability is CROSS_VALIDATED only within its frozen 36-case Gwyddion 2.71 scope, frozen evidence, semantics, and non-claims are specified in the [Gwyddion Median Background compatibility specification](design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md). +## Gwyddion 2.71 Filter flat-disc morphology + +SPM-Kit exposes the frozen Gwyddion 2.71 Filter-tool flat-disc Opening and Closing: + +```python +from spmkit.core.analysis import ( + gwyddion_flat_disc_closing, + gwyddion_flat_disc_opening, +) + +opened = gwyddion_flat_disc_opening(channel, size_px=5) +closed = gwyddion_flat_disc_closing(channel, size_px=5) +``` + +Both functions accept a pixel-based `size_px` in the inclusive range `2..31`, defaulting to +`5`, and return a new `SPMChannel`. The K×K digital ellipse, nearest-edge extension, and +Gwyddion executable even-size anchoring are fixed; erosion, dilation, masks, ROI, ASF, and +physical-radius options are not public parameters. Inputs must be finite, non-empty, and 2D; +the source channel is not mutated, and shape, Z/XY units, ranges, direction, group, and copied +metadata are preserved. + +This capability is `CROSS_VALIDATED` only within the frozen 12-field campaign and six sizes +`2, 3, 4, 5, 30, 31` (72 Opening and 72 Closing cases). The complete scope, executable tie +semantics, evidence identities, and non-claims are recorded in the +[Gwyddion flat-disc morphology compatibility specification](design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md). + ## KPFM statistics ```python diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 0b075d3..b82cbe5 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -41,6 +41,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | | Gwyddion 2.71 Median Background | `core.analysis.background`, `core.analysis._median_background` | Frozen executable reference campaign: 36 logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, direct and radixtree reference paths; public background and corrected fields 36/36 bitwise exact, maximum absolute difference 0 and maximum ULP 0; input mutation maximum 0 and reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen 36-case campaign | Gwyddion 2.71 source, executable probe, independent Python oracle, frozen NPZ/JSON fixture | Finite two-dimensional inputs only; no universal equivalence, performance-equivalence, future-Gwyddion, all-radii, or all-matrices claim; `rank_backend_reference` describes Gwyddion, not an SPM-Kit backend | +| Gwyddion 2.71 Filter flat-disc morphology | `core.analysis.background`, `core.analysis._gwyddion_flat_disc_morphology` | Frozen executable reference campaign: 12 fields, six sizes 2/3/4/5/30/31, 72 Opening and 72 Closing cases; kernels 30/30, Opening 72/72 and Closing 72/72 bitwise exact; maximum absolute difference 0, maximum ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 executable, corrected external probe V3, executable reduction trace, independent oracle V2, frozen NPZ/JSON fixture | Finite full-field data with masks ignored; no universal equivalence, NaN/Inf, ROI, masks, ASF, tip morphology, physical rolling-ball, performance, other builds/versions, public erosion/dilation, or source-only tie claim | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -125,6 +126,42 @@ processes returned, stderr was empty, normal and ASan stdout were byte-identical parsed and recalculated independently, oracle/reference results were bitwise exact, and the fixture stores canonical hashes. +### Gwyddion 2.71 Filter flat-disc morphology + +**Claim:** `CROSS_VALIDATED` only within the frozen executable campaign. The audited Gwyddion +2.71 Filter path is represented by 12 deterministic finite fields at sizes 2, 3, 4, 5, 30, +and 31. Opening and Closing each match the frozen external outputs bitwise in 72/72 cases; +kernels match 30/30, maximum absolute difference and ULP distance are both 0, signed-zero +mismatches are 0, and input mutation is 0. + +The fixed semantics are the K×K inclusive digital ellipse, nearest-edge extension, asymmetric +even-size anchors, and the audited executable Each/Even plus RLE reduction hierarchy. The +source strict ternaries and executable MINSD/MAXSD equality behavior are distinct; SPM-Kit +reproduces the audited executable path. The rejected uninitialised-kernel microprobe is not +evidence; the corrected zero-initialised probe is the valid external record. + +**Traceability:** + +```text +.reference/gwyddion-2.71/source/libprocess/filters-minmax.c + → /tmp/spmkit_flat_disc_probe_v3 + → /tmp/spmkit_flat_disc_reduction_trace_v1 + → /tmp/spmkit_flat_disc_reduction_trace_v1/oracle_v2/flat_disc_morphology_oracle.py + → tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz + → tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json + → src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py + → src/spmkit/core/analysis/background.py + → tests/core/test_gwyddion_flat_disc_morphology_private.py + → tests/core/test_gwyddion_flat_disc_morphology.py + → docs/scientific-status.md +``` + +Evidence was frozen in `2ba366e`; the private kernel is `05c5ae4`. No public or documentation +commit is claimed here. **Non-claims:** no universal equivalence; no NaN or infinity coverage; +no ROI, masks, ASF, tip morphology, physical rolling-ball equivalence, performance parity, +other Gwyddion builds or versions, public erosion or dilation, or claim that source-level C +tie semantics alone reproduce the audited binary. + ## Test-count policy The collection total is measured with: diff --git a/docs/validation/index.md b/docs/validation/index.md index 9ebe871..e9f0d7a 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -39,6 +39,7 @@ references, tolerances, outputs, hashes, and limitations. | Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | | Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | | Gwyddion Median Background 2.71 v1 | Local rank background on 36 frozen logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, and both direct/radixtree reference paths | Public background and corrected fields 36/36 bitwise exact; maximum absolute difference 0, maximum ULP 0, input mutation maximum 0, reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 only; finite inputs; no universal, performance, future-version, all-radii, or all-matrices claim; no public border/shape/rank configuration | +| Gwyddion Filter flat-disc morphology 2.71 v1 | 12 frozen fields, six sizes 2/3/4/5/30/31, full-field mask-ignore Opening and Closing | Kernels 30/30; Opening 72/72 and Closing 72/72 bitwise exact; max absolute difference 0, max ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 executable only; finite full-field data; no universal, NaN/Inf, ROI, mask, ASF, tip, physical rolling-ball, performance, other-build, public erosion/dilation, or source-only tie claim | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and @@ -97,6 +98,38 @@ the parsed outputs, oracle/reference equality, and canonical hashes were indepen These counts describe the frozen focal validation campaign for this capability. They are not the global test total of the SPMKit project. +### Gwyddion 2.71 Filter flat-disc morphology + +The frozen trace is: + +```text +Gwyddion source +→ corrected external probe V3 +→ executable reduction trace +→ independent oracle V2 +→ frozen fixture +→ private SPMKit kernel +→ public bitwise tests +→ CROSS_VALIDATED status +``` + +The records are `.reference/gwyddion-2.71/source/libprocess/filters-minmax.c`, +`/tmp/spmkit_flat_disc_probe_v3`, `/tmp/spmkit_flat_disc_reduction_trace_v1`, +`docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md`, +`tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz`, +`tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json`, +`src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py`, +`src/spmkit/core/analysis/background.py`, +`tests/core/test_gwyddion_flat_disc_morphology_private.py`, and +`tests/core/test_gwyddion_flat_disc_morphology.py`. + +The evidence commit is `2ba366e`; the private-kernel commit is `05c5ae4`. The claim is limited +to the 12 frozen fields and six sizes. Source strict ternaries and executable MINSD/MAXSD +equality behavior are distinguished; the rejected uninitialised-kernel probe is excluded, and +the corrected zero-initialised probe is the valid external evidence. No claim is made for +universal equivalence, non-finite data, ROI/masks, ASF, tip morphology, physical rolling-ball, +performance, other Gwyddion builds, public erosion/dilation, or source-only tie semantics. + ## What remains open - redistributable multi-instrument fixtures for built-in and adapter readers; From d3566ce9b6905493434ba49e0f9834c204830757 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:47:43 -0400 Subject: [PATCH 67/82] test(validation): freeze Gwyddion Path Level evidence --- .../GWYDDION_PATH_LEVEL_COMPATIBILITY.md | 66 + .../path_level/path_level_reference.json | 4967 +++++++++++++++++ .../path_level/path_level_reference.npz | Bin 0 -> 74216 bytes .../test_path_level_fixture_integrity.py | 63 + 4 files changed, 5096 insertions(+) create mode 100644 docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/path_level/path_level_reference.json create mode 100644 tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz create mode 100644 tests/validation/test_path_level_fixture_integrity.py diff --git a/docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md b/docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md new file mode 100644 index 0000000..7d9a78e --- /dev/null +++ b/docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md @@ -0,0 +1,66 @@ +# Gwyddion Path Level Compatibility + +## Status and scope + +`gwyddion_path_level` records the registered Gwyddion `pathlevel` **Path +Level** tool within the frozen, finite, non-empty, full-field campaign. The +reference is the installed `tools.so` module, SHA-256 +`4711c360dd42e3e16257bf0e86d8bd41852b43d1d34540bf097736a603146237`, +Build ID `600b16d9857946609b567704b406abcc74aea698`, whose debug source matches +the frozen `pathlevel.c` SHA-256 +`4c0411c73f7ca883d4d03f35b38ef81a02ea3c7688620754992cb58e8825326f`. + +The evidence consists of 18 field/selection families, four thicknesses, 72 +logical cases, 144 fresh external executions, and an independent Python oracle +with 72/72 bitwise-exact arrays. This is not a universal-equivalence claim. + +## Selection identity and coordinates + +Path Level consumes an ordered collection of straight `GwySelectionLine` +objects, not `GwySelectionPath`. `GwySelectionPath` belongs to unrelated +path/spline tools. Every line is `(x0, y0, x1, y1)` in physical data-field +coordinates. The tool maps horizontal coordinates as `x*xres/xreal` and +vertical coordinates as `y*yres/yreal`; field origin offsets do not participate. + +Each endpoint is floored. If the first Y is greater than the second Y, both +endpoints are swapped. X endpoints and Y bounds are clamped to the field. +The active transition domain is `y0 < row <= y1`; consequently horizontal +lines are excluded. + +## Numerical contract + +For each ordered line object, the tool creates a start and an end change point. +They are ordered by row, then starts before ends, then object ID. This makes +line IDs and user-supplied object order scientifically relevant. Duplicates +and overlap retain multiplicity. + +For an active line, the column at each row transition uses the source integer +formula with C signed integer division truncated toward zero. A thickness +window is inclusive and asymmetric: `(thickness - 1)//2` samples on the lower +column side and `thickness//2` on the upper side, clamped to valid columns. +Its range is 1..128; the future public default is 1. + +Row differences are accumulated as explicit scalar `current - previous` +samples in line-object and increasing-column order, then divided once by the +sample count. The per-row differences are cumulatively summed left to right. +That correction is subtracted from every column of its row. There is no +interpolation, mask, ROI, path, spline, or profile operation in this contract. + +## Publication semantics + +Gwyddion mutates and publishes the selected data field in place, with undo and +tool logging. The future SPMKit kernel returns a new corrected array and does +not mutate its input. This intentionally follows SPMKit immutable-return +convention; it does not claim GUI, undo, logging, or publication parity. + +## Evidence and non-claims + +The frozen fixture preserves external input/output records, independently +regenerated inputs/cases, oracle comparison records, physical ranges, ordered +lines, normalized endpoints, and bitwise uint64 hashes. It covers signed zero, +line-order sensitivity, overlap, clamping, fractional coordinates, and all +four frozen thicknesses. + +No claim is made for NaN or infinity, masks or ROI, spline/polyline paths, +profile extraction, volume line-leveling, `align_rows` equivalence, performance +parity, other Gwyddion builds or versions, or universal equivalence. diff --git a/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json b/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json new file mode 100644 index 0000000..d480173 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json @@ -0,0 +1,4967 @@ +{ + "bases": [ + { + "base_id": "anisotropic_physical_coordinates", + "input_canonical_hash": "6a416ab164f1f7ab8e0e50ae9a44a60e20a1d809763d1ed766bbfb4d928f682a", + "input_key": "input__anisotropic_physical_coordinates", + "input_sha256": "34de16e687ce994aba2ffcd923f193dc229d66720326ce1d25adcd2cc4fb6c81", + "shape": [ + 8, + 9 + ], + "tags": [ + "anisotropic_range", + "physical_to_pixel", + "fractional" + ], + "xoffset": 2.5, + "xreal": 13.0, + "yoffset": -1.25, + "yreal": 7.0 + }, + { + "base_id": "constant_horizontal", + "input_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "input_key": "input__constant_horizontal", + "input_sha256": "6124b8b219e263c168d9ced67da10ba711273d4abe8173adc71b29292ebad730", + "shape": [ + 6, + 7 + ], + "tags": [ + "constant", + "horizontal", + "horizontal_noop_control" + ], + "xoffset": 0.0, + "xreal": 7.0, + "yoffset": 0.0, + "yreal": 6.0 + }, + { + "base_id": "constant_no_lines", + "input_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "input_key": "input__constant_no_lines", + "input_sha256": "6124b8b219e263c168d9ced67da10ba711273d4abe8173adc71b29292ebad730", + "shape": [ + 6, + 7 + ], + "tags": [ + "constant", + "no_lines" + ], + "xoffset": 0.0, + "xreal": 7.0, + "yoffset": 0.0, + "yreal": 6.0 + }, + { + "base_id": "floor_c_truncation_starts_ends", + "input_canonical_hash": "b4e18dfcabe627710e38d3a2dc0d5d60c425927c484c442c3c7fe04490a014a2", + "input_key": "input__floor_c_truncation_starts_ends", + "input_sha256": "c66afcbd6acc665c817399d3abf4229e5744ccd078c7246dc933e635f969cba5", + "shape": [ + 9, + 9 + ], + "tags": [ + "endpoint_floor", + "c_truncation", + "starts_before_ends", + "horizontal_exclusion" + ], + "xoffset": 0.0, + "xreal": 9.0, + "yoffset": 0.0, + "yreal": 9.0 + }, + { + "base_id": "irregular_outside_endpoints", + "input_canonical_hash": "5d3b9265e0c3f0c1c04a2ed6496adad7567fcdbcac5de7a42e1b7dad31a9979e", + "input_key": "input__irregular_outside_endpoints", + "input_sha256": "a1fd7497c6c860b2837f06f8c50289fd06407f307778faae65dcb43f87cdde21", + "shape": [ + 9, + 11 + ], + "tags": [ + "irregular", + "outside_endpoints", + "clamp" + ], + "xoffset": 0.0, + "xreal": 11.0, + "yoffset": 0.0, + "yreal": 9.0 + }, + { + "base_id": "line_order_a", + "input_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "input_key": "input__line_order_a", + "input_sha256": "7f6192489fe38405e1b71a4a8f2ccd3d36ec1e89679a82e58390c27c946db924", + "shape": [ + 10, + 11 + ], + "tags": [ + "line_id_order", + "same_start_end", + "overlap", + "floating_sum_order" + ], + "xoffset": 0.0, + "xreal": 11.0, + "yoffset": 0.0, + "yreal": 10.0 + }, + { + "base_id": "line_order_b_permuted", + "input_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "input_key": "input__line_order_b_permuted", + "input_sha256": "7f6192489fe38405e1b71a4a8f2ccd3d36ec1e89679a82e58390c27c946db924", + "shape": [ + 10, + 11 + ], + "tags": [ + "line_id_order", + "permuted_object_order", + "same_start_end", + "floating_sum_order" + ], + "xoffset": 0.0, + "xreal": 11.0, + "yoffset": 0.0, + "yreal": 10.0 + }, + { + "base_id": "negative_impulse_reversed", + "input_canonical_hash": "03658489207560a26e8f9384c9038edae9c588d11c22b1356cd782edd05e0f2d", + "input_key": "input__negative_impulse_reversed", + "input_sha256": "50614a2893c88b9cf749f15ba45c068d4b9c42a498450fd18327e823a2ed466e", + "shape": [ + 9, + 9 + ], + "tags": [ + "negative_impulse", + "reversed_endpoints", + "normalization" + ], + "xoffset": 0.0, + "xreal": 9.0, + "yoffset": 0.0, + "yreal": 9.0 + }, + { + "base_id": "plateau_signed_zero_partial_clamp", + "input_canonical_hash": "68173761ff74a584231639a78d02744a65601a59c3ef3fc29ac29ad7fabdbc8e", + "input_key": "input__plateau_signed_zero_partial_clamp", + "input_sha256": "cce846fe062457eb38d481a3220f087a621cac10461b7445019e932388be3c9f", + "shape": [ + 6, + 8 + ], + "tags": [ + "plateau", + "ties", + "signed_zero", + "partially_clamped", + "window_clamp" + ], + "xoffset": 0.0, + "xreal": 8.0, + "yoffset": 0.0, + "yreal": 6.0 + }, + { + "base_id": "positive_impulse_fractional", + "input_canonical_hash": "9c00590617b05138f45aaf221281c78ae41defb9816108660b68e100dee68b29", + "input_key": "input__positive_impulse_fractional", + "input_sha256": "bc9754a349f10caa54b4b9e8033801771f722c4e2edb64625037e990d43edc46", + "shape": [ + 9, + 9 + ], + "tags": [ + "positive_impulse", + "fractional_endpoints" + ], + "xoffset": 0.0, + "xreal": 9.0, + "yoffset": 0.0, + "yreal": 9.0 + }, + { + "base_id": "row_offset_duplicate_overlap", + "input_canonical_hash": "085a24234af7acd58e17ab21c025e997739175748553ce19f389faf4d0fb5c40", + "input_key": "input__row_offset_duplicate_overlap", + "input_sha256": "d9ca38fc110c7a823714db55f4cc2147d4c07f51f51a03534966ae2de4d9dac8", + "shape": [ + 8, + 9 + ], + "tags": [ + "row_offset", + "duplicate", + "overlap", + "multiplicity" + ], + "xoffset": 0.0, + "xreal": 9.0, + "yoffset": 0.0, + "yreal": 8.0 + }, + { + "base_id": "signed_gradient_positive_slope", + "input_canonical_hash": "66be0573d9152154da4b83a74c6685149caf3312eff2af2c3b7c9109d77877a8", + "input_key": "input__signed_gradient_positive_slope", + "input_sha256": "09454df9b633f36abd7d8cd81958d90689d1bd69011ceceb92638724d0b2161f", + "shape": [ + 7, + 8 + ], + "tags": [ + "signed_gradient", + "positive_slope", + "first_last_rows" + ], + "xoffset": 0.0, + "xreal": 8.0, + "yoffset": 0.0, + "yreal": 7.0 + }, + { + "base_id": "singleton_1x1_no_lines", + "input_canonical_hash": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input_key": "input__singleton_1x1_no_lines", + "input_sha256": "f52df18731eea8d020801fe2c6b3164648d9d81256a6c37964533a25999961d3", + "shape": [ + 1, + 1 + ], + "tags": [ + "singleton", + "no_lines" + ], + "xoffset": 0.0, + "xreal": 1.0, + "yoffset": 0.0, + "yreal": 1.0 + }, + { + "base_id": "singleton_column_9x1_vertical", + "input_canonical_hash": "fe3c64e8997d8cfb4c242bc6a100fd8ace0308a31e873052e6b4f22bf1bd1e6a", + "input_key": "input__singleton_column_9x1_vertical", + "input_sha256": "16c63e75e56a8d16a04a77bfa9c93fea6748b8ac932abd8a37e7943f0e2179a0", + "shape": [ + 9, + 1 + ], + "tags": [ + "singleton_column", + "vertical" + ], + "xoffset": 0.0, + "xreal": 1.0, + "yoffset": 0.0, + "yreal": 9.0 + }, + { + "base_id": "singleton_row_1x9_horizontal", + "input_canonical_hash": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "input_key": "input__singleton_row_1x9_horizontal", + "input_sha256": "d52da1d0188e4b146a5b19363211229ed0586dc2114e7da30ccf2ea66e118222", + "shape": [ + 1, + 9 + ], + "tags": [ + "singleton_row", + "horizontal", + "signed_zero", + "ties" + ], + "xoffset": 0.0, + "xreal": 9.0, + "yoffset": 0.0, + "yreal": 1.0 + }, + { + "base_id": "step_negative_slope", + "input_canonical_hash": "17b72e293ae5970773ecc3f728d5a83f6b7318a77857f5f92a7c5d324974611b", + "input_key": "input__step_negative_slope", + "input_sha256": "f0f28d2cfc1dd0a82ab57351e44a8f7f9f7b321084f29ad49c0b03233186ac83", + "shape": [ + 8, + 10 + ], + "tags": [ + "step", + "negative_slope" + ], + "xoffset": 0.0, + "xreal": 10.0, + "yoffset": 0.0, + "yreal": 8.0 + }, + { + "base_id": "tall_edge_window", + "input_canonical_hash": "d03a7dd325f7d70525564e56744303ad9751924f581710cdabc8499b679f9368", + "input_key": "input__tall_edge_window", + "input_sha256": "c5a2d6d3ebed6e18ca47962faa96a67827953ab98080c92b312471d7cda212b1", + "shape": [ + 17, + 5 + ], + "tags": [ + "tall", + "first_last_columns", + "edge_window" + ], + "xoffset": 0.0, + "xreal": 5.0, + "yoffset": 0.0, + "yreal": 17.0 + }, + { + "base_id": "wide_edge_window", + "input_canonical_hash": "ef163e701f48a3bc31df9816e555935fa7e95d79067ca791c509193c97f59b4f", + "input_key": "input__wide_edge_window", + "input_sha256": "f9311154b2c88b6b1d760f39f40efa93d25e92e06195c04a03eea10d5a1d8e6b", + "shape": [ + 5, + 17 + ], + "tags": [ + "wide", + "first_last_columns", + "edge_window" + ], + "xoffset": 0.0, + "xreal": 17.0, + "yoffset": 0.0, + "yreal": 5.0 + } + ], + "capability": "gwyddion_path_level", + "cases": [ + { + "base_id": "singleton_1x1_no_lines", + "case_id": "singleton_1x1_no_lines__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "output_key": "corrected__singleton_1x1_no_lines__t1", + "selection_count_unchanged": true, + "tags": [ + "singleton", + "no_lines" + ], + "thickness": 1 + }, + { + "base_id": "singleton_1x1_no_lines", + "case_id": "singleton_1x1_no_lines__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "output_key": "corrected__singleton_1x1_no_lines__t2", + "selection_count_unchanged": true, + "tags": [ + "singleton", + "no_lines" + ], + "thickness": 2 + }, + { + "base_id": "singleton_1x1_no_lines", + "case_id": "singleton_1x1_no_lines__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "output_key": "corrected__singleton_1x1_no_lines__t3", + "selection_count_unchanged": true, + "tags": [ + "singleton", + "no_lines" + ], + "thickness": 3 + }, + { + "base_id": "singleton_1x1_no_lines", + "case_id": "singleton_1x1_no_lines__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "output_key": "corrected__singleton_1x1_no_lines__t128", + "selection_count_unchanged": true, + "tags": [ + "singleton", + "no_lines" + ], + "thickness": 128 + }, + { + "base_id": "singleton_row_1x9_horizontal", + "case_id": "singleton_row_1x9_horizontal__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 0, + 0, + 8, + 0 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "output_key": "corrected__singleton_row_1x9_horizontal__t1", + "selection_count_unchanged": true, + "tags": [ + "singleton_row", + "horizontal", + "signed_zero", + "ties" + ], + "thickness": 1 + }, + { + "base_id": "singleton_row_1x9_horizontal", + "case_id": "singleton_row_1x9_horizontal__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 0, + 0, + 8, + 0 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "output_key": "corrected__singleton_row_1x9_horizontal__t2", + "selection_count_unchanged": true, + "tags": [ + "singleton_row", + "horizontal", + "signed_zero", + "ties" + ], + "thickness": 2 + }, + { + "base_id": "singleton_row_1x9_horizontal", + "case_id": "singleton_row_1x9_horizontal__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 0, + 0, + 8, + 0 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "output_key": "corrected__singleton_row_1x9_horizontal__t3", + "selection_count_unchanged": true, + "tags": [ + "singleton_row", + "horizontal", + "signed_zero", + "ties" + ], + "thickness": 3 + }, + { + "base_id": "singleton_row_1x9_horizontal", + "case_id": "singleton_row_1x9_horizontal__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 0, + 0, + 8, + 0 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000" + ], + "output_canonical_hash": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "output_key": "corrected__singleton_row_1x9_horizontal__t128", + "selection_count_unchanged": true, + "tags": [ + "singleton_row", + "horizontal", + "signed_zero", + "ties" + ], + "thickness": 128 + }, + { + "base_id": "singleton_column_9x1_vertical", + "case_id": "singleton_column_9x1_vertical__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "4008000000000000", + "4010000000000000", + "4014000000000000", + "4018000000000000", + "401c000000000000", + "4020000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "output_canonical_hash": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "output_key": "corrected__singleton_column_9x1_vertical__t1", + "selection_count_unchanged": true, + "tags": [ + "singleton_column", + "vertical" + ], + "thickness": 1 + }, + { + "base_id": "singleton_column_9x1_vertical", + "case_id": "singleton_column_9x1_vertical__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "4008000000000000", + "4010000000000000", + "4014000000000000", + "4018000000000000", + "401c000000000000", + "4020000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "output_canonical_hash": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "output_key": "corrected__singleton_column_9x1_vertical__t2", + "selection_count_unchanged": true, + "tags": [ + "singleton_column", + "vertical" + ], + "thickness": 2 + }, + { + "base_id": "singleton_column_9x1_vertical", + "case_id": "singleton_column_9x1_vertical__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "4008000000000000", + "4010000000000000", + "4014000000000000", + "4018000000000000", + "401c000000000000", + "4020000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "output_canonical_hash": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "output_key": "corrected__singleton_column_9x1_vertical__t3", + "selection_count_unchanged": true, + "tags": [ + "singleton_column", + "vertical" + ], + "thickness": 3 + }, + { + "base_id": "singleton_column_9x1_vertical", + "case_id": "singleton_column_9x1_vertical__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "4008000000000000", + "4010000000000000", + "4014000000000000", + "4018000000000000", + "401c000000000000", + "4020000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "output_canonical_hash": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "output_key": "corrected__singleton_column_9x1_vertical__t128", + "selection_count_unchanged": true, + "tags": [ + "singleton_column", + "vertical" + ], + "thickness": 128 + }, + { + "base_id": "constant_no_lines", + "case_id": "constant_no_lines__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_no_lines__t1", + "selection_count_unchanged": true, + "tags": [ + "constant", + "no_lines" + ], + "thickness": 1 + }, + { + "base_id": "constant_no_lines", + "case_id": "constant_no_lines__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_no_lines__t2", + "selection_count_unchanged": true, + "tags": [ + "constant", + "no_lines" + ], + "thickness": 2 + }, + { + "base_id": "constant_no_lines", + "case_id": "constant_no_lines__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_no_lines__t3", + "selection_count_unchanged": true, + "tags": [ + "constant", + "no_lines" + ], + "thickness": 3 + }, + { + "base_id": "constant_no_lines", + "case_id": "constant_no_lines__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 0, + "lines_hex": [], + "normalized_endpoints": [], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_no_lines__t128", + "selection_count_unchanged": true, + "tags": [ + "constant", + "no_lines" + ], + "thickness": 128 + }, + { + "base_id": "constant_horizontal", + "case_id": "constant_horizontal__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x1.8000000000000p+1", + "0x1.8000000000000p+2", + "0x1.8000000000000p+1" + ], + "normalized_endpoints": [ + 0, + 3, + 6, + 3 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_horizontal__t1", + "selection_count_unchanged": true, + "tags": [ + "constant", + "horizontal", + "horizontal_noop_control" + ], + "thickness": 1 + }, + { + "base_id": "constant_horizontal", + "case_id": "constant_horizontal__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x1.8000000000000p+1", + "0x1.8000000000000p+2", + "0x1.8000000000000p+1" + ], + "normalized_endpoints": [ + 0, + 3, + 6, + 3 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_horizontal__t2", + "selection_count_unchanged": true, + "tags": [ + "constant", + "horizontal", + "horizontal_noop_control" + ], + "thickness": 2 + }, + { + "base_id": "constant_horizontal", + "case_id": "constant_horizontal__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x1.8000000000000p+1", + "0x1.8000000000000p+2", + "0x1.8000000000000p+1" + ], + "normalized_endpoints": [ + 0, + 3, + 6, + 3 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_horizontal__t3", + "selection_count_unchanged": true, + "tags": [ + "constant", + "horizontal", + "horizontal_noop_control" + ], + "thickness": 3 + }, + { + "base_id": "constant_horizontal", + "case_id": "constant_horizontal__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x1.8000000000000p+1", + "0x1.8000000000000p+2", + "0x1.8000000000000p+1" + ], + "normalized_endpoints": [ + 0, + 3, + 6, + 3 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "output_key": "corrected__constant_horizontal__t128", + "selection_count_unchanged": true, + "tags": [ + "constant", + "horizontal", + "horizontal_noop_control" + ], + "thickness": 128 + }, + { + "base_id": "signed_gradient_positive_slope", + "case_id": "signed_gradient_positive_slope__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.8000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 6 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "401c000000000000", + "402c000000000000", + "4035000000000000", + "403c000000000000", + "4041800000000000", + "4041800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "0000000000000000" + ], + "output_canonical_hash": "98d9efe8c730be95299ccedbbfcadfa55c39c5baa5c57cf2eea805b1849b9ebc", + "output_key": "corrected__signed_gradient_positive_slope__t1", + "selection_count_unchanged": true, + "tags": [ + "signed_gradient", + "positive_slope", + "first_last_rows" + ], + "thickness": 1 + }, + { + "base_id": "signed_gradient_positive_slope", + "case_id": "signed_gradient_positive_slope__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.8000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 6 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "401c000000000000", + "402c000000000000", + "4035000000000000", + "403c000000000000", + "4041800000000000", + "4041800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "0000000000000000" + ], + "output_canonical_hash": "98d9efe8c730be95299ccedbbfcadfa55c39c5baa5c57cf2eea805b1849b9ebc", + "output_key": "corrected__signed_gradient_positive_slope__t2", + "selection_count_unchanged": true, + "tags": [ + "signed_gradient", + "positive_slope", + "first_last_rows" + ], + "thickness": 2 + }, + { + "base_id": "signed_gradient_positive_slope", + "case_id": "signed_gradient_positive_slope__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.8000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 6 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "401c000000000000", + "402c000000000000", + "4035000000000000", + "403c000000000000", + "4041800000000000", + "4045000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000" + ], + "output_canonical_hash": "7b0e9c2fafe8c614eb62559bc77e7b8bea4684a2a0503530b5988767f2b9af40", + "output_key": "corrected__signed_gradient_positive_slope__t3", + "selection_count_unchanged": true, + "tags": [ + "signed_gradient", + "positive_slope", + "first_last_rows" + ], + "thickness": 3 + }, + { + "base_id": "signed_gradient_positive_slope", + "case_id": "signed_gradient_positive_slope__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.8000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 6 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "401c000000000000", + "402c000000000000", + "4035000000000000", + "403c000000000000", + "4041800000000000", + "4045000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000", + "401c000000000000" + ], + "output_canonical_hash": "7b0e9c2fafe8c614eb62559bc77e7b8bea4684a2a0503530b5988767f2b9af40", + "output_key": "corrected__signed_gradient_positive_slope__t128", + "selection_count_unchanged": true, + "tags": [ + "signed_gradient", + "positive_slope", + "first_last_rows" + ], + "thickness": 128 + }, + { + "base_id": "row_offset_duplicate_overlap", + "case_id": "row_offset_duplicate_overlap__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 7, + 4, + 0, + 4, + 7, + 1, + 0, + 7, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4059000000000000", + "4069000000000000", + "4072c00000000000", + "4079000000000000", + "407f400000000000", + "4082c00000000000", + "4085e00000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000" + ], + "output_canonical_hash": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "output_key": "corrected__row_offset_duplicate_overlap__t1", + "selection_count_unchanged": true, + "tags": [ + "row_offset", + "duplicate", + "overlap", + "multiplicity" + ], + "thickness": 1 + }, + { + "base_id": "row_offset_duplicate_overlap", + "case_id": "row_offset_duplicate_overlap__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 7, + 4, + 0, + 4, + 7, + 1, + 0, + 7, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4059000000000000", + "4069000000000000", + "4072c00000000000", + "4079000000000000", + "407f400000000000", + "4082c00000000000", + "4085e00000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000" + ], + "output_canonical_hash": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "output_key": "corrected__row_offset_duplicate_overlap__t2", + "selection_count_unchanged": true, + "tags": [ + "row_offset", + "duplicate", + "overlap", + "multiplicity" + ], + "thickness": 2 + }, + { + "base_id": "row_offset_duplicate_overlap", + "case_id": "row_offset_duplicate_overlap__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 7, + 4, + 0, + 4, + 7, + 1, + 0, + 7, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4059000000000000", + "4069000000000000", + "4072c00000000000", + "4079000000000000", + "407f400000000000", + "4082c00000000000", + "4085e00000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000" + ], + "output_canonical_hash": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "output_key": "corrected__row_offset_duplicate_overlap__t3", + "selection_count_unchanged": true, + "tags": [ + "row_offset", + "duplicate", + "overlap", + "multiplicity" + ], + "thickness": 3 + }, + { + "base_id": "row_offset_duplicate_overlap", + "case_id": "row_offset_duplicate_overlap__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.c000000000000p+2", + "0x1.0000000000000p+0", + "0x0.0p+0", + "0x1.c000000000000p+2", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 7, + 4, + 0, + 4, + 7, + 1, + 0, + 7, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4059000000000000", + "4069000000000000", + "4072c00000000000", + "4079000000000000", + "407f400000000000", + "4082c00000000000", + "4085e00000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000", + "4059000000000000" + ], + "output_canonical_hash": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "output_key": "corrected__row_offset_duplicate_overlap__t128", + "selection_count_unchanged": true, + "tags": [ + "row_offset", + "duplicate", + "overlap", + "multiplicity" + ], + "thickness": 128 + }, + { + "base_id": "step_negative_slope", + "case_id": "step_negative_slope__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.2000000000000p+3", + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 9, + 0, + 0, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4000000000000000", + "4010000000000000", + "4018000000000000", + "4020000000000000", + "4024000000000000", + "4028000000000000", + "402c000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000" + ], + "output_canonical_hash": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "output_key": "corrected__step_negative_slope__t1", + "selection_count_unchanged": true, + "tags": [ + "step", + "negative_slope" + ], + "thickness": 1 + }, + { + "base_id": "step_negative_slope", + "case_id": "step_negative_slope__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.2000000000000p+3", + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 9, + 0, + 0, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4000000000000000", + "4010000000000000", + "4018000000000000", + "4020000000000000", + "4024000000000000", + "4028000000000000", + "402c000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000" + ], + "output_canonical_hash": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "output_key": "corrected__step_negative_slope__t2", + "selection_count_unchanged": true, + "tags": [ + "step", + "negative_slope" + ], + "thickness": 2 + }, + { + "base_id": "step_negative_slope", + "case_id": "step_negative_slope__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.2000000000000p+3", + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 9, + 0, + 0, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4000000000000000", + "4010000000000000", + "4018000000000000", + "4020000000000000", + "4024000000000000", + "4028000000000000", + "402c000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000" + ], + "output_canonical_hash": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "output_key": "corrected__step_negative_slope__t3", + "selection_count_unchanged": true, + "tags": [ + "step", + "negative_slope" + ], + "thickness": 3 + }, + { + "base_id": "step_negative_slope", + "case_id": "step_negative_slope__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.2000000000000p+3", + "0x0.0p+0", + "0x0.0p+0", + "0x1.c000000000000p+2" + ], + "normalized_endpoints": [ + 9, + 0, + 0, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4000000000000000", + "4010000000000000", + "4018000000000000", + "4020000000000000", + "4024000000000000", + "4028000000000000", + "402c000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000", + "4000000000000000" + ], + "output_canonical_hash": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "output_key": "corrected__step_negative_slope__t128", + "selection_count_unchanged": true, + "tags": [ + "step", + "negative_slope" + ], + "thickness": 128 + }, + { + "base_id": "positive_impulse_fractional", + "case_id": "positive_impulse_fractional__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.3333333333333p-1", + "0x1.0000000000000p-1", + "0x1.d99999999999ap+2", + "0x1.0666666666666p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff4000000000000", + "4004000000000000", + "400e000000000000", + "4014000000000000", + "c054f00000000000", + "c054a00000000000", + "c054500000000000", + "c054000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000", + "c056300000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000" + ], + "output_canonical_hash": "6c068627660abe9d58c50f8716d222b5619be8162566a9d2893804620eea2af6", + "output_key": "corrected__positive_impulse_fractional__t1", + "selection_count_unchanged": true, + "tags": [ + "positive_impulse", + "fractional_endpoints" + ], + "thickness": 1 + }, + { + "base_id": "positive_impulse_fractional", + "case_id": "positive_impulse_fractional__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.3333333333333p-1", + "0x1.0000000000000p-1", + "0x1.d99999999999ap+2", + "0x1.0666666666666p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff4000000000000", + "4004000000000000", + "400e000000000000", + "4049000000000000", + "4019000000000000", + "401e000000000000", + "4021800000000000", + "4024000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000", + "4047200000000000", + "c045e00000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000" + ], + "output_canonical_hash": "93c99554deb0fec35b49cebb7e78795bace6435849dd7655e915331ae3af1f93", + "output_key": "corrected__positive_impulse_fractional__t2", + "selection_count_unchanged": true, + "tags": [ + "positive_impulse", + "fractional_endpoints" + ], + "thickness": 2 + }, + { + "base_id": "positive_impulse_fractional", + "case_id": "positive_impulse_fractional__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.3333333333333p-1", + "0x1.0000000000000p-1", + "0x1.d99999999999ap+2", + "0x1.0666666666666p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff4000000000000", + "4004000000000000", + "400e000000000000", + "4041800000000000", + "4019000000000000", + "401e000000000000", + "4021800000000000", + "4024000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000", + "403f400000000000", + "c03cc00000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000" + ], + "output_canonical_hash": "e283f7dfa8b6f5e56e0b309a2355371fd74336159dcc48e4e48ceb62d2aa45b6", + "output_key": "corrected__positive_impulse_fractional__t3", + "selection_count_unchanged": true, + "tags": [ + "positive_impulse", + "fractional_endpoints" + ], + "thickness": 3 + }, + { + "base_id": "positive_impulse_fractional", + "case_id": "positive_impulse_fractional__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.3333333333333p-1", + "0x1.0000000000000p-1", + "0x1.d99999999999ap+2", + "0x1.0666666666666p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ff4000000000000", + "4004000000000000", + "400e000000000000", + "402e000000000000", + "4019000000000000", + "401e000000000000", + "4021800000000000", + "4024000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000", + "4026800000000000", + "c021800000000000", + "3ff4000000000000", + "3ff4000000000000", + "3ff4000000000000" + ], + "output_canonical_hash": "5b0046c9ba51d594ee4360a061443829afd33c490360ca259ba3e389ebdb3699", + "output_key": "corrected__positive_impulse_fractional__t128", + "selection_count_unchanged": true, + "tags": [ + "positive_impulse", + "fractional_endpoints" + ], + "thickness": 128 + }, + { + "base_id": "negative_impulse_reversed", + "case_id": "negative_impulse_reversed__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.c000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+0", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 1, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "bfe0000000000000", + "bff0000000000000", + "bff8000000000000", + "c000000000000000", + "c004000000000000", + "c008000000000000", + "c00c000000000000", + "c010000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000" + ], + "output_canonical_hash": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "output_key": "corrected__negative_impulse_reversed__t1", + "selection_count_unchanged": true, + "tags": [ + "negative_impulse", + "reversed_endpoints", + "normalization" + ], + "thickness": 1 + }, + { + "base_id": "negative_impulse_reversed", + "case_id": "negative_impulse_reversed__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.c000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+0", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 1, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "bfe0000000000000", + "bff0000000000000", + "bff8000000000000", + "c000000000000000", + "c004000000000000", + "c008000000000000", + "c00c000000000000", + "c010000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000" + ], + "output_canonical_hash": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "output_key": "corrected__negative_impulse_reversed__t2", + "selection_count_unchanged": true, + "tags": [ + "negative_impulse", + "reversed_endpoints", + "normalization" + ], + "thickness": 2 + }, + { + "base_id": "negative_impulse_reversed", + "case_id": "negative_impulse_reversed__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.c000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+0", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 1, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "bfe0000000000000", + "bff0000000000000", + "bff8000000000000", + "c000000000000000", + "c004000000000000", + "c008000000000000", + "c00c000000000000", + "c010000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000" + ], + "output_canonical_hash": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "output_key": "corrected__negative_impulse_reversed__t3", + "selection_count_unchanged": true, + "tags": [ + "negative_impulse", + "reversed_endpoints", + "normalization" + ], + "thickness": 3 + }, + { + "base_id": "negative_impulse_reversed", + "case_id": "negative_impulse_reversed__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.c000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+0", + "0x0.0p+0" + ], + "normalized_endpoints": [ + 1, + 0, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "bfe0000000000000", + "bff0000000000000", + "bff8000000000000", + "c000000000000000", + "c004000000000000", + "c026aaaaaaaaaaab", + "c00c000000000002", + "c010000000000001" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "bfe0000000000000", + "c021aaaaaaaaaaab", + "401f555555555555", + "bfe0000000000000" + ], + "output_canonical_hash": "bdf60bf5b78e5053cb51c565ccff49725691b19f2619c0e0e17fa6663157c08b", + "output_key": "corrected__negative_impulse_reversed__t128", + "selection_count_unchanged": true, + "tags": [ + "negative_impulse", + "reversed_endpoints", + "normalization" + ], + "thickness": 128 + }, + { + "base_id": "plateau_signed_zero_partial_clamp", + "case_id": "plateau_signed_zero_partial_clamp__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.8000000000000p+1", + "0x0.0p+0", + "0x1.4000000000000p+3", + "0x1.4000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 5 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4010000000000000", + "4010000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4010000000000000", + "0000000000000000" + ], + "output_canonical_hash": "aa07ce88e1d599f243c76465a6ce65156e805f0b85ba495dde97a34741a62f57", + "output_key": "corrected__plateau_signed_zero_partial_clamp__t1", + "selection_count_unchanged": true, + "tags": [ + "plateau", + "ties", + "signed_zero", + "partially_clamped", + "window_clamp" + ], + "thickness": 1 + }, + { + "base_id": "plateau_signed_zero_partial_clamp", + "case_id": "plateau_signed_zero_partial_clamp__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.8000000000000p+1", + "0x0.0p+0", + "0x1.4000000000000p+3", + "0x1.4000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 5 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000", + "4000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "b2c7bd0472ccd4ce7467682ad9d69d4f8f3768892d337d923eeabf04467f3f9b", + "output_key": "corrected__plateau_signed_zero_partial_clamp__t2", + "selection_count_unchanged": true, + "tags": [ + "plateau", + "ties", + "signed_zero", + "partially_clamped", + "window_clamp" + ], + "thickness": 2 + }, + { + "base_id": "plateau_signed_zero_partial_clamp", + "case_id": "plateau_signed_zero_partial_clamp__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.8000000000000p+1", + "0x0.0p+0", + "0x1.4000000000000p+3", + "0x1.4000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 5 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff5555555555555", + "3ff5555555555555" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff5555555555555", + "0000000000000000" + ], + "output_canonical_hash": "2688ef8ad5fa877e031007e52054202f5e01175efeb3b9be707fcaf05fa274a9", + "output_key": "corrected__plateau_signed_zero_partial_clamp__t3", + "selection_count_unchanged": true, + "tags": [ + "plateau", + "ties", + "signed_zero", + "partially_clamped", + "window_clamp" + ], + "thickness": 3 + }, + { + "base_id": "plateau_signed_zero_partial_clamp", + "case_id": "plateau_signed_zero_partial_clamp__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.8000000000000p+1", + "0x0.0p+0", + "0x1.4000000000000p+3", + "0x1.4000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 7, + 5 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "bfe0000000000000", + "bfe0000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "0000000000000000" + ], + "output_canonical_hash": "08c4c42bec90733a5ff5be313afeaf1923fffbc97f796ca4b7f7df0b7de531db", + "output_key": "corrected__plateau_signed_zero_partial_clamp__t128", + "selection_count_unchanged": true, + "tags": [ + "plateau", + "ties", + "signed_zero", + "partially_clamped", + "window_clamp" + ], + "thickness": 128 + }, + { + "base_id": "irregular_outside_endpoints", + "case_id": "irregular_outside_endpoints__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.4000000000000p+2", + "-0x1.0000000000000p+1", + "0x1.e000000000000p+3", + "0x1.8000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 10, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c041800000000000", + "c04f800000000000", + "c055000000000000", + "c056c00000000000", + "c056c00000000000", + "c055000000000000", + "c05c400000000000", + "c05c400000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c041800000000000", + "c03c000000000000", + "c035000000000000", + "c01c000000000000", + "0000000000000000", + "401c000000000000", + "c03d000000000000", + "0000000000000000" + ], + "output_canonical_hash": "91800c865bcbac917f8b230b5539c17fe80d99b9f1637002eb9a473802ba9dd8", + "output_key": "corrected__irregular_outside_endpoints__t1", + "selection_count_unchanged": true, + "tags": [ + "irregular", + "outside_endpoints", + "clamp" + ], + "thickness": 1 + }, + { + "base_id": "irregular_outside_endpoints", + "case_id": "irregular_outside_endpoints__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.4000000000000p+2", + "-0x1.0000000000000p+1", + "0x1.e000000000000p+3", + "0x1.8000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 10, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c024000000000000", + "c02a000000000000", + "c03e800000000000", + "c041000000000000", + "c03e800000000000", + "c034000000000000", + "c038000000000000", + "c038000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c024000000000000", + "c008000000000000", + "c031800000000000", + "c00c000000000000", + "400c000000000000", + "4025000000000000", + "c010000000000000", + "0000000000000000" + ], + "output_canonical_hash": "3728f123d94ae7a56bf848d0a9598794f0bad0c1922b8bd2b2d7f2fbf7138474", + "output_key": "corrected__irregular_outside_endpoints__t2", + "selection_count_unchanged": true, + "tags": [ + "irregular", + "outside_endpoints", + "clamp" + ], + "thickness": 2 + }, + { + "base_id": "irregular_outside_endpoints", + "case_id": "irregular_outside_endpoints__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.4000000000000p+2", + "-0x1.0000000000000p+1", + "0x1.e000000000000p+3", + "0x1.8000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 10, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c019555555555555", + "c016aaaaaaaaaaaa", + "c028aaaaaaaaaaaa", + "c013ffffffffffff", + "4022aaaaaaaaaaac", + "4030555555555556", + "4030000000000001", + "c017fffffffffffc" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c019555555555555", + "3fe5555555555555", + "c01aaaaaaaaaaaab", + "401d555555555555", + "402caaaaaaaaaaab", + "401c000000000000", + "bfd5555555555555", + "c036000000000000" + ], + "output_canonical_hash": "adedd8d927e2807952907de66c79167ebdb95fbff3677293aa57cf3fb2ddbd12", + "output_key": "corrected__irregular_outside_endpoints__t3", + "selection_count_unchanged": true, + "tags": [ + "irregular", + "outside_endpoints", + "clamp" + ], + "thickness": 3 + }, + { + "base_id": "irregular_outside_endpoints", + "case_id": "irregular_outside_endpoints__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "-0x1.4000000000000p+2", + "-0x1.0000000000000p+1", + "0x1.e000000000000p+3", + "0x1.8000000000000p+3" + ], + "normalized_endpoints": [ + 0, + 0, + 10, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3ffa2e8ba2e8ba2f", + "bfe45d1745d1745e", + "c00745d1745d1746", + "bff45d1745d1745d", + "3fd745d1745d1748", + "bffe8ba2e8ba2e8c", + "bfd1745d1745d174", + "3ff5d1745d1745d2" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3ffa2e8ba2e8ba2f", + "c0022e8ba2e8ba2f", + "c0022e8ba2e8ba2f", + "3ffa2e8ba2e8ba2f", + "3ffa2e8ba2e8ba2f", + "c0022e8ba2e8ba2f", + "3ffa2e8ba2e8ba2f", + "3ffa2e8ba2e8ba2f" + ], + "output_canonical_hash": "685f5bbfbea8354a39680e76ac8f95d455948387eab02c8fe9bc0d89adc63d58", + "output_key": "corrected__irregular_outside_endpoints__t128", + "selection_count_unchanged": true, + "tags": [ + "irregular", + "outside_endpoints", + "clamp" + ], + "thickness": 128 + }, + { + "base_id": "wide_edge_window", + "case_id": "wide_edge_window__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 4 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4040c00000000000", + "4042000000000000", + "4043400000000000", + "4044800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4040c00000000000", + "4004000000000000", + "4004000000000000", + "4004000000000000" + ], + "output_canonical_hash": "1e68b58906514cebde1408fd722b3f3e63d3507a33ea2f8ec761cfe2a076864c", + "output_key": "corrected__wide_edge_window__t1", + "selection_count_unchanged": true, + "tags": [ + "wide", + "first_last_columns", + "edge_window" + ], + "thickness": 1 + }, + { + "base_id": "wide_edge_window", + "case_id": "wide_edge_window__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 4 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4032000000000000", + "4034800000000000", + "4037000000000000", + "4039800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4032000000000000", + "4004000000000000", + "4004000000000000", + "4004000000000000" + ], + "output_canonical_hash": "4a154ac77147d3967cdbcce6f6a633ccbf307c4c2956d5fab4183f1073a811db", + "output_key": "corrected__wide_edge_window__t2", + "selection_count_unchanged": true, + "tags": [ + "wide", + "first_last_columns", + "edge_window" + ], + "thickness": 2 + }, + { + "base_id": "wide_edge_window", + "case_id": "wide_edge_window__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 4 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4032000000000000", + "4034800000000000", + "4037000000000000", + "4039800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4032000000000000", + "4004000000000000", + "4004000000000000", + "4004000000000000" + ], + "output_canonical_hash": "4a154ac77147d3967cdbcce6f6a633ccbf307c4c2956d5fab4183f1073a811db", + "output_key": "corrected__wide_edge_window__t3", + "selection_count_unchanged": true, + "tags": [ + "wide", + "first_last_columns", + "edge_window" + ], + "thickness": 3 + }, + { + "base_id": "wide_edge_window", + "case_id": "wide_edge_window__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x0.0p+0", + "0x0.0p+0", + "0x0.0p+0", + "0x1.0000000000000p+2" + ], + "normalized_endpoints": [ + 0, + 0, + 0, + 4 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "40114b4b4b4b4b4b", + "401b4b4b4b4b4b4b", + "4022a5a5a5a5a5a6", + "402a969696969697" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "40114b4b4b4b4b4b", + "4004000000000000", + "4004000000000000", + "400fc3c3c3c3c3c4" + ], + "output_canonical_hash": "0d76a9c4f4c70739ba5254748a41602d81351fec2a8f1fb3b9c50090b06fa1a5", + "output_key": "corrected__wide_edge_window__t128", + "selection_count_unchanged": true, + "tags": [ + "wide", + "first_last_columns", + "edge_window" + ], + "thickness": 128 + }, + { + "base_id": "tall_edge_window", + "case_id": "tall_edge_window__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+4" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 16 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c030c00000000000", + "c032800000000000", + "c034400000000000", + "c036000000000000", + "c037c00000000000", + "c039800000000000", + "c03b400000000000", + "c03d000000000000", + "c03ec00000000000", + "c040400000000000", + "c041200000000000", + "c042000000000000", + "c042e00000000000", + "c043c00000000000", + "c044a00000000000", + "c045800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c030c00000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000" + ], + "output_canonical_hash": "743048c754fd6d005556ab0a551fe1db5082889247da7fbb3bd987a41ae3d72c", + "output_key": "corrected__tall_edge_window__t1", + "selection_count_unchanged": true, + "tags": [ + "tall", + "first_last_columns", + "edge_window" + ], + "thickness": 1 + }, + { + "base_id": "tall_edge_window", + "case_id": "tall_edge_window__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+4" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 16 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c030c00000000000", + "c032800000000000", + "c034400000000000", + "c036000000000000", + "c037c00000000000", + "c039800000000000", + "c03b400000000000", + "c03d000000000000", + "c03ec00000000000", + "c040400000000000", + "c041200000000000", + "c042000000000000", + "c042e00000000000", + "c043c00000000000", + "c044a00000000000", + "c045800000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c030c00000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000" + ], + "output_canonical_hash": "743048c754fd6d005556ab0a551fe1db5082889247da7fbb3bd987a41ae3d72c", + "output_key": "corrected__tall_edge_window__t2", + "selection_count_unchanged": true, + "tags": [ + "tall", + "first_last_columns", + "edge_window" + ], + "thickness": 2 + }, + { + "base_id": "tall_edge_window", + "case_id": "tall_edge_window__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+4" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 16 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c022800000000000", + "c026000000000000", + "c029800000000000", + "c02d000000000000", + "c030400000000000", + "c032000000000000", + "c033c00000000000", + "c035800000000000", + "c037400000000000", + "c039000000000000", + "c03ac00000000000", + "c03c800000000000", + "c03e400000000000", + "c040000000000000", + "c040e00000000000", + "c041c00000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c022800000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000" + ], + "output_canonical_hash": "6fcc09b9955d42781dec3ec9b71c4a1c8918b10709079c6ed4972346ff27722f", + "output_key": "corrected__tall_edge_window__t3", + "selection_count_unchanged": true, + "tags": [ + "tall", + "first_last_columns", + "edge_window" + ], + "thickness": 3 + }, + { + "base_id": "tall_edge_window", + "case_id": "tall_edge_window__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.0000000000000p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+4" + ], + "normalized_endpoints": [ + 4, + 0, + 4, + 16 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "c013000000000000", + "c01a000000000000", + "c020800000000000", + "c024000000000000", + "c027800000000000", + "c02b000000000000", + "c02e800000000000", + "c031000000000000", + "c032c00000000000", + "c034800000000000", + "c036400000000000", + "c038000000000000", + "c039c00000000000", + "c03b800000000000", + "c03d400000000000", + "c040666666666666" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "c013000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "bffc000000000000", + "c00c666666666666" + ], + "output_canonical_hash": "6690a460d28cfe4c05a3e3e2105f26443f8ab4a5805bc2c871b3e2979da63e73", + "output_key": "corrected__tall_edge_window__t128", + "selection_count_unchanged": true, + "tags": [ + "tall", + "first_last_columns", + "edge_window" + ], + "thickness": 128 + }, + { + "base_id": "anisotropic_physical_coordinates", + "case_id": "anisotropic_physical_coordinates__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.7333333333333p+0", + "0x1.8f5c28f5c28f6p-1", + "0x1.73d70a3d70a3dp+3", + "0x1.8b851eb851eb8p+2" + ], + "normalized_endpoints": [ + 1, + 0, + 8, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4008000000000000", + "4018000000000000", + "4022000000000000", + "4028000000000000", + "402e000000000000", + "4032000000000000", + "4032000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "0000000000000000" + ], + "output_canonical_hash": "65883b8732e68b57d3e323a6e0867b96e3579a41fcfa1f6c01a24f64471136c0", + "output_key": "corrected__anisotropic_physical_coordinates__t1", + "selection_count_unchanged": true, + "tags": [ + "anisotropic_range", + "physical_to_pixel", + "fractional" + ], + "thickness": 1 + }, + { + "base_id": "anisotropic_physical_coordinates", + "case_id": "anisotropic_physical_coordinates__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.7333333333333p+0", + "0x1.8f5c28f5c28f6p-1", + "0x1.73d70a3d70a3dp+3", + "0x1.8b851eb851eb8p+2" + ], + "normalized_endpoints": [ + 1, + 0, + 8, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4008000000000000", + "4018000000000000", + "4022000000000000", + "4028000000000000", + "402e000000000000", + "4032000000000000", + "4032000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "0000000000000000" + ], + "output_canonical_hash": "65883b8732e68b57d3e323a6e0867b96e3579a41fcfa1f6c01a24f64471136c0", + "output_key": "corrected__anisotropic_physical_coordinates__t2", + "selection_count_unchanged": true, + "tags": [ + "anisotropic_range", + "physical_to_pixel", + "fractional" + ], + "thickness": 2 + }, + { + "base_id": "anisotropic_physical_coordinates", + "case_id": "anisotropic_physical_coordinates__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.7333333333333p+0", + "0x1.8f5c28f5c28f6p-1", + "0x1.73d70a3d70a3dp+3", + "0x1.8b851eb851eb8p+2" + ], + "normalized_endpoints": [ + 1, + 0, + 8, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4008000000000000", + "4018000000000000", + "4022000000000000", + "4028000000000000", + "402e000000000000", + "4032000000000000", + "4035000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000" + ], + "output_canonical_hash": "4e9fbfa79553687bf0beca278d8b62caec8e63d52df889e6aa098ed926893ba2", + "output_key": "corrected__anisotropic_physical_coordinates__t3", + "selection_count_unchanged": true, + "tags": [ + "anisotropic_range", + "physical_to_pixel", + "fractional" + ], + "thickness": 3 + }, + { + "base_id": "anisotropic_physical_coordinates", + "case_id": "anisotropic_physical_coordinates__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 1, + "lines_hex": [ + "0x1.7333333333333p+0", + "0x1.8f5c28f5c28f6p-1", + "0x1.73d70a3d70a3dp+3", + "0x1.8b851eb851eb8p+2" + ], + "normalized_endpoints": [ + 1, + 0, + 8, + 7 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4008000000000000", + "4018000000000000", + "4022000000000000", + "4028000000000000", + "402e000000000000", + "4032000000000000", + "4035000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000", + "4008000000000000" + ], + "output_canonical_hash": "4e9fbfa79553687bf0beca278d8b62caec8e63d52df889e6aa098ed926893ba2", + "output_key": "corrected__anisotropic_physical_coordinates__t128", + "selection_count_unchanged": true, + "tags": [ + "anisotropic_range", + "physical_to_pixel", + "fractional" + ], + "thickness": 128 + }, + { + "base_id": "floor_c_truncation_starts_ends", + "case_id": "floor_c_truncation_starts_ends__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.799999999999ap+2", + "0x1.999999999999ap-4", + "0x1.999999999999ap-3", + "0x1.3333333333333p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+2", + "0x1.3333333333333p+0", + "0x1.0666666666666p+2", + "0x1.f333333333334p+2", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 5, + 0, + 0, + 4, + 0, + 4, + 8, + 4, + 1, + 4, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4026000000000000", + "4036000000000000", + "4040800000000000", + "4046000000000000", + "404b800000000000", + "4050800000000000", + "4053400000000000", + "4056000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000" + ], + "output_canonical_hash": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "output_key": "corrected__floor_c_truncation_starts_ends__t1", + "selection_count_unchanged": true, + "tags": [ + "endpoint_floor", + "c_truncation", + "starts_before_ends", + "horizontal_exclusion" + ], + "thickness": 1 + }, + { + "base_id": "floor_c_truncation_starts_ends", + "case_id": "floor_c_truncation_starts_ends__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.799999999999ap+2", + "0x1.999999999999ap-4", + "0x1.999999999999ap-3", + "0x1.3333333333333p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+2", + "0x1.3333333333333p+0", + "0x1.0666666666666p+2", + "0x1.f333333333334p+2", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 5, + 0, + 0, + 4, + 0, + 4, + 8, + 4, + 1, + 4, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4026000000000000", + "4036000000000000", + "4040800000000000", + "4046000000000000", + "404b800000000000", + "4050800000000000", + "4053400000000000", + "4056000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000" + ], + "output_canonical_hash": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "output_key": "corrected__floor_c_truncation_starts_ends__t2", + "selection_count_unchanged": true, + "tags": [ + "endpoint_floor", + "c_truncation", + "starts_before_ends", + "horizontal_exclusion" + ], + "thickness": 2 + }, + { + "base_id": "floor_c_truncation_starts_ends", + "case_id": "floor_c_truncation_starts_ends__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.799999999999ap+2", + "0x1.999999999999ap-4", + "0x1.999999999999ap-3", + "0x1.3333333333333p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+2", + "0x1.3333333333333p+0", + "0x1.0666666666666p+2", + "0x1.f333333333334p+2", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 5, + 0, + 0, + 4, + 0, + 4, + 8, + 4, + 1, + 4, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4026000000000000", + "4036000000000000", + "4040800000000000", + "4046000000000000", + "404b800000000000", + "4050800000000000", + "4053400000000000", + "4056000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000" + ], + "output_canonical_hash": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "output_key": "corrected__floor_c_truncation_starts_ends__t3", + "selection_count_unchanged": true, + "tags": [ + "endpoint_floor", + "c_truncation", + "starts_before_ends", + "horizontal_exclusion" + ], + "thickness": 3 + }, + { + "base_id": "floor_c_truncation_starts_ends", + "case_id": "floor_c_truncation_starts_ends__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.799999999999ap+2", + "0x1.999999999999ap-4", + "0x1.999999999999ap-3", + "0x1.3333333333333p+2", + "0x0.0p+0", + "0x1.0000000000000p+2", + "0x1.0000000000000p+3", + "0x1.0000000000000p+2", + "0x1.3333333333333p+0", + "0x1.0666666666666p+2", + "0x1.f333333333334p+2", + "0x1.0000000000000p+3" + ], + "normalized_endpoints": [ + 5, + 0, + 0, + 4, + 0, + 4, + 8, + 4, + 1, + 4, + 7, + 8 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "4026000000000000", + "4036000000000000", + "4040800000000000", + "4046000000000000", + "404b800000000000", + "4050800000000000", + "4053400000000000", + "4056000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000", + "4026000000000000" + ], + "output_canonical_hash": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "output_key": "corrected__floor_c_truncation_starts_ends__t128", + "selection_count_unchanged": true, + "tags": [ + "endpoint_floor", + "c_truncation", + "starts_before_ends", + "horizontal_exclusion" + ], + "thickness": 128 + }, + { + "base_id": "line_order_a", + "case_id": "line_order_a__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3", + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9, + 8, + 0, + 8, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "output_key": "corrected__line_order_a__t1", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "same_start_end", + "overlap", + "floating_sum_order" + ], + "thickness": 1 + }, + { + "base_id": "line_order_a", + "case_id": "line_order_a__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3", + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9, + 8, + 0, + 8, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "output_key": "corrected__line_order_a__t2", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "same_start_end", + "overlap", + "floating_sum_order" + ], + "thickness": 2 + }, + { + "base_id": "line_order_a", + "case_id": "line_order_a__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3", + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9, + 8, + 0, + 8, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "output_key": "corrected__line_order_a__t3", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "same_start_end", + "overlap", + "floating_sum_order" + ], + "thickness": 3 + }, + { + "base_id": "line_order_a", + "case_id": "line_order_a__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3", + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9, + 8, + 0, + 8, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "output_key": "corrected__line_order_a__t128", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "same_start_end", + "overlap", + "floating_sum_order" + ], + "thickness": 128 + }, + { + "base_id": "line_order_b_permuted", + "case_id": "line_order_b_permuted__t1", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3", + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 8, + 0, + 8, + 9, + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3fd5555555555555", + "3fe5555555555555", + "3ff0000000000000", + "3ff5555555555555", + "3ffaaaaaaaaaaaaa", + "3fffffffffffffff", + "4002aaaaaaaaaaaa", + "4005555555555555", + "4008000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555", + "3fd5555555555555" + ], + "output_canonical_hash": "8deb39b674d4f29c27a333036f49f9cebc630c935082d4c3de99e93f3e6c0f78", + "output_key": "corrected__line_order_b_permuted__t1", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "permuted_object_order", + "same_start_end", + "floating_sum_order" + ], + "thickness": 1 + }, + { + "base_id": "line_order_b_permuted", + "case_id": "line_order_b_permuted__t2", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3", + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 8, + 0, + 8, + 9, + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3fc5555555555555", + "3fd5555555555555", + "3fe0000000000000", + "3fe5555555555555", + "3feaaaaaaaaaaaaa", + "3fefffffffffffff", + "3ff2aaaaaaaaaaaa", + "3ff5555555555555", + "3ff8000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555", + "3fc5555555555555" + ], + "output_canonical_hash": "8349941775d84b6000b8d76a068c59c94005493cfcd9ab661723fb5f6b5fbd24", + "output_key": "corrected__line_order_b_permuted__t2", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "permuted_object_order", + "same_start_end", + "floating_sum_order" + ], + "thickness": 2 + }, + { + "base_id": "line_order_b_permuted", + "case_id": "line_order_b_permuted__t3", + "data_changed_signals": 1, + "external_mutation_of_data_field": true, + "external_no_op": false, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3", + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 8, + 0, + 8, + 9, + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "3fbc71c71c71c71c", + "3fcc71c71c71c71c", + "3fd5555555555555", + "3fdc71c71c71c71c", + "3fe1c71c71c71c72", + "3fe5555555555556", + "3fe8e38e38e38e3a", + "3fec71c71c71c71e", + "3ff0000000000001" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c", + "3fbc71c71c71c71c" + ], + "output_canonical_hash": "85561a2c0e1710ff158f7fa914fed201a96ee1fd03bda3929d6529b4cc6c725a", + "output_key": "corrected__line_order_b_permuted__t3", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "permuted_object_order", + "same_start_end", + "floating_sum_order" + ], + "thickness": 3 + }, + { + "base_id": "line_order_b_permuted", + "case_id": "line_order_b_permuted__t128", + "data_changed_signals": 1, + "external_mutation_of_data_field": false, + "external_no_op": true, + "line_count": 3, + "lines_hex": [ + "0x1.0000000000000p+3", + "0x0.0p+0", + "0x1.0000000000000p+3", + "0x1.2000000000000p+3", + "0x1.0000000000000p+1", + "0x0.0p+0", + "0x1.0000000000000p+1", + "0x1.2000000000000p+3", + "0x1.4000000000000p+2", + "0x0.0p+0", + "0x1.4000000000000p+2", + "0x1.2000000000000p+3" + ], + "normalized_endpoints": [ + 8, + 0, + 8, + 9, + 2, + 0, + 2, + 9, + 5, + 0, + 5, + 9 + ], + "oracle_cumulative_correction_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "oracle_row_differences_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "output_canonical_hash": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "output_key": "corrected__line_order_b_permuted__t128", + "selection_count_unchanged": true, + "tags": [ + "line_id_order", + "permuted_object_order", + "same_start_end", + "floating_sum_order" + ], + "thickness": 128 + } + ], + "evidence": { + "external_executed_records": "canonical_reference.json", + "external_oracle_agreement": { + "exact_cases": 72, + "exact_elements": 4652, + "finite_nonzero_mismatches": 0, + "line_order_discriminator_external": true, + "line_order_discriminator_oracle": true, + "max_absolute_difference": 0.0, + "max_ulp_distance": 0, + "mutation_agreement": 72, + "no_op_agreement": 72, + "normalized_endpoint_agreement": 72, + "oracle_input_mutation_maximum": 0, + "schema_version": 1, + "signed_zero_mismatches": 0, + "total_cases": 72, + "total_elements": 4652 + }, + "independent_oracle_outputs": "comparison_ledger.json: 72/72 bitwise exact", + "independent_regeneration": "regenerated_input_report.json: 72/72", + "source_hashes": { + "external_artifacts": { + "build/asan_compile.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build/asan_compile.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build/normal_compile.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build/normal_compile.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "campaign.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "campaign.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "canonical_reference.json": "5dcbd07836de0d6cd856dbfe620f7c24edded25a993c17472746b09e80902d84", + "case_definitions.json": "b133d6ab04a2c75cbaa276abed1be899f0ec914a6e12737ddb96c6e11c7f933d", + "cases/anisotropic_physical_coordinates__t1.ini": "afeffb0651e955cd314e1b2f7d4faf7ef93ce235ad51867924a16d13f0d1bcf1", + "cases/anisotropic_physical_coordinates__t128.ini": "eafbf974a7e5d6b15c2703cb5395f927799a3072e5314548e93528c01a2fd592", + "cases/anisotropic_physical_coordinates__t2.ini": "31bc9cc8a04ed75f9d9a62ce1cb8cc0deb068088b995702b95720b8bcaaffb29", + "cases/anisotropic_physical_coordinates__t3.ini": "ffce1f95486bd574a1adca818a1f15d14cee11d948ae61da8a69776896fd1abd", + "cases/constant_horizontal__t1.ini": "b3f96726abaf641889b16ccc770842a354e3853601da6e014b838283019f43fa", + "cases/constant_horizontal__t128.ini": "cf993d45a873e3003d008095df2d186056ee65d6110798ee305022fcd23612e8", + "cases/constant_horizontal__t2.ini": "67c8b3c950afea142e6eb621cdf66e11c08f51dbb94049588cd8b0addc9bd0a5", + "cases/constant_horizontal__t3.ini": "448eb1e988f5db1b8fbf315b26c1bbb5bb5e8cf5d9d337b9c9c0a3ce1b383ee8", + "cases/constant_no_lines__t1.ini": "00da4a12b37237b3e702c3f4d4712600f195fcea1cba390cf2e77db73f5003f6", + "cases/constant_no_lines__t128.ini": "d8919ea8a00ef4b8ff5afc7517587fa6453a588a7ba1f5e582c37298ac7ab298", + "cases/constant_no_lines__t2.ini": "adfc019941c3a7365dc05f41a8bd124eb2d6664b3de12211d9b1fc5d6ab53568", + "cases/constant_no_lines__t3.ini": "f3aab9d2490a2383cbfea1f0dc7efa3c50a974a894a5c6d09932177a47df88e6", + "cases/floor_c_truncation_starts_ends__t1.ini": "243719896c089a04e315f920e238914c302f3aa98153f41293aff1e39db215d8", + "cases/floor_c_truncation_starts_ends__t128.ini": "00ae6f0a119d05637828858f7bc9e686764b3f5a6dd5d37a599ae0f3ff5fadb9", + "cases/floor_c_truncation_starts_ends__t2.ini": "08c96406a73cc24b564b5ed836f7eaa005694e1e7d2ed96b4d8a5abb018182ae", + "cases/floor_c_truncation_starts_ends__t3.ini": "f4d1e2169492f373c16103c02d1ddbbd46e3f415a997caeebfe64a576334d315", + "cases/irregular_outside_endpoints__t1.ini": "a2f3ebcc8391e2c4f46f28a3a019cb78e2c236368c441f7f716a6567228a8377", + "cases/irregular_outside_endpoints__t128.ini": "425e6e661d48ff51aaaf5bb9691f2b8ed0ef9f684476f5ee8ade73bbb59e27cf", + "cases/irregular_outside_endpoints__t2.ini": "e0a63a25e8400450670a2e75e934fb7693e2af62a7ad3c3da205203e535f135f", + "cases/irregular_outside_endpoints__t3.ini": "0b6e1c561d31e94438c51d3f7fbd3b916ae44f9be2f7a7dd39a2f8fbad9d6a6f", + "cases/line_order_a__t1.ini": "bce8488351f47d16cbe8a3a451e12ede7b34aaf5c214f87931d4581a1c5b4eaa", + "cases/line_order_a__t128.ini": "ba1e3651cf3d794e869e6129d9873ab01cc14168f2525c32f898f2ef70c8f27c", + "cases/line_order_a__t2.ini": "8895a60770f348f9cfabed99c0b69ae1e76c3293dd8345ef015c07ab5a1e68e6", + "cases/line_order_a__t3.ini": "3f001f62306340ca31354185415eb1381f488ab6cbf07c1b16c195bbd55a44db", + "cases/line_order_b_permuted__t1.ini": "a9b3252e97a571518f72f93831b3bbbad40a865cd3c6f94eb77cda15b8acb503", + "cases/line_order_b_permuted__t128.ini": "450efbaf6e39c8ec2a21b14daaa11d1f52501e282008b99fbd3f8eb986041a12", + "cases/line_order_b_permuted__t2.ini": "5da01acad5692b29ef7bdc2386b8ce538d396de3114718741bf3730f631b6cb6", + "cases/line_order_b_permuted__t3.ini": "8c9a1a642bbd5a3bad084bd243e68566dac55bbe35a9f261db3865a078c308a9", + "cases/negative_impulse_reversed__t1.ini": "dad1e60b819354f5e022d75c0ea0ea74c46cf6dc6ad0a0d05c61cd5a5944255b", + "cases/negative_impulse_reversed__t128.ini": "d9fee15053724f6f44251dcc52654beef98527e7b1459a2f9b46573da1d0d220", + "cases/negative_impulse_reversed__t2.ini": "7cc4e82a5004f6fd79b07a48753cfd5b43372df490e30b0fc43954cf8127ce23", + "cases/negative_impulse_reversed__t3.ini": "6cc9d3848992c440d36c53e4b755e0762732845d761f7b5bace1ae644decea48", + "cases/plateau_signed_zero_partial_clamp__t1.ini": "09f1052921ec0ed95b5c713556bfcdb99de4fd473da29a153fe8bbd2d539d1e8", + "cases/plateau_signed_zero_partial_clamp__t128.ini": "c1a81ffd5fcec7bb671cb16e16bda12aa840c64d6fe2aa1e5b6f198f81e35f1f", + "cases/plateau_signed_zero_partial_clamp__t2.ini": "ddb9b7c3bffebae3e37e13b040f315da6177b10f75b790d64341b52f49551676", + "cases/plateau_signed_zero_partial_clamp__t3.ini": "378f8036042f0414478852b406e32c0c3590eb3565b3eaa9c9a6133037b33e78", + "cases/positive_impulse_fractional__t1.ini": "acef0ebdc8e7188f42cb3924ad8757cf3016a4de7fe968b46394edb82b6dc974", + "cases/positive_impulse_fractional__t128.ini": "6f2469efe5bec71452489d59cd596ca494bd72745a22a6f23b2d061fc165a2e0", + "cases/positive_impulse_fractional__t2.ini": "53f45bf596ca7abbd9732046cf9dae4e3402616b12e6af4b9fa78de5405a30ad", + "cases/positive_impulse_fractional__t3.ini": "c3e2a0e5e161ee17794173370a11a414200c51a6ffb1fe2f641043196a2cd1e5", + "cases/row_offset_duplicate_overlap__t1.ini": "16f78567c8c6a47cd215dc85c0da0efc12ef3a6a62ff9e7842fa891523d36c25", + "cases/row_offset_duplicate_overlap__t128.ini": "2e66e3da8ee4d4199f029c60546337dc58c8267fcc782e912604314ab86de1f8", + "cases/row_offset_duplicate_overlap__t2.ini": "7a3cf0664572f17f40f57723fae88b10e50855a8dfae496674f51c78cf644701", + "cases/row_offset_duplicate_overlap__t3.ini": "12fc3185939b4a81d1d79542e6add3f390ec47fc14334df321ddf2dc1ccfbf2b", + "cases/signed_gradient_positive_slope__t1.ini": "f882823f414417afdb283e39e47ec922949a2f7afb232b8db8f9c4ab10b499c0", + "cases/signed_gradient_positive_slope__t128.ini": "38b388380a86a72e6804f1adf174228e958b89a011d3f4bfb809d27a4889c47b", + "cases/signed_gradient_positive_slope__t2.ini": "4ff52d1eaf06e94100610311034c33a915f563ab56b038503dad4ea034bd532e", + "cases/signed_gradient_positive_slope__t3.ini": "52601b0fc9dcfc3091da243545211b673dbacf87ea93cf3c2013a28a12e24341", + "cases/singleton_1x1_no_lines__t1.ini": "705fe80d58e6ee6f1ae9dc34fe6a7db5c4d10471db9f80f35b45f48b6e267d5c", + "cases/singleton_1x1_no_lines__t128.ini": "1c440db00ee86b806a7771a370535a1a490278e2b16777eb8b463bc41b2ac303", + "cases/singleton_1x1_no_lines__t2.ini": "006d758bf8c286943af75614473dd0641bb8c434ae9ae3f8aa691d31085fc409", + "cases/singleton_1x1_no_lines__t3.ini": "d70b16d124464138394c6adb4b0d7a17b911e3fa89f732692db2f63f270f9e11", + "cases/singleton_column_9x1_vertical__t1.ini": "e4c39202647b34baf415336b6a5fb15946b779ff99913da9b2c2c81cf604dccb", + "cases/singleton_column_9x1_vertical__t128.ini": "2f8a849760ef4d66eeeb060097164c3d9d2935a10ccba6fe754cfe643ceb9451", + "cases/singleton_column_9x1_vertical__t2.ini": "465c29c28ce563b3ee27696238399c919d7ac22a1cb573ee6c3d2f724fbc4a8b", + "cases/singleton_column_9x1_vertical__t3.ini": "7e829e7b19afb5c0348612e0d26120412413315119c3cc39e365136e218c83e8", + "cases/singleton_row_1x9_horizontal__t1.ini": "edb792b382e11428183cd2d18765d478e32a6dc61052d6246c0cea1dac3ebca0", + "cases/singleton_row_1x9_horizontal__t128.ini": "8c970ad1d29e56235a619f2b43c52a74d09f76ac6c98ffbe6f6aa1cb7d1b6d99", + "cases/singleton_row_1x9_horizontal__t2.ini": "905bed56f93f5ad4618794870005aca01f1c31bbcc5511cc7efba91b6bd60e62", + "cases/singleton_row_1x9_horizontal__t3.ini": "c0b167ff1b0711da58567cfb605155db7dad1759967e39ac1f9e0dcd806105e8", + "cases/step_negative_slope__t1.ini": "51a4bb4c2fefda5fda160dae20f11de3db1d12a94bc6e382974e31a92af25e42", + "cases/step_negative_slope__t128.ini": "2889917cb160eda58b031390e5bcb1831e5e6d5cbd4ff92cdbc35c946b01c863", + "cases/step_negative_slope__t2.ini": "3902965d669613084b53e6f76d53b3a6841e8518fbeb4544a0ecb52977e690a4", + "cases/step_negative_slope__t3.ini": "4266d291a9cec4c193e8154eb5f336357f038d2b41794af3bbffbbcd6b8650da", + "cases/tall_edge_window__t1.ini": "507045c9241eba005dcf12b7fd2e0f0cd040a6cb608e93d4de5657e913b1c0ae", + "cases/tall_edge_window__t128.ini": "f98e41ae17a0717637fc50eb17dcbc6bd27b2c263176d89919a06c9ccc96d516", + "cases/tall_edge_window__t2.ini": "956e1f398a7e53c1754f2954a64c8be306abfaf6e8d90e4149598b7c9172abe7", + "cases/tall_edge_window__t3.ini": "c16070880f30eadde78b1ddbca2323b7e9b622ed675a8d214be9233f8e3acb46", + "cases/wide_edge_window__t1.ini": "d9ad6680ddf0a1242126abbacfec353433e6ecb3b138808fd1dbe1dfdd53f420", + "cases/wide_edge_window__t128.ini": "900dd890d2914fd6f05712a6be798b5a7db8aa92336b3331ebbec1607fc055c2", + "cases/wide_edge_window__t2.ini": "72aa85e676eb9934ee573fd2aacb4d64b60d98e4d755aa96d7a8b099635933ba", + "cases/wide_edge_window__t3.ini": "1fe2f2be7bc2e89a4080198e80a059f1e917a5b26659bd012ba7db8a2c89db71", + "debugger_symbol_map.gdb": "26885933d3365af23f5ff7ff492336750a4139aa8dc40f8a6d0ab90b47268fab", + "debugger_symbol_map.log": "4359c571dbdb519a649bd9f073f272c02c2d609bd86792c1d67476aad8e061b4", + "debugger_symbol_mapping.json": "ed2a084bd91b10a6185c1a8e41ae0a360a1e208eaf54da58efc90c2c14bc76cb", + "debugger_trace.gdb": "67173ccee0b95e63ff010758959316dfde2bd51f8c37523c7ffd38cc322c8ff1", + "debugger_trace.log": "44924d5065b785d1d2664f7124f86411b858d275f827f2e8f5a3f00f7ef083ff", + "diagnostics/asan_harness_probe.stderr": "2b8775a9f736ed171b0ae6abf80b437e792466248400a352b432369730624fac", + "diagnostics/asan_harness_probe.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/manifest_verification/final_verify.log": "013ea7a0ba15684ca8489bb2329e1c42d6cb7f7c742f0d01c895496ac2306f2f", + "diagnostics/manifest_verification/initial_verify.log": "013ea7a0ba15684ca8489bb2329e1c42d6cb7f7c742f0d01c895496ac2306f2f", + "diagnostics/partial_attempt/build/asan_compile.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/build/asan_compile.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/build/normal_compile.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/build/normal_compile.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/campaign.stderr": "b0c6d60140c0c85eaa8e1a1e699932b72b1cf910c246c425a1e65a91c4d2326b", + "diagnostics/partial_attempt/campaign.stdout": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/case_definitions.json": "b133d6ab04a2c75cbaa276abed1be899f0ec914a6e12737ddb96c6e11c7f933d", + "diagnostics/partial_attempt/cases/anisotropic_physical_coordinates__t1.ini": "afeffb0651e955cd314e1b2f7d4faf7ef93ce235ad51867924a16d13f0d1bcf1", + "diagnostics/partial_attempt/cases/anisotropic_physical_coordinates__t128.ini": "eafbf974a7e5d6b15c2703cb5395f927799a3072e5314548e93528c01a2fd592", + "diagnostics/partial_attempt/cases/anisotropic_physical_coordinates__t2.ini": "31bc9cc8a04ed75f9d9a62ce1cb8cc0deb068088b995702b95720b8bcaaffb29", + "diagnostics/partial_attempt/cases/anisotropic_physical_coordinates__t3.ini": "ffce1f95486bd574a1adca818a1f15d14cee11d948ae61da8a69776896fd1abd", + "diagnostics/partial_attempt/cases/constant_horizontal__t1.ini": "b3f96726abaf641889b16ccc770842a354e3853601da6e014b838283019f43fa", + "diagnostics/partial_attempt/cases/constant_horizontal__t128.ini": "cf993d45a873e3003d008095df2d186056ee65d6110798ee305022fcd23612e8", + "diagnostics/partial_attempt/cases/constant_horizontal__t2.ini": "67c8b3c950afea142e6eb621cdf66e11c08f51dbb94049588cd8b0addc9bd0a5", + "diagnostics/partial_attempt/cases/constant_horizontal__t3.ini": "448eb1e988f5db1b8fbf315b26c1bbb5bb5e8cf5d9d337b9c9c0a3ce1b383ee8", + "diagnostics/partial_attempt/cases/constant_no_lines__t1.ini": "00da4a12b37237b3e702c3f4d4712600f195fcea1cba390cf2e77db73f5003f6", + "diagnostics/partial_attempt/cases/constant_no_lines__t128.ini": "d8919ea8a00ef4b8ff5afc7517587fa6453a588a7ba1f5e582c37298ac7ab298", + "diagnostics/partial_attempt/cases/constant_no_lines__t2.ini": "adfc019941c3a7365dc05f41a8bd124eb2d6664b3de12211d9b1fc5d6ab53568", + "diagnostics/partial_attempt/cases/constant_no_lines__t3.ini": "f3aab9d2490a2383cbfea1f0dc7efa3c50a974a894a5c6d09932177a47df88e6", + "diagnostics/partial_attempt/cases/floor_c_truncation_starts_ends__t1.ini": "243719896c089a04e315f920e238914c302f3aa98153f41293aff1e39db215d8", + "diagnostics/partial_attempt/cases/floor_c_truncation_starts_ends__t128.ini": "00ae6f0a119d05637828858f7bc9e686764b3f5a6dd5d37a599ae0f3ff5fadb9", + "diagnostics/partial_attempt/cases/floor_c_truncation_starts_ends__t2.ini": "08c96406a73cc24b564b5ed836f7eaa005694e1e7d2ed96b4d8a5abb018182ae", + "diagnostics/partial_attempt/cases/floor_c_truncation_starts_ends__t3.ini": "f4d1e2169492f373c16103c02d1ddbbd46e3f415a997caeebfe64a576334d315", + "diagnostics/partial_attempt/cases/irregular_outside_endpoints__t1.ini": "a2f3ebcc8391e2c4f46f28a3a019cb78e2c236368c441f7f716a6567228a8377", + "diagnostics/partial_attempt/cases/irregular_outside_endpoints__t128.ini": "425e6e661d48ff51aaaf5bb9691f2b8ed0ef9f684476f5ee8ade73bbb59e27cf", + "diagnostics/partial_attempt/cases/irregular_outside_endpoints__t2.ini": "e0a63a25e8400450670a2e75e934fb7693e2af62a7ad3c3da205203e535f135f", + "diagnostics/partial_attempt/cases/irregular_outside_endpoints__t3.ini": "0b6e1c561d31e94438c51d3f7fbd3b916ae44f9be2f7a7dd39a2f8fbad9d6a6f", + "diagnostics/partial_attempt/cases/line_order_a__t1.ini": "bce8488351f47d16cbe8a3a451e12ede7b34aaf5c214f87931d4581a1c5b4eaa", + "diagnostics/partial_attempt/cases/line_order_a__t128.ini": "ba1e3651cf3d794e869e6129d9873ab01cc14168f2525c32f898f2ef70c8f27c", + "diagnostics/partial_attempt/cases/line_order_a__t2.ini": "8895a60770f348f9cfabed99c0b69ae1e76c3293dd8345ef015c07ab5a1e68e6", + "diagnostics/partial_attempt/cases/line_order_a__t3.ini": "3f001f62306340ca31354185415eb1381f488ab6cbf07c1b16c195bbd55a44db", + "diagnostics/partial_attempt/cases/line_order_b_permuted__t1.ini": "a9b3252e97a571518f72f93831b3bbbad40a865cd3c6f94eb77cda15b8acb503", + "diagnostics/partial_attempt/cases/line_order_b_permuted__t128.ini": "450efbaf6e39c8ec2a21b14daaa11d1f52501e282008b99fbd3f8eb986041a12", + "diagnostics/partial_attempt/cases/line_order_b_permuted__t2.ini": "5da01acad5692b29ef7bdc2386b8ce538d396de3114718741bf3730f631b6cb6", + "diagnostics/partial_attempt/cases/line_order_b_permuted__t3.ini": "8c9a1a642bbd5a3bad084bd243e68566dac55bbe35a9f261db3865a078c308a9", + "diagnostics/partial_attempt/cases/negative_impulse_reversed__t1.ini": "dad1e60b819354f5e022d75c0ea0ea74c46cf6dc6ad0a0d05c61cd5a5944255b", + "diagnostics/partial_attempt/cases/negative_impulse_reversed__t128.ini": "d9fee15053724f6f44251dcc52654beef98527e7b1459a2f9b46573da1d0d220", + "diagnostics/partial_attempt/cases/negative_impulse_reversed__t2.ini": "7cc4e82a5004f6fd79b07a48753cfd5b43372df490e30b0fc43954cf8127ce23", + "diagnostics/partial_attempt/cases/negative_impulse_reversed__t3.ini": "6cc9d3848992c440d36c53e4b755e0762732845d761f7b5bace1ae644decea48", + "diagnostics/partial_attempt/cases/plateau_signed_zero_partial_clamp__t1.ini": "09f1052921ec0ed95b5c713556bfcdb99de4fd473da29a153fe8bbd2d539d1e8", + "diagnostics/partial_attempt/cases/plateau_signed_zero_partial_clamp__t128.ini": "c1a81ffd5fcec7bb671cb16e16bda12aa840c64d6fe2aa1e5b6f198f81e35f1f", + "diagnostics/partial_attempt/cases/plateau_signed_zero_partial_clamp__t2.ini": "ddb9b7c3bffebae3e37e13b040f315da6177b10f75b790d64341b52f49551676", + "diagnostics/partial_attempt/cases/plateau_signed_zero_partial_clamp__t3.ini": "378f8036042f0414478852b406e32c0c3590eb3565b3eaa9c9a6133037b33e78", + "diagnostics/partial_attempt/cases/positive_impulse_fractional__t1.ini": "acef0ebdc8e7188f42cb3924ad8757cf3016a4de7fe968b46394edb82b6dc974", + "diagnostics/partial_attempt/cases/positive_impulse_fractional__t128.ini": "6f2469efe5bec71452489d59cd596ca494bd72745a22a6f23b2d061fc165a2e0", + "diagnostics/partial_attempt/cases/positive_impulse_fractional__t2.ini": "53f45bf596ca7abbd9732046cf9dae4e3402616b12e6af4b9fa78de5405a30ad", + "diagnostics/partial_attempt/cases/positive_impulse_fractional__t3.ini": "c3e2a0e5e161ee17794173370a11a414200c51a6ffb1fe2f641043196a2cd1e5", + "diagnostics/partial_attempt/cases/row_offset_duplicate_overlap__t1.ini": "16f78567c8c6a47cd215dc85c0da0efc12ef3a6a62ff9e7842fa891523d36c25", + "diagnostics/partial_attempt/cases/row_offset_duplicate_overlap__t128.ini": "2e66e3da8ee4d4199f029c60546337dc58c8267fcc782e912604314ab86de1f8", + "diagnostics/partial_attempt/cases/row_offset_duplicate_overlap__t2.ini": "7a3cf0664572f17f40f57723fae88b10e50855a8dfae496674f51c78cf644701", + "diagnostics/partial_attempt/cases/row_offset_duplicate_overlap__t3.ini": "12fc3185939b4a81d1d79542e6add3f390ec47fc14334df321ddf2dc1ccfbf2b", + "diagnostics/partial_attempt/cases/signed_gradient_positive_slope__t1.ini": "f882823f414417afdb283e39e47ec922949a2f7afb232b8db8f9c4ab10b499c0", + "diagnostics/partial_attempt/cases/signed_gradient_positive_slope__t128.ini": "38b388380a86a72e6804f1adf174228e958b89a011d3f4bfb809d27a4889c47b", + "diagnostics/partial_attempt/cases/signed_gradient_positive_slope__t2.ini": "4ff52d1eaf06e94100610311034c33a915f563ab56b038503dad4ea034bd532e", + "diagnostics/partial_attempt/cases/signed_gradient_positive_slope__t3.ini": "52601b0fc9dcfc3091da243545211b673dbacf87ea93cf3c2013a28a12e24341", + "diagnostics/partial_attempt/cases/singleton_1x1_no_lines__t1.ini": "705fe80d58e6ee6f1ae9dc34fe6a7db5c4d10471db9f80f35b45f48b6e267d5c", + "diagnostics/partial_attempt/cases/singleton_1x1_no_lines__t128.ini": "1c440db00ee86b806a7771a370535a1a490278e2b16777eb8b463bc41b2ac303", + "diagnostics/partial_attempt/cases/singleton_1x1_no_lines__t2.ini": "006d758bf8c286943af75614473dd0641bb8c434ae9ae3f8aa691d31085fc409", + "diagnostics/partial_attempt/cases/singleton_1x1_no_lines__t3.ini": "d70b16d124464138394c6adb4b0d7a17b911e3fa89f732692db2f63f270f9e11", + "diagnostics/partial_attempt/cases/singleton_column_9x1_vertical__t1.ini": "e4c39202647b34baf415336b6a5fb15946b779ff99913da9b2c2c81cf604dccb", + "diagnostics/partial_attempt/cases/singleton_column_9x1_vertical__t128.ini": "2f8a849760ef4d66eeeb060097164c3d9d2935a10ccba6fe754cfe643ceb9451", + "diagnostics/partial_attempt/cases/singleton_column_9x1_vertical__t2.ini": "465c29c28ce563b3ee27696238399c919d7ac22a1cb573ee6c3d2f724fbc4a8b", + "diagnostics/partial_attempt/cases/singleton_column_9x1_vertical__t3.ini": "7e829e7b19afb5c0348612e0d26120412413315119c3cc39e365136e218c83e8", + "diagnostics/partial_attempt/cases/singleton_row_1x9_horizontal__t1.ini": "edb792b382e11428183cd2d18765d478e32a6dc61052d6246c0cea1dac3ebca0", + "diagnostics/partial_attempt/cases/singleton_row_1x9_horizontal__t128.ini": "8c970ad1d29e56235a619f2b43c52a74d09f76ac6c98ffbe6f6aa1cb7d1b6d99", + "diagnostics/partial_attempt/cases/singleton_row_1x9_horizontal__t2.ini": "905bed56f93f5ad4618794870005aca01f1c31bbcc5511cc7efba91b6bd60e62", + "diagnostics/partial_attempt/cases/singleton_row_1x9_horizontal__t3.ini": "c0b167ff1b0711da58567cfb605155db7dad1759967e39ac1f9e0dcd806105e8", + "diagnostics/partial_attempt/cases/step_negative_slope__t1.ini": "51a4bb4c2fefda5fda160dae20f11de3db1d12a94bc6e382974e31a92af25e42", + "diagnostics/partial_attempt/cases/step_negative_slope__t128.ini": "2889917cb160eda58b031390e5bcb1831e5e6d5cbd4ff92cdbc35c946b01c863", + "diagnostics/partial_attempt/cases/step_negative_slope__t2.ini": "3902965d669613084b53e6f76d53b3a6841e8518fbeb4544a0ecb52977e690a4", + "diagnostics/partial_attempt/cases/step_negative_slope__t3.ini": "4266d291a9cec4c193e8154eb5f336357f038d2b41794af3bbffbbcd6b8650da", + "diagnostics/partial_attempt/cases/tall_edge_window__t1.ini": "507045c9241eba005dcf12b7fd2e0f0cd040a6cb608e93d4de5657e913b1c0ae", + "diagnostics/partial_attempt/cases/tall_edge_window__t128.ini": "f98e41ae17a0717637fc50eb17dcbc6bd27b2c263176d89919a06c9ccc96d516", + "diagnostics/partial_attempt/cases/tall_edge_window__t2.ini": "956e1f398a7e53c1754f2954a64c8be306abfaf6e8d90e4149598b7c9172abe7", + "diagnostics/partial_attempt/cases/tall_edge_window__t3.ini": "c16070880f30eadde78b1ddbca2323b7e9b622ed675a8d214be9233f8e3acb46", + "diagnostics/partial_attempt/cases/wide_edge_window__t1.ini": "d9ad6680ddf0a1242126abbacfec353433e6ecb3b138808fd1dbe1dfdd53f420", + "diagnostics/partial_attempt/cases/wide_edge_window__t128.ini": "900dd890d2914fd6f05712a6be798b5a7db8aa92336b3331ebbec1607fc055c2", + "diagnostics/partial_attempt/cases/wide_edge_window__t2.ini": "72aa85e676eb9934ee573fd2aacb4d64b60d98e4d755aa96d7a8b099635933ba", + "diagnostics/partial_attempt/cases/wide_edge_window__t3.ini": "1fe2f2be7bc2e89a4080198e80a059f1e917a5b26659bd012ba7db8a2c89db71", + "diagnostics/partial_attempt/execution/xvfb.stderr": "8c1f239fee83cab6bc55f538764f9d775d4d6a84614fcf64468213888242479a", + "diagnostics/partial_attempt/identity.json": "4de945de9fb37e7c7545b93cd99d0ab4f9bc4b868358aceb7f870c4298aaa72f", + "diagnostics/partial_attempt/pathlevel_harness": "23be2b8e47f125001d28c5da5b3ce08b9830953dd3e0b44cd568778ff42aaedc", + "diagnostics/partial_attempt/pathlevel_harness_asan": "3bd261fd0865fa6cc4da038c8c4880518325596fd3a612d62385674063430149", + "diagnostics/partial_attempt/raw/asan/singleton_1x1_no_lines__t1.jsonl": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/asan/singleton_1x1_no_lines__t1.stderr": "42c2dc0696f4a9d3794fc6e4ff63b169187caae3ceda164df96da71467688302", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t1.jsonl": "66fc7aee07c798976f73d3c02fb548e49028ad7e7f6abf29c8030cfc7941f769", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t128.jsonl": "3efa4fabe260b9e66d500907f93de64b4588aa2087255b91ff7de57d7f34263d", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t2.jsonl": "404138bf058b8c3a20ce8d2a29668110ba5e61c32f43d93404cd97a620f9b557", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t3.jsonl": "c30b6124bb7e3759d63cdeb2394252b639fefc0628b88affae953b883de7d9d5", + "diagnostics/partial_attempt/raw/normal_repeat_1/anisotropic_physical_coordinates__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t1.jsonl": "535a241c5ecda16a314077fe31f5141291af7254b1d88dacbeced8fadeb011aa", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t128.jsonl": "6777a1a29438762a2a6a7a45c0d380932260153ec06cee73f489049b9d637e55", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t2.jsonl": "dd6aa64c6793656912579b1b8347fdd77108c93b0d44d386ec61b84a6f345add", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t3.jsonl": "7774997731b79e6e8c6b24aaeedccfbf8b316149d69790bdd0511c8e37ba135c", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t1.jsonl": "77003691bfd3aa5d7a94927da76d60f61e7c44cc8b16d66bd658fc7109eac64f", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t128.jsonl": "6558b2021d0ad386476b9110d184cbc22dedf5f5067056c35f1c6618ddd9e308", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t2.jsonl": "cb4865c48dd5790e798435e0316efe41d126fc3e9ab627f8aeb052de4cf18a21", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t3.jsonl": "f9a6f9bee105e071e10cfbb7c653c33ece183ae1c94ab50e1f664e150abda64d", + "diagnostics/partial_attempt/raw/normal_repeat_1/constant_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t1.jsonl": "36b143cdb980cab5e8071ea868d9b1f166c7cdad5dd0a52393e4edff84fa4f4a", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t128.jsonl": "c58f964b7f0a8eda2f5d80b782468b0a4100dbd175950249ec07fab9440e52f8", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t2.jsonl": "07712df1d4a00a650e04cd3bdb57e8c69fbda7075d2f8e568bacfee6d310dcbb", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t3.jsonl": "9cb882679721faef074484528f0f8e3231ead67cc30e6fa1b238a8c91d467441", + "diagnostics/partial_attempt/raw/normal_repeat_1/floor_c_truncation_starts_ends__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t1.jsonl": "c255b97d53f57c2c77db702bb095567dbcb68edc054b3627464cf54c5abb9bee", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t128.jsonl": "40b135f2915e413607c58e18bf34af7be5e361776575eef66adbda214219eb1f", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t2.jsonl": "bb29a722e508d7ab9cfb72ae5febd1c838cc9d6c801ebd21641c15cb6dfa1050", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t3.jsonl": "1f81f86278d44c3de4bf2a282859c24983def31a98592aa544b4ecc5dbe0a500", + "diagnostics/partial_attempt/raw/normal_repeat_1/irregular_outside_endpoints__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t1.jsonl": "28670e869a53fadc1ba83d8d616e6e41cc5553a8a7a2d22b65d1787b423410bb", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t128.jsonl": "6f9a24a117d7ed0ccc522de216e81c7366a6136612376676b4837853f84a4977", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t2.jsonl": "396b6f93a669928fcec52d2e9d9e975e9a9522793a2215137a973b858951252b", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t3.jsonl": "53b8fee46e78d2cb9d7bde1044f155c2ac848b2d71538079b9b795577e2e33b4", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_a__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t1.jsonl": "1b453d0198d8ff937e3706d860e2e12ac892ebddf227ddee2015bfb1242571c6", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t128.jsonl": "b6a40f9d746249999d27917a9bcbcfcf79024297bc82c6ed1b3e4b04c63a5ab9", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t2.jsonl": "abf9b94cab9cb091db9b517b061a23b281ea7cdcd07f92f1c09fc008a18d8214", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t3.jsonl": "099788f9100ed2c641f992863f7dd3a6c81f5b231d3e78dd2e863067ce4b9065", + "diagnostics/partial_attempt/raw/normal_repeat_1/line_order_b_permuted__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t1.jsonl": "0cd351c18af4e73f1997fd655a47ede75cc79283bbf97d9ef4f4066db2c3ba35", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t128.jsonl": "be2b21e6d6a1c2880906fcad50d957b0f27318349c4228648303967b0bcaba14", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t2.jsonl": "ae0ba650690b01e7bf6dfb97459f494bc10e1ee3b9c8f3db7afe84298c0fbbb4", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t3.jsonl": "0243715fd70d485949a67b955233890bf70e897f31291b6e4a02cad401461b0f", + "diagnostics/partial_attempt/raw/normal_repeat_1/negative_impulse_reversed__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t1.jsonl": "46ccd9715870233ba0d105ab31e6d84c3ff6714cd455a9a2ca867595b24772e3", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t128.jsonl": "443f0f61ff8a16356da13a58cf8f18ddffe64405f9ba85185200892ae5997225", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t2.jsonl": "b1dd38123d64d122a3c7502eb48ee7b60f6167b440af65a32a2dbe0d860cd36d", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t3.jsonl": "bd4247365e2c8033f51344b6a99a5a1650cd1119e83c658f695ed00b2e74cf4b", + "diagnostics/partial_attempt/raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t1.jsonl": "9d723351aea63732e2d0ee573ed7f351b120fe93621e3d51848f36237862ae7f", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t128.jsonl": "7cdb3d0f26e6e7f93e2d7356c7c934fe78d3fb6a5beab70608b6e402a4c69469", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t2.jsonl": "b597da1ff6853ccb8fe3e0ece556376928c91de025a9965e3cc7ae707ef0a713", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t3.jsonl": "0dc4f0c8e820990ceae418950823b5679da88e313f38763c36108e35a24ded9c", + "diagnostics/partial_attempt/raw/normal_repeat_1/positive_impulse_fractional__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t1.jsonl": "d5eecd56cd15d130ff99cf49805bd02a85bce77ac94db5e27f87807393750c30", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t128.jsonl": "5c81dc66c795754545fdfb74cd20f29c77729e89622ee281e036b24933a069f3", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t2.jsonl": "8f02ad5d5c5004c91dd93d0d66d4fe88dda07bcc4debf4ae08a5ed1cba665a85", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t3.jsonl": "ce256c127432b3373b0ac21b02903abd4d52021749132346f329dd80441a92d6", + "diagnostics/partial_attempt/raw/normal_repeat_1/row_offset_duplicate_overlap__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t1.jsonl": "b31e19897620beb8f1558e7d0c9823480ec5428f585dabb58afe0b1f80c21c75", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t128.jsonl": "3e36b2cfa144d46d97dffda93e5a5bcc53230f4f529ac0f5bbba213990f8a0a7", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t2.jsonl": "1cc65fe29df674f7033923f868c96274137ea852d8cd5816d43d995c32f86367", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t3.jsonl": "704952d3917ab78aad6f57c27210b042a3e8009b4e0674daa7c6825f15d40252", + "diagnostics/partial_attempt/raw/normal_repeat_1/signed_gradient_positive_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t1.jsonl": "756488cc3248d3dd6718206ebf1df55893af4c03e9fe311154053850e73fea31", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t128.jsonl": "6fc04d31ed9495ff92db78486a1700704285c3593754ce77f987e1518fb499a5", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t2.jsonl": "d9f779f7789ad702ab5662fef352a4ae69c01b5215ef397e30fb2d33baf3fe28", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t3.jsonl": "df1dafe9003ed907a08f5c309df38e073453211be313727bf4baeb32aff53fac", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_1x1_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t1.jsonl": "a001a1bc5de31de376b1511ef3d22d697eaef8ac2f4952fd95df3d1a6daf3fbc", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t128.jsonl": "9a137ab721715e85a068ac2de3d1751b5859d11cc8cb9ca6dd7e82033b904afa", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t2.jsonl": "ab5d901c07b821189eb7e43cd55ceb1879f9ae73af19c19c6cdde4dba5423c27", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t3.jsonl": "2e009c43502ae1979ab071cab49645d6d901200170f0011d50c1a148c8bca67b", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_column_9x1_vertical__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t1.jsonl": "4b3e866c5c653902f67f2e956f1fb6d21431a0be903c456dc044d94206b6578c", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t128.jsonl": "0cc09261440a582eb7e0099fc3cd6ae25f9f28be3e142de06cafc77b521e8f41", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t2.jsonl": "20536c1bae6847a545cc5c915df82df41d64a19d032fca99c00ccf50df47601d", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t3.jsonl": "9c341f78f8b425ec6b2777e40c4facda68104512041b21b177dc651fc1d4a6aa", + "diagnostics/partial_attempt/raw/normal_repeat_1/singleton_row_1x9_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t1.jsonl": "f78a39b04faca06cf606c71bf18b5e9550fddc12c8032f86e3a770916643cd6a", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t128.jsonl": "dd1003615bacce43a62223fdd6b749eba1d13c7967806a3de29c6bd22f5b254a", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t2.jsonl": "980022d97ae76b491998e50f197dce03a13d0945c756b39e44f5f067e7fdc320", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t3.jsonl": "d7e89e7b6ce2efb5bb7f21ccab09c2498fb36787988caac126d7909cd32a4b26", + "diagnostics/partial_attempt/raw/normal_repeat_1/step_negative_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t1.jsonl": "70fc151e0962d51ca44773eaa6fbbce0c27bda43f78ee2371d4c733508138502", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t128.jsonl": "723f91f5498f32b1d5f82285082d915fe6f6ddab320bb4361401c136f1d1fbbf", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t2.jsonl": "708369fb3cd60cc461db38d793b376e92c55807aef24fd934e6e93556ca33db5", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t3.jsonl": "39d763f011bb91d88831085274d3e029135558fcba7f035e0704f3dba898c98d", + "diagnostics/partial_attempt/raw/normal_repeat_1/tall_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t1.jsonl": "8ab9fdb5927e915ef40fcda1b95d89df245422a6b8d8039a3a3f0904b9c72a53", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t128.jsonl": "16144ef175f87574c5d7195cd2950bcff706602a7d0685077c8966bd2c7d1115", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t2.jsonl": "4d7cf4fa5e14406cb3af440b804b6605f796dc1eef92ad090094134a4b1065f5", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t3.jsonl": "e89e6d4f0a7fabd5463e2184ae24754e8ba2203d6f05e70f81bc65162e9e693b", + "diagnostics/partial_attempt/raw/normal_repeat_1/wide_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t1.jsonl": "66fc7aee07c798976f73d3c02fb548e49028ad7e7f6abf29c8030cfc7941f769", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t128.jsonl": "3efa4fabe260b9e66d500907f93de64b4588aa2087255b91ff7de57d7f34263d", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t2.jsonl": "404138bf058b8c3a20ce8d2a29668110ba5e61c32f43d93404cd97a620f9b557", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t3.jsonl": "c30b6124bb7e3759d63cdeb2394252b639fefc0628b88affae953b883de7d9d5", + "diagnostics/partial_attempt/raw/normal_repeat_2/anisotropic_physical_coordinates__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t1.jsonl": "535a241c5ecda16a314077fe31f5141291af7254b1d88dacbeced8fadeb011aa", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t128.jsonl": "6777a1a29438762a2a6a7a45c0d380932260153ec06cee73f489049b9d637e55", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t2.jsonl": "dd6aa64c6793656912579b1b8347fdd77108c93b0d44d386ec61b84a6f345add", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t3.jsonl": "7774997731b79e6e8c6b24aaeedccfbf8b316149d69790bdd0511c8e37ba135c", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t1.jsonl": "77003691bfd3aa5d7a94927da76d60f61e7c44cc8b16d66bd658fc7109eac64f", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t128.jsonl": "6558b2021d0ad386476b9110d184cbc22dedf5f5067056c35f1c6618ddd9e308", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t2.jsonl": "cb4865c48dd5790e798435e0316efe41d126fc3e9ab627f8aeb052de4cf18a21", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t3.jsonl": "f9a6f9bee105e071e10cfbb7c653c33ece183ae1c94ab50e1f664e150abda64d", + "diagnostics/partial_attempt/raw/normal_repeat_2/constant_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t1.jsonl": "36b143cdb980cab5e8071ea868d9b1f166c7cdad5dd0a52393e4edff84fa4f4a", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t128.jsonl": "c58f964b7f0a8eda2f5d80b782468b0a4100dbd175950249ec07fab9440e52f8", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t2.jsonl": "07712df1d4a00a650e04cd3bdb57e8c69fbda7075d2f8e568bacfee6d310dcbb", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t3.jsonl": "9cb882679721faef074484528f0f8e3231ead67cc30e6fa1b238a8c91d467441", + "diagnostics/partial_attempt/raw/normal_repeat_2/floor_c_truncation_starts_ends__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t1.jsonl": "c255b97d53f57c2c77db702bb095567dbcb68edc054b3627464cf54c5abb9bee", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t128.jsonl": "40b135f2915e413607c58e18bf34af7be5e361776575eef66adbda214219eb1f", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t2.jsonl": "bb29a722e508d7ab9cfb72ae5febd1c838cc9d6c801ebd21641c15cb6dfa1050", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t3.jsonl": "1f81f86278d44c3de4bf2a282859c24983def31a98592aa544b4ecc5dbe0a500", + "diagnostics/partial_attempt/raw/normal_repeat_2/irregular_outside_endpoints__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t1.jsonl": "28670e869a53fadc1ba83d8d616e6e41cc5553a8a7a2d22b65d1787b423410bb", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t128.jsonl": "6f9a24a117d7ed0ccc522de216e81c7366a6136612376676b4837853f84a4977", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t2.jsonl": "396b6f93a669928fcec52d2e9d9e975e9a9522793a2215137a973b858951252b", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t3.jsonl": "53b8fee46e78d2cb9d7bde1044f155c2ac848b2d71538079b9b795577e2e33b4", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_a__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t1.jsonl": "1b453d0198d8ff937e3706d860e2e12ac892ebddf227ddee2015bfb1242571c6", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t128.jsonl": "b6a40f9d746249999d27917a9bcbcfcf79024297bc82c6ed1b3e4b04c63a5ab9", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t2.jsonl": "abf9b94cab9cb091db9b517b061a23b281ea7cdcd07f92f1c09fc008a18d8214", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t3.jsonl": "099788f9100ed2c641f992863f7dd3a6c81f5b231d3e78dd2e863067ce4b9065", + "diagnostics/partial_attempt/raw/normal_repeat_2/line_order_b_permuted__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t1.jsonl": "0cd351c18af4e73f1997fd655a47ede75cc79283bbf97d9ef4f4066db2c3ba35", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t128.jsonl": "be2b21e6d6a1c2880906fcad50d957b0f27318349c4228648303967b0bcaba14", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t2.jsonl": "ae0ba650690b01e7bf6dfb97459f494bc10e1ee3b9c8f3db7afe84298c0fbbb4", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t3.jsonl": "0243715fd70d485949a67b955233890bf70e897f31291b6e4a02cad401461b0f", + "diagnostics/partial_attempt/raw/normal_repeat_2/negative_impulse_reversed__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t1.jsonl": "46ccd9715870233ba0d105ab31e6d84c3ff6714cd455a9a2ca867595b24772e3", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t128.jsonl": "443f0f61ff8a16356da13a58cf8f18ddffe64405f9ba85185200892ae5997225", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t2.jsonl": "b1dd38123d64d122a3c7502eb48ee7b60f6167b440af65a32a2dbe0d860cd36d", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t3.jsonl": "bd4247365e2c8033f51344b6a99a5a1650cd1119e83c658f695ed00b2e74cf4b", + "diagnostics/partial_attempt/raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t1.jsonl": "9d723351aea63732e2d0ee573ed7f351b120fe93621e3d51848f36237862ae7f", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t128.jsonl": "7cdb3d0f26e6e7f93e2d7356c7c934fe78d3fb6a5beab70608b6e402a4c69469", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t2.jsonl": "b597da1ff6853ccb8fe3e0ece556376928c91de025a9965e3cc7ae707ef0a713", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t3.jsonl": "0dc4f0c8e820990ceae418950823b5679da88e313f38763c36108e35a24ded9c", + "diagnostics/partial_attempt/raw/normal_repeat_2/positive_impulse_fractional__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t1.jsonl": "d5eecd56cd15d130ff99cf49805bd02a85bce77ac94db5e27f87807393750c30", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t128.jsonl": "5c81dc66c795754545fdfb74cd20f29c77729e89622ee281e036b24933a069f3", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t2.jsonl": "8f02ad5d5c5004c91dd93d0d66d4fe88dda07bcc4debf4ae08a5ed1cba665a85", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t3.jsonl": "ce256c127432b3373b0ac21b02903abd4d52021749132346f329dd80441a92d6", + "diagnostics/partial_attempt/raw/normal_repeat_2/row_offset_duplicate_overlap__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t1.jsonl": "b31e19897620beb8f1558e7d0c9823480ec5428f585dabb58afe0b1f80c21c75", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t128.jsonl": "3e36b2cfa144d46d97dffda93e5a5bcc53230f4f529ac0f5bbba213990f8a0a7", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t2.jsonl": "1cc65fe29df674f7033923f868c96274137ea852d8cd5816d43d995c32f86367", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t3.jsonl": "704952d3917ab78aad6f57c27210b042a3e8009b4e0674daa7c6825f15d40252", + "diagnostics/partial_attempt/raw/normal_repeat_2/signed_gradient_positive_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t1.jsonl": "756488cc3248d3dd6718206ebf1df55893af4c03e9fe311154053850e73fea31", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t128.jsonl": "6fc04d31ed9495ff92db78486a1700704285c3593754ce77f987e1518fb499a5", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t2.jsonl": "d9f779f7789ad702ab5662fef352a4ae69c01b5215ef397e30fb2d33baf3fe28", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t3.jsonl": "df1dafe9003ed907a08f5c309df38e073453211be313727bf4baeb32aff53fac", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_1x1_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t1.jsonl": "a001a1bc5de31de376b1511ef3d22d697eaef8ac2f4952fd95df3d1a6daf3fbc", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t128.jsonl": "9a137ab721715e85a068ac2de3d1751b5859d11cc8cb9ca6dd7e82033b904afa", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t2.jsonl": "ab5d901c07b821189eb7e43cd55ceb1879f9ae73af19c19c6cdde4dba5423c27", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t3.jsonl": "2e009c43502ae1979ab071cab49645d6d901200170f0011d50c1a148c8bca67b", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_column_9x1_vertical__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t1.jsonl": "4b3e866c5c653902f67f2e956f1fb6d21431a0be903c456dc044d94206b6578c", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t128.jsonl": "0cc09261440a582eb7e0099fc3cd6ae25f9f28be3e142de06cafc77b521e8f41", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t2.jsonl": "20536c1bae6847a545cc5c915df82df41d64a19d032fca99c00ccf50df47601d", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t3.jsonl": "9c341f78f8b425ec6b2777e40c4facda68104512041b21b177dc651fc1d4a6aa", + "diagnostics/partial_attempt/raw/normal_repeat_2/singleton_row_1x9_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t1.jsonl": "f78a39b04faca06cf606c71bf18b5e9550fddc12c8032f86e3a770916643cd6a", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t128.jsonl": "dd1003615bacce43a62223fdd6b749eba1d13c7967806a3de29c6bd22f5b254a", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t2.jsonl": "980022d97ae76b491998e50f197dce03a13d0945c756b39e44f5f067e7fdc320", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t3.jsonl": "d7e89e7b6ce2efb5bb7f21ccab09c2498fb36787988caac126d7909cd32a4b26", + "diagnostics/partial_attempt/raw/normal_repeat_2/step_negative_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t1.jsonl": "70fc151e0962d51ca44773eaa6fbbce0c27bda43f78ee2371d4c733508138502", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t128.jsonl": "723f91f5498f32b1d5f82285082d915fe6f6ddab320bb4361401c136f1d1fbbf", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t2.jsonl": "708369fb3cd60cc461db38d793b376e92c55807aef24fd934e6e93556ca33db5", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t3.jsonl": "39d763f011bb91d88831085274d3e029135558fcba7f035e0704f3dba898c98d", + "diagnostics/partial_attempt/raw/normal_repeat_2/tall_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t1.jsonl": "8ab9fdb5927e915ef40fcda1b95d89df245422a6b8d8039a3a3f0904b9c72a53", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t128.jsonl": "16144ef175f87574c5d7195cd2950bcff706602a7d0685077c8966bd2c7d1115", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t2.jsonl": "4d7cf4fa5e14406cb3af440b804b6605f796dc1eef92ad090094134a4b1065f5", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t3.jsonl": "e89e6d4f0a7fabd5463e2184ae24754e8ba2203d6f05e70f81bc65162e9e693b", + "diagnostics/partial_attempt/raw/normal_repeat_2/wide_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "execution/xvfb.stderr": "feed30a9679ec98db430a2141afeccb0fd00b95f8d2827a58e3297c793fec185", + "execution_records.json": "ea13871bc393117c0cdf4f4de640a39438a0721c7ea01dbaffc76c6b07e514b0", + "execution_trace.json": "69c2b96ad974c479acfb946ebaadf8abbaa5f81b99265ddc0725e203d817a489", + "harness_compile.log": "d3791a72246328d0afc2911db00f6077c0964e85aee5450d8d630b505c3e62cb", + "identity.json": "4de945de9fb37e7c7545b93cd99d0ab4f9bc4b868358aceb7f870c4298aaa72f", + "linked_library_identity.json": "7748f78e8ee2fb2d95a88e966a6cb60806d9bbb04cb7298d6e1b906f48614a19", + "pathlevel_harness": "23be2b8e47f125001d28c5da5b3ce08b9830953dd3e0b44cd568778ff42aaedc", + "pathlevel_harness.c": "467a275b834e3dcc4a4ee406ef13be053a68733c105392fc391ccfe3bcfadffd", + "pathlevel_harness_asan": "3bd261fd0865fa6cc4da038c8c4880518325596fd3a612d62385674063430149", + "prototype_compile.log": "3da82dc7be198dcdc7651ec390fc3e3b228155a22d66b56165c3b2b1ac2326ff", + "prototype_gdb.log": "1c6ed59e97712c374f3841c691a8c263ff1685593f4141351d3df4c75a322282", + "prototype_stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "prototype_stdout.log": "ea808d13cf135f9cea525ebd0bf3674ed455d4f79d735beda9ed261bdbaf783e", + "provenance.json": "23c204722e19f6592595e3b278ebd2cf4adb8b2c2a771430551a6bfc857be704", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t1.jsonl": "66fc7aee07c798976f73d3c02fb548e49028ad7e7f6abf29c8030cfc7941f769", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t128.jsonl": "3efa4fabe260b9e66d500907f93de64b4588aa2087255b91ff7de57d7f34263d", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t2.jsonl": "404138bf058b8c3a20ce8d2a29668110ba5e61c32f43d93404cd97a620f9b557", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t3.jsonl": "c30b6124bb7e3759d63cdeb2394252b639fefc0628b88affae953b883de7d9d5", + "raw/normal_repeat_1/anisotropic_physical_coordinates__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_horizontal__t1.jsonl": "535a241c5ecda16a314077fe31f5141291af7254b1d88dacbeced8fadeb011aa", + "raw/normal_repeat_1/constant_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_horizontal__t128.jsonl": "6777a1a29438762a2a6a7a45c0d380932260153ec06cee73f489049b9d637e55", + "raw/normal_repeat_1/constant_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_horizontal__t2.jsonl": "dd6aa64c6793656912579b1b8347fdd77108c93b0d44d386ec61b84a6f345add", + "raw/normal_repeat_1/constant_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_horizontal__t3.jsonl": "7774997731b79e6e8c6b24aaeedccfbf8b316149d69790bdd0511c8e37ba135c", + "raw/normal_repeat_1/constant_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_no_lines__t1.jsonl": "77003691bfd3aa5d7a94927da76d60f61e7c44cc8b16d66bd658fc7109eac64f", + "raw/normal_repeat_1/constant_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_no_lines__t128.jsonl": "6558b2021d0ad386476b9110d184cbc22dedf5f5067056c35f1c6618ddd9e308", + "raw/normal_repeat_1/constant_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_no_lines__t2.jsonl": "cb4865c48dd5790e798435e0316efe41d126fc3e9ab627f8aeb052de4cf18a21", + "raw/normal_repeat_1/constant_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/constant_no_lines__t3.jsonl": "f9a6f9bee105e071e10cfbb7c653c33ece183ae1c94ab50e1f664e150abda64d", + "raw/normal_repeat_1/constant_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t1.jsonl": "36b143cdb980cab5e8071ea868d9b1f166c7cdad5dd0a52393e4edff84fa4f4a", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t128.jsonl": "c58f964b7f0a8eda2f5d80b782468b0a4100dbd175950249ec07fab9440e52f8", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t2.jsonl": "07712df1d4a00a650e04cd3bdb57e8c69fbda7075d2f8e568bacfee6d310dcbb", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t3.jsonl": "9cb882679721faef074484528f0f8e3231ead67cc30e6fa1b238a8c91d467441", + "raw/normal_repeat_1/floor_c_truncation_starts_ends__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/irregular_outside_endpoints__t1.jsonl": "c255b97d53f57c2c77db702bb095567dbcb68edc054b3627464cf54c5abb9bee", + "raw/normal_repeat_1/irregular_outside_endpoints__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/irregular_outside_endpoints__t128.jsonl": "40b135f2915e413607c58e18bf34af7be5e361776575eef66adbda214219eb1f", + "raw/normal_repeat_1/irregular_outside_endpoints__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/irregular_outside_endpoints__t2.jsonl": "bb29a722e508d7ab9cfb72ae5febd1c838cc9d6c801ebd21641c15cb6dfa1050", + "raw/normal_repeat_1/irregular_outside_endpoints__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/irregular_outside_endpoints__t3.jsonl": "1f81f86278d44c3de4bf2a282859c24983def31a98592aa544b4ecc5dbe0a500", + "raw/normal_repeat_1/irregular_outside_endpoints__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_a__t1.jsonl": "28670e869a53fadc1ba83d8d616e6e41cc5553a8a7a2d22b65d1787b423410bb", + "raw/normal_repeat_1/line_order_a__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_a__t128.jsonl": "6f9a24a117d7ed0ccc522de216e81c7366a6136612376676b4837853f84a4977", + "raw/normal_repeat_1/line_order_a__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_a__t2.jsonl": "396b6f93a669928fcec52d2e9d9e975e9a9522793a2215137a973b858951252b", + "raw/normal_repeat_1/line_order_a__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_a__t3.jsonl": "53b8fee46e78d2cb9d7bde1044f155c2ac848b2d71538079b9b795577e2e33b4", + "raw/normal_repeat_1/line_order_a__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_b_permuted__t1.jsonl": "1b453d0198d8ff937e3706d860e2e12ac892ebddf227ddee2015bfb1242571c6", + "raw/normal_repeat_1/line_order_b_permuted__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_b_permuted__t128.jsonl": "b6a40f9d746249999d27917a9bcbcfcf79024297bc82c6ed1b3e4b04c63a5ab9", + "raw/normal_repeat_1/line_order_b_permuted__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_b_permuted__t2.jsonl": "abf9b94cab9cb091db9b517b061a23b281ea7cdcd07f92f1c09fc008a18d8214", + "raw/normal_repeat_1/line_order_b_permuted__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/line_order_b_permuted__t3.jsonl": "099788f9100ed2c641f992863f7dd3a6c81f5b231d3e78dd2e863067ce4b9065", + "raw/normal_repeat_1/line_order_b_permuted__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/negative_impulse_reversed__t1.jsonl": "0cd351c18af4e73f1997fd655a47ede75cc79283bbf97d9ef4f4066db2c3ba35", + "raw/normal_repeat_1/negative_impulse_reversed__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/negative_impulse_reversed__t128.jsonl": "be2b21e6d6a1c2880906fcad50d957b0f27318349c4228648303967b0bcaba14", + "raw/normal_repeat_1/negative_impulse_reversed__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/negative_impulse_reversed__t2.jsonl": "ae0ba650690b01e7bf6dfb97459f494bc10e1ee3b9c8f3db7afe84298c0fbbb4", + "raw/normal_repeat_1/negative_impulse_reversed__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/negative_impulse_reversed__t3.jsonl": "0243715fd70d485949a67b955233890bf70e897f31291b6e4a02cad401461b0f", + "raw/normal_repeat_1/negative_impulse_reversed__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t1.jsonl": "46ccd9715870233ba0d105ab31e6d84c3ff6714cd455a9a2ca867595b24772e3", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t128.jsonl": "443f0f61ff8a16356da13a58cf8f18ddffe64405f9ba85185200892ae5997225", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t2.jsonl": "b1dd38123d64d122a3c7502eb48ee7b60f6167b440af65a32a2dbe0d860cd36d", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t3.jsonl": "bd4247365e2c8033f51344b6a99a5a1650cd1119e83c658f695ed00b2e74cf4b", + "raw/normal_repeat_1/plateau_signed_zero_partial_clamp__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/positive_impulse_fractional__t1.jsonl": "9d723351aea63732e2d0ee573ed7f351b120fe93621e3d51848f36237862ae7f", + "raw/normal_repeat_1/positive_impulse_fractional__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/positive_impulse_fractional__t128.jsonl": "7cdb3d0f26e6e7f93e2d7356c7c934fe78d3fb6a5beab70608b6e402a4c69469", + "raw/normal_repeat_1/positive_impulse_fractional__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/positive_impulse_fractional__t2.jsonl": "b597da1ff6853ccb8fe3e0ece556376928c91de025a9965e3cc7ae707ef0a713", + "raw/normal_repeat_1/positive_impulse_fractional__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/positive_impulse_fractional__t3.jsonl": "0dc4f0c8e820990ceae418950823b5679da88e313f38763c36108e35a24ded9c", + "raw/normal_repeat_1/positive_impulse_fractional__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t1.jsonl": "d5eecd56cd15d130ff99cf49805bd02a85bce77ac94db5e27f87807393750c30", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t128.jsonl": "5c81dc66c795754545fdfb74cd20f29c77729e89622ee281e036b24933a069f3", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t2.jsonl": "8f02ad5d5c5004c91dd93d0d66d4fe88dda07bcc4debf4ae08a5ed1cba665a85", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t3.jsonl": "ce256c127432b3373b0ac21b02903abd4d52021749132346f329dd80441a92d6", + "raw/normal_repeat_1/row_offset_duplicate_overlap__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/signed_gradient_positive_slope__t1.jsonl": "b31e19897620beb8f1558e7d0c9823480ec5428f585dabb58afe0b1f80c21c75", + "raw/normal_repeat_1/signed_gradient_positive_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/signed_gradient_positive_slope__t128.jsonl": "3e36b2cfa144d46d97dffda93e5a5bcc53230f4f529ac0f5bbba213990f8a0a7", + "raw/normal_repeat_1/signed_gradient_positive_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/signed_gradient_positive_slope__t2.jsonl": "1cc65fe29df674f7033923f868c96274137ea852d8cd5816d43d995c32f86367", + "raw/normal_repeat_1/signed_gradient_positive_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/signed_gradient_positive_slope__t3.jsonl": "704952d3917ab78aad6f57c27210b042a3e8009b4e0674daa7c6825f15d40252", + "raw/normal_repeat_1/signed_gradient_positive_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_1x1_no_lines__t1.jsonl": "756488cc3248d3dd6718206ebf1df55893af4c03e9fe311154053850e73fea31", + "raw/normal_repeat_1/singleton_1x1_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_1x1_no_lines__t128.jsonl": "6fc04d31ed9495ff92db78486a1700704285c3593754ce77f987e1518fb499a5", + "raw/normal_repeat_1/singleton_1x1_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_1x1_no_lines__t2.jsonl": "d9f779f7789ad702ab5662fef352a4ae69c01b5215ef397e30fb2d33baf3fe28", + "raw/normal_repeat_1/singleton_1x1_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_1x1_no_lines__t3.jsonl": "df1dafe9003ed907a08f5c309df38e073453211be313727bf4baeb32aff53fac", + "raw/normal_repeat_1/singleton_1x1_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t1.jsonl": "a001a1bc5de31de376b1511ef3d22d697eaef8ac2f4952fd95df3d1a6daf3fbc", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t128.jsonl": "9a137ab721715e85a068ac2de3d1751b5859d11cc8cb9ca6dd7e82033b904afa", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t2.jsonl": "ab5d901c07b821189eb7e43cd55ceb1879f9ae73af19c19c6cdde4dba5423c27", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t3.jsonl": "2e009c43502ae1979ab071cab49645d6d901200170f0011d50c1a148c8bca67b", + "raw/normal_repeat_1/singleton_column_9x1_vertical__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t1.jsonl": "4b3e866c5c653902f67f2e956f1fb6d21431a0be903c456dc044d94206b6578c", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t128.jsonl": "0cc09261440a582eb7e0099fc3cd6ae25f9f28be3e142de06cafc77b521e8f41", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t2.jsonl": "20536c1bae6847a545cc5c915df82df41d64a19d032fca99c00ccf50df47601d", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t3.jsonl": "9c341f78f8b425ec6b2777e40c4facda68104512041b21b177dc651fc1d4a6aa", + "raw/normal_repeat_1/singleton_row_1x9_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/step_negative_slope__t1.jsonl": "f78a39b04faca06cf606c71bf18b5e9550fddc12c8032f86e3a770916643cd6a", + "raw/normal_repeat_1/step_negative_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/step_negative_slope__t128.jsonl": "dd1003615bacce43a62223fdd6b749eba1d13c7967806a3de29c6bd22f5b254a", + "raw/normal_repeat_1/step_negative_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/step_negative_slope__t2.jsonl": "980022d97ae76b491998e50f197dce03a13d0945c756b39e44f5f067e7fdc320", + "raw/normal_repeat_1/step_negative_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/step_negative_slope__t3.jsonl": "d7e89e7b6ce2efb5bb7f21ccab09c2498fb36787988caac126d7909cd32a4b26", + "raw/normal_repeat_1/step_negative_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/tall_edge_window__t1.jsonl": "70fc151e0962d51ca44773eaa6fbbce0c27bda43f78ee2371d4c733508138502", + "raw/normal_repeat_1/tall_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/tall_edge_window__t128.jsonl": "723f91f5498f32b1d5f82285082d915fe6f6ddab320bb4361401c136f1d1fbbf", + "raw/normal_repeat_1/tall_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/tall_edge_window__t2.jsonl": "708369fb3cd60cc461db38d793b376e92c55807aef24fd934e6e93556ca33db5", + "raw/normal_repeat_1/tall_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/tall_edge_window__t3.jsonl": "39d763f011bb91d88831085274d3e029135558fcba7f035e0704f3dba898c98d", + "raw/normal_repeat_1/tall_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/wide_edge_window__t1.jsonl": "8ab9fdb5927e915ef40fcda1b95d89df245422a6b8d8039a3a3f0904b9c72a53", + "raw/normal_repeat_1/wide_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/wide_edge_window__t128.jsonl": "16144ef175f87574c5d7195cd2950bcff706602a7d0685077c8966bd2c7d1115", + "raw/normal_repeat_1/wide_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/wide_edge_window__t2.jsonl": "4d7cf4fa5e14406cb3af440b804b6605f796dc1eef92ad090094134a4b1065f5", + "raw/normal_repeat_1/wide_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_1/wide_edge_window__t3.jsonl": "e89e6d4f0a7fabd5463e2184ae24754e8ba2203d6f05e70f81bc65162e9e693b", + "raw/normal_repeat_1/wide_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t1.jsonl": "66fc7aee07c798976f73d3c02fb548e49028ad7e7f6abf29c8030cfc7941f769", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t128.jsonl": "3efa4fabe260b9e66d500907f93de64b4588aa2087255b91ff7de57d7f34263d", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t2.jsonl": "404138bf058b8c3a20ce8d2a29668110ba5e61c32f43d93404cd97a620f9b557", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t3.jsonl": "c30b6124bb7e3759d63cdeb2394252b639fefc0628b88affae953b883de7d9d5", + "raw/normal_repeat_2/anisotropic_physical_coordinates__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_horizontal__t1.jsonl": "535a241c5ecda16a314077fe31f5141291af7254b1d88dacbeced8fadeb011aa", + "raw/normal_repeat_2/constant_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_horizontal__t128.jsonl": "6777a1a29438762a2a6a7a45c0d380932260153ec06cee73f489049b9d637e55", + "raw/normal_repeat_2/constant_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_horizontal__t2.jsonl": "dd6aa64c6793656912579b1b8347fdd77108c93b0d44d386ec61b84a6f345add", + "raw/normal_repeat_2/constant_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_horizontal__t3.jsonl": "7774997731b79e6e8c6b24aaeedccfbf8b316149d69790bdd0511c8e37ba135c", + "raw/normal_repeat_2/constant_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_no_lines__t1.jsonl": "77003691bfd3aa5d7a94927da76d60f61e7c44cc8b16d66bd658fc7109eac64f", + "raw/normal_repeat_2/constant_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_no_lines__t128.jsonl": "6558b2021d0ad386476b9110d184cbc22dedf5f5067056c35f1c6618ddd9e308", + "raw/normal_repeat_2/constant_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_no_lines__t2.jsonl": "cb4865c48dd5790e798435e0316efe41d126fc3e9ab627f8aeb052de4cf18a21", + "raw/normal_repeat_2/constant_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/constant_no_lines__t3.jsonl": "f9a6f9bee105e071e10cfbb7c653c33ece183ae1c94ab50e1f664e150abda64d", + "raw/normal_repeat_2/constant_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t1.jsonl": "36b143cdb980cab5e8071ea868d9b1f166c7cdad5dd0a52393e4edff84fa4f4a", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t128.jsonl": "c58f964b7f0a8eda2f5d80b782468b0a4100dbd175950249ec07fab9440e52f8", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t2.jsonl": "07712df1d4a00a650e04cd3bdb57e8c69fbda7075d2f8e568bacfee6d310dcbb", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t3.jsonl": "9cb882679721faef074484528f0f8e3231ead67cc30e6fa1b238a8c91d467441", + "raw/normal_repeat_2/floor_c_truncation_starts_ends__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/irregular_outside_endpoints__t1.jsonl": "c255b97d53f57c2c77db702bb095567dbcb68edc054b3627464cf54c5abb9bee", + "raw/normal_repeat_2/irregular_outside_endpoints__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/irregular_outside_endpoints__t128.jsonl": "40b135f2915e413607c58e18bf34af7be5e361776575eef66adbda214219eb1f", + "raw/normal_repeat_2/irregular_outside_endpoints__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/irregular_outside_endpoints__t2.jsonl": "bb29a722e508d7ab9cfb72ae5febd1c838cc9d6c801ebd21641c15cb6dfa1050", + "raw/normal_repeat_2/irregular_outside_endpoints__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/irregular_outside_endpoints__t3.jsonl": "1f81f86278d44c3de4bf2a282859c24983def31a98592aa544b4ecc5dbe0a500", + "raw/normal_repeat_2/irregular_outside_endpoints__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_a__t1.jsonl": "28670e869a53fadc1ba83d8d616e6e41cc5553a8a7a2d22b65d1787b423410bb", + "raw/normal_repeat_2/line_order_a__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_a__t128.jsonl": "6f9a24a117d7ed0ccc522de216e81c7366a6136612376676b4837853f84a4977", + "raw/normal_repeat_2/line_order_a__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_a__t2.jsonl": "396b6f93a669928fcec52d2e9d9e975e9a9522793a2215137a973b858951252b", + "raw/normal_repeat_2/line_order_a__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_a__t3.jsonl": "53b8fee46e78d2cb9d7bde1044f155c2ac848b2d71538079b9b795577e2e33b4", + "raw/normal_repeat_2/line_order_a__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_b_permuted__t1.jsonl": "1b453d0198d8ff937e3706d860e2e12ac892ebddf227ddee2015bfb1242571c6", + "raw/normal_repeat_2/line_order_b_permuted__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_b_permuted__t128.jsonl": "b6a40f9d746249999d27917a9bcbcfcf79024297bc82c6ed1b3e4b04c63a5ab9", + "raw/normal_repeat_2/line_order_b_permuted__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_b_permuted__t2.jsonl": "abf9b94cab9cb091db9b517b061a23b281ea7cdcd07f92f1c09fc008a18d8214", + "raw/normal_repeat_2/line_order_b_permuted__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/line_order_b_permuted__t3.jsonl": "099788f9100ed2c641f992863f7dd3a6c81f5b231d3e78dd2e863067ce4b9065", + "raw/normal_repeat_2/line_order_b_permuted__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/negative_impulse_reversed__t1.jsonl": "0cd351c18af4e73f1997fd655a47ede75cc79283bbf97d9ef4f4066db2c3ba35", + "raw/normal_repeat_2/negative_impulse_reversed__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/negative_impulse_reversed__t128.jsonl": "be2b21e6d6a1c2880906fcad50d957b0f27318349c4228648303967b0bcaba14", + "raw/normal_repeat_2/negative_impulse_reversed__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/negative_impulse_reversed__t2.jsonl": "ae0ba650690b01e7bf6dfb97459f494bc10e1ee3b9c8f3db7afe84298c0fbbb4", + "raw/normal_repeat_2/negative_impulse_reversed__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/negative_impulse_reversed__t3.jsonl": "0243715fd70d485949a67b955233890bf70e897f31291b6e4a02cad401461b0f", + "raw/normal_repeat_2/negative_impulse_reversed__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t1.jsonl": "46ccd9715870233ba0d105ab31e6d84c3ff6714cd455a9a2ca867595b24772e3", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t128.jsonl": "443f0f61ff8a16356da13a58cf8f18ddffe64405f9ba85185200892ae5997225", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t2.jsonl": "b1dd38123d64d122a3c7502eb48ee7b60f6167b440af65a32a2dbe0d860cd36d", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t3.jsonl": "bd4247365e2c8033f51344b6a99a5a1650cd1119e83c658f695ed00b2e74cf4b", + "raw/normal_repeat_2/plateau_signed_zero_partial_clamp__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/positive_impulse_fractional__t1.jsonl": "9d723351aea63732e2d0ee573ed7f351b120fe93621e3d51848f36237862ae7f", + "raw/normal_repeat_2/positive_impulse_fractional__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/positive_impulse_fractional__t128.jsonl": "7cdb3d0f26e6e7f93e2d7356c7c934fe78d3fb6a5beab70608b6e402a4c69469", + "raw/normal_repeat_2/positive_impulse_fractional__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/positive_impulse_fractional__t2.jsonl": "b597da1ff6853ccb8fe3e0ece556376928c91de025a9965e3cc7ae707ef0a713", + "raw/normal_repeat_2/positive_impulse_fractional__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/positive_impulse_fractional__t3.jsonl": "0dc4f0c8e820990ceae418950823b5679da88e313f38763c36108e35a24ded9c", + "raw/normal_repeat_2/positive_impulse_fractional__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t1.jsonl": "d5eecd56cd15d130ff99cf49805bd02a85bce77ac94db5e27f87807393750c30", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t128.jsonl": "5c81dc66c795754545fdfb74cd20f29c77729e89622ee281e036b24933a069f3", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t2.jsonl": "8f02ad5d5c5004c91dd93d0d66d4fe88dda07bcc4debf4ae08a5ed1cba665a85", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t3.jsonl": "ce256c127432b3373b0ac21b02903abd4d52021749132346f329dd80441a92d6", + "raw/normal_repeat_2/row_offset_duplicate_overlap__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/signed_gradient_positive_slope__t1.jsonl": "b31e19897620beb8f1558e7d0c9823480ec5428f585dabb58afe0b1f80c21c75", + "raw/normal_repeat_2/signed_gradient_positive_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/signed_gradient_positive_slope__t128.jsonl": "3e36b2cfa144d46d97dffda93e5a5bcc53230f4f529ac0f5bbba213990f8a0a7", + "raw/normal_repeat_2/signed_gradient_positive_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/signed_gradient_positive_slope__t2.jsonl": "1cc65fe29df674f7033923f868c96274137ea852d8cd5816d43d995c32f86367", + "raw/normal_repeat_2/signed_gradient_positive_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/signed_gradient_positive_slope__t3.jsonl": "704952d3917ab78aad6f57c27210b042a3e8009b4e0674daa7c6825f15d40252", + "raw/normal_repeat_2/signed_gradient_positive_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_1x1_no_lines__t1.jsonl": "756488cc3248d3dd6718206ebf1df55893af4c03e9fe311154053850e73fea31", + "raw/normal_repeat_2/singleton_1x1_no_lines__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_1x1_no_lines__t128.jsonl": "6fc04d31ed9495ff92db78486a1700704285c3593754ce77f987e1518fb499a5", + "raw/normal_repeat_2/singleton_1x1_no_lines__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_1x1_no_lines__t2.jsonl": "d9f779f7789ad702ab5662fef352a4ae69c01b5215ef397e30fb2d33baf3fe28", + "raw/normal_repeat_2/singleton_1x1_no_lines__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_1x1_no_lines__t3.jsonl": "df1dafe9003ed907a08f5c309df38e073453211be313727bf4baeb32aff53fac", + "raw/normal_repeat_2/singleton_1x1_no_lines__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t1.jsonl": "a001a1bc5de31de376b1511ef3d22d697eaef8ac2f4952fd95df3d1a6daf3fbc", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t128.jsonl": "9a137ab721715e85a068ac2de3d1751b5859d11cc8cb9ca6dd7e82033b904afa", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t2.jsonl": "ab5d901c07b821189eb7e43cd55ceb1879f9ae73af19c19c6cdde4dba5423c27", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t3.jsonl": "2e009c43502ae1979ab071cab49645d6d901200170f0011d50c1a148c8bca67b", + "raw/normal_repeat_2/singleton_column_9x1_vertical__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t1.jsonl": "4b3e866c5c653902f67f2e956f1fb6d21431a0be903c456dc044d94206b6578c", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t128.jsonl": "0cc09261440a582eb7e0099fc3cd6ae25f9f28be3e142de06cafc77b521e8f41", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t2.jsonl": "20536c1bae6847a545cc5c915df82df41d64a19d032fca99c00ccf50df47601d", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t3.jsonl": "9c341f78f8b425ec6b2777e40c4facda68104512041b21b177dc651fc1d4a6aa", + "raw/normal_repeat_2/singleton_row_1x9_horizontal__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/step_negative_slope__t1.jsonl": "f78a39b04faca06cf606c71bf18b5e9550fddc12c8032f86e3a770916643cd6a", + "raw/normal_repeat_2/step_negative_slope__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/step_negative_slope__t128.jsonl": "dd1003615bacce43a62223fdd6b749eba1d13c7967806a3de29c6bd22f5b254a", + "raw/normal_repeat_2/step_negative_slope__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/step_negative_slope__t2.jsonl": "980022d97ae76b491998e50f197dce03a13d0945c756b39e44f5f067e7fdc320", + "raw/normal_repeat_2/step_negative_slope__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/step_negative_slope__t3.jsonl": "d7e89e7b6ce2efb5bb7f21ccab09c2498fb36787988caac126d7909cd32a4b26", + "raw/normal_repeat_2/step_negative_slope__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/tall_edge_window__t1.jsonl": "70fc151e0962d51ca44773eaa6fbbce0c27bda43f78ee2371d4c733508138502", + "raw/normal_repeat_2/tall_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/tall_edge_window__t128.jsonl": "723f91f5498f32b1d5f82285082d915fe6f6ddab320bb4361401c136f1d1fbbf", + "raw/normal_repeat_2/tall_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/tall_edge_window__t2.jsonl": "708369fb3cd60cc461db38d793b376e92c55807aef24fd934e6e93556ca33db5", + "raw/normal_repeat_2/tall_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/tall_edge_window__t3.jsonl": "39d763f011bb91d88831085274d3e029135558fcba7f035e0704f3dba898c98d", + "raw/normal_repeat_2/tall_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/wide_edge_window__t1.jsonl": "8ab9fdb5927e915ef40fcda1b95d89df245422a6b8d8039a3a3f0904b9c72a53", + "raw/normal_repeat_2/wide_edge_window__t1.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/wide_edge_window__t128.jsonl": "16144ef175f87574c5d7195cd2950bcff706602a7d0685077c8966bd2c7d1115", + "raw/normal_repeat_2/wide_edge_window__t128.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/wide_edge_window__t2.jsonl": "4d7cf4fa5e14406cb3af440b804b6605f796dc1eef92ad090094134a4b1065f5", + "raw/normal_repeat_2/wide_edge_window__t2.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "raw/normal_repeat_2/wide_edge_window__t3.jsonl": "e89e6d4f0a7fabd5463e2184ae24754e8ba2203d6f05e70f81bc65162e9e693b", + "raw/normal_repeat_2/wide_edge_window__t3.stderr": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "run_pathlevel_campaign.py": "4076c930032c20317192b757e8a08d57b33a0f10268eeb21d0533ee4c90f09be", + "sha256_check.log": "e61fb7692f5d879bf91ac10d8ecd676ce287b2c5a1b3081c495ac7d3255f8729", + "sha256_verify.log": "fab42f59f562cc4a988344055becde6afa66620c0d9b9ee16853776cf0b7af58", + "smoke.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "smoke.ini": "7a8696b9b091b8e74e77a47900f01558bf3c49e9f59d62358bdfd245aa73d673", + "smoke.out": "f04372236f40e2e5d21f9472f3b88213a2a0dd189f66a319faaee3b136a2e1cb", + "smoke_1.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "smoke_1.out": "f04372236f40e2e5d21f9472f3b88213a2a0dd189f66a319faaee3b136a2e1cb", + "smoke_2.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "smoke_2.out": "f04372236f40e2e5d21f9472f3b88213a2a0dd189f66a319faaee3b136a2e1cb", + "summary.json": "e6f6e663977b8af8c7a7da9cfbcdf995b7efae179fa27b5df9201a0ccab46f32", + "xvfb_test.log": "1796b1ab4d88c0a0b35915bdda742a7fac9d7dcd9cc317e46ee861c1689fdcb1" + }, + "external_sha256sums": "8aa6a3e68403d3b67fedc63c725b81bfa4b9f391621681da9decf152c3cdc8d8", + "oracle_artifacts": { + "__pycache__/path_level_oracle.cpython-312.pyc": "dad726b661f7ab295a2c4c969d3aaa294d0310bae00b19b02987bff84ce3b4ea", + "__pycache__/regenerate_path_level_cases.cpython-312.pyc": "d2b0a61a13bfe2a08b440a556ae88c808d5ffef3a839252bb8a55ad7e0651722", + "compare_external_reference.py": "3240c45edf7295a34b9b71774157cb910eafd4e99b47f2ee1d47ada718bb87d8", + "comparison_execution.log": "49a9b02fa0c7edfad9b9098465f06eb3f2129b349a20c8e93200e2289129ff6e", + "comparison_ledger.json": "ff6aa434f193263b21fe13a8a76e795565b0838ea6288900eb79c26f056436a8", + "comparison_summary.json": "3b25d20b1e236d886716ad50003cb9ce2530a10e8b141d5bc3303ec57313f851", + "mismatch_classifications.json": "d8b549a7f55003ff8baf35a65980680dc5e80fcca6776d3e62265a8fb0b8a44f", + "oracle_freeze.json": "a9624b1c02afe9701f3d19e061e9eb7cbe50104167e90b9d476f719b8b6c0585", + "oracle_self_tests.py": "63c72fb346936b7bd613e74438fc1bb9d7c5e02cb574888564240634f16e730c", + "path_level_oracle.py": "9b4ec65ba67777c211ab08c73d91bf523ee5082f04ce0424ea4f1fe569ae58f1", + "provenance.json": "0ea5364ade3a2070eb9ae1b9602b4d90ec97716d32a635f37e5cb4f53a6e5445", + "regenerate_path_level_cases.py": "c8a0410436018e3ef1c0ef9dc008cec454213c2b65949d2857d8fabc2752670c", + "regenerated_input_report.json": "b080368d26def8bcc40da259b0be4b9fd2fc9f49fd6ae158849de5dcc5acc31e", + "source_semantics.json": "0ffa339e51b24ee5b4f82d5cbe4e59846306488c1cf3a7780cddb96a214d49fe", + "summary.json": "7788598126f483e00d9115fdbc0c6dbc12cfe6fc77c68e1fb16effb8f4894b58", + "verify_regenerated_inputs.py": "132f7994ea2c4aa31ba21f83bdf96e92c3fbffe2cc32ac743ab36c16c70f5625" + }, + "oracle_sha256sums": "efd796028956bbb2d771589d0b92a77239f7ad3b548b818e1a88fff427fafd42" + } + }, + "fixture": { + "array_count": 90, + "array_hashes": { + "corrected__anisotropic_physical_coordinates__t1": "65883b8732e68b57d3e323a6e0867b96e3579a41fcfa1f6c01a24f64471136c0", + "corrected__anisotropic_physical_coordinates__t128": "4e9fbfa79553687bf0beca278d8b62caec8e63d52df889e6aa098ed926893ba2", + "corrected__anisotropic_physical_coordinates__t2": "65883b8732e68b57d3e323a6e0867b96e3579a41fcfa1f6c01a24f64471136c0", + "corrected__anisotropic_physical_coordinates__t3": "4e9fbfa79553687bf0beca278d8b62caec8e63d52df889e6aa098ed926893ba2", + "corrected__constant_horizontal__t1": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_horizontal__t128": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_horizontal__t2": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_horizontal__t3": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_no_lines__t1": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_no_lines__t128": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_no_lines__t2": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__constant_no_lines__t3": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "corrected__floor_c_truncation_starts_ends__t1": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "corrected__floor_c_truncation_starts_ends__t128": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "corrected__floor_c_truncation_starts_ends__t2": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "corrected__floor_c_truncation_starts_ends__t3": "3df3fd5c6238e7362e1cce67aff6d2796526d8afa6bb905eb6f3c8fba275a7c4", + "corrected__irregular_outside_endpoints__t1": "91800c865bcbac917f8b230b5539c17fe80d99b9f1637002eb9a473802ba9dd8", + "corrected__irregular_outside_endpoints__t128": "685f5bbfbea8354a39680e76ac8f95d455948387eab02c8fe9bc0d89adc63d58", + "corrected__irregular_outside_endpoints__t2": "3728f123d94ae7a56bf848d0a9598794f0bad0c1922b8bd2b2d7f2fbf7138474", + "corrected__irregular_outside_endpoints__t3": "adedd8d927e2807952907de66c79167ebdb95fbff3677293aa57cf3fb2ddbd12", + "corrected__line_order_a__t1": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "corrected__line_order_a__t128": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "corrected__line_order_a__t2": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "corrected__line_order_a__t3": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "corrected__line_order_b_permuted__t1": "8deb39b674d4f29c27a333036f49f9cebc630c935082d4c3de99e93f3e6c0f78", + "corrected__line_order_b_permuted__t128": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "corrected__line_order_b_permuted__t2": "8349941775d84b6000b8d76a068c59c94005493cfcd9ab661723fb5f6b5fbd24", + "corrected__line_order_b_permuted__t3": "85561a2c0e1710ff158f7fa914fed201a96ee1fd03bda3929d6529b4cc6c725a", + "corrected__negative_impulse_reversed__t1": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "corrected__negative_impulse_reversed__t128": "bdf60bf5b78e5053cb51c565ccff49725691b19f2619c0e0e17fa6663157c08b", + "corrected__negative_impulse_reversed__t2": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "corrected__negative_impulse_reversed__t3": "59520dc5ab335da577436c1c9cae0fd64d3dbcb9ea7aa3206b1fcfd51cc8efb9", + "corrected__plateau_signed_zero_partial_clamp__t1": "aa07ce88e1d599f243c76465a6ce65156e805f0b85ba495dde97a34741a62f57", + "corrected__plateau_signed_zero_partial_clamp__t128": "08c4c42bec90733a5ff5be313afeaf1923fffbc97f796ca4b7f7df0b7de531db", + "corrected__plateau_signed_zero_partial_clamp__t2": "b2c7bd0472ccd4ce7467682ad9d69d4f8f3768892d337d923eeabf04467f3f9b", + "corrected__plateau_signed_zero_partial_clamp__t3": "2688ef8ad5fa877e031007e52054202f5e01175efeb3b9be707fcaf05fa274a9", + "corrected__positive_impulse_fractional__t1": "6c068627660abe9d58c50f8716d222b5619be8162566a9d2893804620eea2af6", + "corrected__positive_impulse_fractional__t128": "5b0046c9ba51d594ee4360a061443829afd33c490360ca259ba3e389ebdb3699", + "corrected__positive_impulse_fractional__t2": "93c99554deb0fec35b49cebb7e78795bace6435849dd7655e915331ae3af1f93", + "corrected__positive_impulse_fractional__t3": "e283f7dfa8b6f5e56e0b309a2355371fd74336159dcc48e4e48ceb62d2aa45b6", + "corrected__row_offset_duplicate_overlap__t1": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "corrected__row_offset_duplicate_overlap__t128": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "corrected__row_offset_duplicate_overlap__t2": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "corrected__row_offset_duplicate_overlap__t3": "68f17161caacadee25c2f0b16f545cc636cefdc9676428d146008476442115ec", + "corrected__signed_gradient_positive_slope__t1": "98d9efe8c730be95299ccedbbfcadfa55c39c5baa5c57cf2eea805b1849b9ebc", + "corrected__signed_gradient_positive_slope__t128": "7b0e9c2fafe8c614eb62559bc77e7b8bea4684a2a0503530b5988767f2b9af40", + "corrected__signed_gradient_positive_slope__t2": "98d9efe8c730be95299ccedbbfcadfa55c39c5baa5c57cf2eea805b1849b9ebc", + "corrected__signed_gradient_positive_slope__t3": "7b0e9c2fafe8c614eb62559bc77e7b8bea4684a2a0503530b5988767f2b9af40", + "corrected__singleton_1x1_no_lines__t1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected__singleton_1x1_no_lines__t128": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected__singleton_1x1_no_lines__t2": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected__singleton_1x1_no_lines__t3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected__singleton_column_9x1_vertical__t1": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "corrected__singleton_column_9x1_vertical__t128": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "corrected__singleton_column_9x1_vertical__t2": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "corrected__singleton_column_9x1_vertical__t3": "200d94bc383cca5c1db3e0f689f0254aafc0f249143664c14f6d62b07cc85ac6", + "corrected__singleton_row_1x9_horizontal__t1": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "corrected__singleton_row_1x9_horizontal__t128": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "corrected__singleton_row_1x9_horizontal__t2": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "corrected__singleton_row_1x9_horizontal__t3": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "corrected__step_negative_slope__t1": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "corrected__step_negative_slope__t128": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "corrected__step_negative_slope__t2": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "corrected__step_negative_slope__t3": "a16ab03c4869d6d035d7fa6c7804b3719dfe56b73e893d5d96c8cbc85ba3443d", + "corrected__tall_edge_window__t1": "743048c754fd6d005556ab0a551fe1db5082889247da7fbb3bd987a41ae3d72c", + "corrected__tall_edge_window__t128": "6690a460d28cfe4c05a3e3e2105f26443f8ab4a5805bc2c871b3e2979da63e73", + "corrected__tall_edge_window__t2": "743048c754fd6d005556ab0a551fe1db5082889247da7fbb3bd987a41ae3d72c", + "corrected__tall_edge_window__t3": "6fcc09b9955d42781dec3ec9b71c4a1c8918b10709079c6ed4972346ff27722f", + "corrected__wide_edge_window__t1": "1e68b58906514cebde1408fd722b3f3e63d3507a33ea2f8ec761cfe2a076864c", + "corrected__wide_edge_window__t128": "0d76a9c4f4c70739ba5254748a41602d81351fec2a8f1fb3b9c50090b06fa1a5", + "corrected__wide_edge_window__t2": "4a154ac77147d3967cdbcce6f6a633ccbf307c4c2956d5fab4183f1073a811db", + "corrected__wide_edge_window__t3": "4a154ac77147d3967cdbcce6f6a633ccbf307c4c2956d5fab4183f1073a811db", + "input__anisotropic_physical_coordinates": "6a416ab164f1f7ab8e0e50ae9a44a60e20a1d809763d1ed766bbfb4d928f682a", + "input__constant_horizontal": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "input__constant_no_lines": "c1c6ef99dd2253f1ab8064f12a78776b7cf4f8d3c4cf1da3176b07845e034a4f", + "input__floor_c_truncation_starts_ends": "b4e18dfcabe627710e38d3a2dc0d5d60c425927c484c442c3c7fe04490a014a2", + "input__irregular_outside_endpoints": "5d3b9265e0c3f0c1c04a2ed6496adad7567fcdbcac5de7a42e1b7dad31a9979e", + "input__line_order_a": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "input__line_order_b_permuted": "9c0f061314c5119cbb5a4386c777dfe3529082eb13bbbc439fdcda87ec35137b", + "input__negative_impulse_reversed": "03658489207560a26e8f9384c9038edae9c588d11c22b1356cd782edd05e0f2d", + "input__plateau_signed_zero_partial_clamp": "68173761ff74a584231639a78d02744a65601a59c3ef3fc29ac29ad7fabdbc8e", + "input__positive_impulse_fractional": "9c00590617b05138f45aaf221281c78ae41defb9816108660b68e100dee68b29", + "input__row_offset_duplicate_overlap": "085a24234af7acd58e17ab21c025e997739175748553ce19f389faf4d0fb5c40", + "input__signed_gradient_positive_slope": "66be0573d9152154da4b83a74c6685149caf3312eff2af2c3b7c9109d77877a8", + "input__singleton_1x1_no_lines": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_column_9x1_vertical": "fe3c64e8997d8cfb4c242bc6a100fd8ace0308a31e873052e6b4f22bf1bd1e6a", + "input__singleton_row_1x9_horizontal": "0edc83a0f575bc974f946fc5d7e19df0ee907dce03cdd817f274f77962e5344e", + "input__step_negative_slope": "17b72e293ae5970773ecc3f728d5a83f6b7318a77857f5f92a7c5d324974611b", + "input__tall_edge_window": "d03a7dd325f7d70525564e56744303ad9751924f581710cdabc8499b679f9368", + "input__wide_edge_window": "ef163e701f48a3bc31df9816e555935fa7e95d79067ca791c509193c97f59b4f" + }, + "canonical_hash_algorithm": "dtype.str NUL comma-separated shape NUL C-order bytes", + "npz_sha256": "1544c94bbf6efdb896598ff818e73c79c3ab3997b07115198755116cd4e27126", + "relative_npz": "path_level_reference.npz" + }, + "line_order_discriminator": { + "first_case": "line_order_a__t1", + "outputs_differ": true, + "second_case": "line_order_b_permuted__t1" + }, + "metrics": { + "base_families": 18, + "deterministic_repeat_pairs": "72/72", + "exact_elements": "4652/4652", + "external_oracle_arrays": "72/72 bitwise", + "finite_nonzero_mismatches": 0, + "fresh_external_executions": 144, + "logical_cases": 72, + "max_absolute_difference": 0.0, + "max_ulp_distance": 0, + "mutation_classification": "72/72", + "no_op_classification": "72/72", + "normalized_endpoints": "72/72", + "oracle_input_mutation_maximum": 0, + "signed_zero_mismatches": 0 + }, + "reference_software": { + "name": "Gwyddion", + "version": "2.71" + }, + "schema_version": 1, + "thicknesses": [ + 1, + 2, + 3, + 128 + ] +} diff --git a/tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz b/tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..8f289c23e63b174927e00a03d2c3af6270f8c324 GIT binary patch literal 74216 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 literal 0 HcmV?d00001 diff --git a/tests/validation/test_path_level_fixture_integrity.py b/tests/validation/test_path_level_fixture_integrity.py new file mode 100644 index 0000000..3d9b9cf --- /dev/null +++ b/tests/validation/test_path_level_fixture_integrity.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parent / "fixtures/gwyddion/path_level" + + +def _canonical_hash(array: np.ndarray) -> str: + value = np.ascontiguousarray(array) + digest = hashlib.sha256() + digest.update(value.dtype.str.encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(item) for item in value.shape).encode("ascii")) + digest.update(b"\0") + digest.update(value.tobytes(order="C")) + return digest.hexdigest() + + +def test_path_level_fixture_integrity() -> None: + manifest = json.loads((ROOT / "path_level_reference.json").read_text()) + assert manifest["schema_version"] == 1 + assert manifest["capability"] == "gwyddion_path_level" + assert len(manifest["bases"]) == 18 + assert len(manifest["cases"]) == 72 + assert manifest["thicknesses"] == [1, 2, 3, 128] + assert manifest["metrics"]["exact_elements"] == "4652/4652" + assert manifest["metrics"]["external_oracle_arrays"] == "72/72 bitwise" + assert manifest["line_order_discriminator"]["outputs_differ"] is True + assert len({case["case_id"] for case in manifest["cases"]}) == 72 + assert all(len(case["lines_hex"]) == 4 * case["line_count"] for case in manifest["cases"]) + assert all( + len(case["normalized_endpoints"]) == 4 * case["line_count"] + for case in manifest["cases"] + ) + external_artifacts = manifest["evidence"]["source_hashes"]["external_artifacts"] + assert external_artifacts["canonical_reference.json"] == ( + "5dcbd07836de0d6cd856dbfe620f7c24edded25a993c17472746b09e80902d84" + ) + assert manifest["evidence"]["source_hashes"]["oracle_artifacts"]["path_level_oracle.py"] == ( + "9b4ec65ba67777c211ab08c73d91bf523ee5082f04ce0424ea4f1fe569ae58f1" + ) + with np.load(ROOT / "path_level_reference.npz", allow_pickle=False) as archive: + assert len(archive.files) == 90 + assert set(archive.files) == set(manifest["fixture"]["array_hashes"]) + for name in archive.files: + array = archive[name] + assert array.dtype == np.float64 + assert array.ndim == 2 and array.flags.c_contiguous + assert np.isfinite(array).all() + assert _canonical_hash(array) == manifest["fixture"]["array_hashes"][name] + assert archive["input__singleton_row_1x9_horizontal"].view(np.uint64)[0, 0] == 1 << 63 + for base in manifest["bases"]: + assert base["input_key"] in archive.files + assert list(archive[base["input_key"]].shape) == base["shape"] + for case in manifest["cases"]: + assert case["output_key"] in archive.files + assert archive[case["output_key"]].shape == archive[ + f"input__{case['base_id']}" + ].shape From 4ead95b333500340e7e29a9626dd4c01baad1a9e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:47:43 -0400 Subject: [PATCH 68/82] feat(leveling): add Gwyddion Path Level kernel --- .../core/analysis/_gwyddion_path_level.py | 227 ++++++++++++++++++ .../core/test_gwyddion_path_level_private.py | 174 ++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_path_level.py create mode 100644 tests/core/test_gwyddion_path_level_private.py diff --git a/src/spmkit/core/analysis/_gwyddion_path_level.py b/src/spmkit/core/analysis/_gwyddion_path_level.py new file mode 100644 index 0000000..be96f1d --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_path_level.py @@ -0,0 +1,227 @@ +"""Private numerical kernel for the frozen Gwyddion 2.71 Path Level domain.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +FloatArray = NDArray[np.float64] +_NormalizedLine = tuple[int, int, int, int] + + +@dataclass(frozen=True) +class _GwyddionPathLevelLine: + """One ordered straight selection line in physical field coordinates.""" + + x0: float + y0: float + x1: float + y1: float + + +@dataclass(frozen=True) +class _GwyddionPathLevelResult: + """Independent Path Level output and source-visible numerical diagnostics.""" + + corrected: FloatArray + normalized_lines: tuple[_NormalizedLine, ...] + row_differences: FloatArray + cumulative_row_correction: FloatArray + thickness_px: int + + +def _validated_gwyddion_path_level_data(data: ArrayLike) -> FloatArray: + """Return a finite, non-empty C-contiguous float64 copy of a field.""" + try: + source = np.asarray(data) + except (TypeError, ValueError) as exc: + raise TypeError("Gwyddion Path Level requires array-compatible data") from exc + if source.ndim != 2: + raise ValueError("Gwyddion Path Level requires two-dimensional data") + if 0 in source.shape: + raise ValueError("Gwyddion Path Level requires non-empty dimensions") + if not np.issubdtype(source.dtype, np.number) or np.iscomplexobj(source): + raise TypeError("Gwyddion Path Level requires real numeric data") + values = np.array(source, dtype=np.float64, order="C", copy=True) + if not np.isfinite(values).all(): + raise ValueError("Gwyddion Path Level requires finite data") + return values + + +def _validated_gwyddion_path_level_lines( + lines: object, +) -> tuple[_GwyddionPathLevelLine, ...]: + """Validate an ordered, duplicate-preserving sequence of physical lines.""" + if isinstance(lines, (str, bytes)): + raise TypeError("Gwyddion Path Level lines must be numeric coordinate rows") + try: + source = np.asarray(lines) + except (TypeError, ValueError) as exc: + raise TypeError("Gwyddion Path Level lines must be array-compatible") from exc + if source.size == 0: + if source.ndim not in (1, 2): + raise ValueError("empty Gwyddion Path Level lines must be one- or two-dimensional") + return () + if source.ndim != 2 or source.shape[1] != 4: + raise ValueError("Gwyddion Path Level lines must have shape (n, 4)") + if not np.issubdtype(source.dtype, np.number) or np.iscomplexobj(source): + raise TypeError("Gwyddion Path Level lines must contain real numeric coordinates") + values = np.array(source, dtype=np.float64, order="C", copy=True) + if not np.isfinite(values).all(): + raise ValueError("Gwyddion Path Level lines must be finite") + return tuple(_GwyddionPathLevelLine(*(float(value) for value in row)) for row in values) + + +def _validated_gwyddion_path_level_thickness(thickness_px: object) -> int: + """Validate the source-supported Path Level thickness range.""" + if isinstance(thickness_px, (bool, np.bool_)) or not isinstance( + thickness_px, (int, np.integer) + ): + raise TypeError( + "Gwyddion Path Level thickness_px must be a Python or NumPy integer scalar; " + "booleans are invalid" + ) + value = int(thickness_px) + if not 1 <= value <= 128: + raise ValueError("Gwyddion Path Level thickness_px must be in the inclusive range 1..128") + return value + + +def _gwyddion_physical_to_pixel(value: float, resolution: int, real_extent: float) -> float: + """Map a physical coordinate to the Gwyddion data-field pixel coordinate.""" + return value * resolution / real_extent + + +def _gwyddion_c_trunc_div(numerator: int, denominator: int) -> int: + """Return C signed-integer division truncated toward zero.""" + if denominator == 0: + raise ZeroDivisionError("Gwyddion Path Level line division by zero") + magnitude = abs(numerator) // abs(denominator) + return -magnitude if (numerator < 0) != (denominator < 0) else magnitude + + +def _gwyddion_normalized_path_level_lines( + lines: Sequence[_GwyddionPathLevelLine], + *, + xres: int, + yres: int, + xreal: float, + yreal: float, +) -> tuple[_NormalizedLine, ...]: + """Convert ordered physical selections to source-equivalent integer endpoints.""" + result: list[_NormalizedLine] = [] + for line in lines: + x0 = math.floor(_gwyddion_physical_to_pixel(line.x0, xres, xreal)) + y0 = math.floor(_gwyddion_physical_to_pixel(line.y0, yres, yreal)) + x1 = math.floor(_gwyddion_physical_to_pixel(line.x1, xres, xreal)) + y1 = math.floor(_gwyddion_physical_to_pixel(line.y1, yres, yreal)) + if y0 > y1: + x0, x1 = x1, x0 + y0, y1 = y1, y0 + result.append( + ( + min(max(int(x0), 0), xres - 1), + min(max(math.floor(y0), 0), yres - 1), + min(max(int(x1), 0), xres - 1), + min(max(math.ceil(y1), 0), yres - 1), + ) + ) + return tuple(result) + + +def _validated_extent(value: object, name: str) -> float: + if isinstance(value, (bool, np.bool_)) or not isinstance( + value, (int, float, np.integer, np.floating) + ): + raise TypeError(f"Gwyddion Path Level {name} must be a real scalar") + try: + extent = float(value) + except (TypeError, ValueError) as exc: + raise TypeError(f"Gwyddion Path Level {name} must be a real scalar") from exc + if not math.isfinite(extent) or extent <= 0.0: + raise ValueError(f"Gwyddion Path Level {name} must be finite and positive") + return extent + + +def _line_column(line: _NormalizedLine, row: int) -> int: + x0, y0, x1, y1 = line + horizontal_span = x1 - x0 + vertical_span = y1 - y0 + orientation = 1 if vertical_span > 0 else -1 + numerator = (2 * (row - y0) + 1) * horizontal_span + orientation * vertical_span + denominator = 2 * orientation * vertical_span + return _gwyddion_c_trunc_div(numerator, denominator) + x0 + + +def _gwyddion_path_level_result( + data: ArrayLike, + lines: object, + *, + xreal: object, + yreal: object, + thickness_px: object, +) -> _GwyddionPathLevelResult: + """Compute the frozen Gwyddion 2.71 Path Level corrected field privately.""" + values = _validated_gwyddion_path_level_data(data) + physical_lines = _validated_gwyddion_path_level_lines(lines) + thickness = _validated_gwyddion_path_level_thickness(thickness_px) + horizontal_extent = _validated_extent(xreal, "xreal") + vertical_extent = _validated_extent(yreal, "yreal") + yres, xres = values.shape + normalized = _gwyddion_normalized_path_level_lines( + physical_lines, + xres=xres, + yres=yres, + xreal=horizontal_extent, + yreal=vertical_extent, + ) + changes = sorted( + [(line[1], False, identifier) for identifier, line in enumerate(normalized)] + + [(line[3], True, identifier) for identifier, line in enumerate(normalized)] + ) + active = [False] * len(normalized) + row_differences = np.zeros(yres, dtype=np.float64) + lower_reach = (thickness - 1) // 2 + upper_reach = thickness // 2 + change_index = 0 + + for row in range(yres): + if row: + total = np.float64(0.0) + count = 0 + for identifier, line in enumerate(normalized): + if active[identifier]: + column = _line_column(line, row) + first = max(0, column - lower_reach) + last = min(xres - 1, column + upper_reach) + for sample_column in range(first, last + 1): + difference = values[row, sample_column] - values[row - 1, sample_column] + total = total + difference + count += 1 + if count: + row_differences[row] = total / np.float64(count) + while change_index < len(changes) and changes[change_index][0] == row: + _, is_end, identifier = changes[change_index] + active[identifier] = not is_end + change_index += 1 + + cumulative = np.zeros(yres, dtype=np.float64) + running = np.float64(0.0) + for row in range(yres): + running = running + row_differences[row] + cumulative[row] = running + corrected = values.copy(order="C") + for row in range(yres): + for column in range(xres): + corrected[row, column] = corrected[row, column] - cumulative[row] + return _GwyddionPathLevelResult( + corrected=corrected, + normalized_lines=normalized, + row_differences=row_differences, + cumulative_row_correction=cumulative, + thickness_px=thickness, + ) diff --git a/tests/core/test_gwyddion_path_level_private.py b/tests/core/test_gwyddion_path_level_private.py new file mode 100644 index 0000000..cb4f7c7 --- /dev/null +++ b/tests/core/test_gwyddion_path_level_private.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_path_level import ( + _gwyddion_c_trunc_div, + _gwyddion_normalized_path_level_lines, + _gwyddion_path_level_result, + _validated_gwyddion_path_level_data, + _validated_gwyddion_path_level_lines, + _validated_gwyddion_path_level_thickness, +) + +FIXTURE = Path(__file__).resolve().parents[1] / "validation/fixtures/gwyddion/path_level" + + +def _fixture() -> tuple[dict[str, object], dict[str, np.ndarray]]: + manifest = json.loads((FIXTURE / "path_level_reference.json").read_text()) + with np.load(FIXTURE / "path_level_reference.npz", allow_pickle=False) as archive: + arrays = {name: archive[name].copy(order="C") for name in archive.files} + return manifest, arrays + + +def _bits(array: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(array, dtype=np.float64).view(np.uint64) + + +def _ulp_distance(expected: int, actual: int) -> int: + def ordered(value: int) -> int: + return (~value + 1) & ((1 << 64) - 1) if value >> 63 else value | (1 << 63) + + return abs(ordered(expected) - ordered(actual)) + + +def _assert_bits(case_id: str, expected: np.ndarray, actual: np.ndarray) -> None: + expected_bits = _bits(expected) + actual_bits = _bits(actual) + locations = np.argwhere(expected_bits != actual_bits) + if not len(locations): + return + row, column = (int(value) for value in locations[0]) + wanted = int(expected_bits[row, column]) + received = int(actual_bits[row, column]) + raise AssertionError( + f"{case_id}: coordinate=({row}, {column}), expected={expected[row, column]!r}, " + f"actual={actual[row, column]!r}, expected_uint64={wanted:016x}, " + f"actual_uint64={received:016x}, abs={abs(expected[row, column] - actual[row, column])!r}, " + f"ulp={_ulp_distance(wanted, received)}" + ) + + +def _lines(case: dict[str, object]) -> np.ndarray: + values = [float.fromhex(value) for value in case["lines_hex"]] # type: ignore[index] + return np.array(values, dtype=np.float64).reshape((-1, 4)) if values else np.empty((0, 4)) + + +def _array_from_bits(bits: list[str]) -> np.ndarray: + return np.array([int(value, 16) for value in bits], dtype=np.uint64).view(np.float64) + + +def test_all_frozen_cases_are_bitwise_exact_with_source_diagnostics() -> None: + manifest, arrays = _fixture() + endpoint_matches = mutation_matches = no_op_matches = exact_elements = 0 + for case in manifest["cases"]: # type: ignore[index] + base = next(base for base in manifest["bases"] if base["base_id"] == case["base_id"]) # type: ignore[index] + input_data = arrays[base["input_key"]].copy(order="C") + before = input_data.copy(order="C") + result = _gwyddion_path_level_result( + input_data, + _lines(case), + xreal=base["xreal"], + yreal=base["yreal"], + thickness_px=case["thickness"], + ) + expected = arrays[case["output_key"]] + _assert_bits(case["case_id"], expected, result.corrected) + exact_elements += expected.size + assert result.normalized_lines == tuple( + tuple(case["normalized_endpoints"][index : index + 4]) + for index in range(0, len(case["normalized_endpoints"]), 4) + ) + endpoint_matches += 1 + _assert_bits( + case["case_id"] + "/row_differences", + _array_from_bits(case["oracle_row_differences_bits"]), + result.row_differences, + ) + _assert_bits( + case["case_id"] + "/cumulative", + _array_from_bits(case["oracle_cumulative_correction_bits"]), + result.cumulative_row_correction, + ) + changed = not np.array_equal(_bits(result.corrected), _bits(before)) + mutation_matches += changed == case["external_mutation_of_data_field"] + no_op_matches += (not changed) == case["external_no_op"] + assert np.array_equal(_bits(input_data), _bits(before)) + assert result.corrected.dtype == np.float64 and result.corrected.flags.c_contiguous + assert result.corrected.shape == input_data.shape + assert not np.shares_memory(result.corrected, input_data) + assert endpoint_matches == 72 + assert mutation_matches == 72 + assert no_op_matches == 72 + assert exact_elements == 4652 + + +def test_line_order_discriminator_is_preserved() -> None: + manifest, arrays = _fixture() + selected = {case["case_id"]: case for case in manifest["cases"]} + outputs = [] + for case_id in ("line_order_a__t1", "line_order_b_permuted__t1"): + case = selected[case_id] + base = next(base for base in manifest["bases"] if base["base_id"] == case["base_id"]) + result = _gwyddion_path_level_result( + arrays[base["input_key"]], + _lines(case), + xreal=base["xreal"], + yreal=base["yreal"], + thickness_px=1, + ) + outputs.append(result.corrected) + assert not np.array_equal(_bits(outputs[0]), _bits(outputs[1])) + + +def test_endpoint_geometry_and_c_integer_division_contract() -> None: + lines = _validated_gwyddion_path_level_lines([(7.9, 8.2, 1.1, 0.2), (-5.0, -2.0, 15.0, 12.0)]) + assert _gwyddion_normalized_path_level_lines(lines, xres=9, yres=9, xreal=9.0, yreal=9.0) == ( + (1, 0, 7, 8), + (0, 0, 8, 8), + ) + assert [_gwyddion_c_trunc_div(value, 3) for value in (-8, -7, -1, 0, 1, 7, 8)] == [ + -2, + -2, + 0, + 0, + 0, + 2, + 2, + ] + + +def test_validation_and_memory_contracts() -> None: + assert isinstance(_validated_gwyddion_path_level_thickness(np.uint8(128)), int) + for value in (True, np.bool_(False), np.array(1), 1.0, "1"): + with pytest.raises(TypeError): + _validated_gwyddion_path_level_thickness(value) + for value in (0, 129, 10**100): + with pytest.raises(ValueError): + _validated_gwyddion_path_level_thickness(value) + for value in (np.array([]), np.array([1.0]), np.empty((0, 2)), np.array([[np.nan]])): + with pytest.raises(ValueError): + _validated_gwyddion_path_level_data(value) + for value in ("line", np.array([1.0, 2.0, 3.0]), np.array([[np.inf, 0, 0, 0]])): + with pytest.raises((TypeError, ValueError)): + _validated_gwyddion_path_level_lines(value) + data = [[0, 1], [2, 3]] + result = _gwyddion_path_level_result(data, [], xreal=2.0, yreal=2.0, thickness_px=128) + assert result.corrected.dtype == np.float64 and result.corrected.flags.c_contiguous + assert not np.shares_memory(result.corrected, np.asarray(data)) + + +def test_signed_zero_and_repeated_execution_are_deterministic() -> None: + data = np.array([[-0.0, +0.0], [-0.0, +0.0]], dtype=np.float64) + lines = np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float64) + first = _gwyddion_path_level_result(data, lines, xreal=2.0, yreal=2.0, thickness_px=2) + second = _gwyddion_path_level_result(data, lines, xreal=2.0, yreal=2.0, thickness_px=2) + _assert_bits("signed_zero_repeat", first.corrected, second.corrected) + assert hashlib.sha256(_bits(first.corrected).tobytes()).digest() == hashlib.sha256( + _bits(second.corrected).tobytes() + ).digest() From 15089f3456965940c6b8aa9a965b32fbf357e0cf Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:28:15 -0400 Subject: [PATCH 69/82] feat(leveling): expose Gwyddion Path Level API --- src/spmkit/core/analysis/__init__.py | 2 + src/spmkit/core/analysis/leveling.py | 32 ++++ tests/core/test_gwyddion_path_level.py | 252 +++++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 tests/core/test_gwyddion_path_level.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index d2fae55..c421193 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -52,6 +52,7 @@ from spmkit.core.analysis.forcevolume import VolumeResult, analyze_volume from spmkit.core.analysis.grains import GrainResult from spmkit.core.analysis.kpfm import CPDResult +from spmkit.core.analysis.leveling import gwyddion_path_level from spmkit.core.analysis.mechanics import ( ForceCurve, IndentationResult, @@ -88,6 +89,7 @@ "estimate_gwyddion_sphere_revolution_background", "gwyddion_flat_disc_closing", "gwyddion_flat_disc_opening", + "gwyddion_path_level", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 6ce0536..0e1c15e 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -11,6 +11,7 @@ import numpy as np +from spmkit.core.analysis._gwyddion_path_level import _gwyddion_path_level_result from spmkit.core.geometry import ( bilinear_sample, length_values_from_metres, @@ -194,6 +195,37 @@ def zero_minimum(channel: SPMChannel) -> SPMChannel: return channel.with_data(data - minimum_height) +def gwyddion_path_level( + channel: SPMChannel, + lines: object, + *, + thickness_px: object = 1, +) -> SPMChannel: + """Apply the frozen Gwyddion 2.71 Path Level operation. + + ``lines`` is an ordered collection of straight physical-coordinate + selections ``(x0, y0, x1, y1)``. Duplicates and ordering are meaningful. + ``thickness_px`` is an integer from 1 through 128, with default ``1``. + The operation has fixed Gwyddion Path Level semantics: no interpolation, + horizontal-line exclusion, and a cumulative row correction. Finite, + non-empty two-dimensional data and finite positive channel ranges are + required. The input channel is not mutated. + + Returns + ------- + SPMChannel + A corrected channel with the input context preserved. + """ + result = _gwyddion_path_level_result( + channel.data, + lines, + xreal=channel.x_range, + yreal=channel.y_range, + thickness_px=thickness_px, + ) + return channel.with_data(result.corrected) + + def shift_vertical(channel: SPMChannel, *, offset: float) -> SPMChannel: """Add a finite scalar offset to every height value.""" data = _validated_data(channel, operation="shift_vertical") diff --git a/tests/core/test_gwyddion_path_level.py b/tests/core/test_gwyddion_path_level.py new file mode 100644 index 0000000..95ed194 --- /dev/null +++ b/tests/core/test_gwyddion_path_level.py @@ -0,0 +1,252 @@ +"""Public-contract tests for frozen Gwyddion 2.71 Path Level.""" + +from __future__ import annotations + +import inspect +import json +from pathlib import Path + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.leveling as leveling_module +from spmkit.core.analysis import gwyddion_path_level +from spmkit.core.models import SPMChannel + +_FIXTURE_DIRECTORY = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "path_level" +) +_FIXTURE_PATH = _FIXTURE_DIRECTORY / "path_level_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIRECTORY / "path_level_reference.json" + + +def _manifest() -> dict[str, object]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _channel(data: np.ndarray, *, xreal: float, yreal: float) -> SPMChannel: + return SPMChannel( + name="Path Level fixture", + data=data, + unit="V", + x_range=xreal, + y_range=yreal, + direction="backward", + group="Frozen Path Level evidence", + metadata={"source": "gwyddion-2.71-pathlevel", "context": {"id": 11}}, + ) + + +def _lines(case: dict[str, object]) -> np.ndarray: + values = [float.fromhex(value) for value in case["lines_hex"]] # type: ignore[index] + if not values: + return np.empty((0, 4), dtype=np.float64) + return np.array(values, dtype=np.float64).reshape((-1, 4)) + + +def _ordered_uint64(bits: int) -> int: + sign_bit = 1 << 63 + return ((~bits + 1) & ((1 << 64) - 1)) if bits & sign_bit else bits | sign_bit + + +def _maximum_ulp_distance(expected: np.ndarray, actual: np.ndarray) -> int: + expected_bits = expected.view(np.uint64).ravel() + actual_bits = actual.view(np.uint64).ravel() + return max( + abs(_ordered_uint64(int(wanted)) - _ordered_uint64(int(received))) + for wanted, received in zip(expected_bits, actual_bits, strict=True) + ) + + +def _assert_bitwise_equal(actual: np.ndarray, expected: np.ndarray, *, case_id: str) -> None: + actual_bits = actual.view(np.uint64) + expected_bits = expected.view(np.uint64) + if np.array_equal(actual_bits, expected_bits): + return + row, column = np.argwhere(actual_bits != expected_bits)[0] + actual_value = int(actual_bits[row, column]) + expected_value = int(expected_bits[row, column]) + ulp_distance = abs(_ordered_uint64(actual_value) - _ordered_uint64(expected_value)) + pytest.fail( + f"case={case_id} coordinate=({row}, {column}) " + f"expected={expected[row, column]!r} actual={actual[row, column]!r} " + f"expected_uint64={expected_value} actual_uint64={actual_value} " + f"absolute_difference={abs(actual[row, column] - expected[row, column])!r} " + f"ulp_distance={ulp_distance}" + ) + + +def test_public_export_and_signature() -> None: + assert "gwyddion_path_level" in analysis.__all__ + assert analysis.gwyddion_path_level is gwyddion_path_level + signature = inspect.signature(gwyddion_path_level) + assert list(signature.parameters) == ["channel", "lines", "thickness_px"] + assert signature.parameters["thickness_px"].kind is inspect.Parameter.KEYWORD_ONLY + assert signature.parameters["thickness_px"].default == 1 + for forbidden in ("mask", "roi", "path", "interpolation", "origin"): + assert forbidden not in signature.parameters + for private_name in ( + "_GwyddionPathLevelLine", + "_GwyddionPathLevelResult", + "_gwyddion_c_trunc_div", + "_gwyddion_normalized_path_level_lines", + "_gwyddion_path_level_result", + ): + assert private_name not in analysis.__all__ + assert not hasattr(analysis, private_name) + + +def test_all_frozen_public_outputs_are_bitwise_exact() -> None: + manifest = _manifest() + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + exact_elements = signed_zero_mismatches = mutation_matches = no_op_matches = 0 + maximum_absolute_difference = 0.0 + maximum_ulp_distance = 0 + ordered_outputs: dict[str, np.ndarray] = {} + for case in manifest["cases"]: # type: ignore[index] + base = next( + base for base in manifest["bases"] if base["base_id"] == case["base_id"] # type: ignore[index] + ) + source_data = np.array( + archive[base["input_key"]], + dtype=np.float64, + order="C", + copy=True, + ) + original_bits = source_data.view(np.uint64).copy() + channel = _channel(source_data, xreal=base["xreal"], yreal=base["yreal"]) + output = gwyddion_path_level( + channel, + _lines(case), + thickness_px=case["thickness"], + ) + expected = archive[case["output_key"]] + _assert_bitwise_equal(output.data, expected, case_id=case["case_id"]) + exact_elements += expected.size + maximum_absolute_difference = max( + maximum_absolute_difference, + float(np.max(np.abs(output.data - expected))), + ) + maximum_ulp_distance = max( + maximum_ulp_distance, + _maximum_ulp_distance(expected, output.data), + ) + signed_zero_mismatches += int( + np.count_nonzero( + (output.data == 0.0) + & (expected == 0.0) + & (output.data.view(np.uint64) != expected.view(np.uint64)) + ) + ) + changed = not np.array_equal(output.data.view(np.uint64), original_bits) + mutation_matches += changed == case["external_mutation_of_data_field"] + no_op_matches += (not changed) == case["external_no_op"] + assert np.array_equal(channel.data.view(np.uint64), original_bits) + assert output.data.dtype == np.float64 and output.data.flags.c_contiguous + assert output.data.shape == channel.data.shape + assert not np.shares_memory(output.data, channel.data) + ordered_outputs[case["case_id"]] = output.data + assert exact_elements == 4652 + assert maximum_absolute_difference == 0.0 + assert maximum_ulp_distance == 0 + assert signed_zero_mismatches == 0 + assert mutation_matches == 72 + assert no_op_matches == 72 + assert not np.array_equal( + ordered_outputs["line_order_a__t1"].view(np.uint64), + ordered_outputs["line_order_b_permuted__t1"].view(np.uint64), + ) + + +def test_context_is_preserved_with_independent_metadata_and_data() -> None: + manifest = _manifest() + base = next( + base + for base in manifest["bases"] + if base["base_id"] == "signed_gradient_positive_slope" + ) # type: ignore[index] + case = next(case for case in manifest["cases"] if case["base_id"] == base["base_id"]) # type: ignore[index] + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + channel = _channel( + np.array(archive[base["input_key"]], dtype=np.float64, order="C", copy=True), + xreal=base["xreal"], + yreal=base["yreal"], + ) + output = gwyddion_path_level(channel, _lines(case), thickness_px=case["thickness"]) + assert output.name == channel.name + assert output.unit == channel.unit + assert output.x_range == channel.x_range and output.y_range == channel.y_range + assert output.direction == channel.direction and output.group == channel.group + assert output.metadata == channel.metadata and output.metadata is not channel.metadata + output.metadata["new_key"] = True + assert "new_key" not in channel.metadata + + +@pytest.mark.parametrize( + ("lines", "thickness_px", "error_type"), + [ + ([(0.0, 0.0, 1.0, 1.0)], True, TypeError), + ([(0.0, 0.0, 1.0, 1.0)], np.array(1), TypeError), + ([(0.0, 0.0, 1.0, 1.0)], 0, ValueError), + ([(0.0, 0.0, 1.0, 1.0)], 129, ValueError), + ([(0.0, 0.0, 1.0, 1.0)], 1.0, TypeError), + ([(0.0, 0.0, 1.0, 1.0)], "1", TypeError), + ([(0.0, 1.0)], 1, ValueError), + ("line", 1, TypeError), + (np.array([["a", "b", "c", "d"]], dtype=object), 1, TypeError), + (np.array([[np.inf, 0.0, 1.0, 1.0]]), 1, ValueError), + ], +) +def test_public_validation_is_delegated( + lines: object, + thickness_px: object, + error_type: type[Exception], +) -> None: + channel = _channel(np.ones((3, 4), dtype=np.float64), xreal=4.0, yreal=3.0) + with pytest.raises(error_type): + gwyddion_path_level(channel, lines, thickness_px=thickness_px) + invalid_data = _channel(np.array([[np.nan]], dtype=np.float64), xreal=1.0, yreal=1.0) + with pytest.raises(ValueError, match="finite"): + gwyddion_path_level(invalid_data, [], thickness_px=1) + + +@pytest.mark.parametrize( + ("data", "error_type"), + [ + (np.ones(3), ValueError), + (np.empty((0, 2)), ValueError), + (np.array([["a"]]), TypeError), + (np.array([[np.inf]]), ValueError), + ], +) +def test_public_data_and_extent_validation_is_delegated( + data: np.ndarray, + error_type: type[Exception], +) -> None: + channel = _channel(data, xreal=1.0, yreal=1.0) + with pytest.raises(error_type): + gwyddion_path_level(channel, [], thickness_px=1) + for xreal, yreal in ((0.0, 1.0), (1.0, np.inf)): + valid = _channel(np.ones((2, 2)), xreal=xreal, yreal=yreal) + with pytest.raises(ValueError): + gwyddion_path_level(valid, [], thickness_px=1) + + +def test_each_public_call_invokes_private_entry_once(monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + original = leveling_module._gwyddion_path_level_result + + def counted_entry(*args: object, **kwargs: object) -> object: + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(leveling_module, "_gwyddion_path_level_result", counted_entry) + channel = _channel(np.arange(12, dtype=np.float64).reshape(3, 4), xreal=4.0, yreal=3.0) + gwyddion_path_level(channel, [(0.0, 0.0, 3.0, 2.0)], thickness_px=2) + assert calls == 1 From 08e343f71a8fdc10545d382864c249c4fdd8ef38 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:28:15 -0400 Subject: [PATCH 70/82] docs(validation): close Gwyddion Path Level parity --- docs/api.md | 24 ++++++++++++++++++++++ docs/scientific-status.md | 43 +++++++++++++++++++++++++++++++++++++++ docs/validation/index.md | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/docs/api.md b/docs/api.md index 779f1a4..81e011c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -307,6 +307,30 @@ This capability is `CROSS_VALIDATED` only within the frozen 12-field campaign an semantics, evidence identities, and non-claims are recorded in the [Gwyddion flat-disc morphology compatibility specification](design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md). +## Gwyddion 2.71 Path Level + +SPM-Kit exposes the frozen Gwyddion Path Level operation as a non-mutating channel transform: + +```python +from spmkit.core.analysis import gwyddion_path_level + +lines = [(0.0, 0.0, 4.0e-6, 3.0e-6)] +levelled = gwyddion_path_level(channel, lines, thickness_px=1) +``` + +`gwyddion_path_level(channel, lines, *, thickness_px=1) -> SPMChannel` accepts an ordered +collection of straight physical-coordinate selections `(x0, y0, x1, y1)`. Duplicates and order +are meaningful. `thickness_px` is an integer in the inclusive range `1..128`, defaulting to +`1`. The operation has fixed endpoint conversion, no interpolation, horizontal-line exclusion, +and cumulative row-level correction semantics. It requires finite, non-empty 2D data and finite +positive channel ranges. + +The result is a new `SPMChannel`: shape, Z/XY units, ranges, name, direction, group, and copied +metadata are preserved, while the input remains unchanged. Masks, ROI, `GwySelectionPath`, +splines, polylines, profiles, and GUI publication parameters are not part of this API. The scope, +executable evidence, and non-claims are defined in the +[Gwyddion Path Level compatibility specification](design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md). + ## KPFM statistics ```python diff --git a/docs/scientific-status.md b/docs/scientific-status.md index b82cbe5..fb48938 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -42,6 +42,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | | Gwyddion 2.71 Median Background | `core.analysis.background`, `core.analysis._median_background` | Frozen executable reference campaign: 36 logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, direct and radixtree reference paths; public background and corrected fields 36/36 bitwise exact, maximum absolute difference 0 and maximum ULP 0; input mutation maximum 0 and reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen 36-case campaign | Gwyddion 2.71 source, executable probe, independent Python oracle, frozen NPZ/JSON fixture | Finite two-dimensional inputs only; no universal equivalence, performance-equivalence, future-Gwyddion, all-radii, or all-matrices claim; `rank_backend_reference` describes Gwyddion, not an SPM-Kit backend | | Gwyddion 2.71 Filter flat-disc morphology | `core.analysis.background`, `core.analysis._gwyddion_flat_disc_morphology` | Frozen executable reference campaign: 12 fields, six sizes 2/3/4/5/30/31, 72 Opening and 72 Closing cases; kernels 30/30, Opening 72/72 and Closing 72/72 bitwise exact; maximum absolute difference 0, maximum ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 executable, corrected external probe V3, executable reduction trace, independent oracle V2, frozen NPZ/JSON fixture | Finite full-field data with masks ignored; no universal equivalence, NaN/Inf, ROI, masks, ASF, tip morphology, physical rolling-ball, performance, other builds/versions, public erosion/dilation, or source-only tie claim | +| Gwyddion 2.71 Path Level | `core.analysis.leveling`, `core.analysis._gwyddion_path_level` | Audited executable campaign: 18 base families, thicknesses 1/2/3/128, 72 logical cases, 144 fresh external executions and 72 deterministic repeat pairs; private and public arrays 72/72 bitwise exact, 4,652/4,652 elements exact, max absolute/ULP 0, signed-zero mismatches 0, normalized endpoints and mutation/no-op classifications 72/72 | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 Path Level tool, external probe, independent oracle V1, frozen NPZ/JSON fixture | Finite non-empty full fields and ordered straight selections only; no universal equivalence, NaN/Inf, masks/ROI, paths/splines, profiles, align-rows, volume, GUI, performance, or other-build/version claim | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -162,6 +163,48 @@ no ROI, masks, ASF, tip morphology, physical rolling-ball equivalence, performan other Gwyddion builds or versions, public erosion or dilation, or claim that source-level C tie semantics alone reproduce the audited binary. +### Gwyddion 2.71 Path Level + +**Claim:** `CROSS_VALIDATED` only within the frozen Path Level campaign against the audited +Gwyddion 2.71 tool `/usr/lib/gwyddion/modules/tool/tools.so` +(`4711c360dd42e3e16257bf0e86d8bd41852b43d1d34540bf097736a603146237`, Build ID +`600b16d9857946609b567704b406abcc74aea698`). The campaign contains 18 finite, non-empty, +full-field base families, thicknesses 1, 2, 3, and 128, 72 logical cases, 144 fresh external +executions, and 72/72 deterministic repeat pairs. Private and public `gwyddion_path_level` +arrays are bitwise exact in 72/72 cases and 4,652/4,652 elements: maximum absolute difference +0, maximum ULP 0, signed-zero mismatches 0, normalized endpoints 72/72, mutation/no-op +classification 72/72, and input mutation 0. + +The operation consumes ordered GwySelectionLine-equivalent straight physical-coordinate segments; +duplicates and object order are significant. Its fixed executable semantics include endpoint +conversion, horizontal-line exclusion, C truncating division, inclusive thickness windows, and +left-to-right cumulative row correction. Gwyddion mutates the selected data field in place and +performs GUI publication, undo, and logging; SPM-Kit returns a new `SPMChannel` and claims no +GUI-publication parity. + +**Traceability:** + +```text +.reference/gwyddion-2.71/source/modules/tools/pathlevel.c + → installed Gwyddion 2.71 Path Level tool execution + → /tmp/spmkit_path_level_probe_v1 + → /tmp/spmkit_path_level_oracle_v1 + → tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz + → tests/validation/fixtures/gwyddion/path_level/path_level_reference.json + → src/spmkit/core/analysis/_gwyddion_path_level.py + → src/spmkit/core/analysis/leveling.py + → tests/core/test_gwyddion_path_level_private.py + → tests/core/test_gwyddion_path_level.py + → tests/validation/test_path_level_fixture_integrity.py + → docs/scientific-status.md +``` + +The evidence commit is `d3566ce`; the private-kernel commit is `4ead95b`. No future public or +documentation commit hash is claimed. **Non-claims:** no universal equivalence; no NaN or +infinity coverage; no masks or ROI; no GwySelectionPath, splines, or polylines; no profile +extraction, align-rows equivalence, volume line-leveling, GUI/undo/logging/selection-widget +parity, performance parity, or guarantee for other Gwyddion versions or builds. + ## Test-count policy The collection total is measured with: diff --git a/docs/validation/index.md b/docs/validation/index.md index e9f0d7a..eab40d6 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -40,6 +40,7 @@ references, tolerances, outputs, hashes, and limitations. | Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | | Gwyddion Median Background 2.71 v1 | Local rank background on 36 frozen logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, and both direct/radixtree reference paths | Public background and corrected fields 36/36 bitwise exact; maximum absolute difference 0, maximum ULP 0, input mutation maximum 0, reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 only; finite inputs; no universal, performance, future-version, all-radii, or all-matrices claim; no public border/shape/rank configuration | | Gwyddion Filter flat-disc morphology 2.71 v1 | 12 frozen fields, six sizes 2/3/4/5/30/31, full-field mask-ignore Opening and Closing | Kernels 30/30; Opening 72/72 and Closing 72/72 bitwise exact; max absolute difference 0, max ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 executable only; finite full-field data; no universal, NaN/Inf, ROI, mask, ASF, tip, physical rolling-ball, performance, other-build, public erosion/dilation, or source-only tie claim | +| Gwyddion Path Level 2.71 v1 | 18 frozen finite full-field families, ordered straight physical selections, thicknesses 1/2/3/128, 72 logical cases and 144 fresh external executions | Public arrays 72/72 bitwise exact, 4,652/4,652 elements exact; max absolute/ULP 0, signed-zero mismatches 0, 72/72 repeat pairs, normalized endpoints, and mutation/no-op classifications | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 Path Level executable only; no universal, NaN/Inf, ROI/mask, path/spline, profile, align-rows, volume, GUI, performance, other-build/version claim | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and @@ -130,6 +131,46 @@ the corrected zero-initialised probe is the valid external evidence. No claim is universal equivalence, non-finite data, ROI/masks, ASF, tip morphology, physical rolling-ball, performance, other Gwyddion builds, public erosion/dilation, or source-only tie semantics. +### Gwyddion 2.71 Path Level + +The frozen trace is: + +```text +Gwyddion source +→ installed Path Level tool execution +→ frozen 72-case external probe +→ independent oracle V1 +→ frozen repository fixture +→ private SPMKit kernel +→ public SPMChannel API +→ public bitwise tests +→ CROSS_VALIDATED status +``` + +The records are `.reference/gwyddion-2.71/source/modules/tools/pathlevel.c`, +`/tmp/spmkit_path_level_probe_v1`, `/tmp/spmkit_path_level_oracle_v1`, +`docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md`, +`tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz`, +`tests/validation/fixtures/gwyddion/path_level/path_level_reference.json`, +`src/spmkit/core/analysis/_gwyddion_path_level.py`, `src/spmkit/core/analysis/leveling.py`, +`tests/core/test_gwyddion_path_level_private.py`, and +`tests/core/test_gwyddion_path_level.py`, and +`tests/validation/test_path_level_fixture_integrity.py`. + +The evidence commit is `d3566ce`; the private-kernel commit is `4ead95b`. The claim is limited +to 18 finite, non-empty, full-field families, ordered straight selections, thicknesses 1, 2, 3, +and 128, 72 logical cases, 144 fresh executions, and 72/72 deterministic repeat pairs. Public +arrays are bitwise exact in 72/72 cases and 4,652/4,652 elements, with maximum absolute +difference 0, maximum ULP 0, signed-zero mismatches 0, normalized endpoints and mutation/no-op +classification 72/72, and input mutation 0. The audited module is Gwyddion 2.71 `tools.so` +with SHA-256 `4711c360dd42e3e16257bf0e86d8bd41852b43d1d34540bf097736a603146237` and Build ID +`600b16d9857946609b567704b406abcc74aea698`. + +Gwyddion mutates its selected data field in place and publishes GUI undo/logging state; SPM-Kit +returns a new `SPMChannel`. No claim is made for universal equivalence, NaN/Inf, masks/ROI, +GwySelectionPath, splines/polylines, profiles, align-rows, volume line-leveling, GUI/undo/logging +or selection-widget parity, performance, or other Gwyddion versions or builds. + ## What remains open - redistributable multi-instrument fixtures for built-in and adapter readers; From da987fcdfde16333f2f5059a71ce9695b56a3a00 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:47:18 -0400 Subject: [PATCH 71/82] feat(compat): add Gwyddion source audit foundation --- .../GWYCOMPAT_SOURCE_AUDIT_FOUNDATION.md | 43 ++ src/spmkit/compat/__init__.py | 5 + src/spmkit/compat/gwyddion/__init__.py | 25 ++ src/spmkit/compat/gwyddion/errors.py | 15 + src/spmkit/compat/gwyddion/profiles.py | 109 +++++ src/spmkit/compat/gwyddion/reports.py | 261 ++++++++++++ src/spmkit/compat/gwyddion/source_audit.py | 399 ++++++++++++++++++ src/spmkit/compat/gwyddion/symbols.py | 105 +++++ tests/compat/test_gwyddion_profiles.py | 33 ++ tests/compat/test_gwyddion_reports.py | 40 ++ tests/compat/test_gwyddion_source_audit.py | 103 +++++ 11 files changed, 1138 insertions(+) create mode 100644 docs/design/GWYCOMPAT_SOURCE_AUDIT_FOUNDATION.md create mode 100644 src/spmkit/compat/__init__.py create mode 100644 src/spmkit/compat/gwyddion/__init__.py create mode 100644 src/spmkit/compat/gwyddion/errors.py create mode 100644 src/spmkit/compat/gwyddion/profiles.py create mode 100644 src/spmkit/compat/gwyddion/reports.py create mode 100644 src/spmkit/compat/gwyddion/source_audit.py create mode 100644 src/spmkit/compat/gwyddion/symbols.py create mode 100644 tests/compat/test_gwyddion_profiles.py create mode 100644 tests/compat/test_gwyddion_reports.py create mode 100644 tests/compat/test_gwyddion_source_audit.py diff --git a/docs/design/GWYCOMPAT_SOURCE_AUDIT_FOUNDATION.md b/docs/design/GWYCOMPAT_SOURCE_AUDIT_FOUNDATION.md new file mode 100644 index 0000000..a4a7959 --- /dev/null +++ b/docs/design/GWYCOMPAT_SOURCE_AUDIT_FOUNDATION.md @@ -0,0 +1,43 @@ +# GwyCompat Source Audit Foundation + +## Status and boundary + +`spmkit.compat.gwyddion` is a conservative source-compatible migration and audit layer. This +foundation inventories supplied Gwyddion C source text statically; it does not compile, execute, +translate, import, or load a Gwyddion module or shared object. It provides no binary +compatibility and does not claim that a complete module is portable because a registration or +symbol is recognized. + +The initial profile is limited to frozen Gwyddion 2.71 source. It recognizes module-query macros, +the registered process/tool families, Gwyddion prefixes, and GTK/GLib dependencies. The only +explicit current mappings are data-model facts already represented by `SPMChannel`: x/y +resolution and x/y physical ranges. All other symbols remain `adapter-required`, `unsupported`, +or `unknown` as reported; similar names never establish support. + +## Static audit contract + +The lexical scanner preserves line/column locations, local and system includes, multiline calls, +registration-looking calls, Gwyddion symbols, and GTK/GLib dependencies. It masks comments and +string/character literal contents before detecting symbols and distinguishes function-like calls +from plain references. It deduplicates each symbol while retaining all ordered occurrences. + +This is not a complete C parser. It does not preprocess macros, resolve types, evaluate control +flow, prove mutation, or infer scientific semantics. UI, selection, parameter, publication, and +mutation results are named conservative audit hints. The report is deterministic JSON-compatible +data with a source content SHA-256 and a schema version; the core auditor performs no filesystem +writes. + +## Migration and licensing rules + +GwyCompat does not copy GPL implementation bodies and does not automatically translate scientific +algorithms. License compatibility must be reviewed for every proposed migrated module. Numerical +equivalence remains subject to the established workflow: + +```text +source → external probe → independent oracle → SPMKit implementation → validation +``` + +The closed Flatten Base, Arc, Sphere, Median Background, Flat-Disc, and Path Level specifications +remain scientific evidence for their individual capabilities. They are not a general source +migration authorization. Future data-field, selection, parameter, and publication adapters must +be designed, tested, and licensed independently before a report can move beyond static inventory. diff --git a/src/spmkit/compat/__init__.py b/src/spmkit/compat/__init__.py new file mode 100644 index 0000000..d998418 --- /dev/null +++ b/src/spmkit/compat/__init__.py @@ -0,0 +1,5 @@ +"""Conservative source-compatibility utilities isolated from scientific core code.""" + +from spmkit.compat import gwyddion + +__all__ = ["gwyddion"] diff --git a/src/spmkit/compat/gwyddion/__init__.py b/src/spmkit/compat/gwyddion/__init__.py new file mode 100644 index 0000000..53c7fca --- /dev/null +++ b/src/spmkit/compat/gwyddion/__init__.py @@ -0,0 +1,25 @@ +"""Static audit primitives for conservative Gwyddion source migration work.""" + +from spmkit.compat.gwyddion.profiles import ( + GwyddionCompatibilityProfile, + GwyddionVersion, + gwyddion_2_71_profile, +) +from spmkit.compat.gwyddion.reports import ( + GwyddionModuleAuditReport, + canonical_report_json, + report_from_dict, + report_to_dict, +) +from spmkit.compat.gwyddion.source_audit import audit_gwyddion_source + +__all__ = [ + "GwyddionCompatibilityProfile", + "GwyddionModuleAuditReport", + "GwyddionVersion", + "audit_gwyddion_source", + "canonical_report_json", + "gwyddion_2_71_profile", + "report_from_dict", + "report_to_dict", +] diff --git a/src/spmkit/compat/gwyddion/errors.py b/src/spmkit/compat/gwyddion/errors.py new file mode 100644 index 0000000..51af789 --- /dev/null +++ b/src/spmkit/compat/gwyddion/errors.py @@ -0,0 +1,15 @@ +"""Explicit errors for the static Gwyddion source-audit boundary.""" + +from __future__ import annotations + + +class GwyddionCompatibilityError(Exception): + """Base error for conservative Gwyddion compatibility operations.""" + + +class InvalidGwyddionSourceError(GwyddionCompatibilityError, TypeError): + """Raised when a source audit does not receive source text.""" + + +class UnsupportedGwyddionProfileError(GwyddionCompatibilityError, ValueError): + """Raised when no explicit compatibility profile exists for a version.""" diff --git a/src/spmkit/compat/gwyddion/profiles.py b/src/spmkit/compat/gwyddion/profiles.py new file mode 100644 index 0000000..91ff9ab --- /dev/null +++ b/src/spmkit/compat/gwyddion/profiles.py @@ -0,0 +1,109 @@ +"""Version-scoped, conservative Gwyddion source-audit profiles.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from spmkit.compat.gwyddion.errors import UnsupportedGwyddionProfileError + + +@dataclass(frozen=True, order=True) +class GwyddionVersion: + """A concrete Gwyddion version identity, without compatibility extrapolation.""" + + major: int + minor: int + patch: int = 0 + + def __post_init__(self) -> None: + if any(value < 0 for value in (self.major, self.minor, self.patch)): + raise ValueError("Gwyddion version components must be non-negative") + + def __str__(self) -> str: + if self.patch == 0: + return f"{self.major}.{self.minor}" + return f"{self.major}.{self.minor}.{self.patch}" + + +@dataclass(frozen=True) +class GwyddionCompatibilityProfile: + """Known static-audit facts for one Gwyddion version. + + Recognition does not establish source portability. Exact mappings are + deliberately restricted to current SPMKit data-model facts. + """ + + name: str + version: GwyddionVersion + registration_calls: tuple[str, ...] + gwyddion_symbol_prefixes: tuple[str, ...] + gtk_symbol_prefixes: tuple[str, ...] + glib_symbol_prefixes: tuple[str, ...] + exact_symbol_mappings: tuple[tuple[str, str], ...] + + @property + def mapping_dict(self) -> dict[str, str]: + """Return a fresh lookup for the profile's explicitly supported mappings.""" + return dict(self.exact_symbol_mappings) + + +_GWYDDION_2_71_PROFILE = GwyddionCompatibilityProfile( + name="gwyddion-2.71-source-audit", + version=GwyddionVersion(2, 71), + registration_calls=( + "GWY_MODULE_QUERY", + "GWY_MODULE_QUERY2", + "GWY_MODULE_QUERY3", + "gwy_curve_map_func_register", + "gwy_file_func_register", + "gwy_graph_func_register", + "gwy_layer_func_register", + "gwy_process_func_register", + "gwy_tool_func_register", + "gwy_volume_func_register", + "gwy_xyz_func_register", + ), + gwyddion_symbol_prefixes=("gwy_", "GWY_"), + gtk_symbol_prefixes=( + "gtk_", + "gdk_", + "pango_", + "GTK_", + "GDK_", + "PANGO_", + "Gtk", + "Gdk", + "Pango", + ), + glib_symbol_prefixes=( + "g_", + "G_", + "GLIB_", + "GIO_", + "GObject", + "GType", + "GQuark", + "GList", + "GSList", + ), + exact_symbol_mappings=( + ("gwy_data_field_get_xres", "SPMChannel.shape[1]"), + ("gwy_data_field_get_yres", "SPMChannel.shape[0]"), + ("gwy_data_field_get_xreal", "SPMChannel.x_range"), + ("gwy_data_field_get_yreal", "SPMChannel.y_range"), + ), +) + + +def gwyddion_2_71_profile() -> GwyddionCompatibilityProfile: + """Return the immutable conservative profile for frozen Gwyddion 2.71 source.""" + return _GWYDDION_2_71_PROFILE + + +def profile_for_version(version: GwyddionVersion) -> GwyddionCompatibilityProfile: + """Return the explicitly supported profile or fail without approximation.""" + if not isinstance(version, GwyddionVersion): + raise TypeError("version must be a GwyddionVersion") + if version == _GWYDDION_2_71_PROFILE.version: + return _GWYDDION_2_71_PROFILE + raise UnsupportedGwyddionProfileError(f"no Gwyddion source-audit profile for {version}") diff --git a/src/spmkit/compat/gwyddion/reports.py b/src/spmkit/compat/gwyddion/reports.py new file mode 100644 index 0000000..354465e --- /dev/null +++ b/src/spmkit/compat/gwyddion/reports.py @@ -0,0 +1,261 @@ +"""Deterministic JSON-compatible models for static Gwyddion source audits.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from spmkit.compat.gwyddion.profiles import GwyddionCompatibilityProfile, GwyddionVersion +from spmkit.compat.gwyddion.symbols import ( + DependencyReference, + IncludeReference, + ModuleRegistration, + RegistrationKind, + SourceLocation, + SourceSpan, + SymbolClassification, + SymbolReference, + SymbolSupportStatus, +) + +REPORT_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class AuditEvidence: + """Static-audit provenance and limitations, without an execution claim.""" + + source_sha256: str + scanner: str + scanner_version: int + limitations: tuple[str, ...] + + +@dataclass(frozen=True) +class GwyddionModuleAuditReport: + """Immutable report from a lexical Gwyddion C source inventory.""" + + schema_version: int + module_path: str | None + content_sha256: str + profile: GwyddionCompatibilityProfile + evidence: AuditEvidence + registrations: tuple[ModuleRegistration, ...] + includes: tuple[IncludeReference, ...] + gwyddion_symbols: tuple[SymbolReference, ...] + gtk_glib_dependencies: tuple[DependencyReference, ...] + mapped_total: int + adapter_required_total: int + unsupported_total: int + unknown_total: int + has_ui_dependency: bool + likely_selection_dependencies: tuple[str, ...] + likely_parameter_system_dependencies: tuple[str, ...] + likely_publication_logging_dependencies: tuple[str, ...] + conservative_mutation_hints: tuple[str, ...] + migration_blockers: tuple[str, ...] + migration_warnings: tuple[str, ...] + evidence_limitations: tuple[str, ...] + + +def _location_to_dict(location: SourceLocation) -> dict[str, object]: + return {"column": location.column, "line": location.line, "source_path": location.source_path} + + +def _span_to_dict(span: SourceSpan) -> dict[str, object]: + return {"end": _location_to_dict(span.end), "start": _location_to_dict(span.start)} + + +def _profile_to_dict(profile: GwyddionCompatibilityProfile) -> dict[str, object]: + return { + "exact_symbol_mappings": [list(item) for item in profile.exact_symbol_mappings], + "glib_symbol_prefixes": list(profile.glib_symbol_prefixes), + "gwyddion_symbol_prefixes": list(profile.gwyddion_symbol_prefixes), + "gtk_symbol_prefixes": list(profile.gtk_symbol_prefixes), + "name": profile.name, + "registration_calls": list(profile.registration_calls), + "version": { + "major": profile.version.major, + "minor": profile.version.minor, + "patch": profile.version.patch, + }, + } + + +def report_to_dict(report: GwyddionModuleAuditReport) -> dict[str, object]: + """Return a JSON-compatible, order-preserving representation of an audit report.""" + return { + "adapter_required_total": report.adapter_required_total, + "content_sha256": report.content_sha256, + "conservative_mutation_hints": list(report.conservative_mutation_hints), + "evidence": { + "limitations": list(report.evidence.limitations), + "scanner": report.evidence.scanner, + "scanner_version": report.evidence.scanner_version, + "source_sha256": report.evidence.source_sha256, + }, + "evidence_limitations": list(report.evidence_limitations), + "gtk_glib_dependencies": [ + { + "classification": dependency.classification.value, + "name": dependency.name, + "occurrences": [_span_to_dict(span) for span in dependency.occurrences], + "support_status": dependency.support_status.value, + } + for dependency in report.gtk_glib_dependencies + ], + "gwyddion_symbols": [ + { + "call_occurrences": [_span_to_dict(span) for span in symbol.call_occurrences], + "classification": symbol.classification.value, + "occurrences": [_span_to_dict(span) for span in symbol.occurrences], + "support_status": symbol.support_status.value, + "symbol": symbol.symbol, + } + for symbol in report.gwyddion_symbols + ], + "has_ui_dependency": report.has_ui_dependency, + "includes": [ + { + "is_local": include.is_local, + "name": include.name, + "span": _span_to_dict(include.span), + } + for include in report.includes + ], + "likely_parameter_system_dependencies": list(report.likely_parameter_system_dependencies), + "likely_publication_logging_dependencies": list( + report.likely_publication_logging_dependencies + ), + "likely_selection_dependencies": list(report.likely_selection_dependencies), + "mapped_total": report.mapped_total, + "migration_blockers": list(report.migration_blockers), + "migration_warnings": list(report.migration_warnings), + "module_path": report.module_path, + "registrations": [ + { + "callee": registration.callee, + "declared_name": registration.declared_name, + "kind": registration.kind.value, + "span": _span_to_dict(registration.span), + } + for registration in report.registrations + ], + "profile": _profile_to_dict(report.profile), + "schema_version": report.schema_version, + "unknown_total": report.unknown_total, + "unsupported_total": report.unsupported_total, + } + + +def canonical_report_json(report: GwyddionModuleAuditReport) -> str: + """Serialize a report deterministically without writing a file.""" + return json.dumps( + report_to_dict(report), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _location_from_dict(value: dict[str, Any]) -> SourceLocation: + return SourceLocation(value["source_path"], int(value["line"]), int(value["column"])) + + +def _span_from_dict(value: dict[str, Any]) -> SourceSpan: + return SourceSpan(_location_from_dict(value["start"]), _location_from_dict(value["end"])) + + +def _profile_from_dict(value: dict[str, Any]) -> GwyddionCompatibilityProfile: + version = value["version"] + return GwyddionCompatibilityProfile( + name=str(value["name"]), + version=GwyddionVersion( + int(version["major"]), + int(version["minor"]), + int(version["patch"]), + ), + registration_calls=tuple(str(item) for item in value["registration_calls"]), + gwyddion_symbol_prefixes=tuple(str(item) for item in value["gwyddion_symbol_prefixes"]), + gtk_symbol_prefixes=tuple(str(item) for item in value["gtk_symbol_prefixes"]), + glib_symbol_prefixes=tuple(str(item) for item in value["glib_symbol_prefixes"]), + exact_symbol_mappings=tuple( + (str(item[0]), str(item[1])) for item in value["exact_symbol_mappings"] + ), + ) + + +def report_from_dict(value: dict[str, Any]) -> GwyddionModuleAuditReport: + """Reconstruct a report from :func:`report_to_dict` output.""" + evidence_value = value["evidence"] + return GwyddionModuleAuditReport( + schema_version=int(value["schema_version"]), + module_path=value["module_path"], + content_sha256=str(value["content_sha256"]), + profile=_profile_from_dict(value["profile"]), + evidence=AuditEvidence( + source_sha256=str(evidence_value["source_sha256"]), + scanner=str(evidence_value["scanner"]), + scanner_version=int(evidence_value["scanner_version"]), + limitations=tuple(str(item) for item in evidence_value["limitations"]), + ), + registrations=tuple( + ModuleRegistration( + kind=RegistrationKind(item["kind"]), + callee=str(item["callee"]), + declared_name=item["declared_name"], + span=_span_from_dict(item["span"]), + ) + for item in value["registrations"] + ), + includes=tuple( + IncludeReference( + name=str(item["name"]), + is_local=bool(item["is_local"]), + span=_span_from_dict(item["span"]), + ) + for item in value["includes"] + ), + gwyddion_symbols=tuple( + SymbolReference( + symbol=str(item["symbol"]), + classification=SymbolClassification(item["classification"]), + support_status=SymbolSupportStatus(item["support_status"]), + occurrences=tuple(_span_from_dict(span) for span in item["occurrences"]), + call_occurrences=tuple( + _span_from_dict(span) for span in item["call_occurrences"] + ), + ) + for item in value["gwyddion_symbols"] + ), + gtk_glib_dependencies=tuple( + DependencyReference( + name=str(item["name"]), + classification=SymbolClassification(item["classification"]), + support_status=SymbolSupportStatus(item["support_status"]), + occurrences=tuple(_span_from_dict(span) for span in item["occurrences"]), + ) + for item in value["gtk_glib_dependencies"] + ), + mapped_total=int(value["mapped_total"]), + adapter_required_total=int(value["adapter_required_total"]), + unsupported_total=int(value["unsupported_total"]), + unknown_total=int(value["unknown_total"]), + has_ui_dependency=bool(value["has_ui_dependency"]), + likely_selection_dependencies=tuple( + str(item) for item in value["likely_selection_dependencies"] + ), + likely_parameter_system_dependencies=tuple( + str(item) for item in value["likely_parameter_system_dependencies"] + ), + likely_publication_logging_dependencies=tuple( + str(item) for item in value["likely_publication_logging_dependencies"] + ), + conservative_mutation_hints=tuple( + str(item) for item in value["conservative_mutation_hints"] + ), + migration_blockers=tuple(str(item) for item in value["migration_blockers"]), + migration_warnings=tuple(str(item) for item in value["migration_warnings"]), + evidence_limitations=tuple(str(item) for item in value["evidence_limitations"]), + ) diff --git a/src/spmkit/compat/gwyddion/source_audit.py b/src/spmkit/compat/gwyddion/source_audit.py new file mode 100644 index 0000000..a021efd --- /dev/null +++ b/src/spmkit/compat/gwyddion/source_audit.py @@ -0,0 +1,399 @@ +"""Dependency-free lexical inventory for conservative Gwyddion C source audits. + +This module is intentionally not a complete C parser. It never preprocesses, +compiles, executes, or loads the audited source. +""" + +from __future__ import annotations + +import hashlib +import re +from bisect import bisect_right +from collections import OrderedDict +from collections.abc import Iterable + +from spmkit.compat.gwyddion.errors import InvalidGwyddionSourceError +from spmkit.compat.gwyddion.profiles import ( + GwyddionCompatibilityProfile, + gwyddion_2_71_profile, +) +from spmkit.compat.gwyddion.reports import ( + REPORT_SCHEMA_VERSION, + AuditEvidence, + GwyddionModuleAuditReport, +) +from spmkit.compat.gwyddion.symbols import ( + DependencyReference, + IncludeReference, + ModuleRegistration, + RegistrationKind, + SourceLocation, + SourceSpan, + SymbolClassification, + SymbolReference, + SymbolSupportStatus, +) + +_IDENTIFIER = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b") +_INCLUDE = re.compile(r'(?m)^[ \t]*#[ \t]*include[ \t]*([<"])([^>"]+)[>"]') +_KNOWN_REGISTRATION_KINDS = { + "gwy_curve_map_func_register": RegistrationKind.CURVE_MAP, + "gwy_file_func_register": RegistrationKind.FILE, + "gwy_graph_func_register": RegistrationKind.GRAPH, + "gwy_layer_func_register": RegistrationKind.LAYER, + "gwy_process_func_register": RegistrationKind.PROCESS, + "gwy_tool_func_register": RegistrationKind.TOOL, + "gwy_volume_func_register": RegistrationKind.VOLUME, + "gwy_xyz_func_register": RegistrationKind.XYZ, +} +_PROCESS_NUMERICAL_PREFIXES = ( + "gwy_data_field_area_", + "gwy_data_field_filter_", + "gwy_data_field_elliptic_", + "gwy_data_field_grains_", +) +_MUTATING_DATA_FIELD_MARKERS = ("_set_", "_add_", "_subtract_", "_fill", "_filter_") +_LIMITATIONS = ( + "Lexical inventory only; this is not a complete C parser.", + "No preprocessing, macro expansion, type resolution, or control-flow analysis occurs.", + "Mutation, UI, selection, parameter, and publication findings are audit hints," + " not semantic proof.", + "Recognized symbols and registrations do not establish full-module source portability.", +) + + +def _mask_comments(text: str) -> str: + """Replace comments by spaces while preserving strings and every newline.""" + result = list(text) + index = 0 + state = "normal" + while index < len(text): + current = text[index] + following = text[index + 1] if index + 1 < len(text) else "" + if state == "normal" and current == "/" and following == "/": + result[index] = result[index + 1] = " " + index += 2 + while index < len(text) and text[index] != "\n": + result[index] = " " + index += 1 + continue + if state == "normal" and current == "/" and following == "*": + result[index] = result[index + 1] = " " + index += 2 + while index < len(text): + if text[index] == "*" and index + 1 < len(text) and text[index + 1] == "/": + result[index] = result[index + 1] = " " + index += 2 + break + if text[index] != "\n": + result[index] = " " + index += 1 + continue + if state == "normal" and current in ('"', "'"): + state = current + elif state != "normal" and current == "\\": + index += 2 + continue + elif state != "normal" and current == state: + state = "normal" + index += 1 + return "".join(result) + + +def _mask_literals(text: str) -> str: + """Replace C string and character literal contents while preserving locations.""" + result = list(text) + index = 0 + delimiter: str | None = None + while index < len(text): + current = text[index] + if delimiter is None and current in ('"', "'"): + delimiter = current + result[index] = " " + elif delimiter is not None: + if current != "\n": + result[index] = " " + if current == "\\" and index + 1 < len(text): + index += 1 + if text[index] != "\n": + result[index] = " " + elif current == delimiter: + delimiter = None + index += 1 + return "".join(result) + + +def _line_offsets(text: str) -> list[int]: + return [0, *(match.end() for match in re.finditer("\n", text))] + + +def _location(offset: int, offsets: list[int], source_path: str | None) -> SourceLocation: + line_index = bisect_right(offsets, offset) - 1 + return SourceLocation(source_path, line_index + 1, offset - offsets[line_index] + 1) + + +def _span( + start: int, + end: int, + offsets: list[int], + source_path: str | None, +) -> SourceSpan: + return SourceSpan(_location(start, offsets, source_path), _location(end, offsets, source_path)) + + +def _next_nonspace(text: str, index: int) -> int: + while index < len(text) and text[index].isspace(): + index += 1 + return index + + +def _closing_parenthesis(text: str, open_index: int) -> int | None: + depth = 0 + for index in range(open_index, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + return index + return None + + +def _is_gwyddion_symbol(symbol: str, profile: GwyddionCompatibilityProfile) -> bool: + return symbol.startswith(profile.gwyddion_symbol_prefixes) + + +def _is_gtk_symbol(symbol: str, profile: GwyddionCompatibilityProfile) -> bool: + return symbol.startswith(profile.gtk_symbol_prefixes) + + +def _is_glib_symbol(symbol: str, profile: GwyddionCompatibilityProfile) -> bool: + return symbol.startswith(profile.glib_symbol_prefixes) or symbol in { + "gboolean", + "gchar", + "gdouble", + "gint", + "gpointer", + "guint", + "gulong", + } + + +def _classification(symbol: str, profile: GwyddionCompatibilityProfile) -> SymbolClassification: + if symbol in profile.registration_calls or symbol.startswith("GWY_MODULE_QUERY"): + return SymbolClassification.MODULE_REGISTRATION + if _is_gtk_symbol(symbol, profile): + return SymbolClassification.GUI_GTK + if _is_glib_symbol(symbol, profile): + return SymbolClassification.GLIB_RUNTIME + if symbol.startswith(_PROCESS_NUMERICAL_PREFIXES): + return SymbolClassification.PROCESS_NUMERICAL + if symbol.startswith(("gwy_data_field_", "gwy_data_line_", "gwy_brick_", "gwy_surface_")): + return SymbolClassification.DATA_MODEL + if symbol.startswith(("gwy_selection_", "gwy_layer_", "gwy_vector_layer_", "gwy_plain_tool_")): + return SymbolClassification.SELECTION_LAYER + if symbol.startswith(("gwy_params_", "gwy_param_", "gwy_app_settings_")): + return SymbolClassification.PARAMETERS_SETTINGS + if symbol.startswith(("gwy_app_undo_", "gwy_app_channel_log_", "gwy_container_set_")): + return SymbolClassification.PUBLICATION_LOGGING + if symbol.startswith(("gwy_container_", "gwy_app_")): + return SymbolClassification.CONTAINER_APPLICATION + return SymbolClassification.UNKNOWN + + +def _support_status( + symbol: str, + classification: SymbolClassification, + profile: GwyddionCompatibilityProfile, +) -> SymbolSupportStatus: + if symbol in profile.mapping_dict: + return SymbolSupportStatus.MAPPED + if classification is SymbolClassification.GUI_GTK: + return SymbolSupportStatus.UNSUPPORTED + if classification is SymbolClassification.UNKNOWN: + return SymbolSupportStatus.UNKNOWN + return SymbolSupportStatus.ADAPTER_REQUIRED + + +def _registration_kind(symbol: str) -> RegistrationKind | None: + if symbol in _KNOWN_REGISTRATION_KINDS: + return _KNOWN_REGISTRATION_KINDS[symbol] + if symbol.startswith("GWY_MODULE_QUERY") or symbol.endswith("_func_register"): + return RegistrationKind.UNKNOWN + return None + + +def _registration_name(symbol: str, raw_call: str) -> str | None: + if symbol.startswith("GWY_MODULE_QUERY"): + match = re.search(r",\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)$", raw_call, re.DOTALL) + return match.group(1) if match else None + if symbol in {"gwy_process_func_register", "gwy_file_func_register"}: + match = re.search(r'\(\s*"((?:[^"\\]|\\.)*)"', raw_call, re.DOTALL) + return match.group(1) if match else None + return None + + +def _deduplicated(values: Iterable[str]) -> tuple[str, ...]: + return tuple(OrderedDict.fromkeys(values)) + + +def audit_gwyddion_source( + source_text: str, + *, + source_path: str | None = None, + profile: GwyddionCompatibilityProfile | None = None, +) -> GwyddionModuleAuditReport: + """Audit C source text lexically without executing, compiling, or writing files.""" + if not isinstance(source_text, str): + raise InvalidGwyddionSourceError("source_text must be a str") + if source_path is not None and not isinstance(source_path, str): + raise TypeError("source_path must be a str or None") + if profile is None: + profile = gwyddion_2_71_profile() + if not isinstance(profile, GwyddionCompatibilityProfile): + raise TypeError("profile must be a GwyddionCompatibilityProfile") + + comment_masked = _mask_comments(source_text) + lexical_text = _mask_literals(comment_masked) + offsets = _line_offsets(source_text) + includes = tuple( + IncludeReference( + name=match.group(2), + is_local=match.group(1) == '"', + span=_span(match.start(2), match.end(2), offsets, source_path), + ) + for match in _INCLUDE.finditer(comment_masked) + ) + symbol_occurrences: OrderedDict[str, list[SourceSpan]] = OrderedDict() + call_occurrences: OrderedDict[str, list[SourceSpan]] = OrderedDict() + dependency_occurrences: OrderedDict[str, list[SourceSpan]] = OrderedDict() + registrations: list[ModuleRegistration] = [] + + for match in _IDENTIFIER.finditer(lexical_text): + symbol = match.group(0) + symbol_span = _span(match.start(), match.end(), offsets, source_path) + following = _next_nonspace(lexical_text, match.end()) + is_call = following < len(lexical_text) and lexical_text[following] == "(" + if _is_gwyddion_symbol(symbol, profile): + symbol_occurrences.setdefault(symbol, []).append(symbol_span) + if is_call: + call_occurrences.setdefault(symbol, []).append(symbol_span) + kind = _registration_kind(symbol) + if kind is not None: + closing = _closing_parenthesis(lexical_text, following) + call_end = closing + 1 if closing is not None else match.end() + raw_call = source_text[match.start() : call_end] + registrations.append( + ModuleRegistration( + kind=kind, + callee=symbol, + declared_name=_registration_name(symbol, raw_call), + span=_span(match.start(), call_end, offsets, source_path), + ) + ) + elif _is_gtk_symbol(symbol, profile) or _is_glib_symbol(symbol, profile): + dependency_occurrences.setdefault(symbol, []).append(symbol_span) + + symbols = tuple( + SymbolReference( + symbol=symbol, + classification=_classification(symbol, profile), + support_status=_support_status(symbol, _classification(symbol, profile), profile), + occurrences=tuple(occurrences), + call_occurrences=tuple(call_occurrences.get(symbol, [])), + ) + for symbol, occurrences in symbol_occurrences.items() + ) + dependencies = tuple( + DependencyReference( + name=name, + classification=_classification(name, profile), + support_status=_support_status(name, _classification(name, profile), profile), + occurrences=tuple(occurrences), + ) + for name, occurrences in dependency_occurrences.items() + ) + classifications = {symbol.symbol: symbol.classification for symbol in symbols} + selection = _deduplicated( + symbol.symbol + for symbol in symbols + if classifications[symbol.symbol] is SymbolClassification.SELECTION_LAYER + ) + parameters = _deduplicated( + symbol.symbol + for symbol in symbols + if classifications[symbol.symbol] is SymbolClassification.PARAMETERS_SETTINGS + ) + publication = _deduplicated( + symbol.symbol + for symbol in symbols + if classifications[symbol.symbol] is SymbolClassification.PUBLICATION_LOGGING + ) + mutation_hints = _deduplicated( + f"possible data-field mutation: {symbol.symbol}" + for symbol in symbols + if symbol.symbol.startswith("gwy_data_field_") + and any(marker in symbol.symbol for marker in _MUTATING_DATA_FIELD_MARKERS) + ) + blockers: list[str] = [] + if any( + dependency.classification is SymbolClassification.GUI_GTK + for dependency in dependencies + ): + blockers.append("GUI/GTK dependency requires an explicit adapter and remains unsupported.") + if selection: + blockers.append("Selection/layer dependency requires an explicit adapter.") + if parameters: + blockers.append("Parameter/settings dependency requires an explicit adapter.") + if publication: + blockers.append("Publication/logging dependency requires an explicit adapter.") + blockers.extend( + f"Unknown support status: {symbol.symbol}" + for symbol in symbols + if symbol.support_status is SymbolSupportStatus.UNKNOWN + ) + warnings = _deduplicated( + [ + "Static source inventory does not establish semantic equivalence.", + "No binary compatibility, dynamic module loading, or automatic translation" + " is provided.", + "License compatibility must be reviewed per migrated module.", + ] + ) + totals = dict.fromkeys(SymbolSupportStatus, 0) + for symbol_reference in symbols: + totals[symbol_reference.support_status] += 1 + for dependency_reference in dependencies: + totals[dependency_reference.support_status] += 1 + digest = hashlib.sha256(source_text.encode("utf-8")).hexdigest() + evidence = AuditEvidence( + source_sha256=digest, + scanner="spmkit.compat.gwyddion.lexical-source-audit", + scanner_version=1, + limitations=_LIMITATIONS, + ) + return GwyddionModuleAuditReport( + schema_version=REPORT_SCHEMA_VERSION, + module_path=source_path, + content_sha256=digest, + profile=profile, + evidence=evidence, + registrations=tuple(registrations), + includes=includes, + gwyddion_symbols=symbols, + gtk_glib_dependencies=dependencies, + mapped_total=totals[SymbolSupportStatus.MAPPED], + adapter_required_total=totals[SymbolSupportStatus.ADAPTER_REQUIRED], + unsupported_total=totals[SymbolSupportStatus.UNSUPPORTED], + unknown_total=totals[SymbolSupportStatus.UNKNOWN], + has_ui_dependency=any( + dependency.classification is SymbolClassification.GUI_GTK for dependency in dependencies + ), + likely_selection_dependencies=selection, + likely_parameter_system_dependencies=parameters, + likely_publication_logging_dependencies=publication, + conservative_mutation_hints=mutation_hints, + migration_blockers=tuple(blockers), + migration_warnings=warnings, + evidence_limitations=_LIMITATIONS, + ) diff --git a/src/spmkit/compat/gwyddion/symbols.py b/src/spmkit/compat/gwyddion/symbols.py new file mode 100644 index 0000000..5a3b937 --- /dev/null +++ b/src/spmkit/compat/gwyddion/symbols.py @@ -0,0 +1,105 @@ +"""Immutable source-location and symbol-inventory models for static auditing.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class RegistrationKind(StrEnum): + """Gwyddion module registration families recognized by the source auditor.""" + + PROCESS = "process" + TOOL = "tool" + FILE = "file" + GRAPH = "graph" + LAYER = "layer" + VOLUME = "volume" + XYZ = "xyz" + CURVE_MAP = "curve-map" + UNKNOWN = "unknown" + + +class SymbolClassification(StrEnum): + """Conservative ownership-oriented symbol classes, not semantic proof.""" + + DATA_MODEL = "data/model" + PROCESS_NUMERICAL = "process/numerical" + CONTAINER_APPLICATION = "container/application" + SELECTION_LAYER = "selection/layer" + PARAMETERS_SETTINGS = "parameters/settings" + PUBLICATION_LOGGING = "publication/logging" + GUI_GTK = "GUI/GTK" + GLIB_RUNTIME = "GLib/runtime" + MODULE_REGISTRATION = "module/registration" + UNKNOWN = "unknown" + + +class SymbolSupportStatus(StrEnum): + """Current migration support state; unproven names stay unknown.""" + + MAPPED = "mapped" + ADAPTER_REQUIRED = "adapter-required" + UNSUPPORTED = "unsupported" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class SourceLocation: + """One-based source position controlled entirely by the audit caller.""" + + source_path: str | None + line: int + column: int + + def __post_init__(self) -> None: + if self.line < 1 or self.column < 1: + raise ValueError("source locations are one-based") + + +@dataclass(frozen=True) +class SourceSpan: + """Half-open source range represented by stable start and end locations.""" + + start: SourceLocation + end: SourceLocation + + +@dataclass(frozen=True) +class IncludeReference: + """One literal preprocessor include, retained without preprocessing it.""" + + name: str + is_local: bool + span: SourceSpan + + +@dataclass(frozen=True) +class ModuleRegistration: + """A registration-looking function or macro call detected lexically.""" + + kind: RegistrationKind + callee: str + declared_name: str | None + span: SourceSpan + + +@dataclass(frozen=True) +class SymbolReference: + """A deduplicated symbol with every lexical occurrence retained in order.""" + + symbol: str + classification: SymbolClassification + support_status: SymbolSupportStatus + occurrences: tuple[SourceSpan, ...] + call_occurrences: tuple[SourceSpan, ...] + + +@dataclass(frozen=True) +class DependencyReference: + """A GTK/GLib dependency inventory entry with ordered occurrences.""" + + name: str + classification: SymbolClassification + support_status: SymbolSupportStatus + occurrences: tuple[SourceSpan, ...] diff --git a/tests/compat/test_gwyddion_profiles.py b/tests/compat/test_gwyddion_profiles.py new file mode 100644 index 0000000..5bee21c --- /dev/null +++ b/tests/compat/test_gwyddion_profiles.py @@ -0,0 +1,33 @@ +"""Tests for conservative, version-scoped Gwyddion source-audit profiles.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from spmkit.compat.gwyddion.errors import UnsupportedGwyddionProfileError +from spmkit.compat.gwyddion.profiles import ( + GwyddionVersion, + gwyddion_2_71_profile, + profile_for_version, +) + + +def test_gwyddion_2_71_profile_is_immutable_and_conservative() -> None: + profile = gwyddion_2_71_profile() + assert str(profile.version) == "2.71" + assert "GWY_MODULE_QUERY2" in profile.registration_calls + assert "gwy_tool_func_register" in profile.registration_calls + assert profile.mapping_dict["gwy_data_field_get_xres"] == "SPMChannel.shape[1]" + assert "gwy_data_field_area_filter_min_max" not in profile.mapping_dict + with pytest.raises(FrozenInstanceError): + profile.name = "other" # type: ignore[misc] + + +def test_profile_lookup_never_approximates_an_unsupported_version() -> None: + assert profile_for_version(GwyddionVersion(2, 71)) == gwyddion_2_71_profile() + with pytest.raises(UnsupportedGwyddionProfileError): + profile_for_version(GwyddionVersion(2, 72)) + with pytest.raises(TypeError): + profile_for_version("2.71") # type: ignore[arg-type] diff --git a/tests/compat/test_gwyddion_reports.py b/tests/compat/test_gwyddion_reports.py new file mode 100644 index 0000000..d1645c5 --- /dev/null +++ b/tests/compat/test_gwyddion_reports.py @@ -0,0 +1,40 @@ +"""Deterministic serialization tests for static Gwyddion audit reports.""" + +from __future__ import annotations + +import json + +import pytest + +from spmkit.compat.gwyddion.errors import InvalidGwyddionSourceError +from spmkit.compat.gwyddion.reports import canonical_report_json, report_from_dict, report_to_dict +from spmkit.compat.gwyddion.source_audit import audit_gwyddion_source + + +def _report(): + return audit_gwyddion_source( + """ +#include +GWY_MODULE_QUERY2(module_info, report_sample) +gwy_process_func_register("report-sample", callback); +gwy_data_field_get_yreal(field); +""", + source_path="modules/process/report-sample.c", + ) + + +def test_canonical_json_is_stable_and_round_trips() -> None: + first = _report() + second = _report() + first_json = canonical_report_json(first) + assert first_json == canonical_report_json(second) + reconstructed = report_from_dict(json.loads(first_json)) + assert report_to_dict(reconstructed) == report_to_dict(first) + assert canonical_report_json(reconstructed) == first_json + assert "modules/process/report-sample.c" in first_json + assert "/tmp/" not in first_json + + +def test_audit_rejects_non_text_source_without_writing() -> None: + with pytest.raises(InvalidGwyddionSourceError): + audit_gwyddion_source(b"gwy_process_func_register") # type: ignore[arg-type] diff --git a/tests/compat/test_gwyddion_source_audit.py b/tests/compat/test_gwyddion_source_audit.py new file mode 100644 index 0000000..4249aca --- /dev/null +++ b/tests/compat/test_gwyddion_source_audit.py @@ -0,0 +1,103 @@ +"""Source-fact tests for the lexical Gwyddion migration auditor.""" + +from __future__ import annotations + +from pathlib import Path + +from spmkit.compat.gwyddion.source_audit import audit_gwyddion_source +from spmkit.compat.gwyddion.symbols import ( + RegistrationKind, + SymbolClassification, + SymbolSupportStatus, +) + +_REPOSITORY = Path(__file__).resolve().parents[2] +_SOURCE_ROOT = _REPOSITORY / ".reference/gwyddion-2.71/source" + + +def _audit(relative: str): + return audit_gwyddion_source( + (_SOURCE_ROOT / relative).read_text(encoding="utf-8"), + source_path=relative, + ) + + +def test_lexical_scanner_ignores_comments_and_literals_and_retains_calls() -> None: + source = ''' +#include "local-header.h" +/* gwy_process_func_register("fake", nope); GWY_MODULE_QUERY2(fake, wrong) */ +const char *message = "gwy_tool_func_register(GWY_FAKE)"; +const char quoted = 'g'; +GWY_MODULE_QUERY2(module_info, synthetic) +gwy_process_func_register( + "real-process", + callback, + 0 +); +gwy_data_field_get_xres(field); +gwy_data_field_get_xres(field); +gwy_future_symbol(); +gwy_custom_func_register(); +gtk_widget_show(widget); +gwyish_data_field_get_xres(field); +''' + report = audit_gwyddion_source(source, source_path="synthetic.c") + assert [(item.kind, item.declared_name) for item in report.registrations] == [ + (RegistrationKind.UNKNOWN, "synthetic"), + (RegistrationKind.PROCESS, "real-process"), + (RegistrationKind.UNKNOWN, None), + ] + symbols = {item.symbol: item for item in report.gwyddion_symbols} + assert "gwy_tool_func_register" not in symbols + assert "gwyish_data_field_get_xres" not in symbols + assert len(symbols["gwy_data_field_get_xres"].occurrences) == 2 + assert len(symbols["gwy_data_field_get_xres"].call_occurrences) == 2 + assert symbols["gwy_data_field_get_xres"].support_status is SymbolSupportStatus.MAPPED + assert symbols["gwy_future_symbol"].classification is SymbolClassification.UNKNOWN + assert symbols["gwy_future_symbol"].support_status is SymbolSupportStatus.UNKNOWN + assert report.includes[0].name == "local-header.h" + assert report.includes[0].is_local is True + assert report.has_ui_dependency is True + assert report.unsupported_total == 1 + + +def test_incomplete_source_never_crashes_the_lexical_inventory() -> None: + report = audit_gwyddion_source("gwy_data_field_get_xres(field; /* unfinished") + assert report.gwyddion_symbols[0].symbol == "gwy_data_field_get_xres" + assert len(report.gwyddion_symbols[0].call_occurrences) == 1 + + +def test_real_path_level_source_facts_are_extracted_with_locations() -> None: + report = _audit("modules/tools/pathlevel.c") + assert report.module_path == "modules/tools/pathlevel.c" + assert any(item.kind is RegistrationKind.TOOL for item in report.registrations) + assert any(item.declared_name == "pathlevel" for item in report.registrations) + assert any(include.name == "gtk/gtk.h" for include in report.includes) + assert report.has_ui_dependency is True + assert "gwy_plain_tool_connect_selection" in report.likely_selection_dependencies + assert "gwy_params_new_from_settings" in report.likely_parameter_system_dependencies + tool_registration = next( + item for item in report.registrations if item.kind is RegistrationKind.TOOL + ) + assert tool_registration.span.start.line == 111 + assert tool_registration.span.start.column == 5 + + +def test_real_filter_and_median_sources_remain_audit_inventory_only() -> None: + filter_report = _audit("modules/tools/filter.c") + median_report = _audit("modules/process/median-bg.c") + assert any(item.kind is RegistrationKind.TOOL for item in filter_report.registrations) + filter_symbols = {item.symbol: item for item in filter_report.gwyddion_symbols} + assert filter_symbols["gwy_data_field_area_filter_min_max"].classification is ( + SymbolClassification.PROCESS_NUMERICAL + ) + assert filter_symbols["gwy_data_field_area_filter_min_max"].support_status is ( + SymbolSupportStatus.ADAPTER_REQUIRED + ) + process_registration = next( + item for item in median_report.registrations if item.kind is RegistrationKind.PROCESS + ) + assert process_registration.declared_name == "median-bg" + assert "gwy_app_channel_log_add_proc" in median_report.likely_publication_logging_dependencies + assert median_report.migration_warnings + assert any("does not establish" in warning for warning in median_report.migration_warnings) From bfea4d6f69e0dfc6b6a142d767770a9cc3eae738 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:36:11 -0400 Subject: [PATCH 72/82] test(validation): freeze Gwyddion Align Rows statistics evidence --- ...ION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md | 74 + .../align_rows_statistics_reference.json | 12228 ++++++++++++++++ .../align_rows_statistics_reference.npz | Bin 0 -> 107743 bytes ...align_rows_statistics_fixture_integrity.py | 160 + 4 files changed, 12462 insertions(+) create mode 100644 docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json create mode 100644 tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.npz create mode 100644 tests/validation/test_gwyddion_align_rows_statistics_fixture_integrity.py diff --git a/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md new file mode 100644 index 0000000..add328e --- /dev/null +++ b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md @@ -0,0 +1,74 @@ +# Gwyddion 2.71 Align Rows statistics compatibility boundary + +## Scope and evidence status + +This private SPMKit kernel covers only four Gwyddion Align Rows row-shift statistics methods: + +1. Median (`1`) +2. Median of differences (`2`) +3. Trimmed mean (`5`) +4. Trimmed mean of differences (`6`) + +The frozen campaign contains 64 finite `float64` cases, sixteen per method. It is evidence for this bounded domain, not a public API commitment or a claim of universal, non-finite, other-version, performance, or adapter equivalence. The production contract is `portable_source_semantics`, represented by the frozen independent V2 oracle. The private implementation is tested against that profile; no public `align_rows` code is changed or exposed here. + +The repository fixture freezes a secondary profile, `installed_gwyddion_2_71_fast_math_profile`. It was executed by the installed Gwyddion 2.71 module and is retained as external executable evidence, not as the production arithmetic contract. + +## Source call graph + +The source basis is Gwyddion 2.71 `modules/process/linematch.c` (SHA-256 `79b951a161431ba9822d8d0faba2b512107a5e4822569f78c42201f289e06604`) dispatching Align Rows to `libprocess/correct.c` (SHA-256 `bdac3ea8fcc3555f33644c84d739818c12a8cb9c104cac06ac642c77d2ddaabb`). The final difference-method line fit uses `gwy_data_line_get_line_coeffs()` in `libprocess/dataline.c` (SHA-256 `359f8fed916eb9216441e3afea4238c7128c587b0877100e0a42f2737e8edbf4`). + +The source-driven chain is: + +```text +module dispatch → mask routing → oriented rows / adjacent pairs +→ estimator → row correction sequence → mean or slope normalization +→ correction application → optional input-minus-corrected background +``` + +## Shared input transport + +`Exclude = 0`, `Include = 1`, and `Ignore = 2`. A null mask selects every sample or pair regardless of the stored mode. Ignore likewise discards a present mask before row orientation. Direction `0` works on rows directly; direction `1` transposes field and mask into source-equivalent working rows, then restores the result. The transpose is logical orientation only: the returned field preserves original shape and C-contiguous `float64` storage. + +All inputs, masks, and trim fractions are finite in the frozen production domain. The private kernel rejects invalid geometry, non-real/non-finite fields, mask shape mismatch, invalid enum values, unsupported directions, and trim fractions outside `[0.0, 0.5]`. It never mutates its input field or mask. + +## Absolute methods: Median and Trimmed mean + +For an oriented row `z[r,c]` with mask `m[r,c]`: + +- Include selects `m[r,c] > 0.0`. +- Exclude selects `m[r,c] < 1.0`. +- Ignore selects every `c`. + +Consequently exact `0.5` is selected by both per-row Include and Exclude predicates. The automatic minimum sample count is `floor(log(width) + 1 + 0.5)`. Below that count, including zero and one selected samples, the estimate falls back to the global masked upper median. The source-confirmed global Exclude fallback population uses `m <= 0.0`; this intentional distinction from the per-row Exclude predicate is retained in the portable implementation. + +The median is the upper median: rank `floor(n/2)` after ordering. Trimmed mean computes `trim = floor(fraction*n + 0.5)`, retains `[trim, n-trim)`, and falls back to upper median if trimming would leave no retained sample. The frozen portable reduction explicitly preserves the confirmed sample sorting and binary64 accumulation order. Every absolute row shift is then mean-centred over all oriented rows before subtraction. + +## Difference methods: Median of differences and Trimmed mean of differences + +For adjacent oriented rows, the candidate difference is `z[r+1,c] - z[r,c]`. + +- Include requires **both** masks `> 1.0`. +- Exclude requires **both** masks `< 1.0`. +- Ignore selects every adjacent pair. + +Exact `0.5` participates in Exclude pairs; exact `1.0` participates in neither joint predicate. Below the same automatic count, including zero or one pair, an adjacent increment is `+0.0`. Increments are cumulatively added from row zero. The complete cumulative sequence is then levelled by the source-derived unweighted index-space least-squares line fit using every oriented row. The corrected field is the original oriented sample minus the final correction sequence. + +## Background and representation + +When requested, background is computed as `input - corrected` in the confirmed float64 loop order. The fixture requires the portable and installed background arrays to be bitwise identical for all eight requests (`504/504` elements), and it records both reconstruction relations separately. The result record is frozen; corrected field, optional background, and correction sequence are C-contiguous `float64` arrays. + +## Dual-profile divergence policy + +The frozen V2 portable profile and the installed external profile agree bitwise for `61/64` corrected arrays and `3757/3888` elements. No mismatch is silently normalized. + +| Exception | Frozen classification | Policy | +| --- | --- | --- | +| `median__plateaus_signed_zero__10` | 3 signed-zero-only elements; numerical equality | No output-specific zero-sign patch. | +| `median_of_differences__irregular__11` | 64 finite nonzero elements; max abs `5.329070518200751e-15` | Preserve portable source arithmetic. | +| `trimmed_mean_of_differences__irregular__11` | Same 64-element finite build-profile scope | Preserve portable source arithmetic. | + +The installed package was built with GCC 16.1.1, `-ffast-math`, associative floating-point reassociation, LTO, and package optimization flags. The frozen installed-build diagnosis is `INSTALLED_BUILD_ROOT_CAUSE_CONFIRMED` and `V3_NOT_JUSTIFIED`: disabling associative math in an isolated source build returns the portable result, while disabling LTO or vectorization does not. Emulating this local compiler transformation in SPMKit would overfit a build profile rather than implement portable source semantics. + +## Evidence maturity and non-claims + +This design records `SOURCE_CONFIRMED`, frozen external-probe evidence, and a bounded V2-oracle production contract. Private-kernel test success establishes software evidence only for the listed fixture domain. It does not claim SPMKit numerical verification, cross-validation, universal Gwyddion parity, non-finite equivalence, public API support, or correctness for any other Align Rows method family. Adapter/context needs remain deliberately unimplemented; GwyCompat is unchanged in this batch. diff --git a/tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json b/tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json new file mode 100644 index 0000000..580e842 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json @@ -0,0 +1,12228 @@ +{ + "capability": "gwyddion_align_rows_statistics", + "case_count": 64, + "cases": [ + { + "case_identifier": "median__alternating_offsets__02", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c000000000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4010800000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "bffc000000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4011800000000000", + "4013800000000000", + "4015800000000000", + "4017800000000000", + "4019800000000000", + "401b800000000000", + "401d800000000000", + "401f800000000000", + "4020c00000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4004000000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "4021400000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4006000000000000" + ], + "input_key": "input__median__alternating_offsets__02", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__alternating_offsets__02", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__alternating_offsets__02", + "masking_mode": 2, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__alternating_offsets__02", + "portable_correction_sequence_bits": [ + "c007924924924925", + "40096db6db6db6db", + "c005924924924925", + "400b6db6db6db6db", + "c003924924924925", + "400d6db6db6db6db", + "c001924924924925" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__constant__00", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000" + ], + "input_key": "input__median__constant__00", + "installed_background_key": "installed_background__median__constant__00", + "installed_corrected_key": "installed_corrected__median__constant__00", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 1, + "method_name": "Median", + "portable_background_key": "portable_background__median__constant__00", + "portable_corrected_key": "portable_corrected__median__constant__00", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__impulses__07", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "0000000000000000", + "3fd3333333333333", + "3fe3333333333333", + "3feccccccccccccc", + "3ff3333333333333", + "3ff8000000000000", + "3ffccccccccccccc", + "4000cccccccccccd", + "4003333333333333", + "bfc999999999999a", + "3fb9999999999998", + "4033666666666666", + "3fe6666666666666", + "3ff0000000000000", + "3ff4cccccccccccd", + "3ff9999999999999", + "3ffe666666666667", + "4001999999999999", + "bfd999999999999a", + "bfb999999999999c", + "3fc9999999999998", + "3fdffffffffffffe", + "3fe9999999999999", + "3ff199999999999a", + "3ff6666666666666", + "3ffb333333333334", + "4000000000000000", + "bfe3333333333334", + "bfd3333333333335", + "bca0000000000000", + "3fd3333333333330", + "3fe3333333333332", + "3feccccccccccccc", + "3ff3333333333332", + "3ff8000000000000", + "3ffccccccccccccc", + "bfe999999999999a", + "bfe0000000000000", + "bfc999999999999c", + "3fb9999999999990", + "3fd9999999999998", + "3fe6666666666666", + "3feffffffffffffe", + "3ff4cccccccccccd", + "3ff9999999999999", + "bff0000000000000", + "bfe6666666666666", + "bfd999999999999a", + "bfb99999999999a0", + "3fc9999999999998", + "3fe0000000000000", + "c030333333333333", + "3ff199999999999a", + "3ff6666666666666", + "bff3333333333334", + "bfecccccccccccce", + "bfe3333333333335", + "bfd3333333333338", + "bcb0000000000000", + "3fd3333333333330", + "3fe3333333333330", + "3feccccccccccccc", + "3ff3333333333332" + ], + "input_key": "input__median__impulses__07", + "installed_background_key": "installed_background__median__impulses__07", + "installed_corrected_key": "installed_corrected__median__impulses__07", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median__impulses__07", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": "portable_background__median__impulses__07", + "portable_corrected_key": "portable_corrected__median__impulses__07", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__irregular__11", + "columns": 10, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c02c000000000000", + "4013c00000000000", + "c014800000000000", + "402ba00000000000", + "400e000000000000", + "c019400000000000", + "4029400000000000", + "4004800000000000", + "c01e000000000000", + "4026e00000000000", + "c017e00000000000", + "c022100000000000", + "c028300000000000", + "402bb00000000000", + "4025900000000000", + "401ee00000000000", + "4012a00000000000", + "3ff9800000000000", + "bff7800000000000", + "c012200000000000", + "4000800000000000", + "4018000000000000", + "4023e00000000000", + "402bc00000000000", + "c026600000000000", + "c01d000000000000", + "c00a800000000000", + "3fe4000000000000", + "4012400000000000", + "4021000000000000", + "4024300000000000", + "c01fe00000000000", + "4007c00000000000", + "402bd00000000000", + "c010a00000000000", + "401b200000000000", + "c026900000000000", + "bfd6000000000000", + "4025300000000000", + "c01de00000000000", + "c025c00000000000", + "401c400000000000", + "c010000000000000", + "402be00000000000", + "4007000000000000", + "c020600000000000", + "4023800000000000", + "bff5000000000000", + "c028c00000000000", + "4016400000000000", + "c006c00000000000", + "c01ba00000000000", + "c025f00000000000", + "402bf00000000000", + "4023d00000000000", + "4017600000000000", + "3ffc800000000000", + "c002400000000000", + "c019600000000000", + "c024d00000000000", + "4014c00000000000", + "4020400000000000", + "4026200000000000", + "402c000000000000", + "c028200000000000", + "c022400000000000", + "c018c00000000000", + "c00a000000000000", + "bfd4000000000000", + "4005000000000000", + "402a700000000000", + "c017600000000000", + "4010600000000000", + "402c100000000000", + "c014200000000000", + "4013a00000000000", + "c02c500000000000", + "c010e00000000000", + "4016e00000000000", + "c02ab00000000000" + ], + "input_key": "input__median__irregular__11", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__irregular__11", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__irregular__11", + "masking_mode": 2, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__irregular__11", + "portable_correction_sequence_bits": [ + "3ff3a00000000000", + "bfedc00000000000", + "4000500000000000", + "3fdc800000000000", + "3fd6800000000000", + "c013380000000000", + "3fba000000000000", + "3ff9200000000000" + ], + "portable_mutated": true, + "rows": 8, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.119999999999ap+3", + "yreal_hex": "0x1.f8f5c28f5c28fp+3" + }, + { + "case_identifier": "median__linear__03", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "c004cccccccccccd", + "bff6666666666666", + "bfc9999999999998", + "3ff0000000000002", + "400199999999999a", + "400b333333333333", + "4012666666666668", + "4017333333333334", + "401c000000000000", + "c00f333333333334", + "c004000000000001", + "bff199999999999a", + "3fd3333333333338", + "3ffb333333333334", + "4008cccccccccccd", + "4012000000000001", + "401799999999999b", + "401d333333333334", + "c014cccccccccccd", + "c00cccccccccccce", + "c000000000000000", + "bfd9999999999990", + "3ff3333333333334", + "4006666666666666", + "401199999999999b", + "4018000000000001", + "401e666666666667", + "c01a000000000000", + "c012cccccccccccd", + "c007333333333334", + "bff1999999999998", + "3fe6666666666668", + "4004000000000000", + "4011333333333334", + "4018666666666667", + "401f99999999999a", + "c01f333333333334", + "c017333333333334", + "c00e666666666668", + "bffccccccccccccc", + "3fc99999999999a0", + "400199999999999a", + "4010ccccccccccce", + "4018cccccccccccf", + "4020666666666667" + ], + "input_key": "input__median__linear__03", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__linear__03", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__linear__03", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__linear__03", + "portable_correction_sequence_bits": [ + "3ff8000000000002", + "3ff0000000000002", + "3fe0000000000004", + "3cc0000000000000", + "bfdffffffffffff8", + "bfeffffffffffffc", + "bff7fffffffffffe" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__linear__12", + "columns": 11, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "401ccccccccccccd", + "4020000000000000", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "401ecccccccccccd", + "4021666666666666" + ], + "input_key": "input__median__linear__12", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__linear__12", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median__linear__12", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__linear__12", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 2, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.28f5c28f5c28fp+3", + "yreal_hex": "0x1.147ae147ae148p+2" + }, + { + "case_identifier": "median__multimodal__09", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "c00f000000000000", + "c010000000000000", + "c010000000000000", + "4008000000000000", + "4009000000000000", + "4008000000000000", + "4020000000000000", + "4020000000000000", + "4020400000000000", + "c00e000000000000", + "c00e000000000000", + "c00e000000000000", + "400b000000000000", + "400a000000000000", + "400a000000000000", + "4020800000000000", + "4020c00000000000", + "4020800000000000", + "c00c000000000000", + "c00c000000000000", + "c00b000000000000", + "400c000000000000", + "400c000000000000", + "400c000000000000", + "4021400000000000", + "4021000000000000", + "4021000000000000", + "c00a000000000000", + "c009000000000000", + "c00a000000000000", + "400e000000000000", + "400e000000000000", + "400f000000000000", + "4021800000000000", + "4021800000000000", + "4021800000000000", + "c007000000000000", + "c008000000000000", + "c008000000000000", + "4010000000000000", + "4010800000000000", + "4010000000000000", + "4022000000000000", + "4022000000000000", + "4022400000000000", + "c006000000000000", + "c006000000000000", + "c006000000000000", + "4011800000000000", + "4011000000000000", + "4011000000000000", + "4022800000000000", + "4022c00000000000", + "4022800000000000", + "c004000000000000", + "c004000000000000", + "c003000000000000", + "4012000000000000", + "4012000000000000", + "4012000000000000", + "4023400000000000", + "4023000000000000", + "4023000000000000" + ], + "input_key": "input__median__multimodal__09", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__multimodal__09", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__multimodal__09", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__multimodal__09", + "portable_correction_sequence_bits": [ + "c019aaaaaaaaaaaa", + "c0192aaaaaaaaaaa", + "c019aaaaaaaaaaaa", + "3feaaaaaaaaaaaac", + "3fe2aaaaaaaaaaac", + "3fe6aaaaaaaaaaac", + "4016555555555556", + "4017555555555556", + "4016555555555556" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__nonlinear__04", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3ff1eb851eb851ec", + "3ff2b851eb851eb8", + "40030a3d70a3d70a", + "400ecccccccccccd", + "4012b851eb851eb8", + "401aae147ae147ae", + "4021d70a3d70a3d7", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "3fd6666666666666", + "3ff4cccccccccccc", + "4004147ae147ae15", + "4008a3d70a3d70a4", + "40133d70a3d70a3d", + "401b333333333333", + "40204ccccccccccd", + "bfb47ae147ae1480", + "bfe199999999999a", + "3fc1eb851eb851ea", + "3ff170a3d70a3d70", + "3ff6666666666666", + "4006f5c28f5c28f5", + "4012666666666666", + "4016c28f5c28f5c3", + "401fc28f5c28f5c3", + "bff87ae147ae147b", + "bff1999999999999", + "bfda3d70a3d70a3e", + "bfd70a3d70a3d70c", + "3feb333333333334", + "40028f5c28f5c28f", + "4009333333333332", + "40148f5c28f5c28f", + "401d8f5c28f5c28f", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00199999999999a", + "bff4000000000001", + "bfa47ae147ae1490", + "3fe0f5c28f5c28f4", + "4002147ae147ae13", + "4011000000000000", + "4016666666666666", + "c00d333333333333", + "c0107ae147ae147b", + "c00b70a3d70a3d71", + "c003d70a3d70a3d7", + "c0015c28f5c28f5c", + "bfe6666666666666", + "3ff07ae147ae147a", + "4000f5c28f5c28f6", + "40117ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c014000000000000", + "c013cccccccccccd", + "c00deb851eb851ec", + "c00228f5c28f5c29", + "bff70a3d70a3d70c", + "3fe199999999999a", + "4006666666666667" + ], + "input_key": "input__median__nonlinear__04", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__nonlinear__04", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median__nonlinear__04", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__nonlinear__04", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__nonlinear__13", + "columns": 2, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "bfb47ae147ae1480", + "bfe199999999999a", + "bff87ae147ae147b", + "bff1999999999999", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00d333333333333", + "c0107ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c0200f5c28f5c290", + "c01e666666666668", + "c0248f5c28f5c290", + "c025800000000000", + "c02b8a3d70a3d70a", + "c02aae147ae147ad", + "c030b33333333333", + "c030451eb851eb85" + ], + "input_key": "input__median__nonlinear__13", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__nonlinear__13", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median__nonlinear__13", + "masking_mode": 2, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__nonlinear__13", + "portable_correction_sequence_bits": [ + "3fce147ae147ae10", + "bfce147ae147ae20" + ], + "portable_mutated": true, + "rows": 11, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.5ae147ae147aep+1", + "yreal_hex": "0x1.5828f5c28f5c2p+4" + }, + { + "case_identifier": "median__plane__05", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3ffb333333333333", + "400b333333333333", + "4014666666666666", + "401b333333333333", + "4021000000000000", + "4024666666666666", + "4027cccccccccccd", + "402b333333333333", + "c002666666666666", + "bfdffffffffffffe", + "3ff4cccccccccccd", + "4008cccccccccccc", + "401399999999999a", + "401acccccccccccd", + "4021000000000000", + "402499999999999a", + "4028333333333334", + "c012666666666666", + "c005999999999998", + "bfe9999999999997", + "3ff199999999999a", + "4008000000000000", + "401399999999999a", + "401b333333333333", + "4021666666666667", + "4025333333333333", + "c01b999999999999", + "c013999999999999", + "c007333333333332", + "bfecccccccccccca", + "3ff199999999999c", + "4008ccccccccccce", + "4014666666666666", + "401c666666666668", + "4022333333333334", + "c022666666666666", + "c01c666666666665", + "c013ffffffffffff", + "c007333333333332", + "bfe9999999999994", + "3ff4ccccccccccd0", + "400b333333333334", + "4016000000000002", + "401e666666666667", + "c027000000000000", + "c02299999999999a", + "c01c666666666666", + "c01399999999999a", + "c00599999999999a", + "bfe0000000000000", + "3ffb333333333330", + "400f333333333334", + "4018666666666666", + "c02b999999999999", + "c027000000000000", + "c022666666666666", + "c01b999999999998", + "c012666666666665", + "c002666666666663", + "3cd0000000000000", + "400266666666666c", + "4012666666666668" + ], + "input_key": "input__median__plane__05", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__plane__05", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__median__plane__05", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__plane__05", + "portable_correction_sequence_bits": [ + "c019777777777778", + "c01aaaaaaaaaaaaa", + "c012aaaaaaaaaaaa", + "bfe5555555555558", + "bfe5555555555554", + "3ff5555555555556", + "4014222222222222", + "4015555555555556", + "401d555555555556" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__plateaus_signed_zero__10", + "columns": 8, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "c008000000000000", + "c000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "c000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000" + ], + "input_key": "input__median__plateaus_signed_zero__10", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__plateaus_signed_zero__10", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__plateaus_signed_zero__10", + "masking_mode": 2, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__plateaus_signed_zero__10", + "portable_correction_sequence_bits": [ + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": true, + "rows": 6, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.c5c28f5c28f5cp+2", + "yreal_hex": "0x1.7eb851eb851ebp+3" + }, + { + "case_identifier": "median__row_offsets__01", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd0000000000000", + "3fe0000000000000", + "3fe8000000000000", + "3ff0000000000000", + "3ff4000000000000", + "3ff8000000000000", + "3ffc000000000000", + "4000000000000000", + "4008000000000000", + "400a000000000000", + "400c000000000000", + "400e000000000000", + "4010000000000000", + "4011000000000000", + "4012000000000000", + "4013000000000000", + "4014000000000000", + "4018000000000000", + "4019000000000000", + "401a000000000000", + "401b000000000000", + "401c000000000000", + "401d000000000000", + "401e000000000000", + "401f000000000000", + "4020000000000000", + "4022000000000000", + "4022800000000000", + "4023000000000000", + "4023800000000000", + "4024000000000000", + "4024800000000000", + "4025000000000000", + "4025800000000000", + "4026000000000000", + "4028000000000000", + "4028800000000000", + "4029000000000000", + "4029800000000000", + "402a000000000000", + "402a800000000000", + "402b000000000000", + "402b800000000000", + "402c000000000000", + "402e000000000000", + "402e800000000000", + "402f000000000000", + "402f800000000000", + "4030000000000000", + "4030400000000000", + "4030800000000000", + "4030c00000000000", + "4031000000000000", + "4032000000000000", + "4032400000000000", + "4032800000000000", + "4032c00000000000", + "4033000000000000", + "4033400000000000", + "4033800000000000", + "4033c00000000000", + "4034000000000000" + ], + "input_key": "input__median__row_offsets__01", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__row_offsets__01", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median__row_offsets__01", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__row_offsets__01", + "portable_correction_sequence_bits": [ + "c021a49249249249", + "c018492492492492", + "c008924924924924", + "bfb2492492492480", + "40096db6db6db6dc", + "4017b6db6db6db6e", + "4021db6db6db6db7" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__scars__08", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "c01b666666666666", + "3fd3333333333333", + "3fdccccccccccccc", + "3fe3333333333333", + "3fe8000000000000", + "3feccccccccccccc", + "c017cccccccccccd", + "3ff3333333333333", + "3ff0000000000000", + "40224ccccccccccd", + "3ff4cccccccccccd", + "3ff7333333333333", + "3ff999999999999a", + "3ffc000000000000", + "3ffe666666666666", + "402419999999999a", + "400199999999999a", + "4000000000000000", + "40244ccccccccccd", + "4002666666666666", + "400399999999999a", + "4004cccccccccccd", + "4006000000000000", + "4007333333333333", + "402619999999999a", + "400999999999999a", + "4008000000000000", + "c00ecccccccccccd", + "400a666666666666", + "400b99999999999a", + "400ccccccccccccd", + "400e000000000000", + "400f333333333333", + "c00799999999999a", + "4010cccccccccccd", + "4010000000000000", + "40284ccccccccccd", + "4011333333333333", + "4011cccccccccccd", + "4012666666666666", + "4013000000000000", + "401399999999999a", + "402a19999999999a", + "4014cccccccccccd", + "4014000000000000", + "402a4ccccccccccd", + "4015333333333333", + "4015cccccccccccd", + "4016666666666666", + "4017000000000000", + "401799999999999a", + "402c19999999999a", + "4018cccccccccccd", + "4018000000000000", + "bfeb333333333330", + "4019333333333333", + "4019cccccccccccd", + "401a666666666666", + "401b000000000000", + "401b99999999999a", + "3fa9999999999980", + "401ccccccccccccd" + ], + "input_key": "input__median__scars__08", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__scars__08", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__scars__08", + "portable_correction_sequence_bits": [ + "c0095f15f15f15f0", + "bffdf15f15f15f14", + "bfebe2be2be2be28", + "bfc5f15f15f15f00", + "3ff20ea0ea0ea0ec", + "4001075075075076", + "4006a0ea0ea0ea10" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__step__06", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666" + ], + "input_key": "input__median__step__06", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__step__06", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__median__step__06", + "masking_mode": 2, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__step__06", + "portable_correction_sequence_bits": [ + "c012492492492492", + "c012492492492492", + "c012492492492492", + "400b6db6db6db6db", + "400b6db6db6db6db", + "400b6db6db6db6db", + "400b6db6db6db6db" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median__tall__15", + "columns": 5, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd999999999999a", + "3fe999999999999a", + "3ff3333333333334", + "3ff999999999999a", + "bfe999999999999a", + "bfd5c28f5c28f5c3", + "3fbeb851eb851eb8", + "3fe28f5c28f5c290", + "3ff0a3d70a3d70a4", + "bff999999999999a", + "bff147ae147ae148", + "bfe1eb851eb851ec", + "bfa47ae147ae1470", + "3fdeb851eb851eb8", + "c003333333333334", + "bffd1eb851eb8521", + "bff3d70a3d70a3d8", + "bfe51eb851eb8520", + "bfb47ae147ae1490", + "c00999999999999a", + "c0047ae147ae147c", + "bffeb851eb851eba", + "bff47ae147ae147b", + "bfe47ae147ae147c", + "c010000000000000", + "c00a666666666667", + "c004cccccccccccd", + "bffe666666666666", + "bff3333333333333", + "c013333333333334", + "c01028f5c28f5c29", + "c00a3d70a3d70a40", + "c00428f5c28f5c2a", + "bffc28f5c28f5c2c", + "c016666666666667", + "c0131eb851eb851f", + "c00fae147ae147b0", + "c0091eb851eb8520", + "c0028f5c28f5c290", + "c01999999999999a", + "c016147ae147ae14", + "c0128f5c28f5c290", + "c00e147ae147ae15", + "c0070a3d70a3d70c", + "c01ccccccccccccd", + "c0190a3d70a3d70a", + "c01547ae147ae148", + "c011851eb851eb85", + "c00b851eb851eb84", + "c020000000000000", + "c01c000000000000", + "c018000000000000", + "c014000000000000", + "c010000000000000", + "c02199999999999a", + "c01ef5c28f5c28f6", + "c01ab851eb851eb8", + "c0167ae147ae147c", + "c0123d70a3d70a3f", + "c023333333333334", + "c020f5c28f5c28f6", + "c01d70a3d70a3d72", + "c018f5c28f5c28f8", + "c0147ae147ae147d", + "c024cccccccccccd", + "c02270a3d70a3d71", + "c020147ae147ae14", + "c01b70a3d70a3d70", + "c016b851eb851eb9", + "c026666666666667", + "c023eb851eb851ec", + "c02170a3d70a3d71", + "c01deb851eb851ec", + "c018f5c28f5c28f8", + "c028000000000000", + "c025666666666666", + "c022cccccccccccc", + "c020333333333334", + "c01b333333333334", + "c02999999999999a", + "c026e147ae147ae2", + "c02428f5c28f5c29", + "c02170a3d70a3d72", + "c01d70a3d70a3d72" + ], + "input_key": "input__median__tall__15", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__tall__15", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000" + ], + "mask_key": "mask__median__tall__15", + "masking_mode": 1, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__tall__15", + "portable_correction_sequence_bits": [ + "bff3b645a1cac084", + "bff26e978d4fdf38", + "bfd16872b020c4a0", + "3fe374bc6a7ef9dc", + "4000624dd2f1a9fc" + ], + "portable_mutated": true, + "rows": 17, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.399999999999ap+2", + "yreal_hex": "0x1.07c28f5c28f5cp+5" + }, + { + "case_identifier": "median__wide__14", + "columns": 17, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fb1eb851eb851ec", + "3fd1eb851eb851ec", + "3fe428f5c28f5c2a", + "3ff1eb851eb851ec", + "3ffc000000000001", + "400428f5c28f5c2a", + "400b70a3d70a3d71", + "4011eb851eb851ec", + "4016ae147ae147af", + "401c000000000001", + "4020f0a3d70a3d71", + "402428f5c28f5c2a", + "4027a8f5c28f5c2a", + "402b70a3d70a3d71", + "402f800000000000", + "4031eb851eb851ec", + "3ff199999999999a", + "3ff23d70a3d70a3e", + "3ff51eb851eb851f", + "3ffa3d70a3d70a3e", + "4000cccccccccccd", + "400599999999999b", + "400b851eb851eb86", + "401147ae147ae148", + "40155c28f5c28f5c", + "401a000000000002", + "401f333333333335", + "40227ae147ae147b", + "4025a3d70a3d70a5", + "4029147ae147ae15", + "402ccccccccccccd", + "4030666666666667", + "40328a3d70a3d70b", + "400199999999999a", + "4001ae147ae147ae", + "4002e147ae147ae2", + "4005333333333333", + "4008a3d70a3d70a4", + "400d333333333334", + "401170a3d70a3d71", + "4014d70a3d70a3d8", + "4018ccccccccccce", + "401d51eb851eb853", + "4021333333333334", + "4024051eb851eb86", + "40271eb851eb851f", + "402a800000000001", + "402e28f5c28f5c2a", + "40310ccccccccccd", + "403328f5c28f5c29", + "400a666666666667", + "400a3d70a3d70a3e", + "400b333333333333", + "400d47ae147ae149", + "40103d70a3d70a3d", + "4012666666666667", + "40151eb851eb851f", + "4018666666666667", + "401c3d70a3d70a3f", + "402051eb851eb852", + "4022cccccccccccd", + "40258f5c28f5c290", + "402899999999999b", + "402beb851eb851ed", + "402f851eb851eb85", + "4031b33333333333", + "4033c7ae147ae148", + "401199999999999a", + "4011666666666667", + "4011c28f5c28f5c3", + "4012ae147ae147ae", + "401428f5c28f5c2a", + "4016333333333334", + "4018ccccccccccce", + "401bf5c28f5c28f6", + "401fae147ae147af", + "4021fae147ae147b", + "4024666666666668", + "402719999999999a", + "402a147ae147ae16", + "402d570a3d70a3d9", + "403070a3d70a3d71", + "4032599999999999", + "4034666666666666" + ], + "input_key": "input__median__wide__14", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median__wide__14", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 1, + "method_name": "Median", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median__wide__14", + "portable_correction_sequence_bits": [ + "bffb851eb851eb88", + "bfeb851eb851eb90", + "0000000000000000", + "3feb851eb851eb88", + "3ffb851eb851eb84" + ], + "portable_mutated": true, + "rows": 5, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.b51eb851eb852p+3", + "yreal_hex": "0x1.4199999999999p+3" + }, + { + "case_identifier": "median_of_differences__alternating_offsets__02", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c000000000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4010800000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "bffc000000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4011800000000000", + "4013800000000000", + "4015800000000000", + "4017800000000000", + "4019800000000000", + "401b800000000000", + "401d800000000000", + "401f800000000000", + "4020c00000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4004000000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "4021400000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4006000000000000" + ], + "input_key": "input__median_of_differences__alternating_offsets__02", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__alternating_offsets__02", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__alternating_offsets__02", + "masking_mode": 2, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__alternating_offsets__02", + "portable_correction_sequence_bits": [ + "c004924924924928", + "400b6db6db6db6d9", + "c004924924924926", + "400b6db6db6db6db", + "c004924924924924", + "400b6db6db6db6dd", + "c004924924924922" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__constant__00", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000" + ], + "input_key": "input__median_of_differences__constant__00", + "installed_background_key": "installed_background__median_of_differences__constant__00", + "installed_corrected_key": "installed_corrected__median_of_differences__constant__00", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": "portable_background__median_of_differences__constant__00", + "portable_corrected_key": "portable_corrected__median_of_differences__constant__00", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__impulses__07", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "0000000000000000", + "3fd3333333333333", + "3fe3333333333333", + "3feccccccccccccc", + "3ff3333333333333", + "3ff8000000000000", + "3ffccccccccccccc", + "4000cccccccccccd", + "4003333333333333", + "bfc999999999999a", + "3fb9999999999998", + "4033666666666666", + "3fe6666666666666", + "3ff0000000000000", + "3ff4cccccccccccd", + "3ff9999999999999", + "3ffe666666666667", + "4001999999999999", + "bfd999999999999a", + "bfb999999999999c", + "3fc9999999999998", + "3fdffffffffffffe", + "3fe9999999999999", + "3ff199999999999a", + "3ff6666666666666", + "3ffb333333333334", + "4000000000000000", + "bfe3333333333334", + "bfd3333333333335", + "bca0000000000000", + "3fd3333333333330", + "3fe3333333333332", + "3feccccccccccccc", + "3ff3333333333332", + "3ff8000000000000", + "3ffccccccccccccc", + "bfe999999999999a", + "bfe0000000000000", + "bfc999999999999c", + "3fb9999999999990", + "3fd9999999999998", + "3fe6666666666666", + "3feffffffffffffe", + "3ff4cccccccccccd", + "3ff9999999999999", + "bff0000000000000", + "bfe6666666666666", + "bfd999999999999a", + "bfb99999999999a0", + "3fc9999999999998", + "3fe0000000000000", + "c030333333333333", + "3ff199999999999a", + "3ff6666666666666", + "bff3333333333334", + "bfecccccccccccce", + "bfe3333333333335", + "bfd3333333333338", + "bcb0000000000000", + "3fd3333333333330", + "3fe3333333333330", + "3feccccccccccccc", + "3ff3333333333332" + ], + "input_key": "input__median_of_differences__impulses__07", + "installed_background_key": "installed_background__median_of_differences__impulses__07", + "installed_corrected_key": "installed_corrected__median_of_differences__impulses__07", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median_of_differences__impulses__07", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": "portable_background__median_of_differences__impulses__07", + "portable_corrected_key": "portable_corrected__median_of_differences__impulses__07", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__irregular__11", + "columns": 10, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c02c000000000000", + "4013c00000000000", + "c014800000000000", + "402ba00000000000", + "400e000000000000", + "c019400000000000", + "4029400000000000", + "4004800000000000", + "c01e000000000000", + "4026e00000000000", + "c017e00000000000", + "c022100000000000", + "c028300000000000", + "402bb00000000000", + "4025900000000000", + "401ee00000000000", + "4012a00000000000", + "3ff9800000000000", + "bff7800000000000", + "c012200000000000", + "4000800000000000", + "4018000000000000", + "4023e00000000000", + "402bc00000000000", + "c026600000000000", + "c01d000000000000", + "c00a800000000000", + "3fe4000000000000", + "4012400000000000", + "4021000000000000", + "4024300000000000", + "c01fe00000000000", + "4007c00000000000", + "402bd00000000000", + "c010a00000000000", + "401b200000000000", + "c026900000000000", + "bfd6000000000000", + "4025300000000000", + "c01de00000000000", + "c025c00000000000", + "401c400000000000", + "c010000000000000", + "402be00000000000", + "4007000000000000", + "c020600000000000", + "4023800000000000", + "bff5000000000000", + "c028c00000000000", + "4016400000000000", + "c006c00000000000", + "c01ba00000000000", + "c025f00000000000", + "402bf00000000000", + "4023d00000000000", + "4017600000000000", + "3ffc800000000000", + "c002400000000000", + "c019600000000000", + "c024d00000000000", + "4014c00000000000", + "4020400000000000", + "4026200000000000", + "402c000000000000", + "c028200000000000", + "c022400000000000", + "c018c00000000000", + "c00a000000000000", + "bfd4000000000000", + "4005000000000000", + "402a700000000000", + "c017600000000000", + "4010600000000000", + "402c100000000000", + "c014200000000000", + "4013a00000000000", + "c02c500000000000", + "c010e00000000000", + "4016e00000000000", + "c02ab00000000000" + ], + "input_key": "input__median_of_differences__irregular__11", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__irregular__11", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__irregular__11", + "masking_mode": 2, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__irregular__11", + "portable_correction_sequence_bits": [ + "0000000000000000", + "bffb6db6db6db6db", + "4004924924924925", + "3feb6db6db6db6e0", + "bfeb6db6db6db6d8", + "c004924924924924", + "3ffb6db6db6db6e0", + "0000000000000000" + ], + "portable_mutated": true, + "rows": 8, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.119999999999ap+3", + "yreal_hex": "0x1.f8f5c28f5c28fp+3" + }, + { + "case_identifier": "median_of_differences__linear__03", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "c004cccccccccccd", + "bff6666666666666", + "bfc9999999999998", + "3ff0000000000002", + "400199999999999a", + "400b333333333333", + "4012666666666668", + "4017333333333334", + "401c000000000000", + "c00f333333333334", + "c004000000000001", + "bff199999999999a", + "3fd3333333333338", + "3ffb333333333334", + "4008cccccccccccd", + "4012000000000001", + "401799999999999b", + "401d333333333334", + "c014cccccccccccd", + "c00cccccccccccce", + "c000000000000000", + "bfd9999999999990", + "3ff3333333333334", + "4006666666666666", + "401199999999999b", + "4018000000000001", + "401e666666666667", + "c01a000000000000", + "c012cccccccccccd", + "c007333333333334", + "bff1999999999998", + "3fe6666666666668", + "4004000000000000", + "4011333333333334", + "4018666666666667", + "401f99999999999a", + "c01f333333333334", + "c017333333333334", + "c00e666666666668", + "bffccccccccccccc", + "3fc99999999999a0", + "400199999999999a", + "4010ccccccccccce", + "4018cccccccccccf", + "4020666666666667" + ], + "input_key": "input__median_of_differences__linear__03", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__linear__03", + "installed_mutated": false, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__linear__03", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__linear__03", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__linear__12", + "columns": 11, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "401ccccccccccccd", + "4020000000000000", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "401ecccccccccccd", + "4021666666666666" + ], + "input_key": "input__median_of_differences__linear__12", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__linear__12", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median_of_differences__linear__12", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__linear__12", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 2, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.28f5c28f5c28fp+3", + "yreal_hex": "0x1.147ae147ae148p+2" + }, + { + "case_identifier": "median_of_differences__multimodal__09", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "c00f000000000000", + "c010000000000000", + "c010000000000000", + "4008000000000000", + "4009000000000000", + "4008000000000000", + "4020000000000000", + "4020000000000000", + "4020400000000000", + "c00e000000000000", + "c00e000000000000", + "c00e000000000000", + "400b000000000000", + "400a000000000000", + "400a000000000000", + "4020800000000000", + "4020c00000000000", + "4020800000000000", + "c00c000000000000", + "c00c000000000000", + "c00b000000000000", + "400c000000000000", + "400c000000000000", + "400c000000000000", + "4021400000000000", + "4021000000000000", + "4021000000000000", + "c00a000000000000", + "c009000000000000", + "c00a000000000000", + "400e000000000000", + "400e000000000000", + "400f000000000000", + "4021800000000000", + "4021800000000000", + "4021800000000000", + "c007000000000000", + "c008000000000000", + "c008000000000000", + "4010000000000000", + "4010800000000000", + "4010000000000000", + "4022000000000000", + "4022000000000000", + "4022400000000000", + "c006000000000000", + "c006000000000000", + "c006000000000000", + "4011800000000000", + "4011000000000000", + "4011000000000000", + "4022800000000000", + "4022c00000000000", + "4022800000000000", + "c004000000000000", + "c004000000000000", + "c003000000000000", + "4012000000000000", + "4012000000000000", + "4012000000000000", + "4023400000000000", + "4023000000000000", + "4023000000000000" + ], + "input_key": "input__median_of_differences__multimodal__09", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__multimodal__09", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__multimodal__09", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__multimodal__09", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__nonlinear__04", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3ff1eb851eb851ec", + "3ff2b851eb851eb8", + "40030a3d70a3d70a", + "400ecccccccccccd", + "4012b851eb851eb8", + "401aae147ae147ae", + "4021d70a3d70a3d7", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "3fd6666666666666", + "3ff4cccccccccccc", + "4004147ae147ae15", + "4008a3d70a3d70a4", + "40133d70a3d70a3d", + "401b333333333333", + "40204ccccccccccd", + "bfb47ae147ae1480", + "bfe199999999999a", + "3fc1eb851eb851ea", + "3ff170a3d70a3d70", + "3ff6666666666666", + "4006f5c28f5c28f5", + "4012666666666666", + "4016c28f5c28f5c3", + "401fc28f5c28f5c3", + "bff87ae147ae147b", + "bff1999999999999", + "bfda3d70a3d70a3e", + "bfd70a3d70a3d70c", + "3feb333333333334", + "40028f5c28f5c28f", + "4009333333333332", + "40148f5c28f5c28f", + "401d8f5c28f5c28f", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00199999999999a", + "bff4000000000001", + "bfa47ae147ae1490", + "3fe0f5c28f5c28f4", + "4002147ae147ae13", + "4011000000000000", + "4016666666666666", + "c00d333333333333", + "c0107ae147ae147b", + "c00b70a3d70a3d71", + "c003d70a3d70a3d7", + "c0015c28f5c28f5c", + "bfe6666666666666", + "3ff07ae147ae147a", + "4000f5c28f5c28f6", + "40117ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c014000000000000", + "c013cccccccccccd", + "c00deb851eb851ec", + "c00228f5c28f5c29", + "bff70a3d70a3d70c", + "3fe199999999999a", + "4006666666666667" + ], + "input_key": "input__median_of_differences__nonlinear__04", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__nonlinear__04", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median_of_differences__nonlinear__04", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__nonlinear__04", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__nonlinear__13", + "columns": 2, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "bfb47ae147ae1480", + "bfe199999999999a", + "bff87ae147ae147b", + "bff1999999999999", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00d333333333333", + "c0107ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c0200f5c28f5c290", + "c01e666666666668", + "c0248f5c28f5c290", + "c025800000000000", + "c02b8a3d70a3d70a", + "c02aae147ae147ad", + "c030b33333333333", + "c030451eb851eb85" + ], + "input_key": "input__median_of_differences__nonlinear__13", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__nonlinear__13", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__median_of_differences__nonlinear__13", + "masking_mode": 2, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__nonlinear__13", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 11, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.5ae147ae147aep+1", + "yreal_hex": "0x1.5828f5c28f5c2p+4" + }, + { + "case_identifier": "median_of_differences__plane__05", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3ffb333333333333", + "400b333333333333", + "4014666666666666", + "401b333333333333", + "4021000000000000", + "4024666666666666", + "4027cccccccccccd", + "402b333333333333", + "c002666666666666", + "bfdffffffffffffe", + "3ff4cccccccccccd", + "4008cccccccccccc", + "401399999999999a", + "401acccccccccccd", + "4021000000000000", + "402499999999999a", + "4028333333333334", + "c012666666666666", + "c005999999999998", + "bfe9999999999997", + "3ff199999999999a", + "4008000000000000", + "401399999999999a", + "401b333333333333", + "4021666666666667", + "4025333333333333", + "c01b999999999999", + "c013999999999999", + "c007333333333332", + "bfecccccccccccca", + "3ff199999999999c", + "4008ccccccccccce", + "4014666666666666", + "401c666666666668", + "4022333333333334", + "c022666666666666", + "c01c666666666665", + "c013ffffffffffff", + "c007333333333332", + "bfe9999999999994", + "3ff4ccccccccccd0", + "400b333333333334", + "4016000000000002", + "401e666666666667", + "c027000000000000", + "c02299999999999a", + "c01c666666666666", + "c01399999999999a", + "c00599999999999a", + "bfe0000000000000", + "3ffb333333333330", + "400f333333333334", + "4018666666666666", + "c02b999999999999", + "c027000000000000", + "c022666666666666", + "c01b999999999998", + "c012666666666665", + "c002666666666663", + "3cd0000000000000", + "400266666666666c", + "4012666666666668" + ], + "input_key": "input__median_of_differences__plane__05", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__plane__05", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__median_of_differences__plane__05", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__plane__05", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__plateaus_signed_zero__10", + "columns": 8, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "c008000000000000", + "c000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "c000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000" + ], + "input_key": "input__median_of_differences__plateaus_signed_zero__10", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__plateaus_signed_zero__10", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__plateaus_signed_zero__10", + "masking_mode": 2, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__plateaus_signed_zero__10", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 6, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.c5c28f5c28f5cp+2", + "yreal_hex": "0x1.7eb851eb851ebp+3" + }, + { + "case_identifier": "median_of_differences__row_offsets__01", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd0000000000000", + "3fe0000000000000", + "3fe8000000000000", + "3ff0000000000000", + "3ff4000000000000", + "3ff8000000000000", + "3ffc000000000000", + "4000000000000000", + "4008000000000000", + "400a000000000000", + "400c000000000000", + "400e000000000000", + "4010000000000000", + "4011000000000000", + "4012000000000000", + "4013000000000000", + "4014000000000000", + "4018000000000000", + "4019000000000000", + "401a000000000000", + "401b000000000000", + "401c000000000000", + "401d000000000000", + "401e000000000000", + "401f000000000000", + "4020000000000000", + "4022000000000000", + "4022800000000000", + "4023000000000000", + "4023800000000000", + "4024000000000000", + "4024800000000000", + "4025000000000000", + "4025800000000000", + "4026000000000000", + "4028000000000000", + "4028800000000000", + "4029000000000000", + "4029800000000000", + "402a000000000000", + "402a800000000000", + "402b000000000000", + "402b800000000000", + "402c000000000000", + "402e000000000000", + "402e800000000000", + "402f000000000000", + "402f800000000000", + "4030000000000000", + "4030400000000000", + "4030800000000000", + "4030c00000000000", + "4031000000000000", + "4032000000000000", + "4032400000000000", + "4032800000000000", + "4032c00000000000", + "4033000000000000", + "4033400000000000", + "4033800000000000", + "4033c00000000000", + "4034000000000000" + ], + "input_key": "input__median_of_differences__row_offsets__01", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__row_offsets__01", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__median_of_differences__row_offsets__01", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__row_offsets__01", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__scars__08", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "c01b666666666666", + "3fd3333333333333", + "3fdccccccccccccc", + "3fe3333333333333", + "3fe8000000000000", + "3feccccccccccccc", + "c017cccccccccccd", + "3ff3333333333333", + "3ff0000000000000", + "40224ccccccccccd", + "3ff4cccccccccccd", + "3ff7333333333333", + "3ff999999999999a", + "3ffc000000000000", + "3ffe666666666666", + "402419999999999a", + "400199999999999a", + "4000000000000000", + "40244ccccccccccd", + "4002666666666666", + "400399999999999a", + "4004cccccccccccd", + "4006000000000000", + "4007333333333333", + "402619999999999a", + "400999999999999a", + "4008000000000000", + "c00ecccccccccccd", + "400a666666666666", + "400b99999999999a", + "400ccccccccccccd", + "400e000000000000", + "400f333333333333", + "c00799999999999a", + "4010cccccccccccd", + "4010000000000000", + "40284ccccccccccd", + "4011333333333333", + "4011cccccccccccd", + "4012666666666666", + "4013000000000000", + "401399999999999a", + "402a19999999999a", + "4014cccccccccccd", + "4014000000000000", + "402a4ccccccccccd", + "4015333333333333", + "4015cccccccccccd", + "4016666666666666", + "4017000000000000", + "401799999999999a", + "402c19999999999a", + "4018cccccccccccd", + "4018000000000000", + "bfeb333333333330", + "4019333333333333", + "4019cccccccccccd", + "401a666666666666", + "401b000000000000", + "401b99999999999a", + "3fa9999999999980", + "401ccccccccccccd" + ], + "input_key": "input__median_of_differences__scars__08", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__scars__08", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__scars__08", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__step__06", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666" + ], + "input_key": "input__median_of_differences__step__06", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__step__06", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__median_of_differences__step__06", + "masking_mode": 2, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__step__06", + "portable_correction_sequence_bits": [ + "3fe24924924924b0", + "bff2492492492486", + "c006db6db6db6db2", + "400b6db6db6db6e0", + "3ffb6db6db6db6e0", + "0000000000000000", + "bffb6db6db6db6d8" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "median_of_differences__tall__15", + "columns": 5, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd999999999999a", + "3fe999999999999a", + "3ff3333333333334", + "3ff999999999999a", + "bfe999999999999a", + "bfd5c28f5c28f5c3", + "3fbeb851eb851eb8", + "3fe28f5c28f5c290", + "3ff0a3d70a3d70a4", + "bff999999999999a", + "bff147ae147ae148", + "bfe1eb851eb851ec", + "bfa47ae147ae1470", + "3fdeb851eb851eb8", + "c003333333333334", + "bffd1eb851eb8521", + "bff3d70a3d70a3d8", + "bfe51eb851eb8520", + "bfb47ae147ae1490", + "c00999999999999a", + "c0047ae147ae147c", + "bffeb851eb851eba", + "bff47ae147ae147b", + "bfe47ae147ae147c", + "c010000000000000", + "c00a666666666667", + "c004cccccccccccd", + "bffe666666666666", + "bff3333333333333", + "c013333333333334", + "c01028f5c28f5c29", + "c00a3d70a3d70a40", + "c00428f5c28f5c2a", + "bffc28f5c28f5c2c", + "c016666666666667", + "c0131eb851eb851f", + "c00fae147ae147b0", + "c0091eb851eb8520", + "c0028f5c28f5c290", + "c01999999999999a", + "c016147ae147ae14", + "c0128f5c28f5c290", + "c00e147ae147ae15", + "c0070a3d70a3d70c", + "c01ccccccccccccd", + "c0190a3d70a3d70a", + "c01547ae147ae148", + "c011851eb851eb85", + "c00b851eb851eb84", + "c020000000000000", + "c01c000000000000", + "c018000000000000", + "c014000000000000", + "c010000000000000", + "c02199999999999a", + "c01ef5c28f5c28f6", + "c01ab851eb851eb8", + "c0167ae147ae147c", + "c0123d70a3d70a3f", + "c023333333333334", + "c020f5c28f5c28f6", + "c01d70a3d70a3d72", + "c018f5c28f5c28f8", + "c0147ae147ae147d", + "c024cccccccccccd", + "c02270a3d70a3d71", + "c020147ae147ae14", + "c01b70a3d70a3d70", + "c016b851eb851eb9", + "c026666666666667", + "c023eb851eb851ec", + "c02170a3d70a3d71", + "c01deb851eb851ec", + "c018f5c28f5c28f8", + "c028000000000000", + "c025666666666666", + "c022cccccccccccc", + "c020333333333334", + "c01b333333333334", + "c02999999999999a", + "c026e147ae147ae2", + "c02428f5c28f5c29", + "c02170a3d70a3d72", + "c01d70a3d70a3d72" + ], + "input_key": "input__median_of_differences__tall__15", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__tall__15", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000" + ], + "mask_key": "mask__median_of_differences__tall__15", + "masking_mode": 1, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__tall__15", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 17, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.399999999999ap+2", + "yreal_hex": "0x1.07c28f5c28f5cp+5" + }, + { + "case_identifier": "median_of_differences__wide__14", + "columns": 17, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fb1eb851eb851ec", + "3fd1eb851eb851ec", + "3fe428f5c28f5c2a", + "3ff1eb851eb851ec", + "3ffc000000000001", + "400428f5c28f5c2a", + "400b70a3d70a3d71", + "4011eb851eb851ec", + "4016ae147ae147af", + "401c000000000001", + "4020f0a3d70a3d71", + "402428f5c28f5c2a", + "4027a8f5c28f5c2a", + "402b70a3d70a3d71", + "402f800000000000", + "4031eb851eb851ec", + "3ff199999999999a", + "3ff23d70a3d70a3e", + "3ff51eb851eb851f", + "3ffa3d70a3d70a3e", + "4000cccccccccccd", + "400599999999999b", + "400b851eb851eb86", + "401147ae147ae148", + "40155c28f5c28f5c", + "401a000000000002", + "401f333333333335", + "40227ae147ae147b", + "4025a3d70a3d70a5", + "4029147ae147ae15", + "402ccccccccccccd", + "4030666666666667", + "40328a3d70a3d70b", + "400199999999999a", + "4001ae147ae147ae", + "4002e147ae147ae2", + "4005333333333333", + "4008a3d70a3d70a4", + "400d333333333334", + "401170a3d70a3d71", + "4014d70a3d70a3d8", + "4018ccccccccccce", + "401d51eb851eb853", + "4021333333333334", + "4024051eb851eb86", + "40271eb851eb851f", + "402a800000000001", + "402e28f5c28f5c2a", + "40310ccccccccccd", + "403328f5c28f5c29", + "400a666666666667", + "400a3d70a3d70a3e", + "400b333333333333", + "400d47ae147ae149", + "40103d70a3d70a3d", + "4012666666666667", + "40151eb851eb851f", + "4018666666666667", + "401c3d70a3d70a3f", + "402051eb851eb852", + "4022cccccccccccd", + "40258f5c28f5c290", + "402899999999999b", + "402beb851eb851ed", + "402f851eb851eb85", + "4031b33333333333", + "4033c7ae147ae148", + "401199999999999a", + "4011666666666667", + "4011c28f5c28f5c3", + "4012ae147ae147ae", + "401428f5c28f5c2a", + "4016333333333334", + "4018ccccccccccce", + "401bf5c28f5c28f6", + "401fae147ae147af", + "4021fae147ae147b", + "4024666666666668", + "402719999999999a", + "402a147ae147ae16", + "402d570a3d70a3d9", + "403070a3d70a3d71", + "4032599999999999", + "4034666666666666" + ], + "input_key": "input__median_of_differences__wide__14", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__median_of_differences__wide__14", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 2, + "method_name": "Median of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__median_of_differences__wide__14", + "portable_correction_sequence_bits": [ + "3cd0000000000000", + "0000000000000000", + "3cd0000000000000", + "3cd0000000000000", + "0000000000000000" + ], + "portable_mutated": true, + "rows": 5, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.b51eb851eb852p+3", + "yreal_hex": "0x1.4199999999999p+3" + }, + { + "case_identifier": "trimmed_mean__alternating_offsets__02", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c000000000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4010800000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "bffc000000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4011800000000000", + "4013800000000000", + "4015800000000000", + "4017800000000000", + "4019800000000000", + "401b800000000000", + "401d800000000000", + "401f800000000000", + "4020c00000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4004000000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "4021400000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4006000000000000" + ], + "input_key": "input__trimmed_mean__alternating_offsets__02", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__alternating_offsets__02", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__alternating_offsets__02", + "masking_mode": 2, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__alternating_offsets__02", + "portable_correction_sequence_bits": [ + "c007924924924925", + "40096db6db6db6db", + "c005924924924925", + "400b6db6db6db6db", + "c003924924924925", + "400d6db6db6db6db", + "c001924924924925" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__constant__00", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000" + ], + "input_key": "input__trimmed_mean__constant__00", + "installed_background_key": "installed_background__trimmed_mean__constant__00", + "installed_corrected_key": "installed_corrected__trimmed_mean__constant__00", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": "portable_background__trimmed_mean__constant__00", + "portable_corrected_key": "portable_corrected__trimmed_mean__constant__00", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__impulses__07", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "0000000000000000", + "3fd3333333333333", + "3fe3333333333333", + "3feccccccccccccc", + "3ff3333333333333", + "3ff8000000000000", + "3ffccccccccccccc", + "4000cccccccccccd", + "4003333333333333", + "bfc999999999999a", + "3fb9999999999998", + "4033666666666666", + "3fe6666666666666", + "3ff0000000000000", + "3ff4cccccccccccd", + "3ff9999999999999", + "3ffe666666666667", + "4001999999999999", + "bfd999999999999a", + "bfb999999999999c", + "3fc9999999999998", + "3fdffffffffffffe", + "3fe9999999999999", + "3ff199999999999a", + "3ff6666666666666", + "3ffb333333333334", + "4000000000000000", + "bfe3333333333334", + "bfd3333333333335", + "bca0000000000000", + "3fd3333333333330", + "3fe3333333333332", + "3feccccccccccccc", + "3ff3333333333332", + "3ff8000000000000", + "3ffccccccccccccc", + "bfe999999999999a", + "bfe0000000000000", + "bfc999999999999c", + "3fb9999999999990", + "3fd9999999999998", + "3fe6666666666666", + "3feffffffffffffe", + "3ff4cccccccccccd", + "3ff9999999999999", + "bff0000000000000", + "bfe6666666666666", + "bfd999999999999a", + "bfb99999999999a0", + "3fc9999999999998", + "3fe0000000000000", + "c030333333333333", + "3ff199999999999a", + "3ff6666666666666", + "bff3333333333334", + "bfecccccccccccce", + "bfe3333333333335", + "bfd3333333333338", + "bcb0000000000000", + "3fd3333333333330", + "3fe3333333333330", + "3feccccccccccccc", + "3ff3333333333332" + ], + "input_key": "input__trimmed_mean__impulses__07", + "installed_background_key": "installed_background__trimmed_mean__impulses__07", + "installed_corrected_key": "installed_corrected__trimmed_mean__impulses__07", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean__impulses__07", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": "portable_background__trimmed_mean__impulses__07", + "portable_corrected_key": "portable_corrected__trimmed_mean__impulses__07", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__irregular__11", + "columns": 10, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c02c000000000000", + "4013c00000000000", + "c014800000000000", + "402ba00000000000", + "400e000000000000", + "c019400000000000", + "4029400000000000", + "4004800000000000", + "c01e000000000000", + "4026e00000000000", + "c017e00000000000", + "c022100000000000", + "c028300000000000", + "402bb00000000000", + "4025900000000000", + "401ee00000000000", + "4012a00000000000", + "3ff9800000000000", + "bff7800000000000", + "c012200000000000", + "4000800000000000", + "4018000000000000", + "4023e00000000000", + "402bc00000000000", + "c026600000000000", + "c01d000000000000", + "c00a800000000000", + "3fe4000000000000", + "4012400000000000", + "4021000000000000", + "4024300000000000", + "c01fe00000000000", + "4007c00000000000", + "402bd00000000000", + "c010a00000000000", + "401b200000000000", + "c026900000000000", + "bfd6000000000000", + "4025300000000000", + "c01de00000000000", + "c025c00000000000", + "401c400000000000", + "c010000000000000", + "402be00000000000", + "4007000000000000", + "c020600000000000", + "4023800000000000", + "bff5000000000000", + "c028c00000000000", + "4016400000000000", + "c006c00000000000", + "c01ba00000000000", + "c025f00000000000", + "402bf00000000000", + "4023d00000000000", + "4017600000000000", + "3ffc800000000000", + "c002400000000000", + "c019600000000000", + "c024d00000000000", + "4014c00000000000", + "4020400000000000", + "4026200000000000", + "402c000000000000", + "c028200000000000", + "c022400000000000", + "c018c00000000000", + "c00a000000000000", + "bfd4000000000000", + "4005000000000000", + "402a700000000000", + "c017600000000000", + "4010600000000000", + "402c100000000000", + "c014200000000000", + "4013a00000000000", + "c02c500000000000", + "c010e00000000000", + "4016e00000000000", + "c02ab00000000000" + ], + "input_key": "input__trimmed_mean__irregular__11", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__irregular__11", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__irregular__11", + "masking_mode": 2, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__irregular__11", + "portable_correction_sequence_bits": [ + "3ff3a00000000000", + "bfedc00000000000", + "4000500000000000", + "3fdc800000000000", + "3fd6800000000000", + "c013380000000000", + "3fba000000000000", + "3ff9200000000000" + ], + "portable_mutated": true, + "rows": 8, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.119999999999ap+3", + "yreal_hex": "0x1.f8f5c28f5c28fp+3" + }, + { + "case_identifier": "trimmed_mean__linear__03", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "c004cccccccccccd", + "bff6666666666666", + "bfc9999999999998", + "3ff0000000000002", + "400199999999999a", + "400b333333333333", + "4012666666666668", + "4017333333333334", + "401c000000000000", + "c00f333333333334", + "c004000000000001", + "bff199999999999a", + "3fd3333333333338", + "3ffb333333333334", + "4008cccccccccccd", + "4012000000000001", + "401799999999999b", + "401d333333333334", + "c014cccccccccccd", + "c00cccccccccccce", + "c000000000000000", + "bfd9999999999990", + "3ff3333333333334", + "4006666666666666", + "401199999999999b", + "4018000000000001", + "401e666666666667", + "c01a000000000000", + "c012cccccccccccd", + "c007333333333334", + "bff1999999999998", + "3fe6666666666668", + "4004000000000000", + "4011333333333334", + "4018666666666667", + "401f99999999999a", + "c01f333333333334", + "c017333333333334", + "c00e666666666668", + "bffccccccccccccc", + "3fc99999999999a0", + "400199999999999a", + "4010ccccccccccce", + "4018cccccccccccf", + "4020666666666667" + ], + "input_key": "input__trimmed_mean__linear__03", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__linear__03", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__linear__03", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__linear__03", + "portable_correction_sequence_bits": [ + "3ff8000000000001", + "3ff0000000000001", + "3fe0000000000002", + "0000000000000000", + "bfe0000000000002", + "bff0000000000001", + "bff8000000000002" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__linear__12", + "columns": 11, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "401ccccccccccccd", + "4020000000000000", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "401ecccccccccccd", + "4021666666666666" + ], + "input_key": "input__trimmed_mean__linear__12", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__linear__12", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean__linear__12", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__linear__12", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 2, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.28f5c28f5c28fp+3", + "yreal_hex": "0x1.147ae147ae148p+2" + }, + { + "case_identifier": "trimmed_mean__multimodal__09", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "c00f000000000000", + "c010000000000000", + "c010000000000000", + "4008000000000000", + "4009000000000000", + "4008000000000000", + "4020000000000000", + "4020000000000000", + "4020400000000000", + "c00e000000000000", + "c00e000000000000", + "c00e000000000000", + "400b000000000000", + "400a000000000000", + "400a000000000000", + "4020800000000000", + "4020c00000000000", + "4020800000000000", + "c00c000000000000", + "c00c000000000000", + "c00b000000000000", + "400c000000000000", + "400c000000000000", + "400c000000000000", + "4021400000000000", + "4021000000000000", + "4021000000000000", + "c00a000000000000", + "c009000000000000", + "c00a000000000000", + "400e000000000000", + "400e000000000000", + "400f000000000000", + "4021800000000000", + "4021800000000000", + "4021800000000000", + "c007000000000000", + "c008000000000000", + "c008000000000000", + "4010000000000000", + "4010800000000000", + "4010000000000000", + "4022000000000000", + "4022000000000000", + "4022400000000000", + "c006000000000000", + "c006000000000000", + "c006000000000000", + "4011800000000000", + "4011000000000000", + "4011000000000000", + "4022800000000000", + "4022c00000000000", + "4022800000000000", + "c004000000000000", + "c004000000000000", + "c003000000000000", + "4012000000000000", + "4012000000000000", + "4012000000000000", + "4023400000000000", + "4023000000000000", + "4023000000000000" + ], + "input_key": "input__trimmed_mean__multimodal__09", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__multimodal__09", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__multimodal__09", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__multimodal__09", + "portable_correction_sequence_bits": [ + "c019097b425ed098", + "c01956480f2b9d64", + "c019d6480f2b9d64", + "3fe5d6480f2b9d64", + "3fe7b425ed097b44", + "3fe54dbf86a314dc", + "401629b7f0d4629c", + "4016bac901e573ac", + "4016f684bda12f68" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__nonlinear__04", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3ff1eb851eb851ec", + "3ff2b851eb851eb8", + "40030a3d70a3d70a", + "400ecccccccccccd", + "4012b851eb851eb8", + "401aae147ae147ae", + "4021d70a3d70a3d7", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "3fd6666666666666", + "3ff4cccccccccccc", + "4004147ae147ae15", + "4008a3d70a3d70a4", + "40133d70a3d70a3d", + "401b333333333333", + "40204ccccccccccd", + "bfb47ae147ae1480", + "bfe199999999999a", + "3fc1eb851eb851ea", + "3ff170a3d70a3d70", + "3ff6666666666666", + "4006f5c28f5c28f5", + "4012666666666666", + "4016c28f5c28f5c3", + "401fc28f5c28f5c3", + "bff87ae147ae147b", + "bff1999999999999", + "bfda3d70a3d70a3e", + "bfd70a3d70a3d70c", + "3feb333333333334", + "40028f5c28f5c28f", + "4009333333333332", + "40148f5c28f5c28f", + "401d8f5c28f5c28f", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00199999999999a", + "bff4000000000001", + "bfa47ae147ae1490", + "3fe0f5c28f5c28f4", + "4002147ae147ae13", + "4011000000000000", + "4016666666666666", + "c00d333333333333", + "c0107ae147ae147b", + "c00b70a3d70a3d71", + "c003d70a3d70a3d7", + "c0015c28f5c28f5c", + "bfe6666666666666", + "3ff07ae147ae147a", + "4000f5c28f5c28f6", + "40117ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c014000000000000", + "c013cccccccccccd", + "c00deb851eb851ec", + "c00228f5c28f5c29", + "bff70a3d70a3d70c", + "3fe199999999999a", + "4006666666666667" + ], + "input_key": "input__trimmed_mean__nonlinear__04", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__nonlinear__04", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean__nonlinear__04", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__nonlinear__04", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__nonlinear__13", + "columns": 2, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "bfb47ae147ae1480", + "bfe199999999999a", + "bff87ae147ae147b", + "bff1999999999999", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00d333333333333", + "c0107ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c0200f5c28f5c290", + "c01e666666666668", + "c0248f5c28f5c290", + "c025800000000000", + "c02b8a3d70a3d70a", + "c02aae147ae147ad", + "c030b33333333333", + "c030451eb851eb85" + ], + "input_key": "input__trimmed_mean__nonlinear__13", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__nonlinear__13", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean__nonlinear__13", + "masking_mode": 2, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__nonlinear__13", + "portable_correction_sequence_bits": [ + "bfb0a3d70a3d70c0", + "3fb0a3d70a3d7080" + ], + "portable_mutated": true, + "rows": 11, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.5ae147ae147aep+1", + "yreal_hex": "0x1.5828f5c28f5c2p+4" + }, + { + "case_identifier": "trimmed_mean__plane__05", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3ffb333333333333", + "400b333333333333", + "4014666666666666", + "401b333333333333", + "4021000000000000", + "4024666666666666", + "4027cccccccccccd", + "402b333333333333", + "c002666666666666", + "bfdffffffffffffe", + "3ff4cccccccccccd", + "4008cccccccccccc", + "401399999999999a", + "401acccccccccccd", + "4021000000000000", + "402499999999999a", + "4028333333333334", + "c012666666666666", + "c005999999999998", + "bfe9999999999997", + "3ff199999999999a", + "4008000000000000", + "401399999999999a", + "401b333333333333", + "4021666666666667", + "4025333333333333", + "c01b999999999999", + "c013999999999999", + "c007333333333332", + "bfecccccccccccca", + "3ff199999999999c", + "4008ccccccccccce", + "4014666666666666", + "401c666666666668", + "4022333333333334", + "c022666666666666", + "c01c666666666665", + "c013ffffffffffff", + "c007333333333332", + "bfe9999999999994", + "3ff4ccccccccccd0", + "400b333333333334", + "4016000000000002", + "401e666666666667", + "c027000000000000", + "c02299999999999a", + "c01c666666666666", + "c01399999999999a", + "c00599999999999a", + "bfe0000000000000", + "3ffb333333333330", + "400f333333333334", + "4018666666666666", + "c02b999999999999", + "c027000000000000", + "c022666666666666", + "c01b999999999998", + "c012666666666665", + "c002666666666663", + "3cd0000000000000", + "400266666666666c", + "4012666666666668" + ], + "input_key": "input__trimmed_mean__plane__05", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__plane__05", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__trimmed_mean__plane__05", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__plane__05", + "portable_correction_sequence_bits": [ + "c019777777777778", + "c01aaaaaaaaaaaaa", + "c012aaaaaaaaaaaa", + "bfe5555555555558", + "bfe5555555555554", + "3ff5555555555556", + "4014222222222222", + "4015555555555556", + "401d555555555556" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__plateaus_signed_zero__10", + "columns": 8, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "c008000000000000", + "c000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "c000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000" + ], + "input_key": "input__trimmed_mean__plateaus_signed_zero__10", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__plateaus_signed_zero__10", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__plateaus_signed_zero__10", + "masking_mode": 2, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__plateaus_signed_zero__10", + "portable_correction_sequence_bits": [ + "bfe4000000000000", + "bfd8000000000000", + "bfc0000000000000", + "3fc0000000000000", + "3fd8000000000000", + "3fe4000000000000" + ], + "portable_mutated": true, + "rows": 6, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.c5c28f5c28f5cp+2", + "yreal_hex": "0x1.7eb851eb851ebp+3" + }, + { + "case_identifier": "trimmed_mean__row_offsets__01", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd0000000000000", + "3fe0000000000000", + "3fe8000000000000", + "3ff0000000000000", + "3ff4000000000000", + "3ff8000000000000", + "3ffc000000000000", + "4000000000000000", + "4008000000000000", + "400a000000000000", + "400c000000000000", + "400e000000000000", + "4010000000000000", + "4011000000000000", + "4012000000000000", + "4013000000000000", + "4014000000000000", + "4018000000000000", + "4019000000000000", + "401a000000000000", + "401b000000000000", + "401c000000000000", + "401d000000000000", + "401e000000000000", + "401f000000000000", + "4020000000000000", + "4022000000000000", + "4022800000000000", + "4023000000000000", + "4023800000000000", + "4024000000000000", + "4024800000000000", + "4025000000000000", + "4025800000000000", + "4026000000000000", + "4028000000000000", + "4028800000000000", + "4029000000000000", + "4029800000000000", + "402a000000000000", + "402a800000000000", + "402b000000000000", + "402b800000000000", + "402c000000000000", + "402e000000000000", + "402e800000000000", + "402f000000000000", + "402f800000000000", + "4030000000000000", + "4030400000000000", + "4030800000000000", + "4030c00000000000", + "4031000000000000", + "4032000000000000", + "4032400000000000", + "4032800000000000", + "4032c00000000000", + "4033000000000000", + "4033400000000000", + "4033800000000000", + "4033c00000000000", + "4034000000000000" + ], + "input_key": "input__trimmed_mean__row_offsets__01", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__row_offsets__01", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean__row_offsets__01", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__row_offsets__01", + "portable_correction_sequence_bits": [ + "c02205397829cbc1", + "c017c14e5e0a72f0", + "c00814e5e0a72f04", + "bfb4e5e0a72f0500", + "4007eb1a1f58d0fc", + "40183eb1a1f58d12", + "4021fac687d6343f" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__scars__08", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "c01b666666666666", + "3fd3333333333333", + "3fdccccccccccccc", + "3fe3333333333333", + "3fe8000000000000", + "3feccccccccccccc", + "c017cccccccccccd", + "3ff3333333333333", + "3ff0000000000000", + "40224ccccccccccd", + "3ff4cccccccccccd", + "3ff7333333333333", + "3ff999999999999a", + "3ffc000000000000", + "3ffe666666666666", + "402419999999999a", + "400199999999999a", + "4000000000000000", + "40244ccccccccccd", + "4002666666666666", + "400399999999999a", + "4004cccccccccccd", + "4006000000000000", + "4007333333333333", + "402619999999999a", + "400999999999999a", + "4008000000000000", + "c00ecccccccccccd", + "400a666666666666", + "400b99999999999a", + "400ccccccccccccd", + "400e000000000000", + "400f333333333333", + "c00799999999999a", + "4010cccccccccccd", + "4010000000000000", + "40284ccccccccccd", + "4011333333333333", + "4011cccccccccccd", + "4012666666666666", + "4013000000000000", + "401399999999999a", + "402a19999999999a", + "4014cccccccccccd", + "4014000000000000", + "402a4ccccccccccd", + "4015333333333333", + "4015cccccccccccd", + "4016666666666666", + "4017000000000000", + "401799999999999a", + "402c19999999999a", + "4018cccccccccccd", + "4018000000000000", + "bfeb333333333330", + "4019333333333333", + "4019cccccccccccd", + "401a666666666666", + "401b000000000000", + "401b99999999999a", + "3fa9999999999980", + "401ccccccccccccd" + ], + "input_key": "input__trimmed_mean__scars__08", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__scars__08", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__scars__08", + "portable_correction_sequence_bits": [ + "c0095f15f15f15f0", + "bffdf15f15f15f14", + "bfebe2be2be2be28", + "bfc5f15f15f15f00", + "3ff20ea0ea0ea0ec", + "4001075075075076", + "4006a0ea0ea0ea10" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__step__06", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666" + ], + "input_key": "input__trimmed_mean__step__06", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__step__06", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__trimmed_mean__step__06", + "masking_mode": 2, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__step__06", + "portable_correction_sequence_bits": [ + "c012492492492492", + "c012492492492492", + "c012492492492492", + "400b6db6db6db6dc", + "400b6db6db6db6dc", + "400b6db6db6db6dc", + "400b6db6db6db6dc" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean__tall__15", + "columns": 5, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd999999999999a", + "3fe999999999999a", + "3ff3333333333334", + "3ff999999999999a", + "bfe999999999999a", + "bfd5c28f5c28f5c3", + "3fbeb851eb851eb8", + "3fe28f5c28f5c290", + "3ff0a3d70a3d70a4", + "bff999999999999a", + "bff147ae147ae148", + "bfe1eb851eb851ec", + "bfa47ae147ae1470", + "3fdeb851eb851eb8", + "c003333333333334", + "bffd1eb851eb8521", + "bff3d70a3d70a3d8", + "bfe51eb851eb8520", + "bfb47ae147ae1490", + "c00999999999999a", + "c0047ae147ae147c", + "bffeb851eb851eba", + "bff47ae147ae147b", + "bfe47ae147ae147c", + "c010000000000000", + "c00a666666666667", + "c004cccccccccccd", + "bffe666666666666", + "bff3333333333333", + "c013333333333334", + "c01028f5c28f5c29", + "c00a3d70a3d70a40", + "c00428f5c28f5c2a", + "bffc28f5c28f5c2c", + "c016666666666667", + "c0131eb851eb851f", + "c00fae147ae147b0", + "c0091eb851eb8520", + "c0028f5c28f5c290", + "c01999999999999a", + "c016147ae147ae14", + "c0128f5c28f5c290", + "c00e147ae147ae15", + "c0070a3d70a3d70c", + "c01ccccccccccccd", + "c0190a3d70a3d70a", + "c01547ae147ae148", + "c011851eb851eb85", + "c00b851eb851eb84", + "c020000000000000", + "c01c000000000000", + "c018000000000000", + "c014000000000000", + "c010000000000000", + "c02199999999999a", + "c01ef5c28f5c28f6", + "c01ab851eb851eb8", + "c0167ae147ae147c", + "c0123d70a3d70a3f", + "c023333333333334", + "c020f5c28f5c28f6", + "c01d70a3d70a3d72", + "c018f5c28f5c28f8", + "c0147ae147ae147d", + "c024cccccccccccd", + "c02270a3d70a3d71", + "c020147ae147ae14", + "c01b70a3d70a3d70", + "c016b851eb851eb9", + "c026666666666667", + "c023eb851eb851ec", + "c02170a3d70a3d71", + "c01deb851eb851ec", + "c018f5c28f5c28f8", + "c028000000000000", + "c025666666666666", + "c022cccccccccccc", + "c020333333333334", + "c01b333333333334", + "c02999999999999a", + "c026e147ae147ae2", + "c02428f5c28f5c29", + "c02170a3d70a3d72", + "c01d70a3d70a3d72" + ], + "input_key": "input__trimmed_mean__tall__15", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__tall__15", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean__tall__15", + "masking_mode": 1, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__tall__15", + "portable_correction_sequence_bits": [ + "bffc0ab66df63b8c", + "bff19adc3d1ffaf8", + "3f7e3f5499209c00", + "3ff1401e3f54991c", + "3ffc473517287cc2" + ], + "portable_mutated": true, + "rows": 17, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.399999999999ap+2", + "yreal_hex": "0x1.07c28f5c28f5cp+5" + }, + { + "case_identifier": "trimmed_mean__wide__14", + "columns": 17, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fb1eb851eb851ec", + "3fd1eb851eb851ec", + "3fe428f5c28f5c2a", + "3ff1eb851eb851ec", + "3ffc000000000001", + "400428f5c28f5c2a", + "400b70a3d70a3d71", + "4011eb851eb851ec", + "4016ae147ae147af", + "401c000000000001", + "4020f0a3d70a3d71", + "402428f5c28f5c2a", + "4027a8f5c28f5c2a", + "402b70a3d70a3d71", + "402f800000000000", + "4031eb851eb851ec", + "3ff199999999999a", + "3ff23d70a3d70a3e", + "3ff51eb851eb851f", + "3ffa3d70a3d70a3e", + "4000cccccccccccd", + "400599999999999b", + "400b851eb851eb86", + "401147ae147ae148", + "40155c28f5c28f5c", + "401a000000000002", + "401f333333333335", + "40227ae147ae147b", + "4025a3d70a3d70a5", + "4029147ae147ae15", + "402ccccccccccccd", + "4030666666666667", + "40328a3d70a3d70b", + "400199999999999a", + "4001ae147ae147ae", + "4002e147ae147ae2", + "4005333333333333", + "4008a3d70a3d70a4", + "400d333333333334", + "401170a3d70a3d71", + "4014d70a3d70a3d8", + "4018ccccccccccce", + "401d51eb851eb853", + "4021333333333334", + "4024051eb851eb86", + "40271eb851eb851f", + "402a800000000001", + "402e28f5c28f5c2a", + "40310ccccccccccd", + "403328f5c28f5c29", + "400a666666666667", + "400a3d70a3d70a3e", + "400b333333333333", + "400d47ae147ae149", + "40103d70a3d70a3d", + "4012666666666667", + "40151eb851eb851f", + "4018666666666667", + "401c3d70a3d70a3f", + "402051eb851eb852", + "4022cccccccccccd", + "40258f5c28f5c290", + "402899999999999b", + "402beb851eb851ed", + "402f851eb851eb85", + "4031b33333333333", + "4033c7ae147ae148", + "401199999999999a", + "4011666666666667", + "4011c28f5c28f5c3", + "4012ae147ae147ae", + "401428f5c28f5c2a", + "4016333333333334", + "4018ccccccccccce", + "401bf5c28f5c28f6", + "401fae147ae147af", + "4021fae147ae147b", + "4024666666666668", + "402719999999999a", + "402a147ae147ae16", + "402d570a3d70a3d9", + "403070a3d70a3d71", + "4032599999999999", + "4034666666666666" + ], + "input_key": "input__trimmed_mean__wide__14", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean__wide__14", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 5, + "method_name": "Trimmed mean", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean__wide__14", + "portable_correction_sequence_bits": [ + "bffb851eb851eb88", + "bfeb851eb851eb90", + "0000000000000000", + "3feb851eb851eb88", + "3ffb851eb851eb84" + ], + "portable_mutated": true, + "rows": 5, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.b51eb851eb852p+3", + "yreal_hex": "0x1.4199999999999p+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__alternating_offsets__02", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c000000000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4010800000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "bffc000000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4011800000000000", + "4013800000000000", + "4015800000000000", + "4017800000000000", + "4019800000000000", + "401b800000000000", + "401d800000000000", + "401f800000000000", + "4020c00000000000", + "bff8000000000000", + "bff0000000000000", + "bfe0000000000000", + "0000000000000000", + "3fe0000000000000", + "3ff0000000000000", + "3ff8000000000000", + "4000000000000000", + "4004000000000000", + "4012800000000000", + "4014800000000000", + "4016800000000000", + "4018800000000000", + "401a800000000000", + "401c800000000000", + "401e800000000000", + "4020400000000000", + "4021400000000000", + "bff4000000000000", + "bfe8000000000000", + "bfd0000000000000", + "3fd0000000000000", + "3fe8000000000000", + "3ff4000000000000", + "3ffc000000000000", + "4002000000000000", + "4006000000000000" + ], + "input_key": "input__trimmed_mean_of_differences__alternating_offsets__02", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__alternating_offsets__02", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__alternating_offsets__02", + "masking_mode": 2, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__alternating_offsets__02", + "portable_correction_sequence_bits": [ + "c004924924924928", + "400b6db6db6db6d9", + "c004924924924926", + "400b6db6db6db6db", + "c004924924924924", + "400b6db6db6db6dd", + "c004924924924922" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__constant__00", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000", + "400a000000000000" + ], + "input_key": "input__trimmed_mean_of_differences__constant__00", + "installed_background_key": "installed_background__trimmed_mean_of_differences__constant__00", + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__constant__00", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": "portable_background__trimmed_mean_of_differences__constant__00", + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__constant__00", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__impulses__07", + "columns": 9, + "direction": 0, + "extract_background_request": true, + "input_bits": [ + "0000000000000000", + "3fd3333333333333", + "3fe3333333333333", + "3feccccccccccccc", + "3ff3333333333333", + "3ff8000000000000", + "3ffccccccccccccc", + "4000cccccccccccd", + "4003333333333333", + "bfc999999999999a", + "3fb9999999999998", + "4033666666666666", + "3fe6666666666666", + "3ff0000000000000", + "3ff4cccccccccccd", + "3ff9999999999999", + "3ffe666666666667", + "4001999999999999", + "bfd999999999999a", + "bfb999999999999c", + "3fc9999999999998", + "3fdffffffffffffe", + "3fe9999999999999", + "3ff199999999999a", + "3ff6666666666666", + "3ffb333333333334", + "4000000000000000", + "bfe3333333333334", + "bfd3333333333335", + "bca0000000000000", + "3fd3333333333330", + "3fe3333333333332", + "3feccccccccccccc", + "3ff3333333333332", + "3ff8000000000000", + "3ffccccccccccccc", + "bfe999999999999a", + "bfe0000000000000", + "bfc999999999999c", + "3fb9999999999990", + "3fd9999999999998", + "3fe6666666666666", + "3feffffffffffffe", + "3ff4cccccccccccd", + "3ff9999999999999", + "bff0000000000000", + "bfe6666666666666", + "bfd999999999999a", + "bfb99999999999a0", + "3fc9999999999998", + "3fe0000000000000", + "c030333333333333", + "3ff199999999999a", + "3ff6666666666666", + "bff3333333333334", + "bfecccccccccccce", + "bfe3333333333335", + "bfd3333333333338", + "bcb0000000000000", + "3fd3333333333330", + "3fe3333333333330", + "3feccccccccccccc", + "3ff3333333333332" + ], + "input_key": "input__trimmed_mean_of_differences__impulses__07", + "installed_background_key": "installed_background__trimmed_mean_of_differences__impulses__07", + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__impulses__07", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__impulses__07", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": "portable_background__trimmed_mean_of_differences__impulses__07", + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__impulses__07", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "columns": 10, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c02c000000000000", + "4013c00000000000", + "c014800000000000", + "402ba00000000000", + "400e000000000000", + "c019400000000000", + "4029400000000000", + "4004800000000000", + "c01e000000000000", + "4026e00000000000", + "c017e00000000000", + "c022100000000000", + "c028300000000000", + "402bb00000000000", + "4025900000000000", + "401ee00000000000", + "4012a00000000000", + "3ff9800000000000", + "bff7800000000000", + "c012200000000000", + "4000800000000000", + "4018000000000000", + "4023e00000000000", + "402bc00000000000", + "c026600000000000", + "c01d000000000000", + "c00a800000000000", + "3fe4000000000000", + "4012400000000000", + "4021000000000000", + "4024300000000000", + "c01fe00000000000", + "4007c00000000000", + "402bd00000000000", + "c010a00000000000", + "401b200000000000", + "c026900000000000", + "bfd6000000000000", + "4025300000000000", + "c01de00000000000", + "c025c00000000000", + "401c400000000000", + "c010000000000000", + "402be00000000000", + "4007000000000000", + "c020600000000000", + "4023800000000000", + "bff5000000000000", + "c028c00000000000", + "4016400000000000", + "c006c00000000000", + "c01ba00000000000", + "c025f00000000000", + "402bf00000000000", + "4023d00000000000", + "4017600000000000", + "3ffc800000000000", + "c002400000000000", + "c019600000000000", + "c024d00000000000", + "4014c00000000000", + "4020400000000000", + "4026200000000000", + "402c000000000000", + "c028200000000000", + "c022400000000000", + "c018c00000000000", + "c00a000000000000", + "bfd4000000000000", + "4005000000000000", + "402a700000000000", + "c017600000000000", + "4010600000000000", + "402c100000000000", + "c014200000000000", + "4013a00000000000", + "c02c500000000000", + "c010e00000000000", + "4016e00000000000", + "c02ab00000000000" + ], + "input_key": "input__trimmed_mean_of_differences__irregular__11", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__irregular__11", + "installed_mutated": true, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__irregular__11", + "masking_mode": 2, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__irregular__11", + "portable_correction_sequence_bits": [ + "0000000000000000", + "bffb6db6db6db6db", + "4004924924924925", + "3feb6db6db6db6e0", + "bfeb6db6db6db6d8", + "c004924924924924", + "3ffb6db6db6db6e0", + "0000000000000000" + ], + "portable_mutated": true, + "rows": 8, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.119999999999ap+3", + "yreal_hex": "0x1.f8f5c28f5c28fp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__linear__03", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "c004cccccccccccd", + "bff6666666666666", + "bfc9999999999998", + "3ff0000000000002", + "400199999999999a", + "400b333333333333", + "4012666666666668", + "4017333333333334", + "401c000000000000", + "c00f333333333334", + "c004000000000001", + "bff199999999999a", + "3fd3333333333338", + "3ffb333333333334", + "4008cccccccccccd", + "4012000000000001", + "401799999999999b", + "401d333333333334", + "c014cccccccccccd", + "c00cccccccccccce", + "c000000000000000", + "bfd9999999999990", + "3ff3333333333334", + "4006666666666666", + "401199999999999b", + "4018000000000001", + "401e666666666667", + "c01a000000000000", + "c012cccccccccccd", + "c007333333333334", + "bff1999999999998", + "3fe6666666666668", + "4004000000000000", + "4011333333333334", + "4018666666666667", + "401f99999999999a", + "c01f333333333334", + "c017333333333334", + "c00e666666666668", + "bffccccccccccccc", + "3fc99999999999a0", + "400199999999999a", + "4010ccccccccccce", + "4018cccccccccccf", + "4020666666666667" + ], + "input_key": "input__trimmed_mean_of_differences__linear__03", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__linear__03", + "installed_mutated": false, + "mask_bits": [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__linear__03", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__linear__03", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__linear__12", + "columns": 11, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fe999999999999a", + "3ff999999999999a", + "4003333333333334", + "400999999999999a", + "4010000000000000", + "4013333333333334", + "4016666666666667", + "401999999999999a", + "401ccccccccccccd", + "4020000000000000", + "bff4cccccccccccd", + "bfd3333333333333", + "3fe6666666666667", + "3ffb333333333335", + "400599999999999a", + "400d99999999999a", + "4012ccccccccccce", + "4016ccccccccccce", + "401accccccccccce", + "401ecccccccccccd", + "4021666666666666" + ], + "input_key": "input__trimmed_mean_of_differences__linear__12", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__linear__12", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__linear__12", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__linear__12", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 2, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.28f5c28f5c28fp+3", + "yreal_hex": "0x1.147ae147ae148p+2" + }, + { + "case_identifier": "trimmed_mean_of_differences__multimodal__09", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "c00f000000000000", + "c010000000000000", + "c010000000000000", + "4008000000000000", + "4009000000000000", + "4008000000000000", + "4020000000000000", + "4020000000000000", + "4020400000000000", + "c00e000000000000", + "c00e000000000000", + "c00e000000000000", + "400b000000000000", + "400a000000000000", + "400a000000000000", + "4020800000000000", + "4020c00000000000", + "4020800000000000", + "c00c000000000000", + "c00c000000000000", + "c00b000000000000", + "400c000000000000", + "400c000000000000", + "400c000000000000", + "4021400000000000", + "4021000000000000", + "4021000000000000", + "c00a000000000000", + "c009000000000000", + "c00a000000000000", + "400e000000000000", + "400e000000000000", + "400f000000000000", + "4021800000000000", + "4021800000000000", + "4021800000000000", + "c007000000000000", + "c008000000000000", + "c008000000000000", + "4010000000000000", + "4010800000000000", + "4010000000000000", + "4022000000000000", + "4022000000000000", + "4022400000000000", + "c006000000000000", + "c006000000000000", + "c006000000000000", + "4011800000000000", + "4011000000000000", + "4011000000000000", + "4022800000000000", + "4022c00000000000", + "4022800000000000", + "c004000000000000", + "c004000000000000", + "c003000000000000", + "4012000000000000", + "4012000000000000", + "4012000000000000", + "4023400000000000", + "4023000000000000", + "4023000000000000" + ], + "input_key": "input__trimmed_mean_of_differences__multimodal__09", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__multimodal__09", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__multimodal__09", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__multimodal__09", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__nonlinear__04", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3ff1eb851eb851ec", + "3ff2b851eb851eb8", + "40030a3d70a3d70a", + "400ecccccccccccd", + "4012b851eb851eb8", + "401aae147ae147ae", + "4021d70a3d70a3d7", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "3fd6666666666666", + "3ff4cccccccccccc", + "4004147ae147ae15", + "4008a3d70a3d70a4", + "40133d70a3d70a3d", + "401b333333333333", + "40204ccccccccccd", + "bfb47ae147ae1480", + "bfe199999999999a", + "3fc1eb851eb851ea", + "3ff170a3d70a3d70", + "3ff6666666666666", + "4006f5c28f5c28f5", + "4012666666666666", + "4016c28f5c28f5c3", + "401fc28f5c28f5c3", + "bff87ae147ae147b", + "bff1999999999999", + "bfda3d70a3d70a3e", + "bfd70a3d70a3d70c", + "3feb333333333334", + "40028f5c28f5c28f", + "4009333333333332", + "40148f5c28f5c28f", + "401d8f5c28f5c28f", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00199999999999a", + "bff4000000000001", + "bfa47ae147ae1490", + "3fe0f5c28f5c28f4", + "4002147ae147ae13", + "4011000000000000", + "4016666666666666", + "c00d333333333333", + "c0107ae147ae147b", + "c00b70a3d70a3d71", + "c003d70a3d70a3d7", + "c0015c28f5c28f5c", + "bfe6666666666666", + "3ff07ae147ae147a", + "4000f5c28f5c28f6", + "40117ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c014000000000000", + "c013cccccccccccd", + "c00deb851eb851ec", + "c00228f5c28f5c29", + "bff70a3d70a3d70c", + "3fe199999999999a", + "4006666666666667" + ], + "input_key": "input__trimmed_mean_of_differences__nonlinear__04", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__nonlinear__04", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__nonlinear__04", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__nonlinear__04", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__nonlinear__13", + "columns": 2, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fdb851eb851eb85", + "3fc0a3d70a3d70a3", + "3fe1eb851eb851eb", + "bfb47ae147ae1480", + "bfe199999999999a", + "bff87ae147ae147b", + "bff1999999999999", + "c0035c28f5c28f5d", + "bfffd70a3d70a3d8", + "c00d333333333333", + "c0107ae147ae147b", + "c0187ae147ae147b", + "c016c28f5c28f5c3", + "c0200f5c28f5c290", + "c01e666666666668", + "c0248f5c28f5c290", + "c025800000000000", + "c02b8a3d70a3d70a", + "c02aae147ae147ad", + "c030b33333333333", + "c030451eb851eb85" + ], + "input_key": "input__trimmed_mean_of_differences__nonlinear__13", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__nonlinear__13", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__nonlinear__13", + "masking_mode": 2, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__nonlinear__13", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 11, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.5ae147ae147aep+1", + "yreal_hex": "0x1.5828f5c28f5c2p+4" + }, + { + "case_identifier": "trimmed_mean_of_differences__plane__05", + "columns": 9, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3ffb333333333333", + "400b333333333333", + "4014666666666666", + "401b333333333333", + "4021000000000000", + "4024666666666666", + "4027cccccccccccd", + "402b333333333333", + "c002666666666666", + "bfdffffffffffffe", + "3ff4cccccccccccd", + "4008cccccccccccc", + "401399999999999a", + "401acccccccccccd", + "4021000000000000", + "402499999999999a", + "4028333333333334", + "c012666666666666", + "c005999999999998", + "bfe9999999999997", + "3ff199999999999a", + "4008000000000000", + "401399999999999a", + "401b333333333333", + "4021666666666667", + "4025333333333333", + "c01b999999999999", + "c013999999999999", + "c007333333333332", + "bfecccccccccccca", + "3ff199999999999c", + "4008ccccccccccce", + "4014666666666666", + "401c666666666668", + "4022333333333334", + "c022666666666666", + "c01c666666666665", + "c013ffffffffffff", + "c007333333333332", + "bfe9999999999994", + "3ff4ccccccccccd0", + "400b333333333334", + "4016000000000002", + "401e666666666667", + "c027000000000000", + "c02299999999999a", + "c01c666666666666", + "c01399999999999a", + "c00599999999999a", + "bfe0000000000000", + "3ffb333333333330", + "400f333333333334", + "4018666666666666", + "c02b999999999999", + "c027000000000000", + "c022666666666666", + "c01b999999999998", + "c012666666666665", + "c002666666666663", + "3cd0000000000000", + "400266666666666c", + "4012666666666668" + ], + "input_key": "input__trimmed_mean_of_differences__plane__05", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__plane__05", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__plane__05", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__plane__05", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__plateaus_signed_zero__10", + "columns": 8, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "c008000000000000", + "c000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "c000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "bff0000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "4000000000000000", + "0000000000000000", + "0000000000000000", + "8000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "4000000000000000" + ], + "input_key": "input__trimmed_mean_of_differences__plateaus_signed_zero__10", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__plateaus_signed_zero__10", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__plateaus_signed_zero__10", + "masking_mode": 2, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__plateaus_signed_zero__10", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 6, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.c5c28f5c28f5cp+2", + "yreal_hex": "0x1.7eb851eb851ebp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__row_offsets__01", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd0000000000000", + "3fe0000000000000", + "3fe8000000000000", + "3ff0000000000000", + "3ff4000000000000", + "3ff8000000000000", + "3ffc000000000000", + "4000000000000000", + "4008000000000000", + "400a000000000000", + "400c000000000000", + "400e000000000000", + "4010000000000000", + "4011000000000000", + "4012000000000000", + "4013000000000000", + "4014000000000000", + "4018000000000000", + "4019000000000000", + "401a000000000000", + "401b000000000000", + "401c000000000000", + "401d000000000000", + "401e000000000000", + "401f000000000000", + "4020000000000000", + "4022000000000000", + "4022800000000000", + "4023000000000000", + "4023800000000000", + "4024000000000000", + "4024800000000000", + "4025000000000000", + "4025800000000000", + "4026000000000000", + "4028000000000000", + "4028800000000000", + "4029000000000000", + "4029800000000000", + "402a000000000000", + "402a800000000000", + "402b000000000000", + "402b800000000000", + "402c000000000000", + "402e000000000000", + "402e800000000000", + "402f000000000000", + "402f800000000000", + "4030000000000000", + "4030400000000000", + "4030800000000000", + "4030c00000000000", + "4031000000000000", + "4032000000000000", + "4032400000000000", + "4032800000000000", + "4032c00000000000", + "4033000000000000", + "4033400000000000", + "4033800000000000", + "4033c00000000000", + "4034000000000000" + ], + "input_key": "input__trimmed_mean_of_differences__row_offsets__01", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__row_offsets__01", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__row_offsets__01", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__row_offsets__01", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.999999999999ap-5", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__scars__08", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "c01b666666666666", + "3fd3333333333333", + "3fdccccccccccccc", + "3fe3333333333333", + "3fe8000000000000", + "3feccccccccccccc", + "c017cccccccccccd", + "3ff3333333333333", + "3ff0000000000000", + "40224ccccccccccd", + "3ff4cccccccccccd", + "3ff7333333333333", + "3ff999999999999a", + "3ffc000000000000", + "3ffe666666666666", + "402419999999999a", + "400199999999999a", + "4000000000000000", + "40244ccccccccccd", + "4002666666666666", + "400399999999999a", + "4004cccccccccccd", + "4006000000000000", + "4007333333333333", + "402619999999999a", + "400999999999999a", + "4008000000000000", + "c00ecccccccccccd", + "400a666666666666", + "400b99999999999a", + "400ccccccccccccd", + "400e000000000000", + "400f333333333333", + "c00799999999999a", + "4010cccccccccccd", + "4010000000000000", + "40284ccccccccccd", + "4011333333333333", + "4011cccccccccccd", + "4012666666666666", + "4013000000000000", + "401399999999999a", + "402a19999999999a", + "4014cccccccccccd", + "4014000000000000", + "402a4ccccccccccd", + "4015333333333333", + "4015cccccccccccd", + "4016666666666666", + "4017000000000000", + "401799999999999a", + "402c19999999999a", + "4018cccccccccccd", + "4018000000000000", + "bfeb333333333330", + "4019333333333333", + "4019cccccccccccd", + "401a666666666666", + "401b000000000000", + "401b99999999999a", + "3fa9999999999980", + "401ccccccccccccd" + ], + "input_key": "input__trimmed_mean_of_differences__scars__08", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__scars__08", + "installed_mutated": false, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__scars__08", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 7, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__step__06", + "columns": 9, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "c008000000000000", + "c004cccccccccccd", + "c00199999999999a", + "bffccccccccccccc", + "bff6666666666666", + "bff0000000000000", + "bfe3333333333330", + "bfc9999999999990", + "3fc99999999999a0", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666", + "4014000000000000", + "401599999999999a", + "4017333333333333", + "4018cccccccccccd", + "401a666666666666", + "401c000000000000", + "401d99999999999a", + "401f333333333334", + "4020666666666666" + ], + "input_key": "input__trimmed_mean_of_differences__step__06", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__step__06", + "installed_mutated": true, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000", + "0000000000000000", + "3ff0000000000000", + "3fe0000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__step__06", + "masking_mode": 2, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__step__06", + "portable_correction_sequence_bits": [ + "3fe24924924924b0", + "bff2492492492486", + "c006db6db6db6db2", + "400b6db6db6db6e0", + "3ffb6db6db6db6e0", + "0000000000000000", + "bffb6db6db6db6d8" + ], + "portable_mutated": true, + "rows": 7, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.f47ae147ae148p+2", + "yreal_hex": "0x1.bbd70a3d70a3dp+3" + }, + { + "case_identifier": "trimmed_mean_of_differences__tall__15", + "columns": 5, + "direction": 1, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fd999999999999a", + "3fe999999999999a", + "3ff3333333333334", + "3ff999999999999a", + "bfe999999999999a", + "bfd5c28f5c28f5c3", + "3fbeb851eb851eb8", + "3fe28f5c28f5c290", + "3ff0a3d70a3d70a4", + "bff999999999999a", + "bff147ae147ae148", + "bfe1eb851eb851ec", + "bfa47ae147ae1470", + "3fdeb851eb851eb8", + "c003333333333334", + "bffd1eb851eb8521", + "bff3d70a3d70a3d8", + "bfe51eb851eb8520", + "bfb47ae147ae1490", + "c00999999999999a", + "c0047ae147ae147c", + "bffeb851eb851eba", + "bff47ae147ae147b", + "bfe47ae147ae147c", + "c010000000000000", + "c00a666666666667", + "c004cccccccccccd", + "bffe666666666666", + "bff3333333333333", + "c013333333333334", + "c01028f5c28f5c29", + "c00a3d70a3d70a40", + "c00428f5c28f5c2a", + "bffc28f5c28f5c2c", + "c016666666666667", + "c0131eb851eb851f", + "c00fae147ae147b0", + "c0091eb851eb8520", + "c0028f5c28f5c290", + "c01999999999999a", + "c016147ae147ae14", + "c0128f5c28f5c290", + "c00e147ae147ae15", + "c0070a3d70a3d70c", + "c01ccccccccccccd", + "c0190a3d70a3d70a", + "c01547ae147ae148", + "c011851eb851eb85", + "c00b851eb851eb84", + "c020000000000000", + "c01c000000000000", + "c018000000000000", + "c014000000000000", + "c010000000000000", + "c02199999999999a", + "c01ef5c28f5c28f6", + "c01ab851eb851eb8", + "c0167ae147ae147c", + "c0123d70a3d70a3f", + "c023333333333334", + "c020f5c28f5c28f6", + "c01d70a3d70a3d72", + "c018f5c28f5c28f8", + "c0147ae147ae147d", + "c024cccccccccccd", + "c02270a3d70a3d71", + "c020147ae147ae14", + "c01b70a3d70a3d70", + "c016b851eb851eb9", + "c026666666666667", + "c023eb851eb851ec", + "c02170a3d70a3d71", + "c01deb851eb851ec", + "c018f5c28f5c28f8", + "c028000000000000", + "c025666666666666", + "c022cccccccccccc", + "c020333333333334", + "c01b333333333334", + "c02999999999999a", + "c026e147ae147ae2", + "c02428f5c28f5c29", + "c02170a3d70a3d72", + "c01d70a3d70a3d72" + ], + "input_key": "input__trimmed_mean_of_differences__tall__15", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__tall__15", + "installed_mutated": false, + "mask_bits": [ + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "0000000000000000", + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000", + "0000000000000000" + ], + "mask_key": "mask__trimmed_mean_of_differences__tall__15", + "masking_mode": 1, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__tall__15", + "portable_correction_sequence_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "portable_mutated": false, + "rows": 17, + "trim_fraction_hex": "0x0.0p+0", + "xreal_hex": "0x1.399999999999ap+2", + "yreal_hex": "0x1.07c28f5c28f5cp+5" + }, + { + "case_identifier": "trimmed_mean_of_differences__wide__14", + "columns": 17, + "direction": 0, + "extract_background_request": false, + "input_bits": [ + "0000000000000000", + "3fb1eb851eb851ec", + "3fd1eb851eb851ec", + "3fe428f5c28f5c2a", + "3ff1eb851eb851ec", + "3ffc000000000001", + "400428f5c28f5c2a", + "400b70a3d70a3d71", + "4011eb851eb851ec", + "4016ae147ae147af", + "401c000000000001", + "4020f0a3d70a3d71", + "402428f5c28f5c2a", + "4027a8f5c28f5c2a", + "402b70a3d70a3d71", + "402f800000000000", + "4031eb851eb851ec", + "3ff199999999999a", + "3ff23d70a3d70a3e", + "3ff51eb851eb851f", + "3ffa3d70a3d70a3e", + "4000cccccccccccd", + "400599999999999b", + "400b851eb851eb86", + "401147ae147ae148", + "40155c28f5c28f5c", + "401a000000000002", + "401f333333333335", + "40227ae147ae147b", + "4025a3d70a3d70a5", + "4029147ae147ae15", + "402ccccccccccccd", + "4030666666666667", + "40328a3d70a3d70b", + "400199999999999a", + "4001ae147ae147ae", + "4002e147ae147ae2", + "4005333333333333", + "4008a3d70a3d70a4", + "400d333333333334", + "401170a3d70a3d71", + "4014d70a3d70a3d8", + "4018ccccccccccce", + "401d51eb851eb853", + "4021333333333334", + "4024051eb851eb86", + "40271eb851eb851f", + "402a800000000001", + "402e28f5c28f5c2a", + "40310ccccccccccd", + "403328f5c28f5c29", + "400a666666666667", + "400a3d70a3d70a3e", + "400b333333333333", + "400d47ae147ae149", + "40103d70a3d70a3d", + "4012666666666667", + "40151eb851eb851f", + "4018666666666667", + "401c3d70a3d70a3f", + "402051eb851eb852", + "4022cccccccccccd", + "40258f5c28f5c290", + "402899999999999b", + "402beb851eb851ed", + "402f851eb851eb85", + "4031b33333333333", + "4033c7ae147ae148", + "401199999999999a", + "4011666666666667", + "4011c28f5c28f5c3", + "4012ae147ae147ae", + "401428f5c28f5c2a", + "4016333333333334", + "4018ccccccccccce", + "401bf5c28f5c28f6", + "401fae147ae147af", + "4021fae147ae147b", + "4024666666666668", + "402719999999999a", + "402a147ae147ae16", + "402d570a3d70a3d9", + "403070a3d70a3d71", + "4032599999999999", + "4034666666666666" + ], + "input_key": "input__trimmed_mean_of_differences__wide__14", + "installed_background_key": null, + "installed_corrected_key": "installed_corrected__trimmed_mean_of_differences__wide__14", + "installed_mutated": true, + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 6, + "method_name": "Trimmed mean of differences", + "portable_background_key": null, + "portable_corrected_key": "portable_corrected__trimmed_mean_of_differences__wide__14", + "portable_correction_sequence_bits": [ + "3cd0000000000000", + "0000000000000000", + "3cd0000000000000", + "3cd0000000000000", + "0000000000000000" + ], + "portable_mutated": true, + "rows": 5, + "trim_fraction_hex": "0x1.0000000000000p-1", + "xreal_hex": "0x1.b51eb851eb852p+3", + "yreal_hex": "0x1.4199999999999p+3" + } + ], + "comparison_metrics": { + "authorized_exceptions": [ + { + "case_identifier": "median__plateaus_signed_zero__10", + "classification": "signed-zero behavior", + "element_count": 3, + "elements": [ + { + "absolute_difference": 0.0, + "array": "corrected", + "case_identifier": "median__plateaus_signed_zero__10", + "column": 4, + "external_bits": "0000000000000000", + "external_class": "finite", + "external_value": 0.0, + "index": 4, + "input_bit": "8000000000000000", + "mask_bit": "0000000000000000", + "method": 1, + "method_name": "Median", + "oracle_bits": "8000000000000000", + "oracle_class": "finite", + "oracle_value": -0.0, + "probable_cause": "signed-zero behavior", + "relative_difference": null, + "row": 0, + "ulp_distance": 9223372036854775808 + }, + { + "absolute_difference": 0.0, + "array": "corrected", + "case_identifier": "median__plateaus_signed_zero__10", + "column": 3, + "external_bits": "8000000000000000", + "external_class": "finite", + "external_value": -0.0, + "index": 11, + "input_bit": "8000000000000000", + "mask_bit": "3ff0000000000000", + "method": 1, + "method_name": "Median", + "oracle_bits": "0000000000000000", + "oracle_class": "finite", + "oracle_value": 0.0, + "probable_cause": "signed-zero behavior", + "relative_difference": null, + "row": 1, + "ulp_distance": 9223372036854775808 + }, + { + "absolute_difference": 0.0, + "array": "corrected", + "case_identifier": "median__plateaus_signed_zero__10", + "column": 4, + "external_bits": "0000000000000000", + "external_class": "finite", + "external_value": 0.0, + "index": 36, + "input_bit": "8000000000000000", + "mask_bit": "0000000000000000", + "method": 1, + "method_name": "Median", + "oracle_bits": "8000000000000000", + "oracle_class": "finite", + "oracle_value": -0.0, + "probable_cause": "signed-zero behavior", + "relative_difference": null, + "row": 4, + "ulp_distance": 9223372036854775808 + } + ], + "finite_nonzero_count": 0, + "maximum_absolute_difference": 0.0, + "maximum_ulp_distance": 9223372036854775808, + "signed_zero_only_count": 3 + }, + { + "case_identifier": "median_of_differences__irregular__11", + "classification": "compiler/evaluation-order sensitivity", + "element_count": 64, + "elements": [ + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "c02c000000000003", + "external_class": "finite", + "external_value": -14.000000000000005, + "index": 0, + "input_bit": "c02c000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c02c000000000000", + "oracle_class": "finite", + "oracle_value": -14.0, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8064789415719636e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "4013bffffffffffa", + "external_class": "finite", + "external_value": 4.937499999999995, + "index": 1, + "input_bit": "4013c00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4013c00000000000", + "oracle_class": "finite", + "oracle_value": 4.9375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0793054214077483e-15, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "c014800000000006", + "external_class": "finite", + "external_value": -5.125000000000005, + "index": 2, + "input_bit": "c014800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c014800000000000", + "oracle_class": "finite", + "oracle_value": -5.125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0398186376977065e-15, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "402b9ffffffffffd", + "external_class": "finite", + "external_value": 13.812499999999995, + "index": 3, + "input_bit": "402ba00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "402ba00000000000", + "oracle_class": "finite", + "oracle_value": 13.8125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8581506014123103e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "400dfffffffffff4", + "external_class": "finite", + "external_value": 3.7499999999999947, + "index": 4, + "input_bit": "400e000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "400e000000000000", + "oracle_class": "finite", + "oracle_value": 3.75, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4210854715202024e-15, + "row": 0, + "ulp_distance": 12 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "c019400000000006", + "external_class": "finite", + "external_value": -6.312500000000005, + "index": 5, + "input_bit": "c019400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c019400000000000", + "oracle_class": "finite", + "oracle_value": -6.3125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.442091910020985e-16, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "40293ffffffffffd", + "external_class": "finite", + "external_value": 12.624999999999995, + "index": 6, + "input_bit": "4029400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4029400000000000", + "oracle_class": "finite", + "oracle_value": 12.625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.221045955010498e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "40047ffffffffff4", + "external_class": "finite", + "external_value": 2.5624999999999947, + "index": 7, + "input_bit": "4004800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4004800000000000", + "oracle_class": "finite", + "oracle_value": 2.5625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.0796372753954194e-15, + "row": 0, + "ulp_distance": 12 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "c01e000000000006", + "external_class": "finite", + "external_value": -7.500000000000005, + "index": 8, + "input_bit": "c01e000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c01e000000000000", + "oracle_class": "finite", + "oracle_value": -7.5, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.105427357600996e-16, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "4026dffffffffffd", + "external_class": "finite", + "external_value": 11.437499999999995, + "index": 9, + "input_bit": "4026e00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4026e00000000000", + "oracle_class": "finite", + "oracle_value": 11.4375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.659296627935085e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "c01104924924924e", + "external_class": "finite", + "external_value": -4.25446428571429, + "index": 10, + "input_bit": "c017e00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c011049249249249", + "oracle_class": "finite", + "oracle_value": -4.254464285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0438193389969983e-15, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "c01d44924924924e", + "external_class": "finite", + "external_value": -7.31696428571429, + "index": 11, + "input_bit": "c022100000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c01d449249249249", + "oracle_class": "finite", + "oracle_value": -7.316964285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.069309518390114e-16, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "c024c24924924927", + "external_class": "finite", + "external_value": -10.37946428571429, + "index": 12, + "input_bit": "c028300000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c024c24924924925", + "oracle_class": "finite", + "oracle_value": -10.379464285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.4228295228013413e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "402f1db6db6db6d9", + "external_class": "finite", + "external_value": 15.55803571428571, + "index": 13, + "input_bit": "402bb00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "402f1db6db6db6db", + "oracle_class": "finite", + "oracle_value": 15.558035714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.2835232827871234e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "4028fdb6db6db6d9", + "external_class": "finite", + "external_value": 12.49553571428571, + "index": 14, + "input_bit": "4025900000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4028fdb6db6db6db", + "oracle_class": "finite", + "oracle_value": 12.495535714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.84318636674281e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "4022ddb6db6db6d9", + "external_class": "finite", + "external_value": 9.43303571428571, + "index": 15, + "input_bit": "401ee00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4022ddb6db6db6db", + "oracle_class": "finite", + "oracle_value": 9.433035714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.766246398728408e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "40197b6db6db6db2", + "external_class": "finite", + "external_value": 6.37053571428571, + "index": 16, + "input_bit": "4012a00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "40197b6db6db6db7", + "oracle_class": "finite", + "oracle_value": 6.370535714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.970986896034625e-16, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "400a76db6db6db64", + "external_class": "finite", + "external_value": 3.30803571428571, + "index": 17, + "input_bit": "3ff9800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "400a76db6db6db6e", + "oracle_class": "finite", + "oracle_value": 3.3080357142857144, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.342455911017735e-15, + "row": 1, + "ulp_distance": 10 + }, + { + "absolute_difference": 3.9968028886505635e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "3fcf6db6db6db648", + "external_class": "finite", + "external_value": 0.2455357142857102, + "index": 18, + "input_bit": "bff7800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "3fcf6db6db6db6d8", + "oracle_class": "finite", + "oracle_value": 0.2455357142857142, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.6277888128322567e-14, + "row": 1, + "ulp_distance": 144 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "c00689249249249c", + "external_class": "finite", + "external_value": -2.81696428571429, + "index": 19, + "input_bit": "c012200000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c006892492492492", + "oracle_class": "finite", + "oracle_value": -2.8169642857142856, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.5764815056483974e-15, + "row": 1, + "ulp_distance": 10 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "bfe04924924924ac", + "external_class": "finite", + "external_value": -0.5089285714285743, + "index": 20, + "input_bit": "4000800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "bfe0492492492494", + "oracle_class": "finite", + "oracle_value": -0.5089285714285716, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.23557805296913e-15, + "row": 2, + "ulp_distance": 24 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "400b6db6db6db6d5", + "external_class": "finite", + "external_value": 3.4285714285714257, + "index": 21, + "input_bit": "4018000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "400b6db6db6db6db", + "oracle_class": "finite", + "oracle_value": 3.4285714285714284, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.771561172376103e-16, + "row": 2, + "ulp_distance": 6 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "401d76db6db6db6a", + "external_class": "finite", + "external_value": 7.366071428571425, + "index": 22, + "input_bit": "4023e00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "401d76db6db6db6e", + "oracle_class": "finite", + "oracle_value": 7.366071428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.823077963947349e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "40269b6db6db6db5", + "external_class": "finite", + "external_value": 11.303571428571425, + "index": 23, + "input_bit": "402bc00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "40269b6db6db6db7", + "oracle_class": "finite", + "oracle_value": 11.303571428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.1430010428566843e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "c02b84924924924b", + "external_class": "finite", + "external_value": -13.758928571428575, + "index": 24, + "input_bit": "c026600000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c02b849249249249", + "oracle_class": "finite", + "oracle_value": -13.758928571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.582115068304062e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "c023a4924924924b", + "external_class": "finite", + "external_value": -9.821428571428575, + "index": 25, + "input_bit": "c01d000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c023a49249249249", + "oracle_class": "finite", + "oracle_value": -9.821428571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.6173084729605087e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "c017892492492496", + "external_class": "finite", + "external_value": -5.883928571428575, + "index": 26, + "input_bit": "c00a800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c017892492492492", + "oracle_class": "finite", + "oracle_value": -5.883928571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.037995933621485e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "bfff249249249256", + "external_class": "finite", + "external_value": -1.9464285714285743, + "index": 27, + "input_bit": "3fe4000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "bfff24924924924a", + "oracle_class": "finite", + "oracle_value": -1.9464285714285716, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.368935545959824e-15, + "row": 2, + "ulp_distance": 12 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "3fffdb6db6db6daa", + "external_class": "finite", + "external_value": 1.9910714285714257, + "index": 28, + "input_bit": "4012400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "3fffdb6db6db6db6", + "oracle_class": "finite", + "oracle_value": 1.9910714285714284, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.3382419238531054e-15, + "row": 2, + "ulp_distance": 12 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "4017b6db6db6db6a", + "external_class": "finite", + "external_value": 5.928571428571425, + "index": 29, + "input_bit": "4021000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4017b6db6db6db6e", + "oracle_class": "finite", + "oracle_value": 5.928571428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.992529096771933e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "4000e49249249246", + "external_class": "finite", + "external_value": 2.1116071428571415, + "index": 32, + "input_bit": "4007c00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4000e49249249248", + "oracle_class": "finite", + "oracle_value": 2.1116071428571423, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.20617264297734e-16, + "row": 3, + "ulp_distance": 2 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "c0140db6db6db6dd", + "external_class": "finite", + "external_value": -5.0133928571428585, + "index": 34, + "input_bit": "c010a00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c0140db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -5.013392857142858, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.7716114515835085e-16, + "row": 3, + "ulp_distance": 1 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "4017b24924924923", + "external_class": "finite", + "external_value": 5.9241071428571415, + "index": 35, + "input_bit": "401b200000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4017b24924924924", + "oracle_class": "finite", + "oracle_value": 5.924107142857142, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4992612359670543e-16, + "row": 3, + "ulp_distance": 1 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "bff336db6db6db74", + "external_class": "finite", + "external_value": -1.2008928571428585, + "index": 37, + "input_bit": "bfd6000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "bff336db6db6db70", + "oracle_class": "finite", + "oracle_value": -1.2008928571428577, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.395983866647874e-16, + "row": 3, + "ulp_distance": 4 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "bfd16db6db6db6c0", + "external_class": "finite", + "external_value": -0.27232142857142705, + "index": 50, + "input_bit": "c006c00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "bfd16db6db6db6e0", + "oracle_class": "finite", + "oracle_value": -0.2723214285714288, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.523015279109153e-15, + "row": 5, + "ulp_distance": 32 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "c01156db6db6db6c", + "external_class": "finite", + "external_value": -4.334821428571427, + "index": 51, + "input_bit": "c01ba00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c01156db6db6db6e", + "oracle_class": "finite", + "oracle_value": -4.334821428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.0978777757534115e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "c020cb6db6db6db6", + "external_class": "finite", + "external_value": -8.397321428571427, + "index": 52, + "input_bit": "c025f00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c020cb6db6db6db7", + "oracle_class": "finite", + "oracle_value": -8.397321428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.1153850719067315e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "40308a4924924925", + "external_class": "finite", + "external_value": 16.540178571428573, + "index": 53, + "input_bit": "402bf00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "40308a4924924924", + "oracle_class": "finite", + "oracle_value": 16.54017857142857, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.147929457628373e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "4028f4924924924a", + "external_class": "finite", + "external_value": 12.477678571428573, + "index": 54, + "input_bit": "4023d00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4028f49249249249", + "oracle_class": "finite", + "oracle_value": 12.477678571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4236276637769447e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "4020d4924924924a", + "external_class": "finite", + "external_value": 8.415178571428573, + "index": 55, + "input_bit": "4017600000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4020d49249249249", + "oracle_class": "finite", + "oracle_value": 8.415178571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.1108961911175385e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "4011692492492494", + "external_class": "finite", + "external_value": 4.352678571428573, + "index": 56, + "input_bit": "3ffc800000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4011692492492492", + "oracle_class": "finite", + "oracle_value": 4.352678571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.0810659694939073e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "3fd2924924924940", + "external_class": "finite", + "external_value": 0.29017857142857295, + "index": 57, + "input_bit": "c002400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "3fd2924924924920", + "oracle_class": "finite", + "oracle_value": 0.2901785714285712, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.121598954240831e-15, + "row": 5, + "ulp_distance": 32 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "c00e2db6db6db6d8", + "external_class": "finite", + "external_value": -3.772321428571427, + "index": 58, + "input_bit": "c019600000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c00e2db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -3.772321428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.708922272492974e-16, + "row": 5, + "ulp_distance": 4 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "c01f56db6db6db6c", + "external_class": "finite", + "external_value": -7.834821428571427, + "index": 59, + "input_bit": "c024d00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c01f56db6db6db6e", + "oracle_class": "finite", + "oracle_value": -7.834821428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.267258871941061e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "400bc92492492498", + "external_class": "finite", + "external_value": 3.4732142857142883, + "index": 60, + "input_bit": "4014c00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "400bc92492492490", + "oracle_class": "finite", + "oracle_value": 3.4732142857142847, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.022889285412997e-15, + "row": 6, + "ulp_distance": 8 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "4019a4924924924c", + "external_class": "finite", + "external_value": 6.410714285714288, + "index": 61, + "input_bit": "4020400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4019a49249249248", + "oracle_class": "finite", + "oracle_value": 6.410714285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.541837493393537e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "4022b24924924926", + "external_class": "finite", + "external_value": 9.348214285714288, + "index": 62, + "input_bit": "4026200000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4022b24924924924", + "oracle_class": "finite", + "oracle_value": 9.348214285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8004195990989113e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "4028924924924926", + "external_class": "finite", + "external_value": 12.285714285714288, + "index": 63, + "input_bit": "402c000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4028924924924924", + "oracle_class": "finite", + "oracle_value": 12.285714285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.8917436920469186e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "c02b8db6db6db6da", + "external_class": "finite", + "external_value": -13.776785714285712, + "index": 64, + "input_bit": "c028200000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c02b8db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -13.776785714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.578768192000364e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "c025adb6db6db6da", + "external_class": "finite", + "external_value": -10.839285714285712, + "index": 65, + "input_bit": "c022400000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c025adb6db6db6dc", + "oracle_class": "finite", + "oracle_value": -10.839285714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.2776271171800345e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "c01f9b6db6db6db4", + "external_class": "finite", + "external_value": -7.901785714285712, + "index": 66, + "input_bit": "c018c00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c01f9b6db6db6db8", + "oracle_class": "finite", + "oracle_value": -7.901785714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.496089627408545e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "c013db6db6db6db4", + "external_class": "finite", + "external_value": -4.964285714285712, + "index": 67, + "input_bit": "c00a000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c013db6db6db6db8", + "oracle_class": "finite", + "oracle_value": -4.964285714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.15654554002979e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "c00036db6db6db68", + "external_class": "finite", + "external_value": -2.0267857142857117, + "index": 68, + "input_bit": "bfd4000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c00036db6db6db70", + "oracle_class": "finite", + "oracle_value": -2.0267857142857153, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.7528807578222758e-15, + "row": 6, + "ulp_distance": 8 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "3fed249249249260", + "external_class": "finite", + "external_value": 0.9107142857142883, + "index": 69, + "input_bit": "4005000000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "3fed249249249240", + "oracle_class": "finite", + "oracle_value": 0.9107142857142847, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.90101894142799e-15, + "row": 6, + "ulp_distance": 32 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 0, + "external_bits": "402a700000000002", + "external_class": "finite", + "external_value": 13.218750000000004, + "index": 70, + "input_bit": "402a700000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "402a700000000000", + "oracle_class": "finite", + "oracle_value": 13.21875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.6876320974377306e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 1, + "external_bits": "c0175ffffffffffc", + "external_class": "finite", + "external_value": -5.8437499999999964, + "index": 71, + "input_bit": "c017600000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c017600000000000", + "oracle_class": "finite", + "oracle_value": -5.84375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.079510038589096e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 2, + "external_bits": "4010600000000004", + "external_class": "finite", + "external_value": 4.0937500000000036, + "index": 72, + "input_bit": "4010600000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4010600000000000", + "oracle_class": "finite", + "oracle_value": 4.09375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.67838455890198e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 3, + "external_bits": "402c100000000002", + "external_class": "finite", + "external_value": 14.031250000000004, + "index": 73, + "input_bit": "402c100000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "402c100000000000", + "oracle_class": "finite", + "oracle_value": 14.03125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.532000840125078e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 4, + "external_bits": "c0141ffffffffffc", + "external_class": "finite", + "external_value": -5.0312499999999964, + "index": 74, + "input_bit": "c014200000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c014200000000000", + "oracle_class": "finite", + "oracle_value": -5.03125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.061294268423361e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 5, + "external_bits": "4013a00000000004", + "external_class": "finite", + "external_value": 4.9062500000000036, + "index": 75, + "input_bit": "4013a00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4013a00000000000", + "oracle_class": "finite", + "oracle_value": 4.90625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.241199854879996e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 6, + "external_bits": "c02c4ffffffffffe", + "external_class": "finite", + "external_value": -14.156249999999996, + "index": 76, + "input_bit": "c02c500000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c02c500000000000", + "oracle_class": "finite", + "oracle_value": -14.15625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.5096432168127164e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 7, + "external_bits": "c010dffffffffffc", + "external_class": "finite", + "external_value": -4.2187499999999964, + "index": 77, + "input_bit": "c010e00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c010e00000000000", + "oracle_class": "finite", + "oracle_value": -4.21875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.421247238638232e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 8, + "external_bits": "4016e00000000004", + "external_class": "finite", + "external_value": 5.7187500000000036, + "index": 78, + "input_bit": "4016e00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "4016e00000000000", + "oracle_class": "finite", + "oracle_value": 5.71875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.21239550391344e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "median_of_differences__irregular__11", + "column": 9, + "external_bits": "c02aaffffffffffe", + "external_class": "finite", + "external_value": -13.343749999999996, + "index": 79, + "input_bit": "c02ab00000000000", + "mask_bit": "3ff0000000000000", + "method": 2, + "method_name": "Median of differences", + "oracle_bits": "c02ab00000000000", + "oracle_class": "finite", + "oracle_value": -13.34375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.662455215962905e-16, + "row": 7, + "ulp_distance": 2 + } + ], + "finite_nonzero_count": 64, + "maximum_absolute_difference": 5.329070518200751e-15, + "maximum_ulp_distance": 144, + "signed_zero_only_count": 0 + }, + { + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "classification": "compiler/evaluation-order sensitivity", + "element_count": 64, + "elements": [ + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "c02c000000000003", + "external_class": "finite", + "external_value": -14.000000000000005, + "index": 0, + "input_bit": "c02c000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c02c000000000000", + "oracle_class": "finite", + "oracle_value": -14.0, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8064789415719636e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "4013bffffffffffa", + "external_class": "finite", + "external_value": 4.937499999999995, + "index": 1, + "input_bit": "4013c00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4013c00000000000", + "oracle_class": "finite", + "oracle_value": 4.9375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0793054214077483e-15, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "c014800000000006", + "external_class": "finite", + "external_value": -5.125000000000005, + "index": 2, + "input_bit": "c014800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c014800000000000", + "oracle_class": "finite", + "oracle_value": -5.125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0398186376977065e-15, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "402b9ffffffffffd", + "external_class": "finite", + "external_value": 13.812499999999995, + "index": 3, + "input_bit": "402ba00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "402ba00000000000", + "oracle_class": "finite", + "oracle_value": 13.8125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8581506014123103e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "400dfffffffffff4", + "external_class": "finite", + "external_value": 3.7499999999999947, + "index": 4, + "input_bit": "400e000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "400e000000000000", + "oracle_class": "finite", + "oracle_value": 3.75, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4210854715202024e-15, + "row": 0, + "ulp_distance": 12 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "c019400000000006", + "external_class": "finite", + "external_value": -6.312500000000005, + "index": 5, + "input_bit": "c019400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c019400000000000", + "oracle_class": "finite", + "oracle_value": -6.3125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.442091910020985e-16, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "40293ffffffffffd", + "external_class": "finite", + "external_value": 12.624999999999995, + "index": 6, + "input_bit": "4029400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4029400000000000", + "oracle_class": "finite", + "oracle_value": 12.625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.221045955010498e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "40047ffffffffff4", + "external_class": "finite", + "external_value": 2.5624999999999947, + "index": 7, + "input_bit": "4004800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4004800000000000", + "oracle_class": "finite", + "oracle_value": 2.5625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.0796372753954194e-15, + "row": 0, + "ulp_distance": 12 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "c01e000000000006", + "external_class": "finite", + "external_value": -7.500000000000005, + "index": 8, + "input_bit": "c01e000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c01e000000000000", + "oracle_class": "finite", + "oracle_value": -7.5, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.105427357600996e-16, + "row": 0, + "ulp_distance": 6 + }, + { + "absolute_difference": 5.329070518200751e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "4026dffffffffffd", + "external_class": "finite", + "external_value": 11.437499999999995, + "index": 9, + "input_bit": "4026e00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4026e00000000000", + "oracle_class": "finite", + "oracle_value": 11.4375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.659296627935085e-16, + "row": 0, + "ulp_distance": 3 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "c01104924924924e", + "external_class": "finite", + "external_value": -4.25446428571429, + "index": 10, + "input_bit": "c017e00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c011049249249249", + "oracle_class": "finite", + "oracle_value": -4.254464285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.0438193389969983e-15, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "c01d44924924924e", + "external_class": "finite", + "external_value": -7.31696428571429, + "index": 11, + "input_bit": "c022100000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c01d449249249249", + "oracle_class": "finite", + "oracle_value": -7.316964285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.069309518390114e-16, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "c024c24924924927", + "external_class": "finite", + "external_value": -10.37946428571429, + "index": 12, + "input_bit": "c028300000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c024c24924924925", + "oracle_class": "finite", + "oracle_value": -10.379464285714286, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.4228295228013413e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "402f1db6db6db6d9", + "external_class": "finite", + "external_value": 15.55803571428571, + "index": 13, + "input_bit": "402bb00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "402f1db6db6db6db", + "oracle_class": "finite", + "oracle_value": 15.558035714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.2835232827871234e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "4028fdb6db6db6d9", + "external_class": "finite", + "external_value": 12.49553571428571, + "index": 14, + "input_bit": "4025900000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4028fdb6db6db6db", + "oracle_class": "finite", + "oracle_value": 12.495535714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.84318636674281e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "4022ddb6db6db6d9", + "external_class": "finite", + "external_value": 9.43303571428571, + "index": 15, + "input_bit": "401ee00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4022ddb6db6db6db", + "oracle_class": "finite", + "oracle_value": 9.433035714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.766246398728408e-16, + "row": 1, + "ulp_distance": 2 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "40197b6db6db6db2", + "external_class": "finite", + "external_value": 6.37053571428571, + "index": 16, + "input_bit": "4012a00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "40197b6db6db6db7", + "oracle_class": "finite", + "oracle_value": 6.370535714285714, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.970986896034625e-16, + "row": 1, + "ulp_distance": 5 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "400a76db6db6db64", + "external_class": "finite", + "external_value": 3.30803571428571, + "index": 17, + "input_bit": "3ff9800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "400a76db6db6db6e", + "oracle_class": "finite", + "oracle_value": 3.3080357142857144, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.342455911017735e-15, + "row": 1, + "ulp_distance": 10 + }, + { + "absolute_difference": 3.9968028886505635e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "3fcf6db6db6db648", + "external_class": "finite", + "external_value": 0.2455357142857102, + "index": 18, + "input_bit": "bff7800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "3fcf6db6db6db6d8", + "oracle_class": "finite", + "oracle_value": 0.2455357142857142, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.6277888128322567e-14, + "row": 1, + "ulp_distance": 144 + }, + { + "absolute_difference": 4.440892098500626e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "c00689249249249c", + "external_class": "finite", + "external_value": -2.81696428571429, + "index": 19, + "input_bit": "c012200000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c006892492492492", + "oracle_class": "finite", + "oracle_value": -2.8169642857142856, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.5764815056483974e-15, + "row": 1, + "ulp_distance": 10 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "bfe04924924924ac", + "external_class": "finite", + "external_value": -0.5089285714285743, + "index": 20, + "input_bit": "4000800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "bfe0492492492494", + "oracle_class": "finite", + "oracle_value": -0.5089285714285716, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.23557805296913e-15, + "row": 2, + "ulp_distance": 24 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "400b6db6db6db6d5", + "external_class": "finite", + "external_value": 3.4285714285714257, + "index": 21, + "input_bit": "4018000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "400b6db6db6db6db", + "oracle_class": "finite", + "oracle_value": 3.4285714285714284, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.771561172376103e-16, + "row": 2, + "ulp_distance": 6 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "401d76db6db6db6a", + "external_class": "finite", + "external_value": 7.366071428571425, + "index": 22, + "input_bit": "4023e00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "401d76db6db6db6e", + "oracle_class": "finite", + "oracle_value": 7.366071428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.823077963947349e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "40269b6db6db6db5", + "external_class": "finite", + "external_value": 11.303571428571425, + "index": 23, + "input_bit": "402bc00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "40269b6db6db6db7", + "oracle_class": "finite", + "oracle_value": 11.303571428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.1430010428566843e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "c02b84924924924b", + "external_class": "finite", + "external_value": -13.758928571428575, + "index": 24, + "input_bit": "c026600000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c02b849249249249", + "oracle_class": "finite", + "oracle_value": -13.758928571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.582115068304062e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "c023a4924924924b", + "external_class": "finite", + "external_value": -9.821428571428575, + "index": 25, + "input_bit": "c01d000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c023a49249249249", + "oracle_class": "finite", + "oracle_value": -9.821428571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.6173084729605087e-16, + "row": 2, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "c017892492492496", + "external_class": "finite", + "external_value": -5.883928571428575, + "index": 26, + "input_bit": "c00a800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c017892492492492", + "oracle_class": "finite", + "oracle_value": -5.883928571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.037995933621485e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "bfff249249249256", + "external_class": "finite", + "external_value": -1.9464285714285743, + "index": 27, + "input_bit": "3fe4000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "bfff24924924924a", + "oracle_class": "finite", + "oracle_value": -1.9464285714285716, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.368935545959824e-15, + "row": 2, + "ulp_distance": 12 + }, + { + "absolute_difference": 2.6645352591003757e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "3fffdb6db6db6daa", + "external_class": "finite", + "external_value": 1.9910714285714257, + "index": 28, + "input_bit": "4012400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "3fffdb6db6db6db6", + "oracle_class": "finite", + "oracle_value": 1.9910714285714284, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.3382419238531054e-15, + "row": 2, + "ulp_distance": 12 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "4017b6db6db6db6a", + "external_class": "finite", + "external_value": 5.928571428571425, + "index": 29, + "input_bit": "4021000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4017b6db6db6db6e", + "oracle_class": "finite", + "oracle_value": 5.928571428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.992529096771933e-16, + "row": 2, + "ulp_distance": 4 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "4000e49249249246", + "external_class": "finite", + "external_value": 2.1116071428571415, + "index": 32, + "input_bit": "4007c00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4000e49249249248", + "oracle_class": "finite", + "oracle_value": 2.1116071428571423, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.20617264297734e-16, + "row": 3, + "ulp_distance": 2 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "c0140db6db6db6dd", + "external_class": "finite", + "external_value": -5.0133928571428585, + "index": 34, + "input_bit": "c010a00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c0140db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -5.013392857142858, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.7716114515835085e-16, + "row": 3, + "ulp_distance": 1 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "4017b24924924923", + "external_class": "finite", + "external_value": 5.9241071428571415, + "index": 35, + "input_bit": "401b200000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4017b24924924924", + "oracle_class": "finite", + "oracle_value": 5.924107142857142, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4992612359670543e-16, + "row": 3, + "ulp_distance": 1 + }, + { + "absolute_difference": 8.881784197001252e-16, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "bff336db6db6db74", + "external_class": "finite", + "external_value": -1.2008928571428585, + "index": 37, + "input_bit": "bfd6000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "bff336db6db6db70", + "oracle_class": "finite", + "oracle_value": -1.2008928571428577, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.395983866647874e-16, + "row": 3, + "ulp_distance": 4 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "bfd16db6db6db6c0", + "external_class": "finite", + "external_value": -0.27232142857142705, + "index": 50, + "input_bit": "c006c00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "bfd16db6db6db6e0", + "oracle_class": "finite", + "oracle_value": -0.2723214285714288, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.523015279109153e-15, + "row": 5, + "ulp_distance": 32 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "c01156db6db6db6c", + "external_class": "finite", + "external_value": -4.334821428571427, + "index": 51, + "input_bit": "c01ba00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c01156db6db6db6e", + "oracle_class": "finite", + "oracle_value": -4.334821428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.0978777757534115e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "c020cb6db6db6db6", + "external_class": "finite", + "external_value": -8.397321428571427, + "index": 52, + "input_bit": "c025f00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c020cb6db6db6db7", + "oracle_class": "finite", + "oracle_value": -8.397321428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.1153850719067315e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "40308a4924924925", + "external_class": "finite", + "external_value": 16.540178571428573, + "index": 53, + "input_bit": "402bf00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "40308a4924924924", + "oracle_class": "finite", + "oracle_value": 16.54017857142857, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.147929457628373e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "4028f4924924924a", + "external_class": "finite", + "external_value": 12.477678571428573, + "index": 54, + "input_bit": "4023d00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4028f49249249249", + "oracle_class": "finite", + "oracle_value": 12.477678571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.4236276637769447e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "4020d4924924924a", + "external_class": "finite", + "external_value": 8.415178571428573, + "index": 55, + "input_bit": "4017600000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4020d49249249249", + "oracle_class": "finite", + "oracle_value": 8.415178571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.1108961911175385e-16, + "row": 5, + "ulp_distance": 1 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "4011692492492494", + "external_class": "finite", + "external_value": 4.352678571428573, + "index": 56, + "input_bit": "3ffc800000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4011692492492492", + "oracle_class": "finite", + "oracle_value": 4.352678571428571, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.0810659694939073e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "3fd2924924924940", + "external_class": "finite", + "external_value": 0.29017857142857295, + "index": 57, + "input_bit": "c002400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "3fd2924924924920", + "oracle_class": "finite", + "oracle_value": 0.2901785714285712, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.121598954240831e-15, + "row": 5, + "ulp_distance": 32 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "c00e2db6db6db6d8", + "external_class": "finite", + "external_value": -3.772321428571427, + "index": 58, + "input_bit": "c019600000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c00e2db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -3.772321428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.708922272492974e-16, + "row": 5, + "ulp_distance": 4 + }, + { + "absolute_difference": 1.7763568394002505e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "c01f56db6db6db6c", + "external_class": "finite", + "external_value": -7.834821428571427, + "index": 59, + "input_bit": "c024d00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c01f56db6db6db6e", + "oracle_class": "finite", + "oracle_value": -7.834821428571429, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.267258871941061e-16, + "row": 5, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "400bc92492492498", + "external_class": "finite", + "external_value": 3.4732142857142883, + "index": 60, + "input_bit": "4014c00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "400bc92492492490", + "oracle_class": "finite", + "oracle_value": 3.4732142857142847, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.022889285412997e-15, + "row": 6, + "ulp_distance": 8 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "4019a4924924924c", + "external_class": "finite", + "external_value": 6.410714285714288, + "index": 61, + "input_bit": "4020400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4019a49249249248", + "oracle_class": "finite", + "oracle_value": 6.410714285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 5.541837493393537e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "4022b24924924926", + "external_class": "finite", + "external_value": 9.348214285714288, + "index": 62, + "input_bit": "4026200000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4022b24924924924", + "oracle_class": "finite", + "oracle_value": 9.348214285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.8004195990989113e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "4028924924924926", + "external_class": "finite", + "external_value": 12.285714285714288, + "index": 63, + "input_bit": "402c000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4028924924924924", + "oracle_class": "finite", + "oracle_value": 12.285714285714285, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.8917436920469186e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "c02b8db6db6db6da", + "external_class": "finite", + "external_value": -13.776785714285712, + "index": 64, + "input_bit": "c028200000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c02b8db6db6db6dc", + "oracle_class": "finite", + "oracle_value": -13.776785714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.578768192000364e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "c025adb6db6db6da", + "external_class": "finite", + "external_value": -10.839285714285712, + "index": 65, + "input_bit": "c022400000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c025adb6db6db6dc", + "oracle_class": "finite", + "oracle_value": -10.839285714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.2776271171800345e-16, + "row": 6, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "c01f9b6db6db6db4", + "external_class": "finite", + "external_value": -7.901785714285712, + "index": 66, + "input_bit": "c018c00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c01f9b6db6db6db8", + "oracle_class": "finite", + "oracle_value": -7.901785714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 4.496089627408545e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "c013db6db6db6db4", + "external_class": "finite", + "external_value": -4.964285714285712, + "index": 67, + "input_bit": "c00a000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c013db6db6db6db8", + "oracle_class": "finite", + "oracle_value": -4.964285714285715, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.15654554002979e-16, + "row": 6, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "c00036db6db6db68", + "external_class": "finite", + "external_value": -2.0267857142857117, + "index": 68, + "input_bit": "bfd4000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c00036db6db6db70", + "oracle_class": "finite", + "oracle_value": -2.0267857142857153, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 1.7528807578222758e-15, + "row": 6, + "ulp_distance": 8 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "3fed249249249260", + "external_class": "finite", + "external_value": 0.9107142857142883, + "index": 69, + "input_bit": "4005000000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "3fed249249249240", + "oracle_class": "finite", + "oracle_value": 0.9107142857142847, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 3.90101894142799e-15, + "row": 6, + "ulp_distance": 32 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 0, + "external_bits": "402a700000000002", + "external_class": "finite", + "external_value": 13.218750000000004, + "index": 70, + "input_bit": "402a700000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "402a700000000000", + "oracle_class": "finite", + "oracle_value": 13.21875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.6876320974377306e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 1, + "external_bits": "c0175ffffffffffc", + "external_class": "finite", + "external_value": -5.8437499999999964, + "index": 71, + "input_bit": "c017600000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c017600000000000", + "oracle_class": "finite", + "oracle_value": -5.84375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.079510038589096e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 2, + "external_bits": "4010600000000004", + "external_class": "finite", + "external_value": 4.0937500000000036, + "index": 72, + "input_bit": "4010600000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4010600000000000", + "oracle_class": "finite", + "oracle_value": 4.09375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.67838455890198e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 3, + "external_bits": "402c100000000002", + "external_class": "finite", + "external_value": 14.031250000000004, + "index": 73, + "input_bit": "402c100000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "402c100000000000", + "oracle_class": "finite", + "oracle_value": 14.03125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.532000840125078e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 4, + "external_bits": "c0141ffffffffffc", + "external_class": "finite", + "external_value": -5.0312499999999964, + "index": 74, + "input_bit": "c014200000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c014200000000000", + "oracle_class": "finite", + "oracle_value": -5.03125, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.061294268423361e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 5, + "external_bits": "4013a00000000004", + "external_class": "finite", + "external_value": 4.9062500000000036, + "index": 75, + "input_bit": "4013a00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4013a00000000000", + "oracle_class": "finite", + "oracle_value": 4.90625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 7.241199854879996e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 6, + "external_bits": "c02c4ffffffffffe", + "external_class": "finite", + "external_value": -14.156249999999996, + "index": 76, + "input_bit": "c02c500000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c02c500000000000", + "oracle_class": "finite", + "oracle_value": -14.15625, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.5096432168127164e-16, + "row": 7, + "ulp_distance": 2 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 7, + "external_bits": "c010dffffffffffc", + "external_class": "finite", + "external_value": -4.2187499999999964, + "index": 77, + "input_bit": "c010e00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c010e00000000000", + "oracle_class": "finite", + "oracle_value": -4.21875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 8.421247238638232e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 8, + "external_bits": "4016e00000000004", + "external_class": "finite", + "external_value": 5.7187500000000036, + "index": 78, + "input_bit": "4016e00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "4016e00000000000", + "oracle_class": "finite", + "oracle_value": 5.71875, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 6.21239550391344e-16, + "row": 7, + "ulp_distance": 4 + }, + { + "absolute_difference": 3.552713678800501e-15, + "array": "corrected", + "case_identifier": "trimmed_mean_of_differences__irregular__11", + "column": 9, + "external_bits": "c02aaffffffffffe", + "external_class": "finite", + "external_value": -13.343749999999996, + "index": 79, + "input_bit": "c02ab00000000000", + "mask_bit": "3ff0000000000000", + "method": 6, + "method_name": "Trimmed mean of differences", + "oracle_bits": "c02ab00000000000", + "oracle_class": "finite", + "oracle_value": -13.34375, + "probable_cause": "compiler/evaluation-order sensitivity", + "relative_difference": 2.662455215962905e-16, + "row": 7, + "ulp_distance": 2 + } + ], + "finite_nonzero_count": 64, + "maximum_absolute_difference": 5.329070518200751e-15, + "maximum_ulp_distance": 144, + "signed_zero_only_count": 0 + } + ], + "background": { + "arrays_bitwise_exact": 8, + "elements_bitwise_exact": 504, + "first_mismatch": null, + "infinity_mismatches": 0, + "logical_arrays_compared": 8, + "max_absolute_difference": 0.0, + "max_relative_difference": 0.0, + "max_ulp_distance": 0, + "nan_mismatches": 0, + "nonzero_mismatches": 0, + "signed_zero_only_mismatches": 0, + "total_elements": 504 + }, + "corrected": { + "arrays_bitwise_exact": 61, + "elements_bitwise_exact": 3757, + "first_mismatch": { + "absolute_difference": 0.0, + "array": "corrected", + "case_identifier": "median__plateaus_signed_zero__10", + "column": 4, + "external_bits": "0000000000000000", + "external_class": "finite", + "external_value": 0.0, + "index": 4, + "input_bit": "8000000000000000", + "mask_bit": "0000000000000000", + "method": 1, + "method_name": "Median", + "oracle_bits": "8000000000000000", + "oracle_class": "finite", + "oracle_value": -0.0, + "probable_cause": "signed-zero behavior", + "relative_difference": null, + "row": 0, + "ulp_distance": 9223372036854775808 + }, + "infinity_mismatches": 0, + "logical_arrays_compared": 64, + "max_absolute_difference": 5.329070518200751e-15, + "max_relative_difference": 1.6277888128322567e-14, + "max_ulp_distance": 9223372036854775808, + "nan_mismatches": 0, + "nonzero_mismatches": 128, + "signed_zero_only_mismatches": 3, + "total_elements": 3888 + }, + "mutation_noop_agreement": { + "arrays": "64/64" + } + }, + "evidence": { + "evidence_vocabulary": [ + "SOURCE_CONFIRMED", + "EXTERNAL_PROBE_CONFIRMED", + "ORACLE_CONFIRMED", + "SOFTWARE_VERIFIED" + ], + "installed_build_diagnosis": [ + "INSTALLED_BUILD_ROOT_CAUSE_CONFIRMED", + "V3_NOT_JUSTIFIED" + ], + "non_claims": [ + "No public API is frozen by this private fixture.", + "No universal, non-finite, other-version, performance, or production-adapter equivalence is claimed.", + "The installed fast-math reassociation is not normalized or emulated by portable production arithmetic." + ], + "source_hashes": { + "aggregate_comparison.json": "8c017ccecaa64a2d09b9462928ec8c87c7638d509583ad569b097ae869d7c70b", + "canonical_reference.json": "e2fa6d094acc5ec04f87901aa345244f9d22e70577d25f963a8cdb74363e457e", + "installed_build_matrix.json": "0f5889fe40aca3fbdbcd5cefe217a6a4cc492e77087a7f3d28ea8bfd3ef1752b", + "installed_build_root_cause_report.md": "eeb835e456c395337fe6fb8ef71e65f4f9ceaa8b95edc285778b52f2c5a9bbd4", + "mask_root_cause_report.md": "c0e0c34a205c07de06ab4397a01e235935de2fe36e7e431cf3613285726ba521", + "mismatch_ledger_v2.json": "49a8a763b92e24223791a9b6c3f0177910b51d86182a9661601b693bbe12b04c", + "oracle_candidate_outputs_v2.json": "7da7283019d698089d1a9cca4cec712860529fce42c2cb0752c2b136ecd1cb30", + "oracle_v2.py": "06585bf85b58392640e5c185694a2b4e47742dae8969b65a689f1bb5c717b198", + "sanitized_input_only_cases.json": "70aa072427df43bb9275e0c946322afd656ab7f7919afee403723644b04ca50e" + } + }, + "fixture": { + "array_hashes": { + "input__median__alternating_offsets__02": "357a6cfeffcac0560df03db6751fb1e35aef3070f6a027d0f71d569f9f3907bf", + "input__median__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "input__median__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "input__median__irregular__11": "90b41a79dd042fc9968e4542bcb8744ebc5ea3bcbc0215b3ca79337b059dc7e0", + "input__median__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "input__median__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "input__median__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "input__median__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "input__median__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "input__median__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "input__median__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "input__median__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "input__median__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "input__median__step__06": "d2af277d46bc5e074665ea196e664d21ad1cc891a96b4f84f9bc715dc40f0057", + "input__median__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "input__median__wide__14": "d6b0650aa9ab258fd54d7df089d829cf074e266af1b9c22b0638a5ea14311c8c", + "input__median_of_differences__alternating_offsets__02": "357a6cfeffcac0560df03db6751fb1e35aef3070f6a027d0f71d569f9f3907bf", + "input__median_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "input__median_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "input__median_of_differences__irregular__11": "90b41a79dd042fc9968e4542bcb8744ebc5ea3bcbc0215b3ca79337b059dc7e0", + "input__median_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "input__median_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "input__median_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "input__median_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "input__median_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "input__median_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "input__median_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "input__median_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "input__median_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "input__median_of_differences__step__06": "d2af277d46bc5e074665ea196e664d21ad1cc891a96b4f84f9bc715dc40f0057", + "input__median_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "input__median_of_differences__wide__14": "d6b0650aa9ab258fd54d7df089d829cf074e266af1b9c22b0638a5ea14311c8c", + "input__trimmed_mean__alternating_offsets__02": "357a6cfeffcac0560df03db6751fb1e35aef3070f6a027d0f71d569f9f3907bf", + "input__trimmed_mean__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "input__trimmed_mean__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "input__trimmed_mean__irregular__11": "90b41a79dd042fc9968e4542bcb8744ebc5ea3bcbc0215b3ca79337b059dc7e0", + "input__trimmed_mean__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "input__trimmed_mean__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "input__trimmed_mean__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "input__trimmed_mean__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "input__trimmed_mean__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "input__trimmed_mean__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "input__trimmed_mean__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "input__trimmed_mean__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "input__trimmed_mean__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "input__trimmed_mean__step__06": "d2af277d46bc5e074665ea196e664d21ad1cc891a96b4f84f9bc715dc40f0057", + "input__trimmed_mean__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "input__trimmed_mean__wide__14": "d6b0650aa9ab258fd54d7df089d829cf074e266af1b9c22b0638a5ea14311c8c", + "input__trimmed_mean_of_differences__alternating_offsets__02": "357a6cfeffcac0560df03db6751fb1e35aef3070f6a027d0f71d569f9f3907bf", + "input__trimmed_mean_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "input__trimmed_mean_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "input__trimmed_mean_of_differences__irregular__11": "90b41a79dd042fc9968e4542bcb8744ebc5ea3bcbc0215b3ca79337b059dc7e0", + "input__trimmed_mean_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "input__trimmed_mean_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "input__trimmed_mean_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "input__trimmed_mean_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "input__trimmed_mean_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "input__trimmed_mean_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "input__trimmed_mean_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "input__trimmed_mean_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "input__trimmed_mean_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "input__trimmed_mean_of_differences__step__06": "d2af277d46bc5e074665ea196e664d21ad1cc891a96b4f84f9bc715dc40f0057", + "input__trimmed_mean_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "input__trimmed_mean_of_differences__wide__14": "d6b0650aa9ab258fd54d7df089d829cf074e266af1b9c22b0638a5ea14311c8c", + "installed_background__median__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__median__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__median_of_differences__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__median_of_differences__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__trimmed_mean__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__trimmed_mean__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__trimmed_mean_of_differences__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_background__trimmed_mean_of_differences__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "installed_corrected__median__alternating_offsets__02": "61234be3d9301f80ddeb6c9a9a13123934657cba5ecb4202175a54d6dae60efe", + "installed_corrected__median__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "installed_corrected__median__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "installed_corrected__median__irregular__11": "9e7828288f37ca568f85366e21e29c8c814806eda24fe97afcf1297ca2cb172a", + "installed_corrected__median__linear__03": "06563cc7c8c90d58ecf2309509a1a315bc1b37dd854b181e927316f26181e27f", + "installed_corrected__median__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "installed_corrected__median__multimodal__09": "c2c8140cce2425b50592e5b02e802860018e1e82d84b5d44d1a2ffb5f2e88fe5", + "installed_corrected__median__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "installed_corrected__median__nonlinear__13": "4cc7882bbb8b3e92f75bacee75ed0d5dd9f4a37cae1be1bf6d3228166a761269", + "installed_corrected__median__plane__05": "7addfac9c462a047d7ab56e2199333741cf2a39679e7389841032cdb7fcc9ca0", + "installed_corrected__median__plateaus_signed_zero__10": "68df12fea4f5f3fd313bc4969b6c5ae0e9c69ad1df0fd1fd405a0eccdc97ba4f", + "installed_corrected__median__row_offsets__01": "7ab1578e2b5cd0a33868790e38a9c241949674e0fbb42000239525b2f26622cb", + "installed_corrected__median__scars__08": "aeec794980c492601fedb134023e9a0b7109b58cfb853ead18a7eed437700298", + "installed_corrected__median__step__06": "c70637dfda8c8a28434080e0bb0c95d0fd5355923aab02fd09c8e3aa83c5cc08", + "installed_corrected__median__tall__15": "979e082e8d0461e258217a631f4bb2d60a32701abd35adeadc833d1f7448eaf4", + "installed_corrected__median__wide__14": "5c02a5bdf8e3cfd840d88a81b9892413092cd3b6dfabbbccd35a0111fb6cc228", + "installed_corrected__median_of_differences__alternating_offsets__02": "94d2adc27887a0cb929a111683d05d048e1e9b5a2baedd542f6218fddfa951b4", + "installed_corrected__median_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "installed_corrected__median_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "installed_corrected__median_of_differences__irregular__11": "928ff5351111e14a0db920c86c2076ee3ef63f0032a9d84996d7b3ae282ca960", + "installed_corrected__median_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "installed_corrected__median_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "installed_corrected__median_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "installed_corrected__median_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "installed_corrected__median_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "installed_corrected__median_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "installed_corrected__median_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "installed_corrected__median_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "installed_corrected__median_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "installed_corrected__median_of_differences__step__06": "85fabb79e3c47302e3406e2d06761008d6559e8c4ddc2a7125efa435d03478e4", + "installed_corrected__median_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "installed_corrected__median_of_differences__wide__14": "b8e3f5a33d7da639d3288a249ea0019b174440c69e67f8218acfe17c98be3b05", + "installed_corrected__trimmed_mean__alternating_offsets__02": "61234be3d9301f80ddeb6c9a9a13123934657cba5ecb4202175a54d6dae60efe", + "installed_corrected__trimmed_mean__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "installed_corrected__trimmed_mean__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "installed_corrected__trimmed_mean__irregular__11": "9e7828288f37ca568f85366e21e29c8c814806eda24fe97afcf1297ca2cb172a", + "installed_corrected__trimmed_mean__linear__03": "08643ee6e1365003e650b4dca6e524edbbcae65fccaa83875d705fd59807dc20", + "installed_corrected__trimmed_mean__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "installed_corrected__trimmed_mean__multimodal__09": "bd2bd5f4e6e349d9169b2bab1091558a0fc50f178d37842d13ed05a56a80d3f4", + "installed_corrected__trimmed_mean__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "installed_corrected__trimmed_mean__nonlinear__13": "ad40ebce383b2c9bc80fb7fb841633016f5ffa7117a130769c620f1b9feba558", + "installed_corrected__trimmed_mean__plane__05": "7addfac9c462a047d7ab56e2199333741cf2a39679e7389841032cdb7fcc9ca0", + "installed_corrected__trimmed_mean__plateaus_signed_zero__10": "784dc81f68fb2e2325ada327ec49c93ffcdaaaf8498d5aac822a8a765b66ba7e", + "installed_corrected__trimmed_mean__row_offsets__01": "02778f1a7461b3b70ab6f82a9cd19893b8d49e4b2f7ff42e5b88a14599f7945c", + "installed_corrected__trimmed_mean__scars__08": "aeec794980c492601fedb134023e9a0b7109b58cfb853ead18a7eed437700298", + "installed_corrected__trimmed_mean__step__06": "334ca055c7b71e4ef33d44b98d2023362759294517bcbf44770311082e1f4e6c", + "installed_corrected__trimmed_mean__tall__15": "1d789d30ba5a12fd8d3c2b59258d15e2ccc38190a3d5f40f8a3b429e91e3501f", + "installed_corrected__trimmed_mean__wide__14": "5c02a5bdf8e3cfd840d88a81b9892413092cd3b6dfabbbccd35a0111fb6cc228", + "installed_corrected__trimmed_mean_of_differences__alternating_offsets__02": "94d2adc27887a0cb929a111683d05d048e1e9b5a2baedd542f6218fddfa951b4", + "installed_corrected__trimmed_mean_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "installed_corrected__trimmed_mean_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "installed_corrected__trimmed_mean_of_differences__irregular__11": "928ff5351111e14a0db920c86c2076ee3ef63f0032a9d84996d7b3ae282ca960", + "installed_corrected__trimmed_mean_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "installed_corrected__trimmed_mean_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "installed_corrected__trimmed_mean_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "installed_corrected__trimmed_mean_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "installed_corrected__trimmed_mean_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "installed_corrected__trimmed_mean_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "installed_corrected__trimmed_mean_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "installed_corrected__trimmed_mean_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "installed_corrected__trimmed_mean_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "installed_corrected__trimmed_mean_of_differences__step__06": "85fabb79e3c47302e3406e2d06761008d6559e8c4ddc2a7125efa435d03478e4", + "installed_corrected__trimmed_mean_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "installed_corrected__trimmed_mean_of_differences__wide__14": "b8e3f5a33d7da639d3288a249ea0019b174440c69e67f8218acfe17c98be3b05", + "mask__median__alternating_offsets__02": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median__impulses__07": "a1e6f7825ecf367c48a842a75009c3b80f539f15fd6065f1253e57e8f99ee50f", + "mask__median__irregular__11": "f451b46961e212eb73e212ae2a17b2850d05cc6f329ad865ac6a50eff607b422", + "mask__median__linear__03": "ab4302588815a75df4757d07e679699c83c6d798879935bebdd64ac49964176e", + "mask__median__linear__12": "ec8d5aa71ca4cca07a3343d31b0836cb9b18fedc51ea47e42a2c30b1d723025e", + "mask__median__multimodal__09": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median__nonlinear__04": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "mask__median__nonlinear__13": "1e0feb0bc85f55e91e89430145467acf4779312af547004fab993ea165f87c8a", + "mask__median__plane__05": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__median__plateaus_signed_zero__10": "3fbc16fea18639d063c1f465df6424b5d75869c15978627ea98c018113590a83", + "mask__median__row_offsets__01": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median__step__06": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__median__tall__15": "8b871ddffd98c092f975df14ed97b80e7f6fb9e6eae968a2483bfd6097f352d0", + "mask__median_of_differences__alternating_offsets__02": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median_of_differences__impulses__07": "a1e6f7825ecf367c48a842a75009c3b80f539f15fd6065f1253e57e8f99ee50f", + "mask__median_of_differences__irregular__11": "f451b46961e212eb73e212ae2a17b2850d05cc6f329ad865ac6a50eff607b422", + "mask__median_of_differences__linear__03": "ab4302588815a75df4757d07e679699c83c6d798879935bebdd64ac49964176e", + "mask__median_of_differences__linear__12": "ec8d5aa71ca4cca07a3343d31b0836cb9b18fedc51ea47e42a2c30b1d723025e", + "mask__median_of_differences__multimodal__09": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median_of_differences__nonlinear__04": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "mask__median_of_differences__nonlinear__13": "1e0feb0bc85f55e91e89430145467acf4779312af547004fab993ea165f87c8a", + "mask__median_of_differences__plane__05": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__median_of_differences__plateaus_signed_zero__10": "3fbc16fea18639d063c1f465df6424b5d75869c15978627ea98c018113590a83", + "mask__median_of_differences__row_offsets__01": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__median_of_differences__step__06": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__median_of_differences__tall__15": "8b871ddffd98c092f975df14ed97b80e7f6fb9e6eae968a2483bfd6097f352d0", + "mask__trimmed_mean__alternating_offsets__02": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean__impulses__07": "a1e6f7825ecf367c48a842a75009c3b80f539f15fd6065f1253e57e8f99ee50f", + "mask__trimmed_mean__irregular__11": "f451b46961e212eb73e212ae2a17b2850d05cc6f329ad865ac6a50eff607b422", + "mask__trimmed_mean__linear__03": "ab4302588815a75df4757d07e679699c83c6d798879935bebdd64ac49964176e", + "mask__trimmed_mean__linear__12": "ec8d5aa71ca4cca07a3343d31b0836cb9b18fedc51ea47e42a2c30b1d723025e", + "mask__trimmed_mean__multimodal__09": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean__nonlinear__04": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "mask__trimmed_mean__nonlinear__13": "1e0feb0bc85f55e91e89430145467acf4779312af547004fab993ea165f87c8a", + "mask__trimmed_mean__plane__05": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__trimmed_mean__plateaus_signed_zero__10": "3fbc16fea18639d063c1f465df6424b5d75869c15978627ea98c018113590a83", + "mask__trimmed_mean__row_offsets__01": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean__step__06": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__trimmed_mean__tall__15": "8b871ddffd98c092f975df14ed97b80e7f6fb9e6eae968a2483bfd6097f352d0", + "mask__trimmed_mean_of_differences__alternating_offsets__02": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean_of_differences__impulses__07": "a1e6f7825ecf367c48a842a75009c3b80f539f15fd6065f1253e57e8f99ee50f", + "mask__trimmed_mean_of_differences__irregular__11": "f451b46961e212eb73e212ae2a17b2850d05cc6f329ad865ac6a50eff607b422", + "mask__trimmed_mean_of_differences__linear__03": "ab4302588815a75df4757d07e679699c83c6d798879935bebdd64ac49964176e", + "mask__trimmed_mean_of_differences__linear__12": "ec8d5aa71ca4cca07a3343d31b0836cb9b18fedc51ea47e42a2c30b1d723025e", + "mask__trimmed_mean_of_differences__multimodal__09": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean_of_differences__nonlinear__04": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "mask__trimmed_mean_of_differences__nonlinear__13": "1e0feb0bc85f55e91e89430145467acf4779312af547004fab993ea165f87c8a", + "mask__trimmed_mean_of_differences__plane__05": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__trimmed_mean_of_differences__plateaus_signed_zero__10": "3fbc16fea18639d063c1f465df6424b5d75869c15978627ea98c018113590a83", + "mask__trimmed_mean_of_differences__row_offsets__01": "8813bb2f612042c432b6d772ff65b124167f6a65d083b3ac95184fd2e660d5f4", + "mask__trimmed_mean_of_differences__step__06": "80606141f1fde982222a459ed69c7334baadb70a37d9877341964de603e109d2", + "mask__trimmed_mean_of_differences__tall__15": "8b871ddffd98c092f975df14ed97b80e7f6fb9e6eae968a2483bfd6097f352d0", + "portable_background__median__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__median__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__median_of_differences__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__median_of_differences__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__trimmed_mean__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__trimmed_mean__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__trimmed_mean_of_differences__constant__00": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_background__trimmed_mean_of_differences__impulses__07": "b17ffde1c70ab26d31f0b40d3b4cb8faf7784f84b726036eda5a8cad2a8e13bb", + "portable_corrected__median__alternating_offsets__02": "61234be3d9301f80ddeb6c9a9a13123934657cba5ecb4202175a54d6dae60efe", + "portable_corrected__median__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "portable_corrected__median__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "portable_corrected__median__irregular__11": "9e7828288f37ca568f85366e21e29c8c814806eda24fe97afcf1297ca2cb172a", + "portable_corrected__median__linear__03": "06563cc7c8c90d58ecf2309509a1a315bc1b37dd854b181e927316f26181e27f", + "portable_corrected__median__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "portable_corrected__median__multimodal__09": "c2c8140cce2425b50592e5b02e802860018e1e82d84b5d44d1a2ffb5f2e88fe5", + "portable_corrected__median__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "portable_corrected__median__nonlinear__13": "4cc7882bbb8b3e92f75bacee75ed0d5dd9f4a37cae1be1bf6d3228166a761269", + "portable_corrected__median__plane__05": "7addfac9c462a047d7ab56e2199333741cf2a39679e7389841032cdb7fcc9ca0", + "portable_corrected__median__plateaus_signed_zero__10": "0f1ef690046cfd1b5a2df56f734dfc73c303e0956eaffbced81cb560ae7aad5b", + "portable_corrected__median__row_offsets__01": "7ab1578e2b5cd0a33868790e38a9c241949674e0fbb42000239525b2f26622cb", + "portable_corrected__median__scars__08": "aeec794980c492601fedb134023e9a0b7109b58cfb853ead18a7eed437700298", + "portable_corrected__median__step__06": "c70637dfda8c8a28434080e0bb0c95d0fd5355923aab02fd09c8e3aa83c5cc08", + "portable_corrected__median__tall__15": "979e082e8d0461e258217a631f4bb2d60a32701abd35adeadc833d1f7448eaf4", + "portable_corrected__median__wide__14": "5c02a5bdf8e3cfd840d88a81b9892413092cd3b6dfabbbccd35a0111fb6cc228", + "portable_corrected__median_of_differences__alternating_offsets__02": "94d2adc27887a0cb929a111683d05d048e1e9b5a2baedd542f6218fddfa951b4", + "portable_corrected__median_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "portable_corrected__median_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "portable_corrected__median_of_differences__irregular__11": "0a934ba3d3bc59d1baec577bc0c74e420fd99c79747503ecfcde45a2bb8fdcf7", + "portable_corrected__median_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "portable_corrected__median_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "portable_corrected__median_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "portable_corrected__median_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "portable_corrected__median_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "portable_corrected__median_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "portable_corrected__median_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "portable_corrected__median_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "portable_corrected__median_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "portable_corrected__median_of_differences__step__06": "85fabb79e3c47302e3406e2d06761008d6559e8c4ddc2a7125efa435d03478e4", + "portable_corrected__median_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "portable_corrected__median_of_differences__wide__14": "b8e3f5a33d7da639d3288a249ea0019b174440c69e67f8218acfe17c98be3b05", + "portable_corrected__trimmed_mean__alternating_offsets__02": "61234be3d9301f80ddeb6c9a9a13123934657cba5ecb4202175a54d6dae60efe", + "portable_corrected__trimmed_mean__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "portable_corrected__trimmed_mean__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "portable_corrected__trimmed_mean__irregular__11": "9e7828288f37ca568f85366e21e29c8c814806eda24fe97afcf1297ca2cb172a", + "portable_corrected__trimmed_mean__linear__03": "08643ee6e1365003e650b4dca6e524edbbcae65fccaa83875d705fd59807dc20", + "portable_corrected__trimmed_mean__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "portable_corrected__trimmed_mean__multimodal__09": "bd2bd5f4e6e349d9169b2bab1091558a0fc50f178d37842d13ed05a56a80d3f4", + "portable_corrected__trimmed_mean__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "portable_corrected__trimmed_mean__nonlinear__13": "ad40ebce383b2c9bc80fb7fb841633016f5ffa7117a130769c620f1b9feba558", + "portable_corrected__trimmed_mean__plane__05": "7addfac9c462a047d7ab56e2199333741cf2a39679e7389841032cdb7fcc9ca0", + "portable_corrected__trimmed_mean__plateaus_signed_zero__10": "784dc81f68fb2e2325ada327ec49c93ffcdaaaf8498d5aac822a8a765b66ba7e", + "portable_corrected__trimmed_mean__row_offsets__01": "02778f1a7461b3b70ab6f82a9cd19893b8d49e4b2f7ff42e5b88a14599f7945c", + "portable_corrected__trimmed_mean__scars__08": "aeec794980c492601fedb134023e9a0b7109b58cfb853ead18a7eed437700298", + "portable_corrected__trimmed_mean__step__06": "334ca055c7b71e4ef33d44b98d2023362759294517bcbf44770311082e1f4e6c", + "portable_corrected__trimmed_mean__tall__15": "1d789d30ba5a12fd8d3c2b59258d15e2ccc38190a3d5f40f8a3b429e91e3501f", + "portable_corrected__trimmed_mean__wide__14": "5c02a5bdf8e3cfd840d88a81b9892413092cd3b6dfabbbccd35a0111fb6cc228", + "portable_corrected__trimmed_mean_of_differences__alternating_offsets__02": "94d2adc27887a0cb929a111683d05d048e1e9b5a2baedd542f6218fddfa951b4", + "portable_corrected__trimmed_mean_of_differences__constant__00": "4eda4b8ccbb316e4b0cfdb08428b80234eba9e7a2b2ad75b470e1884a46d4dca", + "portable_corrected__trimmed_mean_of_differences__impulses__07": "02e66377214151ca19419b134537b31cd89a4961486073e18f52fea91a186519", + "portable_corrected__trimmed_mean_of_differences__irregular__11": "0a934ba3d3bc59d1baec577bc0c74e420fd99c79747503ecfcde45a2bb8fdcf7", + "portable_corrected__trimmed_mean_of_differences__linear__03": "a57e5975b58f59b5dec4cf97843a2a7b6e9865ab1111735ed00250ac3f465ad7", + "portable_corrected__trimmed_mean_of_differences__linear__12": "7cc8792314d79a8aceb45ec0d38e78e66b96464c4befb26d628200eb023abe47", + "portable_corrected__trimmed_mean_of_differences__multimodal__09": "2ed0e7fd37cdf7300361874e5ae013283f07b6946031417dccd720f18aa6340d", + "portable_corrected__trimmed_mean_of_differences__nonlinear__04": "249f8b503536170dd2e60319d9d8443c6e86cb2f0fddf0da032f016378dfbcdc", + "portable_corrected__trimmed_mean_of_differences__nonlinear__13": "293b812f2c82c95d50d977fb2319e65fb2ff697b5c3289e0426ad5ab4770ebfb", + "portable_corrected__trimmed_mean_of_differences__plane__05": "fb09450ac88fb3df030531ae16503d47ed04d34f0d47a5c45a0d6e1e5433572e", + "portable_corrected__trimmed_mean_of_differences__plateaus_signed_zero__10": "d58704c1572e727abd8e1dc362875a554736a61832919243e88e281756224056", + "portable_corrected__trimmed_mean_of_differences__row_offsets__01": "79ff6e8d2cbed5e71aff4bb8a8dca49a3448ff892aa6e20a750df75b37e67743", + "portable_corrected__trimmed_mean_of_differences__scars__08": "2b11105397a9b6371bd9b91513f0237db84d2d55b7403b62c59ea68bfeb53f30", + "portable_corrected__trimmed_mean_of_differences__step__06": "85fabb79e3c47302e3406e2d06761008d6559e8c4ddc2a7125efa435d03478e4", + "portable_corrected__trimmed_mean_of_differences__tall__15": "ce87de967ff114fcdf07b36b380c47e6fe96af215869876d637fdc3c79bdcec5", + "portable_corrected__trimmed_mean_of_differences__wide__14": "b8e3f5a33d7da639d3288a249ea0019b174440c69e67f8218acfe17c98be3b05" + }, + "npz_filename": "align_rows_statistics_reference.npz", + "npz_sha256": "0098e804597440419fd1eea2914ddccd7bbb5412c8691a8e3c34f0538983119e" + }, + "method_counts": { + "Median": 16, + "Median of differences": 16, + "Trimmed mean": 16, + "Trimmed mean of differences": 16 + }, + "profiles": { + "installed_gwyddion_2_71_fast_math_profile": { + "build": { + "associative_reassociation": true, + "compiler": "GCC 16.1.1", + "fast_math": true, + "lto": true + }, + "campaign": "row_shift_statistics", + "canonical_reference_sha256": "e2fa6d094acc5ec04f87901aa345244f9d22e70577d25f963a8cdb74363e457e", + "description": "Frozen installed Gwyddion executable profile; secondary external evidence.", + "gwyddion_version": "2.71", + "module_sha256": "c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451" + }, + "portable_source_semantics": { + "candidate_output_sha256": "7da7283019d698089d1a9cca4cec712860529fce42c2cb0752c2b136ecd1cb30", + "description": "Frozen independent V2 source-semantic oracle; primary production contract.", + "method_evidence": { + "Median": "ORACLE_CONFIRMED_NUMERIC_WITH_EXPLAINED_SIGNED_ZERO", + "Median of differences": "ORACLE_MISMATCHED", + "Trimmed mean": "ORACLE_CONFIRMED_BITWISE", + "Trimmed mean of differences": "ORACLE_MISMATCHED" + }, + "oracle_source_sha256": "06585bf85b58392640e5c185694a2b4e47742dae8969b65a689f1bb5c717b198" + } + }, + "schema_version": 1 +} diff --git a/tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.npz b/tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..80c4b62d9683b3da335759378f3ca0c4b179bbb8 GIT binary patch literal 107743 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 E0 str: + return hashlib.sha256(file_path.read_bytes()).hexdigest() + + +def _array_hash(array: np.ndarray) -> str: + value = np.ascontiguousarray(array, dtype=np.float64) + digest = hashlib.sha256() + digest.update(value.dtype.str.encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(item) for item in value.shape).encode("ascii")) + digest.update(b"\0") + digest.update(value.tobytes(order="C")) + return digest.hexdigest() + + +def _bits(array: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(array, dtype=np.float64).view(np.uint64) + + +def _subtraction_in_order(input_data: np.ndarray, corrected: np.ndarray) -> np.ndarray: + result = np.empty_like(input_data, order="C") + for row in range(input_data.shape[0]): + for column in range(input_data.shape[1]): + result[row, column] = input_data[row, column] - corrected[row, column] + return result + + +def _load() -> tuple[dict[str, Any], dict[str, np.ndarray]]: + manifest = json.loads((ROOT / "align_rows_statistics_reference.json").read_text()) + with np.load(ROOT / "align_rows_statistics_reference.npz", allow_pickle=False) as archive: + arrays = {name: archive[name].copy(order="C") for name in archive.files} + return manifest, arrays + + +def test_fixture_hashes_inventory_and_deterministic_loading() -> None: + assert _digest(ROOT / "align_rows_statistics_reference.json") == MANIFEST_SHA256 + assert _digest(ROOT / "align_rows_statistics_reference.npz") == NPZ_SHA256 + manifest, first = _load() + _, second = _load() + assert manifest["schema_version"] == 1 + assert manifest["capability"] == "gwyddion_align_rows_statistics" + assert manifest["case_count"] == 64 + assert manifest["method_counts"] == { + "Median": 16, + "Median of differences": 16, + "Trimmed mean": 16, + "Trimmed mean of differences": 16, + } + cases = manifest["cases"] + assert len(cases) == 64 + assert len({case["case_identifier"] for case in cases}) == 64 + assert set(first) == set(manifest["fixture"]["array_hashes"]) + assert set(first) == set(second) + for name, array in first.items(): + assert array.dtype == np.float64 and array.flags.c_contiguous + assert array.ndim == 2 and np.isfinite(array).all() + assert _array_hash(array) == manifest["fixture"]["array_hashes"][name] + assert np.array_equal(_bits(array), _bits(second[name])) + + +def test_profile_identity_exception_scope_and_background_relations() -> None: + manifest, arrays = _load() + assert manifest["profiles"]["portable_source_semantics"]["candidate_output_sha256"] == ( + "7da7283019d698089d1a9cca4cec712860529fce42c2cb0752c2b136ecd1cb30" + ) + assert manifest["profiles"]["installed_gwyddion_2_71_fast_math_profile"][ + "canonical_reference_sha256" + ] == ("e2fa6d094acc5ec04f87901aa345244f9d22e70577d25f963a8cdb74363e457e") + assert manifest["profiles"]["installed_gwyddion_2_71_fast_math_profile"]["module_sha256"] == ( + "c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451" + ) + assert manifest["evidence"]["installed_build_diagnosis"] == [ + "INSTALLED_BUILD_ROOT_CAUSE_CONFIRMED", + "V3_NOT_JUSTIFIED", + ] + + mismatching_cases: set[str] = set() + finite_nonzero = signed_zero = nan = infinity = exact_arrays = exact_elements = 0 + background_arrays = background_elements = mutation_matches = 0 + for case in manifest["cases"]: + input_data = arrays[case["input_key"]] + portable = arrays[case["portable_corrected_key"]] + installed = arrays[case["installed_corrected_key"]] + assert input_data.shape == (case["rows"], case["columns"]) + assert _bits(input_data).ravel().tolist() == [ + int(value, 16) for value in case["input_bits"] + ] + if case["mask_key"] is None: + assert case["mask_bits"] is None + else: + assert _bits(arrays[case["mask_key"]]).ravel().tolist() == [ + int(value, 16) for value in case["mask_bits"] + ] + portable_bits = _bits(portable) + installed_bits = _bits(installed) + differing = portable_bits != installed_bits + exact_elements += int((~differing).sum()) + exact_arrays += int(not differing.any()) + if differing.any(): + mismatching_cases.add(case["case_identifier"]) + for row, column in np.argwhere(differing): + left = portable[row, column] + right = installed[row, column] + if np.isnan(left) or np.isnan(right): + nan += 1 + elif np.isinf(left) or np.isinf(right): + infinity += 1 + elif left == right == 0.0: + signed_zero += 1 + else: + finite_nonzero += 1 + portable_mutated = bool((_bits(portable) != _bits(input_data)).any()) + installed_mutated = bool((_bits(installed) != _bits(input_data)).any()) + mutation_matches += int(portable_mutated == installed_mutated == case["installed_mutated"]) + if case["extract_background_request"]: + portable_background = arrays[case["portable_background_key"]] + installed_background = arrays[case["installed_background_key"]] + assert np.array_equal(_bits(portable_background), _bits(installed_background)) + assert np.array_equal( + _bits(portable_background), _bits(_subtraction_in_order(input_data, portable)) + ) + assert np.array_equal( + _bits(installed_background), _bits(_subtraction_in_order(input_data, installed)) + ) + background_arrays += 1 + background_elements += portable_background.size + assert exact_arrays == 61 + assert exact_elements == 3757 + assert finite_nonzero == 128 + assert signed_zero == 3 + assert nan == infinity == 0 + assert mismatching_cases == { + "median__plateaus_signed_zero__10", + "median_of_differences__irregular__11", + "trimmed_mean_of_differences__irregular__11", + } + assert background_arrays == 8 and background_elements == 504 + assert mutation_matches == 64 + exceptions = manifest["comparison_metrics"]["authorized_exceptions"] + assert {item["case_identifier"] for item in exceptions} == mismatching_cases + assert sum(item["finite_nonzero_count"] for item in exceptions) == 128 + assert sum(item["signed_zero_only_count"] for item in exceptions) == 3 + assert ( + manifest["comparison_metrics"]["corrected"]["max_absolute_difference"] + == 5.329070518200751e-15 + ) From a6ca1ef456b74bb55b198d41951282cce8fce76e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:36:11 -0400 Subject: [PATCH 73/82] feat(leveling): add Gwyddion Align Rows statistics engine --- .../_gwyddion_align_rows_statistics.py | 373 ++++++++++++++++++ ..._gwyddion_align_rows_statistics_private.py | 357 +++++++++++++++++ 2 files changed, 730 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py create mode 100644 tests/core/test_gwyddion_align_rows_statistics_private.py diff --git a/src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py b/src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py new file mode 100644 index 0000000..6d9aef5 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py @@ -0,0 +1,373 @@ +"""Private portable Gwyddion 2.71 Align Rows statistics kernel. + +This module intentionally implements only the four source-confirmed row-shift +statistics methods. It is not a public API and does not emulate the installed +package's compiler-specific reassociation profile. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import IntEnum +from typing import cast + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +FloatArray = NDArray[np.float64] + + +class _GwyddionAlignRowsMethod(IntEnum): + """The four source-confirmed Align Rows row-shift methods.""" + + MEDIAN = 1 + MEDIAN_OF_DIFFERENCES = 2 + TRIMMED_MEAN = 5 + TRIMMED_MEAN_OF_DIFFERENCES = 6 + + +class _GwyddionMaskMode(IntEnum): + """Gwyddion's stored mask enum, including its source value order.""" + + EXCLUDE = 0 + INCLUDE = 1 + IGNORE = 2 + + +class _GwyddionAlignRowsDirection(IntEnum): + """Source row orientation before optional transpose/restore.""" + + HORIZONTAL = 0 + VERTICAL = 1 + + +@dataclass(frozen=True) +class _GwyddionAlignRowsStatisticsResult: + """Corrected private result with optional extracted background diagnostics.""" + + corrected: FloatArray + background: FloatArray | None + correction_sequence: FloatArray + + +def _validated_field(value: ArrayLike, *, label: str) -> FloatArray: + try: + source = np.asarray(value) + except (TypeError, ValueError) as exc: + raise TypeError(f"Gwyddion Align Rows {label} must be array-compatible") from exc + if source.ndim != 2: + raise ValueError(f"Gwyddion Align Rows {label} must be two-dimensional") + if 0 in source.shape: + raise ValueError(f"Gwyddion Align Rows {label} must have non-empty dimensions") + if not np.issubdtype(source.dtype, np.number) or np.iscomplexobj(source): + raise TypeError(f"Gwyddion Align Rows {label} must contain real numeric values") + values = np.array(source, dtype=np.float64, order="C", copy=True) + if not np.isfinite(values).all(): + raise ValueError(f"Gwyddion Align Rows {label} must be finite") + return values + + +def _validated_mask(value: ArrayLike | None, shape: tuple[int, int]) -> FloatArray | None: + if value is None: + return None + mask = _validated_field(value, label="mask") + if mask.shape != shape: + raise ValueError("Gwyddion Align Rows mask shape must match data") + return mask + + +def _validated_enum(value: object, enum_type: type[IntEnum], label: str) -> IntEnum: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer, IntEnum)): + raise TypeError(f"Gwyddion Align Rows {label} must be an integer enum value") + try: + return enum_type(int(value)) + except ValueError as exc: + allowed = ", ".join(str(int(member)) for member in enum_type) + raise ValueError(f"Gwyddion Align Rows {label} must be one of {allowed}") from exc + + +def _validated_trim_fraction(value: object) -> float: + if isinstance(value, (bool, np.bool_)) or not isinstance( + value, (int, float, np.integer, np.floating) + ): + raise TypeError("Gwyddion Align Rows trim_fraction must be a real scalar") + fraction = float(value) + if not math.isfinite(fraction): + raise ValueError("Gwyddion Align Rows trim_fraction must be finite") + if not 0.0 <= fraction <= 0.5: + raise ValueError( + "Gwyddion Align Rows trim_fraction must be in the inclusive range 0.0..0.5" + ) + return fraction + + +def _round_nonnegative(value: float) -> int: + """Return the source's non-negative ``floor(x + 0.5)`` conversion.""" + return math.floor(value + 0.5) + + +def _mean_in_order(values: list[float]) -> float: + if not values: + raise ValueError("Gwyddion Align Rows mean requires samples") + total = 0.0 + for value in values: + total = total + value + return total / len(values) + + +def _upper_median(values: list[float]) -> float: + if not values: + raise ValueError("Gwyddion Align Rows median requires samples") + ordered = sorted(values) + return ordered[len(ordered) // 2] + + +def _move_min_to_front(values: list[float]) -> None: + smallest = values[0] + for index in range(1, len(values)): + candidate = values[index] + if candidate < smallest: + values[index] = smallest + smallest = candidate + values[0] = smallest + + +def _move_max_to_back(values: list[float]) -> None: + largest = values[-1] + final = len(values) - 1 + for index in range(final): + candidate = values[index] + if candidate > largest: + values[index] = largest + largest = candidate + values[final] = largest + + +def _trimmed_mean_or_median(values: list[float], trim_fraction: float) -> float: + """Reduce selected samples in the frozen portable binary64 operation order.""" + count = len(values) + if not count: + raise ValueError("Gwyddion Align Rows reduction requires samples") + trim_count = _round_nonnegative(trim_fraction * count) + if 2 * trim_count + 1 >= count: + return _upper_median(values) + work = list(values) + if trim_count == 0: + return _mean_in_order(work) + if trim_count == 1: + if count % 2: + _move_min_to_front(work) + tail = work[1:] + _move_max_to_back(tail) + work[1:] = tail + else: + _move_max_to_back(work) + head = work[:-1] + _move_min_to_front(head) + work[:-1] = head + return _mean_in_order(work[1:-1]) + ordered = sorted(work) + return _mean_in_order(ordered[trim_count : count - trim_count]) + + +def _minimum_sample_count(width: int) -> int: + return _round_nonnegative(math.log(width) + 1.0) + + +def _selected_row_values( + row: FloatArray, mask_row: FloatArray | None, mode: _GwyddionMaskMode +) -> list[float]: + if mask_row is None or mode is _GwyddionMaskMode.IGNORE: + return [float(value) for value in row] + if mode is _GwyddionMaskMode.INCLUDE: + return [ + float(value) + for value, mask_value in zip(row, mask_row, strict=True) + if mask_value > 0.0 + ] + return [ + float(value) for value, mask_value in zip(row, mask_row, strict=True) if mask_value < 1.0 + ] + + +def _selected_global_values( + data: FloatArray, mask: FloatArray | None, mode: _GwyddionMaskMode +) -> list[float]: + """Select the source-confirmed absolute-method fallback population. + + The global Exclude fallback uses ``mask <= 0`` rather than the per-row + ``mask < 1`` predicate. This source distinction is retained deliberately. + """ + if mask is None or mode is _GwyddionMaskMode.IGNORE: + return [float(value) for value in data.ravel(order="C")] + values: list[float] = [] + for row, mask_row in zip(data, mask, strict=True): + for value, mask_value in zip(row, mask_row, strict=True): + if ( + mode is _GwyddionMaskMode.INCLUDE + and mask_value > 0.0 + or mode is _GwyddionMaskMode.EXCLUDE + and mask_value <= 0.0 + ): + values.append(float(value)) + return values + + +def _paired_differences( + data: FloatArray, mask: FloatArray | None, mode: _GwyddionMaskMode, row: int +) -> list[float]: + first = data[row] + second = data[row + 1] + if mask is None or mode is _GwyddionMaskMode.IGNORE: + return [float(second[column] - first[column]) for column in range(first.size)] + first_mask = mask[row] + second_mask = mask[row + 1] + differences: list[float] = [] + for column in range(first.size): + if mode is _GwyddionMaskMode.INCLUDE: + keep = first_mask[column] > 1.0 and second_mask[column] > 1.0 + else: + keep = first_mask[column] < 1.0 and second_mask[column] < 1.0 + if keep: + differences.append(float(second[column] - first[column])) + return differences + + +def _absolute_corrections( + data: FloatArray, mask: FloatArray | None, mode: _GwyddionMaskMode, trim_fraction: float +) -> FloatArray: + threshold = _minimum_sample_count(data.shape[1]) + global_values = _selected_global_values(data, mask, mode) + fallback = _upper_median(global_values) if global_values else 0.0 + shifts: list[float] = [] + for row in range(data.shape[0]): + selected = _selected_row_values(data[row], None if mask is None else mask[row], mode) + shifts.append( + _trimmed_mean_or_median(selected, trim_fraction) + if len(selected) >= threshold + else fallback + ) + offset = _mean_in_order(shifts) + return np.array([shift - offset for shift in shifts], dtype=np.float64, order="C") + + +def _slope_level(shifts: FloatArray) -> FloatArray: + count = float(shifts.size) + mean_index = (count - 1.0) / 2.0 + mean_index_square = (2.0 * count - 1.0) * (count - 1.0) / 6.0 + shift_values = [float(value) for value in shifts] + mean_shifts = _mean_in_order(shift_values) + index_weighted = 0.0 + for index, shift in enumerate(shift_values): + index_weighted = index_weighted + shift * index + index_weighted = index_weighted / count + denominator = mean_index_square - mean_index * mean_index + slope = (index_weighted - mean_shifts * mean_index) / denominator + intercept = (mean_shifts * mean_index_square - mean_index * index_weighted) / denominator + return np.array( + [shift - (intercept + slope * index) for index, shift in enumerate(shift_values)], + dtype=np.float64, + order="C", + ) + + +def _difference_corrections( + data: FloatArray, mask: FloatArray | None, mode: _GwyddionMaskMode, trim_fraction: float +) -> FloatArray: + threshold = _minimum_sample_count(data.shape[1]) + shifts = np.zeros(data.shape[0], dtype=np.float64) + for row in range(data.shape[0] - 1): + selected = _paired_differences(data, mask, mode, row) + shifts[row + 1] = ( + _trimmed_mean_or_median(selected, trim_fraction) if len(selected) >= threshold else 0.0 + ) + for row in range(1, shifts.size): + shifts[row] = shifts[row] + shifts[row - 1] + return _slope_level(shifts) + + +def _apply_corrections(data: FloatArray, corrections: FloatArray) -> FloatArray: + corrected = data.copy(order="C") + for row in range(corrected.shape[0]): + for column in range(corrected.shape[1]): + corrected[row, column] = corrected[row, column] - corrections[row] + return corrected + + +def _background_in_order(input_data: FloatArray, corrected: FloatArray) -> FloatArray: + background = np.empty_like(input_data, order="C") + for row in range(input_data.shape[0]): + for column in range(input_data.shape[1]): + background[row, column] = input_data[row, column] - corrected[row, column] + return background + + +def _gwyddion_align_rows_statistics_result( + data: ArrayLike, + *, + method: object, + masking_mode: object, + direction: object, + trim_fraction: object, + mask: ArrayLike | None = None, + extract_background: object = False, +) -> _GwyddionAlignRowsStatisticsResult: + """Compute one private portable Align Rows statistics result without input mutation.""" + values = _validated_field(data, label="data") + validated_mask = _validated_mask(mask, values.shape) + selected_method = cast( + _GwyddionAlignRowsMethod, + _validated_enum(method, _GwyddionAlignRowsMethod, "method"), + ) + selected_mode = cast( + _GwyddionMaskMode, + _validated_enum(masking_mode, _GwyddionMaskMode, "masking_mode"), + ) + selected_direction = cast( + _GwyddionAlignRowsDirection, + _validated_enum(direction, _GwyddionAlignRowsDirection, "direction"), + ) + fraction = _validated_trim_fraction(trim_fraction) + if not isinstance(extract_background, (bool, np.bool_)): + raise TypeError("Gwyddion Align Rows extract_background must be boolean") + + effective_mask = ( + None + if validated_mask is None or selected_mode is _GwyddionMaskMode.IGNORE + else validated_mask + ) + if selected_direction is _GwyddionAlignRowsDirection.HORIZONTAL: + working = values + working_mask = effective_mask + else: + working = np.ascontiguousarray(values.T, dtype=np.float64) + working_mask = ( + None + if effective_mask is None + else np.ascontiguousarray(effective_mask.T, dtype=np.float64) + ) + + if selected_method in (_GwyddionAlignRowsMethod.MEDIAN, _GwyddionAlignRowsMethod.TRIMMED_MEAN): + reduction_fraction = 0.5 if selected_method is _GwyddionAlignRowsMethod.MEDIAN else fraction + corrections = _absolute_corrections( + working, working_mask, selected_mode, reduction_fraction + ) + else: + reduction_fraction = ( + 0.5 if selected_method is _GwyddionAlignRowsMethod.MEDIAN_OF_DIFFERENCES else fraction + ) + corrections = _difference_corrections( + working, working_mask, selected_mode, reduction_fraction + ) + + corrected_working = _apply_corrections(working, corrections) + corrected = ( + corrected_working + if selected_direction is _GwyddionAlignRowsDirection.HORIZONTAL + else np.ascontiguousarray(corrected_working.T) + ) + background = _background_in_order(values, corrected) if extract_background else None + return _GwyddionAlignRowsStatisticsResult( + corrected=corrected, background=background, correction_sequence=corrections + ) diff --git a/tests/core/test_gwyddion_align_rows_statistics_private.py b/tests/core/test_gwyddion_align_rows_statistics_private.py new file mode 100644 index 0000000..539f3a6 --- /dev/null +++ b/tests/core/test_gwyddion_align_rows_statistics_private.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_align_rows_statistics import ( + _gwyddion_align_rows_statistics_result, + _GwyddionAlignRowsDirection, + _GwyddionAlignRowsMethod, + _GwyddionAlignRowsStatisticsResult, + _GwyddionMaskMode, + _minimum_sample_count, + _paired_differences, + _selected_row_values, + _trimmed_mean_or_median, +) + +FIXTURE = Path(__file__).resolve().parents[1] / "validation/fixtures/gwyddion/align_rows_statistics" + + +def _load() -> tuple[dict[str, Any], dict[str, np.ndarray]]: + manifest = json.loads((FIXTURE / "align_rows_statistics_reference.json").read_text()) + with np.load(FIXTURE / "align_rows_statistics_reference.npz", allow_pickle=False) as archive: + arrays = {name: archive[name].copy(order="C") for name in archive.files} + return manifest, arrays + + +def _bits(array: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(array, dtype=np.float64).view(np.uint64) + + +def _assert_bits(case_id: str, expected: np.ndarray, actual: np.ndarray) -> None: + mismatch = np.argwhere(_bits(expected) != _bits(actual)) + if not len(mismatch): + return + row, column = (int(value) for value in mismatch[0]) + raise AssertionError( + f"{case_id}: row={row}, column={column}, expected={_bits(expected)[row, column]:016x}, " + f"actual={_bits(actual)[row, column]:016x}" + ) + + +def _ulp_distance(left: np.uint64, right: np.uint64) -> int: + def ordered(value: int) -> int: + return (~value + 1) & ((1 << 64) - 1) if value >> 63 else value | (1 << 63) + + return abs(ordered(int(left)) - ordered(int(right))) + + +def _run(case: dict[str, Any], arrays: dict[str, np.ndarray]) -> _GwyddionAlignRowsStatisticsResult: + return _gwyddion_align_rows_statistics_result( + arrays[case["input_key"]], + method=case["method"], + masking_mode=case["masking_mode"], + direction=case["direction"], + trim_fraction=float.fromhex(case["trim_fraction_hex"]), + mask=None if case["mask_key"] is None else arrays[case["mask_key"]], + extract_background=case["extract_background_request"], + ) + + +def test_all_portable_v2_cases_are_bitwise_exact_and_non_mutating() -> None: + manifest, arrays = _load() + exact = backgrounds = mutation_matches = 0 + for case in manifest["cases"]: + input_data = arrays[case["input_key"]] + mask = None if case["mask_key"] is None else arrays[case["mask_key"]] + input_before = input_data.copy(order="C") + mask_before = None if mask is None else mask.copy(order="C") + first = _run(case, arrays) + second = _run(case, arrays) + _assert_bits( + case["case_identifier"], arrays[case["portable_corrected_key"]], first.corrected + ) + _assert_bits(case["case_identifier"] + "/repeat", first.corrected, second.corrected) + expected_corrections = np.array( + [int(value, 16) for value in case["portable_correction_sequence_bits"]], dtype=np.uint64 + ).view(np.float64) + _assert_bits( + case["case_identifier"] + "/corrections", + expected_corrections, + first.correction_sequence, + ) + assert first.corrected.dtype == np.float64 and first.corrected.flags.c_contiguous + assert ( + first.correction_sequence.dtype == np.float64 + and first.correction_sequence.flags.c_contiguous + ) + assert not np.shares_memory(first.corrected, input_data) + assert np.array_equal(_bits(input_data), _bits(input_before)) + if mask is not None: + assert mask_before is not None + assert np.array_equal(_bits(mask), _bits(mask_before)) + if case["extract_background_request"]: + assert first.background is not None + _assert_bits( + case["case_identifier"] + "/background", + arrays[case["portable_background_key"]], + first.background, + ) + reconstruction = np.empty_like(input_data) + for row in range(input_data.shape[0]): + for column in range(input_data.shape[1]): + reconstruction[row, column] = ( + input_data[row, column] - first.background[row, column] + ) + _assert_bits( + case["case_identifier"] + "/reconstruction", first.corrected, reconstruction + ) + backgrounds += first.background.size + else: + assert first.background is None + exact += first.corrected.size + changed = bool((_bits(first.corrected) != _bits(input_data)).any()) + mutation_matches += int(changed == case["installed_mutated"]) + assert exact == 3888 + assert backgrounds == 504 + assert mutation_matches == 64 + + +def test_installed_profile_divergence_policy_is_exactly_preserved() -> None: + manifest, arrays = _load() + exceptional = { + "median__plateaus_signed_zero__10", + "median_of_differences__irregular__11", + "trimmed_mean_of_differences__irregular__11", + } + finite_nonzero = signed_zero = exact = maximum_ulp = 0 + maximum_absolute = 0.0 + for case in manifest["cases"]: + portable = _run(case, arrays).corrected + installed = arrays[case["installed_corrected_key"]] + differing = _bits(portable) != _bits(installed) + if differing.any(): + assert case["case_identifier"] in exceptional + for row, column in np.argwhere(differing): + if portable[row, column] == installed[row, column] == 0.0: + signed_zero += 1 + else: + assert np.isfinite(portable[row, column]) and np.isfinite(installed[row, column]) + finite_nonzero += 1 + maximum_absolute = max( + maximum_absolute, abs(portable[row, column] - installed[row, column]) + ) + maximum_ulp = max( + maximum_ulp, + _ulp_distance(_bits(portable)[row, column], _bits(installed)[row, column]), + ) + exact += int((~differing).sum()) + if case["extract_background_request"]: + result = _run(case, arrays) + assert result.background is not None + _assert_bits( + case["case_identifier"] + "/installed-background", + arrays[case["installed_background_key"]], + result.background, + ) + assert exact == 3757 + assert finite_nonzero == 128 + assert signed_zero == 3 + assert maximum_absolute <= 5.329070518200751e-15 + assert maximum_ulp <= 144 + + +def test_mask_threshold_fallback_and_reduction_contracts() -> None: + data = np.array([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]], dtype=np.float64) + mask = np.array([[0.0, 0.5, 1.0], [-1.0, 0.5, 2.0]], dtype=np.float64) + include = _gwyddion_align_rows_statistics_result( + data, method=1, masking_mode=1, direction=0, trim_fraction=0.05, mask=mask + ) + exclude = _gwyddion_align_rows_statistics_result( + data, method=1, masking_mode=0, direction=0, trim_fraction=0.05, mask=mask + ) + ignored = _gwyddion_align_rows_statistics_result( + data, method=1, masking_mode=2, direction=0, trim_fraction=0.05, mask=mask + ) + assert not np.array_equal(_bits(include.corrected), _bits(exclude.corrected)) + assert _selected_row_values(data[0], mask[0], _GwyddionMaskMode.INCLUDE) == [2.0, 3.0] + assert _selected_row_values(data[0], mask[0], _GwyddionMaskMode.EXCLUDE) == [1.0, 2.0] + assert _selected_row_values(data[0], mask[0], _GwyddionMaskMode.IGNORE) == [1.0, 2.0, 3.0] + assert _paired_differences(data, mask, _GwyddionMaskMode.INCLUDE, 0) == [] + assert _paired_differences(data, mask, _GwyddionMaskMode.EXCLUDE, 0) == [9.0, 18.0] + assert ignored.correction_sequence.shape == (2,) + assert _minimum_sample_count(3) == 2 + assert _trimmed_mean_or_median([1.0, 2.0, 9.0], 0.0) == 4.0 + assert _trimmed_mean_or_median([1.0, 2.0, 9.0], 0.5) == 2.0 + assert _trimmed_mean_or_median(list(range(10)), 0.05) == 4.5 + assert _trimmed_mean_or_median(list(range(11)), 0.05) == 5.0 + no_mask = _gwyddion_align_rows_statistics_result( + data, method=2, masking_mode=0, direction=0, trim_fraction=0.05, mask=None + ) + no_mask_ignore = _gwyddion_align_rows_statistics_result( + data, method=2, masking_mode=2, direction=0, trim_fraction=0.05, mask=None + ) + _assert_bits("no_mask_mode", no_mask.corrected, no_mask_ignore.corrected) + assert _GwyddionMaskMode.EXCLUDE == 0 + assert _GwyddionMaskMode.INCLUDE == 1 + assert _GwyddionMaskMode.IGNORE == 2 + assert _GwyddionAlignRowsDirection.HORIZONTAL == 0 + assert _GwyddionAlignRowsDirection.VERTICAL == 1 + assert _GwyddionAlignRowsMethod.MEDIAN_OF_DIFFERENCES == 2 + + +def test_zero_one_selection_fallbacks_and_vertical_transpose_contract() -> None: + absolute_data = np.array( + [[0.0, 70.0, 80.0, 90.0], [10.0, 100.0, 80.0, 90.0], [30.0, 70.0, 80.0, 90.0]], + dtype=np.float64, + ) + absolute_mask = np.array( + [[1.0, 0.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + dtype=np.float64, + ) + absolute = _gwyddion_align_rows_statistics_result( + absolute_data, + method=1, + masking_mode=1, + direction=0, + trim_fraction=0.05, + mask=absolute_mask, + ) + _assert_bits( + "absolute_zero_one_fallback", + np.array([-30.0, 60.0, -30.0], dtype=np.float64), + absolute.correction_sequence, + ) + + difference_data = np.arange(12, dtype=np.float64).reshape(3, 4) + difference_mask = np.array( + [[2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0]], + dtype=np.float64, + ) + difference = _gwyddion_align_rows_statistics_result( + difference_data, + method=2, + masking_mode=1, + direction=0, + trim_fraction=0.05, + mask=difference_mask, + ) + _assert_bits( + "difference_zero_one_fallback", + np.zeros(3, dtype=np.float64), + difference.correction_sequence, + ) + + vertical = _gwyddion_align_rows_statistics_result( + absolute_data, + method=5, + masking_mode=0, + direction=1, + trim_fraction=0.05, + mask=absolute_mask, + ) + transposed = _gwyddion_align_rows_statistics_result( + absolute_data.T, + method=5, + masking_mode=0, + direction=0, + trim_fraction=0.05, + mask=absolute_mask.T, + ) + _assert_bits("vertical_transpose", vertical.corrected, transposed.corrected.T) + + +@pytest.mark.parametrize( + "kwargs, error_type", + [ + ( + { + "data": np.array([1.0]), + "method": 1, + "masking_mode": 2, + "direction": 0, + "trim_fraction": 0.05, + }, + ValueError, + ), + ( + { + "data": np.array([[np.nan]]), + "method": 1, + "masking_mode": 2, + "direction": 0, + "trim_fraction": 0.05, + }, + ValueError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 99, + "masking_mode": 2, + "direction": 0, + "trim_fraction": 0.05, + }, + ValueError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 1, + "masking_mode": 3, + "direction": 0, + "trim_fraction": 0.05, + }, + ValueError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 1, + "masking_mode": 2, + "direction": 7, + "trim_fraction": 0.05, + }, + ValueError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 1, + "masking_mode": 2, + "direction": 0, + "trim_fraction": 0.6, + }, + ValueError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 1, + "masking_mode": 2, + "direction": 0, + "trim_fraction": True, + }, + TypeError, + ), + ( + { + "data": np.ones((2, 2)), + "method": 1, + "masking_mode": 2, + "direction": 0, + "trim_fraction": 0.05, + "mask": np.ones((3, 2)), + }, + ValueError, + ), + ], +) +def test_invalid_contracts(kwargs: dict[str, Any], error_type: type[Exception]) -> None: + with pytest.raises(error_type): + _gwyddion_align_rows_statistics_result(**kwargs) From 6886faf88aa8362e154ff7360cc969433aea0e48 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:58:57 -0400 Subject: [PATCH 74/82] feat(leveling): expose Gwyddion Align Rows statistics API --- src/spmkit/core/analysis/__init__.py | 16 +- src/spmkit/core/analysis/leveling.py | 144 +++++++ .../test_gwyddion_align_rows_statistics.py | 391 ++++++++++++++++++ 3 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_gwyddion_align_rows_statistics.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index c421193..0cff264 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -52,7 +52,15 @@ from spmkit.core.analysis.forcevolume import VolumeResult, analyze_volume from spmkit.core.analysis.grains import GrainResult from spmkit.core.analysis.kpfm import CPDResult -from spmkit.core.analysis.leveling import gwyddion_path_level +from spmkit.core.analysis.leveling import ( + GwyddionAlignRowsDirection, + GwyddionAlignRowsMaskMode, + gwyddion_align_rows_median, + gwyddion_align_rows_median_of_differences, + gwyddion_align_rows_trimmed_mean, + gwyddion_align_rows_trimmed_mean_of_differences, + gwyddion_path_level, +) from spmkit.core.analysis.mechanics import ( ForceCurve, IndentationResult, @@ -89,6 +97,12 @@ "estimate_gwyddion_sphere_revolution_background", "gwyddion_flat_disc_closing", "gwyddion_flat_disc_opening", + "GwyddionAlignRowsDirection", + "GwyddionAlignRowsMaskMode", + "gwyddion_align_rows_median", + "gwyddion_align_rows_median_of_differences", + "gwyddion_align_rows_trimmed_mean", + "gwyddion_align_rows_trimmed_mean_of_differences", "gwyddion_path_level", "estimate_median_background", "estimate_polynomial_background", diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 0e1c15e..12eccdf 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -11,6 +11,12 @@ import numpy as np +from spmkit.core.analysis._gwyddion_align_rows_statistics import ( + _gwyddion_align_rows_statistics_result, + _GwyddionAlignRowsDirection, + _GwyddionAlignRowsMethod, + _GwyddionMaskMode, +) from spmkit.core.analysis._gwyddion_path_level import _gwyddion_path_level_result from spmkit.core.geometry import ( bilinear_sample, @@ -21,6 +27,19 @@ ) from spmkit.core.models import SPMChannel +GwyddionAlignRowsMaskMode = Literal["exclude", "include", "ignore"] +GwyddionAlignRowsDirection = Literal["horizontal", "vertical"] + +_GWYDDION_ALIGN_ROWS_MASK_MODES: dict[str, _GwyddionMaskMode] = { + "exclude": _GwyddionMaskMode.EXCLUDE, + "include": _GwyddionMaskMode.INCLUDE, + "ignore": _GwyddionMaskMode.IGNORE, +} +_GWYDDION_ALIGN_ROWS_DIRECTIONS: dict[str, _GwyddionAlignRowsDirection] = { + "horizontal": _GwyddionAlignRowsDirection.HORIZONTAL, + "vertical": _GwyddionAlignRowsDirection.VERTICAL, +} + def _validated_data(channel: SPMChannel, *, operation: str) -> np.ndarray: """Return valid 2D, numeric, finite channel data.""" @@ -226,6 +245,131 @@ def gwyddion_path_level( return channel.with_data(result.corrected) +def _gwyddion_align_rows_statistics_channel( + channel: SPMChannel, + *, + method: _GwyddionAlignRowsMethod, + mask: np.ndarray | None, + mask_mode: GwyddionAlignRowsMaskMode, + direction: GwyddionAlignRowsDirection, + trim_fraction: float, +) -> SPMChannel: + """Apply one fixed private Align Rows method and preserve channel context.""" + if not isinstance(channel, SPMChannel): + raise TypeError("Gwyddion Align Rows requires an SPMChannel") + if not isinstance(mask_mode, str) or mask_mode not in _GWYDDION_ALIGN_ROWS_MASK_MODES: + raise ValueError("Gwyddion Align Rows mask_mode must be 'exclude', 'include', or 'ignore'") + if not isinstance(direction, str) or direction not in _GWYDDION_ALIGN_ROWS_DIRECTIONS: + raise ValueError("Gwyddion Align Rows direction must be 'horizontal' or 'vertical'") + + result = _gwyddion_align_rows_statistics_result( + channel.data, + method=method, + masking_mode=_GWYDDION_ALIGN_ROWS_MASK_MODES[mask_mode], + direction=_GWYDDION_ALIGN_ROWS_DIRECTIONS[direction], + trim_fraction=trim_fraction, + mask=mask, + ) + return channel.with_data(result.corrected) + + +def gwyddion_align_rows_median( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: GwyddionAlignRowsMaskMode = "ignore", + direction: GwyddionAlignRowsDirection = "horizontal", +) -> SPMChannel: + """Apply Gwyddion 2.71 Align Rows Median with portable source semantics. + + ``mask`` is an optional finite numeric array matching the channel shape. + ``mask_mode`` is ``"exclude"``, ``"include"``, or ``"ignore"``; an absent + mask always selects all values. ``direction`` selects horizontal rows or + source-equivalent vertical transpose/restore processing. The result is a + new ``SPMChannel`` with the input context preserved. + """ + return _gwyddion_align_rows_statistics_channel( + channel, + method=_GwyddionAlignRowsMethod.MEDIAN, + mask=mask, + mask_mode=mask_mode, + direction=direction, + trim_fraction=0.05, + ) + + +def gwyddion_align_rows_median_of_differences( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: GwyddionAlignRowsMaskMode = "ignore", + direction: GwyddionAlignRowsDirection = "horizontal", +) -> SPMChannel: + """Apply Gwyddion 2.71 Align Rows Median of differences. + + The optional numeric mask and orientation use the same public contract as + :func:`gwyddion_align_rows_median`. This wrapper retains the private + engine's portable source-semantic cumulative correction and slope removal. + """ + return _gwyddion_align_rows_statistics_channel( + channel, + method=_GwyddionAlignRowsMethod.MEDIAN_OF_DIFFERENCES, + mask=mask, + mask_mode=mask_mode, + direction=direction, + trim_fraction=0.05, + ) + + +def gwyddion_align_rows_trimmed_mean( + channel: SPMChannel, + *, + trim_fraction: float = 0.05, + mask: np.ndarray | None = None, + mask_mode: GwyddionAlignRowsMaskMode = "ignore", + direction: GwyddionAlignRowsDirection = "horizontal", +) -> SPMChannel: + """Apply Gwyddion 2.71 Align Rows Trimmed mean. + + ``trim_fraction`` is a finite real value in the inclusive range ``0.0`` to + ``0.5``. The optional numeric mask and orientation use the public Median + contract. The result is a new context-preserving ``SPMChannel``. + """ + return _gwyddion_align_rows_statistics_channel( + channel, + method=_GwyddionAlignRowsMethod.TRIMMED_MEAN, + mask=mask, + mask_mode=mask_mode, + direction=direction, + trim_fraction=trim_fraction, + ) + + +def gwyddion_align_rows_trimmed_mean_of_differences( + channel: SPMChannel, + *, + trim_fraction: float = 0.05, + mask: np.ndarray | None = None, + mask_mode: GwyddionAlignRowsMaskMode = "ignore", + direction: GwyddionAlignRowsDirection = "horizontal", +) -> SPMChannel: + """Apply Gwyddion 2.71 Align Rows Trimmed mean of differences. + + ``trim_fraction`` is a finite real value in the inclusive range ``0.0`` to + ``0.5``. The optional numeric mask and orientation use the public Median + contract. Portable source semantics, rather than an installed + package-specific reassociation profile, define the returned channel. + """ + return _gwyddion_align_rows_statistics_channel( + channel, + method=_GwyddionAlignRowsMethod.TRIMMED_MEAN_OF_DIFFERENCES, + mask=mask, + mask_mode=mask_mode, + direction=direction, + trim_fraction=trim_fraction, + ) + + def shift_vertical(channel: SPMChannel, *, offset: float) -> SPMChannel: """Add a finite scalar offset to every height value.""" data = _validated_data(channel, operation="shift_vertical") diff --git a/tests/core/test_gwyddion_align_rows_statistics.py b/tests/core/test_gwyddion_align_rows_statistics.py new file mode 100644 index 0000000..0c3cec2 --- /dev/null +++ b/tests/core/test_gwyddion_align_rows_statistics.py @@ -0,0 +1,391 @@ +"""Public-contract tests for Gwyddion 2.71 Align Rows statistics.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from typing import Any, get_args + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.leveling as leveling_module +from spmkit.core.analysis import ( + GwyddionAlignRowsDirection, + GwyddionAlignRowsMaskMode, + gwyddion_align_rows_median, + gwyddion_align_rows_median_of_differences, + gwyddion_align_rows_trimmed_mean, + gwyddion_align_rows_trimmed_mean_of_differences, +) +from spmkit.core.models import SPMChannel + +_FIXTURE = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "align_rows_statistics" +) +_FUNCTIONS: dict[int, Callable[..., SPMChannel]] = { + 1: gwyddion_align_rows_median, + 2: gwyddion_align_rows_median_of_differences, + 5: gwyddion_align_rows_trimmed_mean, + 6: gwyddion_align_rows_trimmed_mean_of_differences, +} +_MASK_MODES: dict[int, str] = {0: "exclude", 1: "include", 2: "ignore"} +_DIRECTIONS: dict[int, str] = {0: "horizontal", 1: "vertical"} +_EXCEPTIONAL_CASES = { + "median__plateaus_signed_zero__10", + "median_of_differences__irregular__11", + "trimmed_mean_of_differences__irregular__11", +} + + +def _load() -> tuple[dict[str, Any], dict[str, np.ndarray]]: + manifest = json.loads((_FIXTURE / "align_rows_statistics_reference.json").read_text()) + with np.load(_FIXTURE / "align_rows_statistics_reference.npz", allow_pickle=False) as archive: + arrays = { + name: np.array(archive[name], dtype=np.float64, order="C", copy=True) + for name in archive.files + } + return manifest, arrays + + +def _bits(array: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(array, dtype=np.float64).view(np.uint64) + + +def _ordered_uint64(bits: int) -> int: + return (~bits + 1) & ((1 << 64) - 1) if bits >> 63 else bits | (1 << 63) + + +def _ulp_distance(left: np.uint64, right: np.uint64) -> int: + return abs(_ordered_uint64(int(left)) - _ordered_uint64(int(right))) + + +def _assert_bitwise(actual: np.ndarray, expected: np.ndarray, *, case_id: str) -> None: + differing = _bits(actual) != _bits(expected) + if not differing.any(): + return + row, column = (int(item) for item in np.argwhere(differing)[0]) + pytest.fail( + f"case={case_id} coordinate=({row}, {column}) " + f"expected_bits={_bits(expected)[row, column]:016x} " + f"actual_bits={_bits(actual)[row, column]:016x}" + ) + + +def _channel(data: np.ndarray, *, xreal: float, yreal: float) -> SPMChannel: + return SPMChannel( + name="Align Rows fixture", + data=data, + unit="V", + x_range=xreal, + y_range=yreal, + direction="backward", + group="Frozen Align Rows evidence", + metadata={"source": "gwyddion-2.71-align-rows", "context": {"id": 64}}, + ) + + +def _run(case: dict[str, Any], arrays: dict[str, np.ndarray], channel: SPMChannel) -> SPMChannel: + function = _FUNCTIONS[int(case["method"])] + kwargs: dict[str, object] = { + "mask": None if case["mask_key"] is None else arrays[str(case["mask_key"])], + "mask_mode": _MASK_MODES[int(case["masking_mode"])], + "direction": _DIRECTIONS[int(case["direction"])], + } + if int(case["method"]) in {5, 6}: + kwargs["trim_fraction"] = float.fromhex(str(case["trim_fraction_hex"])) + return function(channel, **kwargs) + + +def test_public_exports_types_and_signatures() -> None: + expected = { + "GwyddionAlignRowsDirection", + "GwyddionAlignRowsMaskMode", + "gwyddion_align_rows_median", + "gwyddion_align_rows_median_of_differences", + "gwyddion_align_rows_trimmed_mean", + "gwyddion_align_rows_trimmed_mean_of_differences", + } + assert expected <= set(analysis.__all__) + assert get_args(GwyddionAlignRowsMaskMode) == ("exclude", "include", "ignore") + assert get_args(GwyddionAlignRowsDirection) == ("horizontal", "vertical") + + for method, function in _FUNCTIONS.items(): + assert getattr(analysis, function.__name__) is function + signature = inspect.signature(function) + assert list(signature.parameters) == ( + ["channel", "trim_fraction", "mask", "mask_mode", "direction"] + if method in {5, 6} + else ["channel", "mask", "mask_mode", "direction"] + ) + assert all( + parameter.kind is inspect.Parameter.KEYWORD_ONLY + for name, parameter in signature.parameters.items() + if name != "channel" + ) + assert "extract_background" not in signature.parameters + assert "method" not in signature.parameters + for private_name in ( + "_GwyddionAlignRowsDirection", + "_GwyddionAlignRowsMethod", + "_GwyddionAlignRowsStatisticsResult", + "_GwyddionMaskMode", + "_gwyddion_align_rows_statistics_result", + ): + assert private_name not in analysis.__all__ + assert not hasattr(analysis, private_name) + + +def test_all_portable_cases_are_bitwise_exact_deterministic_and_non_mutating() -> None: + manifest, arrays = _load() + exact_elements = mutation_matches = no_op_matches = 0 + seen_methods: set[int] = set() + seen_modes: set[int] = set() + seen_directions: set[int] = set() + seen_trims: set[float] = set() + absent_mask_modes: set[int] = set() + for case in manifest["cases"]: + source = arrays[case["input_key"]] + mask = None if case["mask_key"] is None else arrays[case["mask_key"]] + source_before = source.copy(order="C") + mask_before = None if mask is None else mask.copy(order="C") + channel = _channel( + source, + xreal=float.fromhex(case["xreal_hex"]), + yreal=float.fromhex(case["yreal_hex"]), + ) + first = _run(case, arrays, channel) + second = _run(case, arrays, channel) + expected = arrays[case["portable_corrected_key"]] + _assert_bitwise(first.data, expected, case_id=case["case_identifier"]) + _assert_bitwise(second.data, first.data, case_id=case["case_identifier"] + "/repeat") + assert first.data.dtype == np.float64 and first.data.flags.c_contiguous + assert first.data.shape == source.shape + assert not np.shares_memory(first.data, source) + assert np.array_equal(_bits(source), _bits(source_before)) + if mask is not None: + assert mask_before is not None + assert np.array_equal(_bits(mask), _bits(mask_before)) + else: + absent_mask_modes.add(int(case["masking_mode"])) + changed = bool((_bits(first.data) != _bits(source)).any()) + mutation_matches += int(changed == case["portable_mutated"] == case["installed_mutated"]) + no_op_matches += int((not changed) == (not case["portable_mutated"])) + exact_elements += first.data.size + seen_methods.add(int(case["method"])) + seen_modes.add(int(case["masking_mode"])) + seen_directions.add(int(case["direction"])) + seen_trims.add(float.fromhex(case["trim_fraction_hex"])) + assert exact_elements == 3888 + assert mutation_matches == no_op_matches == 64 + assert seen_methods == {1, 2, 5, 6} + assert seen_modes == {0, 1, 2} + assert seen_directions == {0, 1} + assert {0.0, 0.05, 0.5} <= seen_trims + assert absent_mask_modes == {0} + + +@pytest.mark.parametrize("function", list(_FUNCTIONS.values())) +def test_absent_mask_ignores_the_stored_mask_mode(function: Callable[..., SPMChannel]) -> None: + channel = _channel( + np.array([[1.0, 2.0, 4.0], [4.0, 5.0, 8.0], [7.0, 8.0, 12.0]], dtype=np.float64), + xreal=3.0, + yreal=3.0, + ) + kwargs: dict[str, object] = {"mask": None, "direction": "vertical"} + if function in { + gwyddion_align_rows_trimmed_mean, + gwyddion_align_rows_trimmed_mean_of_differences, + }: + kwargs["trim_fraction"] = 0.05 + outputs = [ + function(channel, mask_mode=mask_mode, **kwargs) + for mask_mode in ("exclude", "include", "ignore") + ] + _assert_bitwise(outputs[0].data, outputs[1].data, case_id="absent-mask/include") + _assert_bitwise(outputs[0].data, outputs[2].data, case_id="absent-mask/ignore") + + +def test_absolute_mask_thresholds_ignore_routing_and_global_fallback() -> None: + data = np.array([[100.0, -1000.0, 7.0, 0.0], [200.0, -800.0, 9.0, 10.0]]) + mask = np.array([[-0.0, 0.0, 0.5, 1.0], [-0.0, 0.0, 0.5, 1.0]]) + channel = _channel(data, xreal=4.0, yreal=2.0) + included = gwyddion_align_rows_median(channel, mask=mask, mask_mode="include") + excluded = gwyddion_align_rows_median(channel, mask=mask, mask_mode="exclude") + ignored = gwyddion_align_rows_median(channel, mask=mask, mask_mode="ignore") + _assert_bitwise( + included.data, + np.array([[101.5, -998.5, 8.5, 1.5], [198.5, -801.5, 7.5, 8.5]]), + case_id="absolute/include", + ) + _assert_bitwise( + excluded.data, + np.array([[101.0, -999.0, 8.0, 1.0], [199.0, -801.0, 8.0, 9.0]]), + case_id="absolute/exclude", + ) + _assert_bitwise(included.data, ignored.data, case_id="absolute/ignore") + + fallback_data = np.array( + [[0.0, 70.0, 80.0, 90.0], [10.0, 100.0, 80.0, 90.0], [30.0, 70.0, 80.0, 90.0]] + ) + fallback_mask = np.array([[1.0, 0.0, 0.0, 0.0], [1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]]) + fallback = gwyddion_align_rows_median( + _channel(fallback_data, xreal=4.0, yreal=3.0), + mask=fallback_mask, + mask_mode="include", + ) + _assert_bitwise( + fallback.data, + fallback_data - np.array([[-30.0], [60.0], [-30.0]]), + case_id="absolute/zero-one-fallback", + ) + + +def test_difference_joint_thresholds_and_zero_one_pair_fallback() -> None: + data = np.array( + [[0.0, 0.0, 100.0, 500.0], [10.0, 10.0, 1000.0, 700.0], [40.0, 40.0, 2000.0, 900.0]] + ) + channel = _channel(data, xreal=4.0, yreal=3.0) + include_mask = np.array([[2.0, 2.0, 1.0, 0.5]] * 3) + exclude_mask = np.array([[0.0, 0.5, 1.0, 2.0]] * 3) + included = gwyddion_align_rows_median_of_differences( + channel, mask=include_mask, mask_mode="include" + ) + excluded = gwyddion_align_rows_median_of_differences( + channel, mask=exclude_mask, mask_mode="exclude" + ) + assert not np.array_equal(_bits(included.data), _bits(data)) + assert not np.array_equal(_bits(excluded.data), _bits(data)) + + one_pair_mask = np.array([[2.0, 0.0, 0.0, 0.0]] * 3) + fallback = gwyddion_align_rows_median_of_differences( + channel, mask=one_pair_mask, mask_mode="include" + ) + _assert_bitwise(fallback.data, data, case_id="difference/zero-one-pair-fallback") + + +def test_installed_fast_math_profile_matches_the_frozen_exception_policy() -> None: + manifest, arrays = _load() + exact_arrays = exact_elements = finite_nonzero = signed_zero = nan = infinity = 0 + maximum_absolute = 0.0 + maximum_ulp = 0 + exceptional_cases: set[str] = set() + mutation_matches = 0 + for case in manifest["cases"]: + source = arrays[case["input_key"]] + channel = _channel( + source, + xreal=float.fromhex(case["xreal_hex"]), + yreal=float.fromhex(case["yreal_hex"]), + ) + portable = _run(case, arrays, channel).data + installed = arrays[case["installed_corrected_key"]] + differing = _bits(portable) != _bits(installed) + exact_arrays += int(not differing.any()) + exact_elements += int((~differing).sum()) + if differing.any(): + exceptional_cases.add(case["case_identifier"]) + for row, column in np.argwhere(differing): + left = portable[row, column] + right = installed[row, column] + if np.isnan(left) or np.isnan(right): + nan += 1 + elif np.isinf(left) or np.isinf(right): + infinity += 1 + elif left == right == 0.0: + signed_zero += 1 + else: + finite_nonzero += 1 + maximum_absolute = max(maximum_absolute, abs(left - right)) + maximum_ulp = max( + maximum_ulp, + _ulp_distance(_bits(portable)[row, column], _bits(installed)[row, column]), + ) + changed = bool((_bits(portable) != _bits(source)).any()) + mutation_matches += int(changed == case["installed_mutated"]) + assert exact_arrays == 61 + assert exact_elements == 3757 + assert finite_nonzero == 128 + assert signed_zero == 3 + assert nan == infinity == 0 + assert maximum_absolute <= 5.329070518200751e-15 + assert maximum_ulp <= 144 + assert exceptional_cases == _EXCEPTIONAL_CASES + assert mutation_matches == 64 + + +def test_channel_context_is_preserved_with_independent_metadata_and_output() -> None: + manifest, arrays = _load() + case = next( + item for item in manifest["cases"] if item["case_identifier"] == "median__constant__00" + ) + source = arrays[case["input_key"]].copy(order="C") + channel = _channel( + source, + xreal=float.fromhex(case["xreal_hex"]), + yreal=float.fromhex(case["yreal_hex"]), + ) + output = _run(case, arrays, channel) + assert output.name == channel.name and output.unit == channel.unit + assert output.x_range == channel.x_range and output.y_range == channel.y_range + assert output.direction == channel.direction and output.group == channel.group + assert output.metadata == channel.metadata and output.metadata is not channel.metadata + output.metadata["new_key"] = True + assert "new_key" not in channel.metadata + assert output.data.flags.c_contiguous and not np.shares_memory(output.data, channel.data) + + +@pytest.mark.parametrize( + ("function", "kwargs", "error_type"), + [ + (gwyddion_align_rows_median, {"mask_mode": "selected"}, ValueError), + (gwyddion_align_rows_median, {"direction": "diagonal"}, ValueError), + (gwyddion_align_rows_median, {"mask": np.ones((2, 3))}, ValueError), + (gwyddion_align_rows_median, {"mask": np.array([[np.nan, 0.0], [0.0, 0.0]])}, ValueError), + (gwyddion_align_rows_median, {"mask": np.array([["mask"]])}, TypeError), + (gwyddion_align_rows_trimmed_mean, {"trim_fraction": -0.01}, ValueError), + (gwyddion_align_rows_trimmed_mean, {"trim_fraction": 0.51}, ValueError), + (gwyddion_align_rows_trimmed_mean_of_differences, {"trim_fraction": True}, TypeError), + ], +) +def test_public_validation_errors( + function: Callable[..., SPMChannel], kwargs: dict[str, object], error_type: type[Exception] +) -> None: + channel = _channel(np.ones((2, 2), dtype=np.float64), xreal=2.0, yreal=2.0) + with pytest.raises(error_type): + function(channel, **kwargs) + + +def test_each_public_call_delegates_to_the_private_entry_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + def counted_entry(*args: object, **kwargs: object) -> SimpleNamespace: + calls.append(dict(kwargs)) + return SimpleNamespace(corrected=np.ones((2, 2), dtype=np.float64)) + + monkeypatch.setattr(leveling_module, "_gwyddion_align_rows_statistics_result", counted_entry) + channel = _channel(np.zeros((2, 2), dtype=np.float64), xreal=2.0, yreal=2.0) + for method, function in _FUNCTIONS.items(): + kwargs: dict[str, object] = {} + if method in {5, 6}: + kwargs["trim_fraction"] = 0.5 + output = function(channel, **kwargs) + assert output.data.flags.c_contiguous and output.data.dtype == np.float64 + assert len(calls) == 4 + assert [call["method"] for call in calls] == [ + leveling_module._GwyddionAlignRowsMethod.MEDIAN, + leveling_module._GwyddionAlignRowsMethod.MEDIAN_OF_DIFFERENCES, + leveling_module._GwyddionAlignRowsMethod.TRIMMED_MEAN, + leveling_module._GwyddionAlignRowsMethod.TRIMMED_MEAN_OF_DIFFERENCES, + ] + assert all("extract_background" not in call for call in calls) From 71709381bcbe449a1ca0f192bf64291dc5995ce2 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:58:57 -0400 Subject: [PATCH 75/82] docs(validation): close Gwyddion Align Rows statistics parity --- docs/api.md | 57 +++++++++++++++++++ ...ION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md | 4 +- docs/scientific-status.md | 46 +++++++++++++++ docs/validation/index.md | 48 ++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 81e011c..247cd5d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -331,6 +331,63 @@ splines, polylines, profiles, and GUI publication parameters are not part of thi executable evidence, and non-claims are defined in the [Gwyddion Path Level compatibility specification](design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md). +## Gwyddion 2.71 Align Rows statistics + +SPM-Kit exposes four explicit, non-mutating Gwyddion Align Rows statistics transforms. They are +separate from the existing generic `align_rows`, whose semantics are not described as +Gwyddion-compatible. + +```python +from spmkit.core.analysis import ( + gwyddion_align_rows_median, + gwyddion_align_rows_median_of_differences, + gwyddion_align_rows_trimmed_mean, + gwyddion_align_rows_trimmed_mean_of_differences, +) + +median = gwyddion_align_rows_median(channel, mask=mask, mask_mode="include") +differences = gwyddion_align_rows_median_of_differences(channel, direction="vertical") +trimmed = gwyddion_align_rows_trimmed_mean(channel, trim_fraction=0.05) +trimmed_differences = gwyddion_align_rows_trimmed_mean_of_differences( + channel, trim_fraction=0.05 +) +``` + +The public signatures are +`gwyddion_align_rows_median(channel, *, mask=None, mask_mode="ignore", direction="horizontal")` +and `gwyddion_align_rows_median_of_differences(channel, *, mask=None, mask_mode="ignore", +direction="horizontal")`; the two trimmed variants add the keyword-only +`trim_fraction=0.05`. `GwyddionAlignRowsMaskMode` is the typed literal +`"exclude" | "include" | "ignore"`; `GwyddionAlignRowsDirection` is +`"horizontal" | "vertical"`. A mask is optional, finite, numeric, and exactly channel-shaped. +Without a mask, every stored mode selects all samples. Outputs are independent C-contiguous +`float64` fields in a new `SPMChannel`, preserving name, units, physical ranges, direction, +group, and copied metadata. + +The fixed source semantics are: `Exclude = 0`, `Include = 1`, and `Ignore = 2`; vertical +processing is transpose/restore. For absolute methods Include selects mask values `> 0.0`, +Exclude selects values `< 1.0`, and undersampled rows use the global masked upper-median fallback +before mean centring all row shifts. Difference methods require both adjacent mask values `> 1.0` +(Include) or `< 1.0` (Exclude), use `+0.0` for undersampled pairs, accumulate from row zero, and +remove an unweighted least-squares row-index slope. Median is upper median. Trimmed methods use +`floor(fraction*n + 0.5)` and use upper median when trimming would leave no retained value. +The current public boundary returns only the corrected channel; private correction/background +diagnostics are intentionally not a new public result architecture. + +`portable_source_semantics` is the production contract. Public end-to-end tests are +`CROSS_VALIDATED` only within the frozen finite 64-case campaign: all `64/64` corrected arrays and +`3888/3888` elements are bitwise exact to the independent portable V2 oracle. The secondary +`installed_gwyddion_2_71_fast_math_profile` is bitwise exact in `61/64` arrays and `3757/3888` +elements. Its only recorded differences are three signed-zero elements in +`median__plateaus_signed_zero__10` and 64 finite elements in each of +`median_of_differences__irregular__11` and +`trimmed_mean_of_differences__irregular__11`, bounded by absolute difference +`5.329070518200751e-15`. The installed `process.so` +(`c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451`) was built with GCC 16.1.1 +`-ffast-math`, associative reassociation, and LTO. SPM-Kit does not emulate that local build; +no V3 was justified. The complete evidence, profile policy, and non-claims are in the +[Gwyddion Align Rows statistics compatibility specification](design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md). + ## KPFM statistics ```python diff --git a/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md index add328e..048dd7e 100644 --- a/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md +++ b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md @@ -9,7 +9,7 @@ This private SPMKit kernel covers only four Gwyddion Align Rows row-shift statis 3. Trimmed mean (`5`) 4. Trimmed mean of differences (`6`) -The frozen campaign contains 64 finite `float64` cases, sixteen per method. It is evidence for this bounded domain, not a public API commitment or a claim of universal, non-finite, other-version, performance, or adapter equivalence. The production contract is `portable_source_semantics`, represented by the frozen independent V2 oracle. The private implementation is tested against that profile; no public `align_rows` code is changed or exposed here. +The frozen campaign contains 64 finite `float64` cases, sixteen per method. It is evidence for this bounded domain, not a claim of universal, non-finite, other-version, performance, or adapter equivalence. The production contract is `portable_source_semantics`, represented by the frozen independent V2 oracle. Four explicit public `SPMChannel` wrappers delegate once to this private implementation; the existing generic `align_rows` code is unchanged and is not described as Gwyddion-compatible. The repository fixture freezes a secondary profile, `installed_gwyddion_2_71_fast_math_profile`. It was executed by the installed Gwyddion 2.71 module and is retained as external executable evidence, not as the production arithmetic contract. @@ -71,4 +71,4 @@ The installed package was built with GCC 16.1.1, `-ffast-math`, associative floa ## Evidence maturity and non-claims -This design records `SOURCE_CONFIRMED`, frozen external-probe evidence, and a bounded V2-oracle production contract. Private-kernel test success establishes software evidence only for the listed fixture domain. It does not claim SPMKit numerical verification, cross-validation, universal Gwyddion parity, non-finite equivalence, public API support, or correctness for any other Align Rows method family. Adapter/context needs remain deliberately unimplemented; GwyCompat is unchanged in this batch. +This design records `SOURCE_CONFIRMED`, frozen external-probe evidence, and a bounded V2-oracle production contract. Public end-to-end tests are `CROSS_VALIDATED` only for the listed finite 64-case campaign: the portable profile is bitwise exact, while the installed fast-math profile retains its explicit bounded exceptions. It does not claim universal Gwyddion parity, non-finite equivalence, other-version/build or performance equivalence, adapter support, or correctness for any other Align Rows method family. GwyCompat is unchanged in this batch. diff --git a/docs/scientific-status.md b/docs/scientific-status.md index fb48938..d1b9569 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -43,6 +43,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Gwyddion 2.71 Median Background | `core.analysis.background`, `core.analysis._median_background` | Frozen executable reference campaign: 36 logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, direct and radixtree reference paths; public background and corrected fields 36/36 bitwise exact, maximum absolute difference 0 and maximum ULP 0; input mutation maximum 0 and reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen 36-case campaign | Gwyddion 2.71 source, executable probe, independent Python oracle, frozen NPZ/JSON fixture | Finite two-dimensional inputs only; no universal equivalence, performance-equivalence, future-Gwyddion, all-radii, or all-matrices claim; `rank_backend_reference` describes Gwyddion, not an SPM-Kit backend | | Gwyddion 2.71 Filter flat-disc morphology | `core.analysis.background`, `core.analysis._gwyddion_flat_disc_morphology` | Frozen executable reference campaign: 12 fields, six sizes 2/3/4/5/30/31, 72 Opening and 72 Closing cases; kernels 30/30, Opening 72/72 and Closing 72/72 bitwise exact; maximum absolute difference 0, maximum ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 executable, corrected external probe V3, executable reduction trace, independent oracle V2, frozen NPZ/JSON fixture | Finite full-field data with masks ignored; no universal equivalence, NaN/Inf, ROI, masks, ASF, tip morphology, physical rolling-ball, performance, other builds/versions, public erosion/dilation, or source-only tie claim | | Gwyddion 2.71 Path Level | `core.analysis.leveling`, `core.analysis._gwyddion_path_level` | Audited executable campaign: 18 base families, thicknesses 1/2/3/128, 72 logical cases, 144 fresh external executions and 72 deterministic repeat pairs; private and public arrays 72/72 bitwise exact, 4,652/4,652 elements exact, max absolute/ULP 0, signed-zero mismatches 0, normalized endpoints and mutation/no-op classifications 72/72 | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 Path Level tool, external probe, independent oracle V1, frozen NPZ/JSON fixture | Finite non-empty full fields and ordered straight selections only; no universal equivalence, NaN/Inf, masks/ROI, paths/splines, profiles, align-rows, volume, GUI, performance, or other-build/version claim | +| Gwyddion 2.71 Align Rows statistics | `core.analysis.leveling`, `core.analysis._gwyddion_align_rows_statistics` | Public 64-case finite campaign: portable source semantics 64/64 arrays and 3,888/3,888 elements bitwise exact; installed fast-math profile 61/64 arrays and 3,757/3,888 elements exact, with only three signed-zero and 128 independently explained reassociation differences | CROSS_VALIDATED within the frozen dual-profile campaign | Gwyddion 2.71 source, external executable probe, independent portable V2 oracle, frozen NPZ/JSON fixture, installed-build diagnosis | Four methods only; finite full fields, frozen masks/directions/trims; no universal, non-finite, performance, other-version/build, GUI, or generic-`align_rows` compatibility claim | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -205,6 +206,51 @@ infinity coverage; no masks or ROI; no GwySelectionPath, splines, or polylines; extraction, align-rows equivalence, volume line-leveling, GUI/undo/logging/selection-widget parity, performance parity, or guarantee for other Gwyddion versions or builds. +### Gwyddion 2.71 Align Rows statistics + +**Claim:** `CROSS_VALIDATED` only within the frozen finite 64-case public campaign, with sixteen +cases each for Median, Median of differences, Trimmed mean, and Trimmed mean of differences. +The production contract is `portable_source_semantics`: the public wrappers are bitwise exact to +the independent V2 oracle in `64/64` corrected arrays and `3888/3888` elements, retaining all +frozen mask modes, absent-mask routes, horizontal/vertical orientations, trim fractions `0.0`, +`0.05`, and `0.5`, mutation/no-op classifications, and deterministic output. The wrappers return +new context-preserving `SPMChannel` instances and do not claim Gwyddion GUI, publication, undo, +or mutation behavior. + +The secondary `installed_gwyddion_2_71_fast_math_profile` is external executable evidence from +`/usr/lib/gwyddion/modules/process/process.so` +(`c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451`). It is bitwise exact for +`61/64` arrays and `3757/3888` elements. The complete and bounded exception set is three +signed-zero-only Median elements in `median__plateaus_signed_zero__10` plus 64 finite elements in +each of `median_of_differences__irregular__11` and +`trimmed_mean_of_differences__irregular__11`; their maximum absolute difference is +`5.329070518200751e-15`, with no NaN or infinity discrepancy. All eight requested backgrounds +(`504/504` elements) are bitwise exact and mutation/no-op classifications agree `64/64`. + +The installed package build diagnosis is `INSTALLED_BUILD_ROOT_CAUSE_CONFIRMED` and +`V3_NOT_JUSTIFIED`: GCC 16.1.1 `-ffast-math`, associative floating-point reassociation, and LTO +produce the two irregular difference-method residuals. The portable source-semantic arithmetic +is deliberate; SPM-Kit does not emulate that package-specific transformation and introduces no +named-case or signed-zero patch. The public functions are explicit alternatives to, not a +compatibility claim for, the existing generic `align_rows`. + +**Traceability:** + +```text +.reference/gwyddion-2.71/source/modules/process/linematch.c + → /tmp/spmkit_align_rows_probe_v1 + → /tmp/spmkit_align_rows_oracle_stats_v2 + → tests/validation/fixtures/gwyddion/align_rows_statistics/ + → src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py + → src/spmkit/core/analysis/leveling.py + → tests/core/test_gwyddion_align_rows_statistics.py + → docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md +``` + +**Non-claims:** no universal equivalence; no NaN/Inf, other Gwyddion version or build, untested +matrix, performance, ROI/GUI, adapter, or other Align Rows method-family claim. This finite +campaign does not establish physical validation or general SPMKit parity. + ## Test-count policy The collection total is measured with: diff --git a/docs/validation/index.md b/docs/validation/index.md index eab40d6..6cf93e8 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -41,6 +41,7 @@ references, tolerances, outputs, hashes, and limitations. | Gwyddion Median Background 2.71 v1 | Local rank background on 36 frozen logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, and both direct/radixtree reference paths | Public background and corrected fields 36/36 bitwise exact; maximum absolute difference 0, maximum ULP 0, input mutation maximum 0, reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 only; finite inputs; no universal, performance, future-version, all-radii, or all-matrices claim; no public border/shape/rank configuration | | Gwyddion Filter flat-disc morphology 2.71 v1 | 12 frozen fields, six sizes 2/3/4/5/30/31, full-field mask-ignore Opening and Closing | Kernels 30/30; Opening 72/72 and Closing 72/72 bitwise exact; max absolute difference 0, max ULP 0, signed-zero mismatches 0, input mutation 0 | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 executable only; finite full-field data; no universal, NaN/Inf, ROI, mask, ASF, tip, physical rolling-ball, performance, other-build, public erosion/dilation, or source-only tie claim | | Gwyddion Path Level 2.71 v1 | 18 frozen finite full-field families, ordered straight physical selections, thicknesses 1/2/3/128, 72 logical cases and 144 fresh external executions | Public arrays 72/72 bitwise exact, 4,652/4,652 elements exact; max absolute/ULP 0, signed-zero mismatches 0, 72/72 repeat pairs, normalized endpoints, and mutation/no-op classifications | CROSS_VALIDATED within the frozen campaign | Audited Gwyddion 2.71 Path Level executable only; no universal, NaN/Inf, ROI/mask, path/spline, profile, align-rows, volume, GUI, performance, other-build/version claim | +| Gwyddion Align Rows statistics 2.71 v1 | 64 finite cases, 16 each for Median, Median of differences, Trimmed mean, and Trimmed mean of differences; numeric masks, absent masks, both directions, and trims 0/0.05/0.5 | Portable source semantics: public 64/64 arrays and 3,888/3,888 elements bitwise exact. Installed fast-math profile: 61/64 arrays and 3,757/3,888 elements exact; only 3 signed-zero and 128 explained reassociation differences | CROSS_VALIDATED within the frozen dual-profile campaign | Finite frozen domain only; no universal, NaN/Inf, performance, other-version/build, GUI, adapter, or generic-`align_rows` compatibility claim | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and @@ -171,6 +172,53 @@ returns a new `SPMChannel`. No claim is made for universal equivalence, NaN/Inf, GwySelectionPath, splines/polylines, profiles, align-rows, volume line-leveling, GUI/undo/logging or selection-widget parity, performance, or other Gwyddion versions or builds. +### Gwyddion 2.71 Align Rows statistics + +The public validation trace is: + +```text +Gwyddion source +→ installed external probe +→ independent portable V2 oracle +→ frozen dual-profile repository fixture +→ private SPMKit kernel +→ public SPMChannel wrappers +→ public bitwise tests +→ CROSS_VALIDATED status +``` + +The records are `.reference/gwyddion-2.71/source/modules/process/linematch.c`, +`/tmp/spmkit_align_rows_probe_v1`, `/tmp/spmkit_align_rows_oracle_stats_v2`, +`tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.npz`, +`tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json`, +`docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md`, +`src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py`, +`src/spmkit/core/analysis/leveling.py`, +`tests/core/test_gwyddion_align_rows_statistics_private.py`, +`tests/core/test_gwyddion_align_rows_statistics.py`, and +`tests/validation/test_gwyddion_align_rows_statistics_fixture_integrity.py`. + +`portable_source_semantics` is the production contract. Public output is bitwise exact to the +frozen V2 oracle for all `64/64` corrected arrays and `3888/3888` elements. This is +`CROSS_VALIDATED` only in the frozen finite campaign: 16 cases per supported method, numeric and +absent masks, Exclude/Include/Ignore routing, horizontal/vertical orientation, and trim fractions +`0.0`, `0.05`, and `0.5`. All eight requested background arrays (`504/504` elements) are bitwise +exact across profiles, and mutation/no-op classifications agree `64/64`. + +The installed Gwyddion 2.71 `process.so` +(`c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451`) is a secondary profile, +`installed_gwyddion_2_71_fast_math_profile`: public corrected arrays are bitwise exact in `61/64` +arrays and `3757/3888` elements. The exact exception set is three signed-zero-only elements in +`median__plateaus_signed_zero__10`, and 64 finite elements each in +`median_of_differences__irregular__11` and +`trimmed_mean_of_differences__irregular__11`, bounded by maximum absolute difference +`5.329070518200751e-15`, with no NaN/Inf mismatch. The installed-build diagnosis confirms GCC +16.1.1 `-ffast-math` associative reassociation with LTO; SPM-Kit deliberately preserves portable +source arithmetic rather than emulate that local build. Therefore V3 is not justified. + +No claim is made for non-finite fields, universal or performance equivalence, another Gwyddion +version/build, ROI/GUI/adapters, other Align Rows families, or the existing generic `align_rows`. + ## What remains open - redistributable multi-instrument fixtures for built-in and adapter readers; From 02c609d0b7b0eca20d413257e9da130604fc372b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:37:36 -0400 Subject: [PATCH 76/82] fix(leveling): preserve legacy align_rows contract --- ...ION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md | 2 +- src/spmkit/core/analysis/leveling.py | 36 ++++++++++ .../test_gwyddion_align_rows_statistics.py | 18 +++++ tests/core/test_leveling.py | 70 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md index 048dd7e..1d51d10 100644 --- a/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md +++ b/docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md @@ -9,7 +9,7 @@ This private SPMKit kernel covers only four Gwyddion Align Rows row-shift statis 3. Trimmed mean (`5`) 4. Trimmed mean of differences (`6`) -The frozen campaign contains 64 finite `float64` cases, sixteen per method. It is evidence for this bounded domain, not a claim of universal, non-finite, other-version, performance, or adapter equivalence. The production contract is `portable_source_semantics`, represented by the frozen independent V2 oracle. Four explicit public `SPMChannel` wrappers delegate once to this private implementation; the existing generic `align_rows` code is unchanged and is not described as Gwyddion-compatible. +The frozen campaign contains 64 finite `float64` cases, sixteen per method. It is evidence for this bounded domain, not a claim of universal, non-finite, other-version, performance, or adapter equivalence. The production contract is `portable_source_semantics`, represented by the frozen independent V2 oracle. Four explicit public `SPMChannel` wrappers delegate once to this private implementation. The generic SPMKit `align_rows` dispatcher remains a separate backward-compatible extension for historical median/mean calls and is not described as Gwyddion-compatible. The repository fixture freezes a secondary profile, `installed_gwyddion_2_71_fast_math_profile`. It was executed by the installed Gwyddion 2.71 module and is retained as external executable evidence, not as the production arithmetic contract. diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 12eccdf..8b50603 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -1169,6 +1169,19 @@ def _matching_row_corrections( return corrections +def _legacy_align_rows( + channel: SPMChannel, + method: Literal["median", "mean"], +) -> SPMChannel: + """Preserve the origin/main default median/mean call semantics.""" + data = channel.data + if method == "median": + baseline = np.median(data, axis=1, keepdims=True) + else: + baseline = np.mean(data, axis=1, keepdims=True) + return channel.with_data(data - baseline) + + def _difference_row_corrections( data: np.ndarray, selection: np.ndarray, @@ -1231,10 +1244,33 @@ def align_rows( ) -> SPMChannel: """Align rows by subtracting a fitted or representative row background. + Historical ``method="median"``/``"mean"`` calls, including positional + method arguments, retain their SPMKit behavior. The additional methods + and keyword options are a backward-compatible SPMKit extension; this + dispatcher is not the Gwyddion compatibility contract. Use the four + explicit ``gwyddion_align_rows_*`` functions for that contract. + ``preserve_mean=False`` retains the historical SPMKit behaviour. ``preserve_mean=True`` keeps the mean correction at zero, matching the absolute-level convention used by Gwyddion. """ + legacy_defaults = ( + method in {"median", "mean"} + and mask is None + and mask_mode == "ignore" + and preserve_mean is False + and preserve_tilt is True + and isinstance(trim_fraction, (int, float, np.integer, np.floating)) + and float(trim_fraction) == 0.0 + and isinstance(polynomial_degree, (int, np.integer)) + and not isinstance(polynomial_degree, (bool, np.bool_)) + and int(polynomial_degree) == 1 + ) + if legacy_defaults and method == "median": + return _legacy_align_rows(channel, "median") + if legacy_defaults and method == "mean": + return _legacy_align_rows(channel, "mean") + data = _validated_data(channel, operation="align_rows") allowed_methods = { diff --git a/tests/core/test_gwyddion_align_rows_statistics.py b/tests/core/test_gwyddion_align_rows_statistics.py index 0c3cec2..2cc42c5 100644 --- a/tests/core/test_gwyddion_align_rows_statistics.py +++ b/tests/core/test_gwyddion_align_rows_statistics.py @@ -144,6 +144,24 @@ def test_public_exports_types_and_signatures() -> None: assert not hasattr(analysis, private_name) +def test_explicit_gwyddion_wrappers_remain_separate_from_generic_align_rows() -> None: + """The validated Gwyddion entry point is explicit and returns a new channel.""" + channel = _channel( + np.array([[1.0, 2.0], [4.0, 8.0]], dtype=np.float64), + xreal=2.0, + yreal=2.0, + ) + + gwyddion_result = gwyddion_align_rows_median(channel) + generic_result = leveling_module.align_rows(channel, method="median") + + assert gwyddion_result is not generic_result + assert gwyddion_result is not channel + assert generic_result is not channel + assert not np.shares_memory(gwyddion_result.data, generic_result.data) + assert not np.array_equal(_bits(gwyddion_result.data), _bits(generic_result.data)) + + def test_all_portable_cases_are_bitwise_exact_deterministic_and_non_mutating() -> None: manifest, arrays = _load() exact_elements = mutation_matches = no_op_matches = 0 diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index 015b425..a8e51f1 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -40,6 +40,76 @@ def test_align_rows() -> None: assert np.allclose(leveled.data, 0.0) +def test_align_rows_preserves_historical_median_mean_calls() -> None: + """The original default, positional, and keyword calls remain equivalent.""" + data = np.array( + [[1.0, 3.0, 5.0], [10.0, 20.0, 30.0]], + dtype=np.float64, + ) + channel = SPMChannel( + name="legacy", + data=data, + unit="nm", + x_range=3e-6, + y_range=2e-6, + direction="backward", + group="legacy-group", + metadata={"source": "legacy"}, + ) + original = data.copy() + + expected_median = data - np.median(data, axis=1, keepdims=True) + expected_mean = data - np.mean(data, axis=1, keepdims=True) + results = ( + leveling.align_rows(channel), + leveling.align_rows(channel, "median"), + leveling.align_rows(channel, method="median"), + leveling.align_rows(channel, "mean"), + leveling.align_rows(channel, method="mean"), + ) + + for result in results[:3]: + assert np.array_equal(result.data, expected_median) + assert result is not channel + assert not np.shares_memory(result.data, channel.data) + assert result.name == channel.name + assert result.unit == channel.unit + assert result.x_range == channel.x_range + assert result.y_range == channel.y_range + assert result.direction == channel.direction + assert result.group == channel.group + assert result.metadata == channel.metadata + + assert np.array_equal(results[3].data, expected_mean) + assert np.array_equal(results[4].data, expected_mean) + assert np.array_equal(channel.data, original) + + with pytest.raises(ValueError): + leveling.align_rows(channel, method="unknown") # type: ignore[arg-type] + + +@pytest.mark.parametrize("method", ["median", "mean"]) +def test_align_rows_legacy_calls_retain_nonfinite_behavior(method: str) -> None: + """Legacy defaults retain origin/main handling outside the strict extension.""" + data = np.array([[1.0, np.nan], [np.inf, 4.0]], dtype=np.float64) + channel = SPMChannel( + name="legacy-nonfinite", + data=data, + unit="nm", + x_range=2e-6, + y_range=2e-6, + ) + + with np.errstate(all="ignore"): + result = leveling.align_rows(channel, method=method) # type: ignore[arg-type] + + if method == "median": + expected = data - np.median(data, axis=1, keepdims=True) + else: + expected = data - np.mean(data, axis=1, keepdims=True) + assert np.array_equal(result.data, expected, equal_nan=True) + + def test_plane_fit_returns_new_channel_without_mutating_input( tilted_surface: SPMChannel, ) -> None: From f55be0f2b1c8a332b94e55873b61379a0a065597 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:37:36 -0400 Subject: [PATCH 77/82] fix(types): restore strict mypy compliance --- src/spmkit/core/analysis/_flatten_base.py | 2 +- src/spmkit/gui/viewmodels/image_vm.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index c203ff9..324f102 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -558,7 +558,7 @@ def rank_and_condition( ) best_parameters = parameters.copy() - residual_sum_best = finite_limit + residual_sum_best: float = finite_limit gradient = np.empty(parameter_count, dtype=float) normal = np.empty(packed_size, dtype=float) diff --git a/src/spmkit/gui/viewmodels/image_vm.py b/src/spmkit/gui/viewmodels/image_vm.py index 1e99007..7e8e69d 100644 --- a/src/spmkit/gui/viewmodels/image_vm.py +++ b/src/spmkit/gui/viewmodels/image_vm.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal, cast from PyQt6.QtCore import QObject, pyqtSignal @@ -17,6 +17,8 @@ from spmkit.core.analysis.profiles import line as profile_line from spmkit.core.models import SPMChannel, SPMData +RowStatistic = Literal["median", "mean"] + def channel_labels(data: SPMData | None) -> list[str]: """Etiquetas **únicas** por canal, para los selectores de la GUI. @@ -52,7 +54,7 @@ def __init__(self, parent: QObject | None = None) -> None: self._channel_index = 0 # identidad del canal activo (por posición, no por nombre) self._leveling = "plane" self._poly_order = 2 # grado del nivelado polinómico - self._row_stat = "median" # estadístico del alineado por filas + self._row_stat: RowStatistic = "median" # estadístico del alineado por filas self._tip_work_function: float | None = None # eV; para phi de la muestra (KPFM) self._last_profile: Profile | None = None @@ -131,7 +133,7 @@ def poly_order(self) -> int: return self._poly_order @property - def row_stat(self) -> str: + def row_stat(self) -> RowStatistic: return self._row_stat def set_poly_order(self, order: int) -> None: @@ -144,7 +146,7 @@ def set_poly_order(self, order: int) -> None: def set_row_stat(self, stat: str) -> None: """Estadístico del alineado por filas (``"median"``/``"mean"``).""" if stat != self._row_stat and stat in ("median", "mean"): - self._row_stat = stat + self._row_stat = cast(RowStatistic, stat) if self._leveling == "rows": self.channelChanged.emit(self.channel) From 704562b0849965757952ae3fb4773a53f7f4038f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:30:33 -0400 Subject: [PATCH 78/82] style: format Gwyddion parity implementation --- src/spmkit/compat/gwyddion/reports.py | 4 +- src/spmkit/compat/gwyddion/source_audit.py | 3 +- src/spmkit/core/analysis/_flatten_base.py | 484 ++++-------------- .../core/analysis/_gwyddion_arc_revolution.py | 75 +-- .../analysis/_gwyddion_sphere_revolution.py | 4 +- .../core/analysis/_median_background.py | 15 +- src/spmkit/core/analysis/_pspline.py | 4 +- src/spmkit/core/analysis/background.py | 19 +- src/spmkit/core/analysis/leveling.py | 24 +- tests/core/test_flatten_base_core.py | 180 ++----- ...test_gwyddion_arc_revolution_background.py | 12 +- .../test_gwyddion_arc_revolution_kernel.py | 86 ++-- .../test_gwyddion_flat_disc_morphology.py | 4 +- ...t_gwyddion_flat_disc_morphology_private.py | 6 +- tests/core/test_gwyddion_median_background.py | 4 +- ...test_gwyddion_median_background_private.py | 6 +- tests/core/test_gwyddion_path_level.py | 10 +- .../core/test_gwyddion_path_level_private.py | 7 +- .../test_arc_revolution_vs_gwyddion.py | 51 +- 19 files changed, 289 insertions(+), 709 deletions(-) diff --git a/src/spmkit/compat/gwyddion/reports.py b/src/spmkit/compat/gwyddion/reports.py index 354465e..a4cbe87 100644 --- a/src/spmkit/compat/gwyddion/reports.py +++ b/src/spmkit/compat/gwyddion/reports.py @@ -223,9 +223,7 @@ def report_from_dict(value: dict[str, Any]) -> GwyddionModuleAuditReport: classification=SymbolClassification(item["classification"]), support_status=SymbolSupportStatus(item["support_status"]), occurrences=tuple(_span_from_dict(span) for span in item["occurrences"]), - call_occurrences=tuple( - _span_from_dict(span) for span in item["call_occurrences"] - ), + call_occurrences=tuple(_span_from_dict(span) for span in item["call_occurrences"]), ) for item in value["gwyddion_symbols"] ), diff --git a/src/spmkit/compat/gwyddion/source_audit.py b/src/spmkit/compat/gwyddion/source_audit.py index a021efd..3a8914a 100644 --- a/src/spmkit/compat/gwyddion/source_audit.py +++ b/src/spmkit/compat/gwyddion/source_audit.py @@ -337,8 +337,7 @@ def audit_gwyddion_source( ) blockers: list[str] = [] if any( - dependency.classification is SymbolClassification.GUI_GTK - for dependency in dependencies + dependency.classification is SymbolClassification.GUI_GTK for dependency in dependencies ): blockers.append("GUI/GTK dependency requires an explicit adapter and remains unsupported.") if selection: diff --git a/src/spmkit/core/analysis/_flatten_base.py b/src/spmkit/core/analysis/_flatten_base.py index 324f102..e827105 100644 --- a/src/spmkit/core/analysis/_flatten_base.py +++ b/src/spmkit/core/analysis/_flatten_base.py @@ -59,9 +59,7 @@ def _gwyddion_height_distribution(data: np.ndarray) -> HeightDistribution: centers = minimum + (np.arange(bin_count, dtype=float) + 0.5) * bin_width flat_values = values.ravel() - indices = np.floor( - (flat_values - minimum) * bin_count / histogram_range - ).astype(np.intp) + indices = np.floor((flat_values - minimum) * bin_count / histogram_range).astype(np.intp) indices[flat_values == maximum] = bin_count - 1 valid = (indices >= 0) & (indices < bin_count) @@ -114,9 +112,7 @@ def _select_base_peak_window( if centers.size != density.size: raise ValueError("base peak estimation requires matching centers and density") if centers.size < 7: - raise ValueError( - "base peak estimation requires at least seven histogram bins" - ) + raise ValueError("base peak estimation requires at least seven histogram bins") if not np.all(np.isfinite(centers)) or not np.all(np.isfinite(density)): raise ValueError("base peak estimation requires finite histogram data") if not np.isfinite(distribution.bin_width) or distribution.bin_width <= 0.0: @@ -212,14 +208,10 @@ def _gwyddion_cholesky_decompose( ) -> bool: """Decompose a packed SPD matrix using Gwyddion's loop order.""" for diagonal in range(dimension): - value = float( - packed[_packed_lower_index(diagonal, diagonal)] - ) + value = float(packed[_packed_lower_index(diagonal, diagonal)]) for index in range(diagonal): - factor = float( - packed[_packed_lower_index(diagonal, index)] - ) + factor = float(packed[_packed_lower_index(diagonal, index)]) value -= factor * factor if value <= 0.0: @@ -229,27 +221,14 @@ def _gwyddion_cholesky_decompose( packed[_packed_lower_index(diagonal, diagonal)] = root for row in range(diagonal + 1, dimension): - value = float( - packed[_packed_lower_index(row, diagonal)] - ) + value = float(packed[_packed_lower_index(row, diagonal)]) for index in range(diagonal): - value -= ( - float( - packed[ - _packed_lower_index(diagonal, index) - ] - ) - * float( - packed[ - _packed_lower_index(row, index) - ] - ) + value -= float(packed[_packed_lower_index(diagonal, index)]) * float( + packed[_packed_lower_index(row, index)] ) - packed[_packed_lower_index(row, diagonal)] = ( - value / root - ) + packed[_packed_lower_index(row, diagonal)] = value / root return True @@ -263,28 +242,18 @@ def _gwyddion_cholesky_solve( for row in range(dimension): for column in range(row): right_hand_side[row] -= ( - decomposition[ - _packed_lower_index(row, column) - ] - * right_hand_side[column] + decomposition[_packed_lower_index(row, column)] * right_hand_side[column] ) - right_hand_side[row] /= decomposition[ - _packed_lower_index(row, row) - ] + right_hand_side[row] /= decomposition[_packed_lower_index(row, row)] for row in range(dimension - 1, -1, -1): for column in range(row + 1, dimension): right_hand_side[row] -= ( - decomposition[ - _packed_lower_index(column, row) - ] - * right_hand_side[column] + decomposition[_packed_lower_index(column, row)] * right_hand_side[column] ) - right_hand_side[row] /= decomposition[ - _packed_lower_index(row, row) - ] + right_hand_side[row] /= decomposition[_packed_lower_index(row, row)] def _gwyddion_cholesky_invert( @@ -315,9 +284,7 @@ def _gwyddion_cholesky_invert( for index in range(packed_offset, row_end): packed[index - (row + 1)] = ( - packed[index + 1] - + element - * temporary[index - packed_offset] + packed[index + 1] + element * temporary[index - packed_offset] ) packed[row_end] = 1.0 / scale @@ -336,24 +303,13 @@ def _fit_base_peak_gwyddion_lm( density = np.asarray(window.density, dtype=float) if centers.ndim != 1 or density.ndim != 1: - raise ValueError( - "base peak fitting requires one-dimensional data" - ) + raise ValueError("base peak fitting requires one-dimensional data") if centers.size != density.size: - raise ValueError( - "base peak fitting requires matching centers and density" - ) + raise ValueError("base peak fitting requires matching centers and density") if centers.size < 4: - raise ValueError( - "base peak fitting requires at least four samples" - ) - if ( - not np.all(np.isfinite(centers)) - or not np.all(np.isfinite(density)) - ): - raise ValueError( - "base peak fitting requires finite data" - ) + raise ValueError("base peak fitting requires at least four samples") + if not np.all(np.isfinite(centers)) or not np.all(np.isfinite(density)): + raise ValueError("base peak fitting requires finite data") parameters = np.array( [ @@ -366,13 +322,9 @@ def _fit_base_peak_gwyddion_lm( ) if not np.all(np.isfinite(parameters)): - raise ValueError( - "base peak fitting requires finite initial parameters" - ) + raise ValueError("base peak fitting requires finite initial parameters") if parameters[3] == 0.0: - raise ValueError( - "base peak fitting requires a non-zero initial width" - ) + raise ValueError("base peak fitting requires a non-zero initial width") parameter_count = 4 packed_size = parameter_count * (parameter_count + 1) // 2 @@ -401,19 +353,13 @@ def gaussian_value( if width == 0.0: return 0.0, False - scaled = ( - float(coordinate) - float(current[0]) - ) / width + scaled = (float(coordinate) - float(current[0])) / width with np.errstate( over="ignore", invalid="ignore", ): - value = ( - float(current[2]) - * float(np.exp(-(scaled * scaled))) - + float(current[1]) - ) + value = float(current[2]) * float(np.exp(-(scaled * scaled))) + float(current[1]) return value, True @@ -449,10 +395,7 @@ def calculate_derivatives( perturbed = current.copy() for parameter_index in range(parameter_count): - step = ( - abs(float(perturbed[parameter_index])) - * derivative_scale - ) + step = abs(float(perturbed[parameter_index])) * derivative_scale if step == 0.0: step = derivative_scale @@ -475,12 +418,8 @@ def calculate_derivatives( if not valid: return derivatives, False - derivatives[parameter_index] = ( - (right - left) / (2.0 * step) - ) - perturbed[parameter_index] = current[ - parameter_index - ] + derivatives[parameter_index] = (right - left) / (2.0 * step) + perturbed[parameter_index] = current[parameter_index] return derivatives, True @@ -508,37 +447,19 @@ def rank_and_condition( compute_uv=False, ) - if ( - singular_values.size == 0 - or singular_values[0] == 0.0 - ): + if singular_values.size == 0 or singular_values[0] == 0.0: return 0, float("inf") - tolerance = ( - np.finfo(float).eps - * max(jacobian.shape) - * singular_values[0] - ) - rank = int( - np.count_nonzero( - singular_values > tolerance - ) - ) + tolerance = np.finfo(float).eps * max(jacobian.shape) * singular_values[0] + rank = int(np.count_nonzero(singular_values > tolerance)) - if ( - rank < parameter_count - or singular_values[-1] <= tolerance - ): + if rank < parameter_count or singular_values[-1] <= tolerance: return rank, float("inf") - condition = float( - singular_values[0] / singular_values[-1] - ) + condition = float(singular_values[0] / singular_values[-1]) return rank, condition - residuals, residual_sum_new, evaluation_valid = ( - calculate_residuals(parameters) - ) + residuals, residual_sum_new, evaluation_valid = calculate_residuals(parameters) if not evaluation_valid: width = abs(float(parameters[3])) @@ -590,18 +511,12 @@ def rank_and_condition( break for row in range(parameter_count): - gradient[row] += ( - derivatives[row] - * residuals[sample_index] - ) + gradient[row] += derivatives[row] * residuals[sample_index] packed_row = row * (row + 1) // 2 for column in range(row + 1): - normal[packed_row + column] += ( - derivatives[row] - * derivatives[column] - ) + normal[packed_row + column] += derivatives[row] * derivatives[column] if not evaluation_valid: break @@ -617,10 +532,7 @@ def rank_and_condition( positive_definite = False first_pass = True - while ( - not positive_definite - and np.isfinite(damping) - ): + while not positive_definite and np.isfinite(damping): if not first_pass: normal[:] = saved_normal else: @@ -629,25 +541,16 @@ def rank_and_condition( step = -gradient.copy() for parameter_index in range(parameter_count): - diagonal = ( - parameter_index - * (parameter_index + 3) - // 2 - ) + diagonal = parameter_index * (parameter_index + 3) // 2 if saved_normal[diagonal] == 0.0: normal[diagonal] = damping else: - normal[diagonal] = ( - saved_normal[diagonal] - * (1.0 + damping) - ) - - positive_definite = ( - _gwyddion_cholesky_decompose( - parameter_count, - normal, - ) + normal[diagonal] = saved_normal[diagonal] * (1.0 + damping) + + positive_definite = _gwyddion_cholesky_decompose( + parameter_count, + normal, ) if not positive_definite: @@ -673,12 +576,7 @@ def rank_and_condition( for parameter_index in range(parameter_count): if ( - abs( - float(parameters[parameter_index]) - - float( - saved_parameters[parameter_index] - ) - ) + abs(float(parameters[parameter_index]) - float(saved_parameters[parameter_index])) == 0.0 ): unchanged += 1 @@ -696,19 +594,10 @@ def rank_and_condition( residual_sum_best = -1.0 break - if ( - residual_sum_new == 0.0 - or ( - iteration > 2 - and abs( - ( - residual_sum_best - - residual_sum_new - ) - / residual_sum_best - ) - < convergence_tolerance - ) + if residual_sum_new == 0.0 or ( + iteration > 2 + and abs((residual_sum_best - residual_sum_new) / residual_sum_best) + < convergence_tolerance ): finished = True @@ -743,72 +632,45 @@ def rank_and_condition( covariance = saved_normal.copy() for parameter_index in range(parameter_count): - diagonal = ( - parameter_index - * (parameter_index + 3) - // 2 - ) + diagonal = parameter_index * (parameter_index + 3) // 2 if original_normal[diagonal] == 0.0: covariance[diagonal] = 1.0 - covariance_available = ( - _gwyddion_cholesky_invert( - parameter_count, - covariance, - ) + covariance_available = _gwyddion_cholesky_invert( + parameter_count, + covariance, ) if not covariance_available: covariance = original_normal.copy() for parameter_index in range(parameter_count): - diagonal = ( - parameter_index - * (parameter_index + 3) - // 2 - ) + diagonal = parameter_index * (parameter_index + 3) // 2 if original_normal[diagonal] == 0.0: covariance[diagonal] = 1.0 covariance[diagonal] *= 1.0001 - covariance_available = ( - _gwyddion_cholesky_invert( - parameter_count, - covariance, - ) + covariance_available = _gwyddion_cholesky_invert( + parameter_count, + covariance, ) - covariance_available = bool( - covariance_available - and np.all(np.isfinite(covariance)) - ) + covariance_available = bool(covariance_available and np.all(np.isfinite(covariance))) - finite_parameters = bool( - np.all(np.isfinite(parameters)) - ) + finite_parameters = bool(np.all(np.isfinite(parameters))) if not finite_parameters: covariance_available = False - jacobian_rank, condition_estimate = ( - rank_and_condition(parameters) - ) + jacobian_rank, condition_estimate = rank_and_condition(parameters) width = abs(float(parameters[3])) - solver_success = bool( - covariance_available - and finite_parameters - and residual_sum_best >= 0.0 - ) + solver_success = bool(covariance_available and finite_parameters and residual_sum_best >= 0.0) - residual_norm = ( - float(np.sqrt(residual_sum_best)) - if residual_sum_best >= 0.0 - else float("inf") - ) + residual_norm = float(np.sqrt(residual_sum_best)) if residual_sum_best >= 0.0 else float("inf") return BasePeakFit( mean=float(parameters[0]), @@ -899,11 +761,7 @@ def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: if singular_values.size == 0 or singular_values[0] == 0.0: return 0, float("inf") - tolerance = ( - np.finfo(float).eps - * max(matrix.shape) - * singular_values[0] - ) + tolerance = np.finfo(float).eps * max(matrix.shape) * singular_values[0] rank = int(np.count_nonzero(singular_values > tolerance)) if rank < 4 or singular_values[-1] <= tolerance: @@ -924,9 +782,7 @@ def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: ], dtype=float, ) - jacobian_rank, condition_estimate = rank_and_condition( - jacobian(parameters) - ) + jacobian_rank, condition_estimate = rank_and_condition(jacobian(parameters)) return BasePeakFit( mean=float(parameters[0]), @@ -954,9 +810,7 @@ def rank_and_condition(matrix: np.ndarray) -> tuple[int, float]: initial_width=initial_width, ) - return _fit_base_peak_gwyddion_lm( - normalized_window - ) + return _fit_base_peak_gwyddion_lm(normalized_window) @dataclass(frozen=True) @@ -983,7 +837,6 @@ def rms(self) -> float: return self.fit.rms - def _estimate_base_peak(data: np.ndarray) -> BasePeakEstimate: """Estimate the dominant base peak from a two-dimensional field.""" distribution = _gwyddion_height_distribution(data) @@ -1045,33 +898,19 @@ def positive_pixel_size(value: float, *, name: str) -> float: try: scalar = float(value) except (TypeError, ValueError) as exc: - raise TypeError( - f"facet-plane estimation requires {name} to be real" - ) from exc + raise TypeError(f"facet-plane estimation requires {name} to be real") from exc if not np.isfinite(scalar) or scalar <= 0.0: - raise ValueError( - f"facet-plane estimation requires {name} to be positive" - ) + raise ValueError(f"facet-plane estimation requires {name} to be positive") return scalar dx = positive_pixel_size(pixel_size_x, name="pixel_size_x") dy = positive_pixel_size(pixel_size_y, name="pixel_size_y") - x_slopes = ( - values[1:, 1:] - + values[:-1, 1:] - - values[1:, :-1] - - values[:-1, :-1] - ) / (2.0 * dx) + x_slopes = (values[1:, 1:] + values[:-1, 1:] - values[1:, :-1] - values[:-1, :-1]) / (2.0 * dx) - y_slopes = ( - values[1:, :-1] - + values[1:, 1:] - - values[:-1, :-1] - - values[:-1, 1:] - ) / (2.0 * dy) + y_slopes = (values[1:, :-1] + values[1:, 1:] - values[:-1, :-1] - values[:-1, 1:]) / (2.0 * dy) if not np.all(np.isfinite(x_slopes)) or not np.all(np.isfinite(y_slopes)): raise ValueError("facet-plane estimation produced non-finite slopes") @@ -1105,10 +944,7 @@ def positive_pixel_size(value: float, *, name: str) -> float: x_coefficient = physical_slope_x * dx y_coefficient = physical_slope_y * dy rows, columns = values.shape - intercept = -0.5 * ( - x_coefficient * columns - + y_coefficient * rows - ) + intercept = -0.5 * (x_coefficient * columns + y_coefficient * rows) return FacetPlaneEstimate( intercept=float(intercept), @@ -1160,20 +996,14 @@ def _run_flatten_base_facet_stage( if np.issubdtype(array.dtype, np.bool_) or np.iscomplexobj(array): raise TypeError("flatten-base facet stage requires real-valued data") if array.ndim != 2: - raise ValueError( - "flatten-base facet stage requires a two-dimensional array" - ) + raise ValueError("flatten-base facet stage requires a two-dimensional array") if array.shape[0] < 2 or array.shape[1] < 2: - raise ValueError( - "flatten-base facet stage requires at least one pixel cell" - ) + raise ValueError("flatten-base facet stage requires at least one pixel cell") try: working = np.array(array, dtype=float, copy=True) except (TypeError, ValueError) as exc: - raise TypeError( - "flatten-base facet stage requires numeric data" - ) from exc + raise TypeError("flatten-base facet stage requires numeric data") from exc if not np.all(np.isfinite(working)): raise ValueError("flatten-base facet stage requires finite data") @@ -1200,16 +1030,10 @@ def _run_flatten_base_facet_stage( termination = "degenerate_plane" break - plane_surface = ( - plane.intercept - + plane.x_coefficient * xx - + plane.y_coefficient * yy - ) + plane_surface = plane.intercept + plane.x_coefficient * xx + plane.y_coefficient * yy if not np.all(np.isfinite(plane_surface)): - raise ValueError( - "flatten-base facet stage produced a non-finite plane" - ) + raise ValueError("flatten-base facet stage produced a non-finite plane") working -= plane_surface background += plane_surface @@ -1326,22 +1150,14 @@ def _grow_mask_conn4( ) for neighbour_row, neighbour_column in neighbours: - if not ( - 0 <= neighbour_row < rows - and 0 <= neighbour_column < columns - ): + if not (0 <= neighbour_row < rows and 0 <= neighbour_column < columns): continue - if ( - distances[neighbour_row, neighbour_column] - != unreachable - ): + if distances[neighbour_row, neighbour_column] != unreachable: continue distances[neighbour_row, neighbour_column] = next_distance - next_queue.append( - (neighbour_row, neighbour_column) - ) + next_queue.append((neighbour_row, neighbour_column)) if not next_queue: break @@ -1394,9 +1210,7 @@ def _build_flatten_base_mask( if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): raise TypeError("Flatten Base masking requires real-valued data") if values.ndim != 2: - raise ValueError( - "Flatten Base masking requires a two-dimensional array" - ) + raise ValueError("Flatten Base masking requires a two-dimensional array") if isinstance(degree, (bool, np.bool_)) or not isinstance( degree, (int, np.integer), @@ -1406,16 +1220,12 @@ def _build_flatten_base_mask( degree_value = int(degree) if degree_value < 0: - raise ValueError( - "Flatten Base masking requires a non-negative degree" - ) + raise ValueError("Flatten Base masking requires a non-negative degree") try: numeric = np.asarray(values, dtype=float) except (TypeError, ValueError) as exc: - raise TypeError( - "Flatten Base masking requires numeric data" - ) from exc + raise TypeError("Flatten Base masking requires numeric data") from exc if not np.all(np.isfinite(numeric)): raise ValueError("Flatten Base masking requires finite data") @@ -1424,13 +1234,9 @@ def _build_flatten_base_mask( rms = float(peak.rms) if not np.isfinite(mean) or not np.isfinite(rms): - raise ValueError( - "Flatten Base masking requires finite peak parameters" - ) + raise ValueError("Flatten Base masking requires finite peak parameters") if rms < 0.0: - raise ValueError( - "Flatten Base masking requires non-negative peak RMS" - ) + raise ValueError("Flatten Base masking requires non-negative peak RMS") threshold = mean + 3.0 * rms growth_radius = 1 + degree_value // 2 @@ -1466,7 +1272,6 @@ def _build_flatten_base_mask( ) - @dataclass(frozen=True) class FlattenBasePolynomialIteration: """Evidence from one masked polynomial correction.""" @@ -1494,45 +1299,29 @@ def _run_flatten_base_polynomial_iteration( values = np.asarray(data) if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): - raise TypeError( - "Flatten Base polynomial iteration requires real-valued data" - ) + raise TypeError("Flatten Base polynomial iteration requires real-valued data") if values.ndim != 2: - raise ValueError( - "Flatten Base polynomial iteration requires " - "a two-dimensional array" - ) + raise ValueError("Flatten Base polynomial iteration requires " "a two-dimensional array") if values.size == 0: - raise ValueError( - "Flatten Base polynomial iteration requires non-empty data" - ) + raise ValueError("Flatten Base polynomial iteration requires non-empty data") if isinstance(degree, (bool, np.bool_)) or not isinstance( degree, (int, np.integer), ): - raise TypeError( - "Flatten Base polynomial iteration requires an integer degree" - ) + raise TypeError("Flatten Base polynomial iteration requires an integer degree") degree_value = int(degree) if degree_value < 0: - raise ValueError( - "Flatten Base polynomial iteration requires " - "a non-negative degree" - ) + raise ValueError("Flatten Base polynomial iteration requires " "a non-negative degree") try: numeric = np.asarray(values, dtype=float) except (TypeError, ValueError) as exc: - raise TypeError( - "Flatten Base polynomial iteration requires numeric data" - ) from exc + raise TypeError("Flatten Base polynomial iteration requires numeric data") from exc if not np.all(np.isfinite(numeric)): - raise ValueError( - "Flatten Base polynomial iteration requires finite data" - ) + raise ValueError("Flatten Base polynomial iteration requires finite data") if float(np.max(numeric)) <= float(np.min(numeric)): background = np.zeros_like(numeric) @@ -1611,29 +1400,17 @@ def _run_flatten_base_polynomial_iteration( ) if background.shape != values.shape: - raise ValueError( - "Flatten Base polynomial fit returned an invalid background shape" - ) + raise ValueError("Flatten Base polynomial fit returned an invalid background shape") if coefficients.ndim != 1 or coefficients.size != len(powers): - raise ValueError( - "Flatten Base polynomial fit returned invalid coefficients" - ) + raise ValueError("Flatten Base polynomial fit returned invalid coefficients") if singular_values.ndim != 1: - raise ValueError( - "Flatten Base polynomial fit returned invalid singular values" - ) + raise ValueError("Flatten Base polynomial fit returned invalid singular values") if not np.all(np.isfinite(background)): - raise ValueError( - "Flatten Base polynomial fit returned a non-finite background" - ) + raise ValueError("Flatten Base polynomial fit returned a non-finite background") if not np.all(np.isfinite(coefficients)): - raise ValueError( - "Flatten Base polynomial fit returned non-finite coefficients" - ) + raise ValueError("Flatten Base polynomial fit returned non-finite coefficients") if not np.all(np.isfinite(singular_values)): - raise ValueError( - "Flatten Base polynomial fit returned non-finite singular values" - ) + raise ValueError("Flatten Base polynomial fit returned non-finite singular values") corrected = np.array( values - background, @@ -1674,18 +1451,13 @@ class FlattenBasePolynomialStage: @property def attempted_degrees(self) -> tuple[int, ...]: """Polynomial degrees attempted by the stage.""" - return tuple( - iteration.degree - for iteration in self.iterations - ) + return tuple(iteration.degree for iteration in self.iterations) @property def completed_degrees(self) -> tuple[int, ...]: """Polynomial degrees that actually subtracted a background.""" return tuple( - iteration.degree - for iteration in self.iterations - if getattr(iteration, "applied", True) + iteration.degree for iteration in self.iterations if getattr(iteration, "applied", True) ) @@ -1698,25 +1470,17 @@ def _run_flatten_base_polynomial_stage( values = np.asarray(data) if np.issubdtype(values.dtype, np.bool_) or np.iscomplexobj(values): - raise TypeError( - "Flatten Base polynomial stage requires real-valued data" - ) + raise TypeError("Flatten Base polynomial stage requires real-valued data") if values.ndim != 2: - raise ValueError( - "Flatten Base polynomial stage requires a two-dimensional array" - ) + raise ValueError("Flatten Base polynomial stage requires a two-dimensional array") try: working = np.array(values, dtype=float, copy=True) except (TypeError, ValueError) as exc: - raise TypeError( - "Flatten Base polynomial stage requires numeric data" - ) from exc + raise TypeError("Flatten Base polynomial stage requires numeric data") from exc if not np.all(np.isfinite(working)): - raise ValueError( - "Flatten Base polynomial stage requires finite data" - ) + raise ValueError("Flatten Base polynomial stage requires finite data") accumulated_background = np.zeros_like(working) iterations: list[FlattenBasePolynomialIteration] = [] @@ -1741,23 +1505,19 @@ def _run_flatten_base_polynomial_stage( if iteration_background.shape != working.shape: raise ValueError( - "Flatten Base polynomial iteration returned " - "an invalid background shape" + "Flatten Base polynomial iteration returned " "an invalid background shape" ) if iteration_corrected.shape != working.shape: raise ValueError( - "Flatten Base polynomial iteration returned " - "an invalid corrected shape" + "Flatten Base polynomial iteration returned " "an invalid corrected shape" ) if not np.all(np.isfinite(iteration_background)): raise ValueError( - "Flatten Base polynomial iteration returned " - "a non-finite background" + "Flatten Base polynomial iteration returned " "a non-finite background" ) if not np.all(np.isfinite(iteration_corrected)): raise ValueError( - "Flatten Base polynomial iteration returned " - "non-finite corrected data" + "Flatten Base polynomial iteration returned " "non-finite corrected data" ) accumulated_background += iteration_background @@ -1855,53 +1615,29 @@ def _run_flatten_base( ) if background.shape != corrected.shape: - raise ValueError( - "Flatten Base facet stage returned incompatible shapes" - ) + raise ValueError("Flatten Base facet stage returned incompatible shapes") if polynomial_background.shape != corrected.shape: - raise ValueError( - "Flatten Base polynomial stage returned " - "incompatible shapes" - ) + raise ValueError("Flatten Base polynomial stage returned " "incompatible shapes") if corrected.size == 0: - raise ValueError( - "Flatten Base requires non-empty corrected data" - ) + raise ValueError("Flatten Base requires non-empty corrected data") if not np.all(np.isfinite(corrected)): - raise ValueError( - "Flatten Base polynomial stage returned " - "non-finite corrected data" - ) + raise ValueError("Flatten Base polynomial stage returned " "non-finite corrected data") if not np.all(np.isfinite(background)): - raise ValueError( - "Flatten Base facet stage returned " - "a non-finite background" - ) + raise ValueError("Flatten Base facet stage returned " "a non-finite background") if not np.all(np.isfinite(polynomial_background)): - raise ValueError( - "Flatten Base polynomial stage returned " - "a non-finite background" - ) + raise ValueError("Flatten Base polynomial stage returned " "a non-finite background") background += polynomial_background mean_centered = bool(final_peak.success) - mean_offset = ( - float(final_peak.mean) - if mean_centered - else 0.0 - ) + mean_offset = float(final_peak.mean) if mean_centered else 0.0 if mean_centered: corrected -= mean_offset background += mean_offset remaining_minimum = float(np.min(corrected)) - minimum_offset = ( - remaining_minimum - if remaining_minimum > 0.0 - else 0.0 - ) + minimum_offset = remaining_minimum if remaining_minimum > 0.0 else 0.0 if minimum_offset > 0.0: corrected -= minimum_offset diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index 2f8206f..acd6fc6 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -112,21 +112,12 @@ def _make_gwyddion_arc( if use_flat_arc_expansion: squared_offset = normalized_offset * normalized_offset height = ( - squared_offset - / 2.0 - * ( - 1.0 - + squared_offset - / 4.0 - * (1.0 + squared_offset / 2.0) - ) + squared_offset / 2.0 * (1.0 + squared_offset / 4.0 * (1.0 + squared_offset / 2.0)) ) elif normalized_offset > 1.0: height = 1.0 else: - height = 1.0 - math.sqrt( - 1.0 - normalized_offset * normalized_offset - ) + height = 1.0 - math.sqrt(1.0 - normalized_offset * normalized_offset) arc[size + offset] = height arc[size - offset] = height @@ -242,8 +233,7 @@ def _moving_sums( phase_3b_start = resolution - 1 - right_half if phase_3b_start <= 0 and phase_3b_start <= left_half: raise ValueError( - "Gwyddion 2.71 moving sums are undefined for this " - "resolution and window size" + "Gwyddion 2.71 moving sums are undefined for this " "resolution and window size" ) # Phase 1: fill the first output element. @@ -271,11 +261,7 @@ def _moving_sums( leaving = float(values[index - left_half - 1]) sums[index] = sums[index - 1] + entering - leaving - squared_sums[index] = ( - squared_sums[index - 1] - + entering * entering - - leaving * leaving - ) + squared_sums[index] = squared_sums[index - 1] + entering * entering - leaving * leaving # Phase 3b: a window larger than the available interior remains fixed. for index in range( @@ -369,35 +355,34 @@ def _gwyddion_arc_horizontal( for column_index in range(column_count): local_mean = local_sums[column_index] / weights[column_index] local_variance = ( - local_squared_sums[column_index] / weights[column_index] - - local_mean * local_mean + local_squared_sums[column_index] / weights[column_index] - local_mean * local_mean ) - local_rms = ( - float("nan") - if local_variance < 0.0 - else math.sqrt(local_variance) - ) + local_rms = float("nan") if local_variance < 0.0 else math.sqrt(local_variance) lower_envelope = local_mean - 2.5 * local_rms source_value = float(source_row[column_index]) # Preserve the argument ordering of GLib's MAX(a, b) macro. clipped_row[column_index] = ( - source_value - if source_value > lower_envelope - else lower_envelope + source_value if source_value > lower_envelope else lower_envelope ) for column_index in range(column_count): - first_offset = max( - 0, - column_index - half_width, - ) - column_index - final_offset = min( - column_index + half_width, - column_count - 1, - ) - column_index + first_offset = ( + max( + 0, + column_index - half_width, + ) + - column_index + ) + final_offset = ( + min( + column_index + half_width, + column_count - 1, + ) + - column_index + ) minimum = math.inf @@ -405,10 +390,7 @@ def _gwyddion_arc_horizontal( first_offset, final_offset + 1, ): - candidate = ( - -scaled_arc[half_width + offset] - + clipped_row[column_index + offset] - ) + candidate = -scaled_arc[half_width + offset] + clipped_row[column_index + offset] if candidate < minimum: minimum = float(candidate) @@ -453,8 +435,7 @@ def _gwyddion_arc_background( if direction not in ("horizontal", "vertical", "both"): raise ValueError( - "Gwyddion arc direction must be one of " - "'horizontal', 'vertical', or 'both'" + "Gwyddion arc direction must be one of " "'horizontal', 'vertical', or 'both'" ) if not isinstance(inverted, (bool, np.bool_)): @@ -463,11 +444,7 @@ def _gwyddion_arc_background( inverted_value = bool(inverted) source = np.asarray(data) - working = ( - -np.asarray(source, dtype=np.float64) - if inverted_value - else source - ) + working = -np.asarray(source, dtype=np.float64) if inverted_value else source if direction == "horizontal": background = _gwyddion_arc_horizontal( @@ -514,9 +491,7 @@ def _gwyddion_arc_result( direction=direction, inverted=inverted, ) - corrected = _readonly_float_array( - np.asarray(data, dtype=np.float64) - background - ) + corrected = _readonly_float_array(np.asarray(data, dtype=np.float64) - background) return background, corrected diff --git a/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py index a54de27..88570df 100644 --- a/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py @@ -146,9 +146,7 @@ def _gwyddion_sphere_background( ) for row in range(sphere_resolution): for column in range(sphere_resolution): - sphere_scaled[row, column] = ( - -q * float(sphere_z[row, column]) - ) + sphere_scaled[row, column] = -q * float(sphere_z[row, column]) # 6. Direct local mean field if local_filter_size == 0: diff --git a/src/spmkit/core/analysis/_median_background.py b/src/spmkit/core/analysis/_median_background.py index 403e3ab..a150033 100644 --- a/src/spmkit/core/analysis/_median_background.py +++ b/src/spmkit/core/analysis/_median_background.py @@ -47,8 +47,7 @@ def _validated_median_background_radius(radius_px: object) -> int: radius = int(radius_px) if not 1 <= radius <= 1024: raise ValueError( - "Gwyddion Median Background radius_px must be in the inclusive " - "range 1..1024" + "Gwyddion Median Background radius_px must be in the inclusive " "range 1..1024" ) return radius @@ -95,9 +94,7 @@ def _cached_median_background_active_offsets(radius_px: int) -> IntArray: offsets = np.empty((active_count, 2), dtype=np.int_, order="C") position = 0 - for dr, max_abs_dc in zip( - range(-radius_px, radius_px + 1), max_columns_by_row, strict=True - ): + for dr, max_abs_dc in zip(range(-radius_px, radius_px + 1), max_columns_by_row, strict=True): row_count = 2 * max_abs_dc + 1 stop = position + row_count offsets[position:stop, 0] = dr @@ -119,18 +116,14 @@ def _cached_median_background_active_offsets(radius_px: int) -> IntArray: def _median_background_active_offsets(radius_px: object) -> IntArray: """Return cached active digital-ellipse offsets for ``radius_px``.""" - return _cached_median_background_active_offsets( - _validated_median_background_radius(radius_px) - ) + return _cached_median_background_active_offsets(_validated_median_background_radius(radius_px)) def _median_background_kernel_spec(radius_px: object) -> _MedianBackgroundKernelSpec: """Construct the immutable Gwyddion Median Background kernel specification.""" radius = _validated_median_background_radius(radius_px) active_count = _cached_median_background_active_offsets(radius).shape[0] - backend: _GwyddionMedianBackgroundBackend = ( - "direct" if active_count <= 25 else "radixtree" - ) + backend: _GwyddionMedianBackgroundBackend = "direct" if active_count <= 25 else "radixtree" return _MedianBackgroundKernelSpec( radius_px=radius, diff --git a/src/spmkit/core/analysis/_pspline.py b/src/spmkit/core/analysis/_pspline.py index c58df17..0ab09e4 100644 --- a/src/spmkit/core/analysis/_pspline.py +++ b/src/spmkit/core/analysis/_pspline.py @@ -255,7 +255,9 @@ def _check_penalty_null_space_identifiability( * surface.reshape( data_shape, order="C", - ).ravel(order="C")[selected] + ).ravel( + order="C" + )[selected] ) null_design = np.column_stack(null_surfaces) diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 5dd3a9a..8207950 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -128,21 +128,16 @@ def _validated_gwyddion_radius_px( or np.iscomplexobj(radius_data) or isinstance(radius_px, (bool, np.bool_)) ): - raise TypeError( - f"{operation} requires radius_px to be a real scalar" - ) + raise TypeError(f"{operation} requires radius_px to be a real scalar") value = float(radius_data.item()) if not np.isfinite(value): - raise ValueError( - f"{operation} requires radius_px to be finite" - ) + raise ValueError(f"{operation} requires radius_px to be finite") if not 1.0 <= value <= 1000.0: raise ValueError( - f"{operation} requires radius_px to be between " - "1.0 and 1000.0 inclusive" + f"{operation} requires radius_px to be between " "1.0 and 1000.0 inclusive" ) return value @@ -542,9 +537,7 @@ def _gwyddion_arc_channels( ) if not isinstance(inverted, (bool, np.bool_)): - raise TypeError( - f"{operation} requires inverted to be a boolean" - ) + raise TypeError(f"{operation} requires inverted to be a boolean") inverted_value = bool(inverted) @@ -644,9 +637,7 @@ def _gwyddion_sphere_channels( ) if not isinstance(inverted, (bool, np.bool_)): - raise TypeError( - f"{operation} requires inverted to be a boolean" - ) + raise TypeError(f"{operation} requires inverted to be a boolean") inverted_value = bool(inverted) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 8b50603..e954a32 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -851,27 +851,14 @@ def _fit_polynomial_surface_data( selected_count = int(np.count_nonzero(selected_points)) if selected_count < len(powers): - raise ValueError( - f"{operation} requires at least {len(powers)} selected points" - ) + raise ValueError(f"{operation} requires at least {len(powers)} selected points") rows, columns = values.shape - x_coordinates = ( - np.linspace(-1.0, 1.0, columns) - if columns > 1 - else np.zeros(columns) - ) - y_coordinates = ( - np.linspace(-1.0, 1.0, rows) - if rows > 1 - else np.zeros(rows) - ) + x_coordinates = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(columns) + y_coordinates = np.linspace(-1.0, 1.0, rows) if rows > 1 else np.zeros(rows) xx, yy = np.meshgrid(x_coordinates, y_coordinates) - terms = [ - (xx**x_power) * (yy**y_power) - for x_power, y_power in powers - ] + terms = [(xx**x_power) * (yy**y_power) for x_power, y_power in powers] design = np.column_stack([term.ravel() for term in terms]) selected = selected_points.ravel() @@ -883,8 +870,7 @@ def _fit_polynomial_surface_data( if rank < len(powers): raise ValueError( - f"{operation} selected points do not define " - "a unique polynomial background" + f"{operation} selected points do not define " "a unique polynomial background" ) background = (design @ coefficients).reshape(values.shape) diff --git a/tests/core/test_flatten_base_core.py b/tests/core/test_flatten_base_core.py index c0a625e..3761dea 100644 --- a/tests/core/test_flatten_base_core.py +++ b/tests/core/test_flatten_base_core.py @@ -63,7 +63,6 @@ def test_height_distribution_preserves_gwyddion_constant_field_convention() -> N assert np.sum(result.density) * result.bin_width == pytest.approx(1.0) - def test_base_peak_window_matches_gwyddion_selection_rules() -> None: centers = np.arange(9, dtype=float) + 0.5 density = np.array( @@ -178,9 +177,7 @@ def test_base_peak_fit_matches_gwyddion_271_reference() -> None: """Cross-check a perturbed Gaussian against a direct Gwyddion 2.71 probe.""" centers = -3.0 + 0.375 * np.arange(17, dtype=float) density = ( - 0.18 - + 2.4 * np.exp(-np.square((centers - 0.35) / 1.1)) - + 0.015 * np.sin(1.7 * centers) + 0.18 + 2.4 * np.exp(-np.square((centers - 0.35) / 1.1)) + 0.015 * np.sin(1.7 * centers) ) peak_index = int(np.argmax(density)) @@ -329,13 +326,8 @@ def test_gwyddion_facet_plane_recovers_exact_physical_tilt() -> None: expected_x_coefficient = expected_physical_x * pixel_size_x expected_y_coefficient = expected_physical_y * pixel_size_y - expected_scale_squared = ( - expected_physical_x**2 + expected_physical_y**2 - ) / 20.0 - expected_intercept = -0.5 * ( - expected_x_coefficient * columns - + expected_y_coefficient * rows - ) + expected_scale_squared = (expected_physical_x**2 + expected_physical_y**2) / 20.0 + expected_intercept = -0.5 * (expected_x_coefficient * columns + expected_y_coefficient * rows) expected_cells = (rows - 1) * (columns - 1) assert not result.degenerate @@ -345,12 +337,8 @@ def test_gwyddion_facet_plane_recovers_exact_physical_tilt() -> None: assert result.x_coefficient == pytest.approx(expected_x_coefficient) assert result.y_coefficient == pytest.approx(expected_y_coefficient) assert result.intercept == pytest.approx(expected_intercept) - assert result.slope_scale_squared == pytest.approx( - expected_scale_squared - ) - assert result.weight_sum == pytest.approx( - expected_cells * np.exp(-20.0) - ) + assert result.slope_scale_squared == pytest.approx(expected_scale_squared) + assert result.weight_sum == pytest.approx(expected_cells * np.exp(-20.0)) np.testing.assert_array_equal(data, original) @@ -388,12 +376,7 @@ def test_gwyddion_facet_plane_matches_gwyddion_271_reference() -> None: for column in range(columns): x = column * pixel_size_x y = row * pixel_size_y - value = ( - 7.0 - + 0.3 * x - - 0.2 * y - + 0.04 * np.sin(0.7 * column + 0.3 * row) - ) + value = 7.0 + 0.3 * x - 0.2 * y + 0.04 * np.sin(0.7 * column + 0.3 * row) if row == 1 and column == 2: value += 4.0 @@ -504,11 +487,7 @@ def fake_peak(received: np.ndarray) -> SuccessfulPeak: column_indices = np.arange(columns, dtype=float) row_indices = np.arange(rows, dtype=float) xx, yy = np.meshgrid(column_indices, row_indices) - single_plane = ( - plane.intercept - + plane.x_coefficient * xx - + plane.y_coefficient * yy - ) + single_plane = plane.intercept + plane.x_coefficient * xx + plane.y_coefficient * yy np.testing.assert_allclose( result.background, @@ -675,11 +654,7 @@ def fake_facet( np.arange(columns, dtype=float), np.arange(rows, dtype=float), ) - expected_plane = ( - plane.intercept - + plane.x_coefficient * xx - + plane.y_coefficient * yy - ) + expected_plane = plane.intercept + plane.x_coefficient * xx + plane.y_coefficient * yy assert facet_calls == 1 assert peak_results == [] @@ -705,11 +680,11 @@ def test_grow_mask_conn4_forms_inclusive_city_block_diamond() -> None: expected = np.array( [ - [False, False, True, False, False], - [False, True, True, True, False], - [True, True, True, True, True ], - [False, True, True, True, False], - [False, False, True, False, False], + [False, False, True, False, False], + [False, True, True, True, False], + [True, True, True, True, True], + [False, True, True, True, False], + [False, False, True, False, False], ], dtype=bool, ) @@ -728,11 +703,11 @@ def test_grow_mask_conn4_matches_gwyddion_corner_handling() -> None: expected = np.array( [ - [True, True, True, True, True], + [True, True, True, True, True], [True, False, False, False, True], [True, False, False, False, True], [True, False, False, False, True], - [True, True, True, True, True], + [True, True, True, True, True], ], dtype=bool, ) @@ -754,9 +729,9 @@ def test_grow_mask_conn4_matches_gwyddion_interior_merge_reference() -> None: expected = np.array( [ [False, False, False, False, False], - [False, True, False, True, False], - [False, True, True, True, False], - [False, True, False, True, False], + [False, True, False, True, False], + [False, True, True, True, False], + [False, True, False, True, False], [False, False, False, False, False], ], dtype=bool, @@ -794,10 +769,10 @@ def test_grow_mask_conn4_matches_gwyddion_empty_mask_handling() -> None: expected = np.array( [ - [True, True, True, True, True], + [True, True, True, True, True], [True, False, False, False, True], [True, False, False, False, True], - [True, True, True, True, True], + [True, True, True, True, True], ], dtype=bool, ) @@ -907,9 +882,7 @@ class Peak: expected_raw[3, 3] = True yy, xx = np.mgrid[0:7, 0:7] - expected_grown = ( - np.abs(yy - 3) + np.abs(xx - 3) - ) <= 3 + expected_grown = (np.abs(yy - 3) + np.abs(xx - 3)) <= 3 assert result.threshold == 3.0 assert result.growth_radius == 3 @@ -1086,12 +1059,7 @@ def test_flatten_base_polynomial_iteration_recovers_exact_surface( xx, yy = np.meshgrid(x, y) expected_background = ( - 0.10 - + 0.05 * xx - - 0.04 * yy - + 0.03 * xx * yy - + 0.02 * xx**2 - - 0.01 * yy**2 + 0.10 + 0.05 * xx - 0.04 * yy + 0.03 * xx * yy + 0.02 * xx**2 - 0.01 * yy**2 ) data = expected_background.copy() @@ -1176,7 +1144,6 @@ def fake_updated_peak(received: np.ndarray) -> UpdatedPeak: np.testing.assert_array_equal(data, original) - def test_grow_mask_conn4_matches_gwyddion_271_right_edge_reference() -> None: mask = np.zeros((8, 9), dtype=bool) mask[2, 4] = True @@ -1361,9 +1328,7 @@ def __init__(self, label: str) -> None: peaks = [Peak(f"peak-{index}") for index in range(5)] calls: list[tuple[np.ndarray, Peak, int]] = [] - produced_iterations: list[ - flatten_base_core.FlattenBasePolynomialIteration - ] = [] + produced_iterations: list[flatten_base_core.FlattenBasePolynomialIteration] = [] def fake_iteration( received: np.ndarray, @@ -1406,19 +1371,17 @@ def fake_iteration( grown_count=0, ) - iteration = ( - flatten_base_core.FlattenBasePolynomialIteration( - degree=degree, - powers=((0, 0),), - mask=automatic_mask, - selected_count=data.size, - coefficients=coefficients, - rank=1, - singular_values=singular_values, - background=background, - corrected=corrected, - peak=peaks[expected_index + 1], - ) + iteration = flatten_base_core.FlattenBasePolynomialIteration( + degree=degree, + powers=((0, 0),), + mask=automatic_mask, + selected_count=data.size, + coefficients=coefficients, + rank=1, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=peaks[expected_index + 1], ) produced_iterations.append(iteration) return iteration @@ -1499,9 +1462,7 @@ def fake_iteration( assert peak is degree_two_peak next_peak = failed_peak else: - raise AssertionError( - f"unexpected polynomial degree after failure: {degree}" - ) + raise AssertionError(f"unexpected polynomial degree after failure: {degree}") background = np.full_like(received, float(degree)) corrected = received - background @@ -1671,15 +1632,11 @@ class UpdatedPeak: def forbidden_mask(*args: object, **kwargs: object) -> None: del args, kwargs - raise AssertionError( - "constant-field iteration must not construct a mask" - ) + raise AssertionError("constant-field iteration must not construct a mask") def forbidden_fit(*args: object, **kwargs: object) -> None: del args, kwargs - raise AssertionError( - "constant-field iteration must not fit a polynomial" - ) + raise AssertionError("constant-field iteration must not fit a polynomial") def fake_peak(received: np.ndarray) -> UpdatedPeak: peak_calls.append(received.copy()) @@ -1758,20 +1715,18 @@ def __init__(self, success: bool) -> None: coefficients.setflags(write=False) singular_values.setflags(write=False) - skipped_iteration = ( - flatten_base_core.FlattenBasePolynomialIteration( - degree=2, - powers=(), - mask=None, - selected_count=0, - coefficients=coefficients, - rank=0, - singular_values=singular_values, - background=background, - corrected=corrected, - peak=failed_peak, - applied=False, - ) + skipped_iteration = flatten_base_core.FlattenBasePolynomialIteration( + degree=2, + powers=(), + mask=None, + selected_count=0, + coefficients=coefficients, + rank=0, + singular_values=singular_values, + background=background, + corrected=corrected, + peak=failed_peak, + applied=False, ) calls: list[int] = [] @@ -1916,15 +1871,9 @@ def fake_polynomial_stage( after_mean_centering = polynomial_corrected - final_peak.mean expected_minimum_offset = float(np.min(after_mean_centering)) - expected_corrected = ( - after_mean_centering - - expected_minimum_offset - ) + expected_corrected = after_mean_centering - expected_minimum_offset expected_background = ( - facet_background - + polynomial_background - + final_peak.mean - + expected_minimum_offset + facet_background + polynomial_background + final_peak.mean + expected_minimum_offset ) assert calls == { @@ -2027,11 +1976,7 @@ class FinalIteration: expected_minimum_offset = 5.0 expected_corrected = polynomial_corrected - expected_minimum_offset - expected_background = ( - facet_background - + polynomial_background - + expected_minimum_offset - ) + expected_background = facet_background + polynomial_background + expected_minimum_offset assert result.final_peak is failed_peak assert not result.mean_centered @@ -2094,10 +2039,7 @@ class FinalPeak: ) polynomial_background = np.full_like(data, 2.0) - polynomial_corrected = ( - facet_corrected - - polynomial_background - ) + polynomial_corrected = facet_corrected - polynomial_background class FinalIteration: degree = 5 @@ -2130,11 +2072,7 @@ class FinalIteration: ) expected_corrected = polynomial_corrected - final_peak.mean - expected_background = ( - facet_background - + polynomial_background - + final_peak.mean - ) + expected_background = facet_background + polynomial_background + final_peak.mean assert result.final_peak is final_peak assert result.mean_centered @@ -2202,9 +2140,7 @@ def test_gwyddion_lm_reproduces_edge_peak_solution() -> None: initial_width=0.39368120701621939, ) - result = flatten_base_core._fit_base_peak_gwyddion_lm( - window - ) + result = flatten_base_core._fit_base_peak_gwyddion_lm(window) assert result.solver_success assert result.covariance_available @@ -2263,11 +2199,7 @@ def test_gwyddion_packed_cholesky_matches_dense_reference() -> None: ) packed = np.array( - [ - matrix[row, column] - for row in range(matrix.shape[0]) - for column in range(row + 1) - ], + [matrix[row, column] for row in range(matrix.shape[0]) for column in range(row + 1)], dtype=float, ) diff --git a/tests/core/test_gwyddion_arc_revolution_background.py b/tests/core/test_gwyddion_arc_revolution_background.py index 08ab76a..de45bb0 100644 --- a/tests/core/test_gwyddion_arc_revolution_background.py +++ b/tests/core/test_gwyddion_arc_revolution_background.py @@ -417,11 +417,7 @@ def test_invalid_radius_types_are_rejected( def test_invalid_direction_is_rejected( direction: object, ) -> None: - expected_exception = ( - TypeError - if not isinstance(direction, str) - else ValueError - ) + expected_exception = TypeError if not isinstance(direction, str) else ValueError with pytest.raises(expected_exception): estimate_gwyddion_arc_revolution_background( @@ -459,11 +455,7 @@ def test_non_boolean_inversion_is_rejected( def test_invalid_channel_data_is_rejected( data: np.ndarray, ) -> None: - expected_exception = ( - TypeError - if np.iscomplexobj(data) - else ValueError - ) + expected_exception = TypeError if np.iscomplexobj(data) else ValueError with pytest.raises(expected_exception): estimate_gwyddion_arc_revolution_background( diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py index 0414c52..6c54ee8 100644 --- a/tests/core/test_gwyddion_arc_revolution_kernel.py +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -368,25 +368,51 @@ def test_horizontal_kernel_matches_asymmetric_gwyddion_reference() -> None: expected = np.array( [ - [2.0, 2.1240452701624606, 2.2675450774512851, - 2.3728213964070148, 2.4667243867011543, - 2.570674096470047, 2.6947193666325076], - [1.9416825502692594, 2.0657278204317202, - 2.2179520157249768, 2.3362474198729988, - 2.4602926900354594, 2.5755264216212703, - 2.6995716917837309], - [1.8815206827269855, 2.0055659528894463, - 2.1637952144760346, 2.299476503169311, - 2.4235217733317715, 2.5554972079457752, - 2.7090772468957436], - [-1.2814298042916905, -1.7517211295957433, - -1.8757663997582039, -1.7517211295957433, - -1.2814298042916905, -0.38992491109192096, - 2.7225247038845315], - [1.7499872080912451, 1.8740324782537057, - 2.0419994344855796, 2.1963790371016558, - 2.3565602920599771, 2.5375416304908565, - 2.738580395034298], + [ + 2.0, + 2.1240452701624606, + 2.2675450774512851, + 2.3728213964070148, + 2.4667243867011543, + 2.570674096470047, + 2.6947193666325076, + ], + [ + 1.9416825502692594, + 2.0657278204317202, + 2.2179520157249768, + 2.3362474198729988, + 2.4602926900354594, + 2.5755264216212703, + 2.6995716917837309, + ], + [ + 1.8815206827269855, + 2.0055659528894463, + 2.1637952144760346, + 2.299476503169311, + 2.4235217733317715, + 2.5554972079457752, + 2.7090772468957436, + ], + [ + -1.2814298042916905, + -1.7517211295957433, + -1.8757663997582039, + -1.7517211295957433, + -1.2814298042916905, + -0.38992491109192096, + 2.7225247038845315, + ], + [ + 1.7499872080912451, + 1.8740324782537057, + 2.0419994344855796, + 2.1963790371016558, + 2.3565602920599771, + 2.5375416304908565, + 2.738580395034298, + ], ] ) @@ -421,13 +447,15 @@ def test_horizontal_kernel_matches_single_row_reference() -> None: data = np.array([[0.0, 1.0, -2.0, 4.0, 1.0]]) expected = np.array( - [[ - 0.0, - -1.2800008456684795, - -2.0, - -1.2800008456684795, - 0.82747338686665239, - ]] + [ + [ + 0.0, + -1.2800008456684795, + -2.0, + -1.2800008456684795, + 0.82747338686665239, + ] + ] ) result = _gwyddion_arc_horizontal(data, 1.5) @@ -668,11 +696,7 @@ def test_directional_background_rejects_invalid_direction( _gwyddion_arc_background, ) - expected_exception = ( - TypeError - if not isinstance(direction, str) - else ValueError - ) + expected_exception = TypeError if not isinstance(direction, str) else ValueError with pytest.raises(expected_exception): _gwyddion_arc_background( diff --git a/tests/core/test_gwyddion_flat_disc_morphology.py b/tests/core/test_gwyddion_flat_disc_morphology.py index 189c118..7fa8aac 100644 --- a/tests/core/test_gwyddion_flat_disc_morphology.py +++ b/tests/core/test_gwyddion_flat_disc_morphology.py @@ -70,9 +70,7 @@ def _assert_bitwise_equal( row, column = np.argwhere(actual_bits != expected_bits)[0] actual_bits_value = int(actual_bits[row, column]) expected_bits_value = int(expected_bits[row, column]) - ulp_distance = abs( - _ordered_uint64(actual_bits_value) - _ordered_uint64(expected_bits_value) - ) + ulp_distance = abs(_ordered_uint64(actual_bits_value) - _ordered_uint64(expected_bits_value)) pytest.fail( f"case={case_id} operation={operation} coordinate=({row}, {column}) " f"expected={expected[row, column]!r} actual={actual[row, column]!r} " diff --git a/tests/core/test_gwyddion_flat_disc_morphology_private.py b/tests/core/test_gwyddion_flat_disc_morphology_private.py index ac5a65f..7c73740 100644 --- a/tests/core/test_gwyddion_flat_disc_morphology_private.py +++ b/tests/core/test_gwyddion_flat_disc_morphology_private.py @@ -82,11 +82,7 @@ def _requirement_signature(plan: Plan) -> tuple[tuple[object, ...], ...]: def test_requirement_tree_is_complete_and_deterministic() -> None: - lengths = { - int(segment[2]) - for size_px in range(2, 32) - for segment in _segments(size_px, False) - } + lengths = {int(segment[2]) for size_px in range(2, 32) for segment in _segments(size_px, False)} first = Plan() for length in sorted(lengths): _build_requirement(first, length, False) diff --git a/tests/core/test_gwyddion_median_background.py b/tests/core/test_gwyddion_median_background.py index a2dba01..f5ecac6 100644 --- a/tests/core/test_gwyddion_median_background.py +++ b/tests/core/test_gwyddion_median_background.py @@ -100,9 +100,7 @@ def _assert_bitwise_equal( row, column = np.argwhere(actual_bits != expected_bits)[0] actual_bit_value = int(actual_bits[row, column]) expected_bit_value = int(expected_bits[row, column]) - ulp_distance = abs( - _ordered_bits(actual_bit_value) - _ordered_bits(expected_bit_value) - ) + ulp_distance = abs(_ordered_bits(actual_bit_value) - _ordered_bits(expected_bit_value)) pytest.fail( f"case={case} operation={operation} array={array} " f"coordinate=({row}, {column}) expected={expected[row, column]!r} " diff --git a/tests/core/test_gwyddion_median_background_private.py b/tests/core/test_gwyddion_median_background_private.py index c0a62a0..0310e26 100644 --- a/tests/core/test_gwyddion_median_background_private.py +++ b/tests/core/test_gwyddion_median_background_private.py @@ -16,11 +16,7 @@ ) _FIXTURE_DIR = ( - Path(__file__).parents[1] - / "validation" - / "fixtures" - / "gwyddion" - / "median_background" + Path(__file__).parents[1] / "validation" / "fixtures" / "gwyddion" / "median_background" ) _FIXTURE_PATH = _FIXTURE_DIR / "median_background_reference.npz" _MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" diff --git a/tests/core/test_gwyddion_path_level.py b/tests/core/test_gwyddion_path_level.py index 95ed194..7af7a00 100644 --- a/tests/core/test_gwyddion_path_level.py +++ b/tests/core/test_gwyddion_path_level.py @@ -15,11 +15,7 @@ from spmkit.core.models import SPMChannel _FIXTURE_DIRECTORY = ( - Path(__file__).resolve().parents[1] - / "validation" - / "fixtures" - / "gwyddion" - / "path_level" + Path(__file__).resolve().parents[1] / "validation" / "fixtures" / "gwyddion" / "path_level" ) _FIXTURE_PATH = _FIXTURE_DIRECTORY / "path_level_reference.npz" _MANIFEST_PATH = _FIXTURE_DIRECTORY / "path_level_reference.json" @@ -166,9 +162,7 @@ def test_all_frozen_public_outputs_are_bitwise_exact() -> None: def test_context_is_preserved_with_independent_metadata_and_data() -> None: manifest = _manifest() base = next( - base - for base in manifest["bases"] - if base["base_id"] == "signed_gradient_positive_slope" + base for base in manifest["bases"] if base["base_id"] == "signed_gradient_positive_slope" ) # type: ignore[index] case = next(case for case in manifest["cases"] if case["base_id"] == base["base_id"]) # type: ignore[index] with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: diff --git a/tests/core/test_gwyddion_path_level_private.py b/tests/core/test_gwyddion_path_level_private.py index cb4f7c7..59d77de 100644 --- a/tests/core/test_gwyddion_path_level_private.py +++ b/tests/core/test_gwyddion_path_level_private.py @@ -169,6 +169,7 @@ def test_signed_zero_and_repeated_execution_are_deterministic() -> None: first = _gwyddion_path_level_result(data, lines, xreal=2.0, yreal=2.0, thickness_px=2) second = _gwyddion_path_level_result(data, lines, xreal=2.0, yreal=2.0, thickness_px=2) _assert_bits("signed_zero_repeat", first.corrected, second.corrected) - assert hashlib.sha256(_bits(first.corrected).tobytes()).digest() == hashlib.sha256( - _bits(second.corrected).tobytes() - ).digest() + assert ( + hashlib.sha256(_bits(first.corrected).tobytes()).digest() + == hashlib.sha256(_bits(second.corrected).tobytes()).digest() + ) diff --git a/tests/validation/test_arc_revolution_vs_gwyddion.py b/tests/validation/test_arc_revolution_vs_gwyddion.py index 85cf51a..73b884a 100644 --- a/tests/validation/test_arc_revolution_vs_gwyddion.py +++ b/tests/validation/test_arc_revolution_vs_gwyddion.py @@ -12,12 +12,7 @@ _gwyddion_arc_corrected, ) -_FIXTURE_DIR = ( - Path(__file__).resolve().parent - / "fixtures" - / "gwyddion" - / "arc_revolution" -) +_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "gwyddion" / "arc_revolution" _METADATA_PATH = _FIXTURE_DIR / "gwyddion_2_71_directional.json" _METADATA = json.loads(_METADATA_PATH.read_text(encoding="utf-8")) _CASE_NAMES = tuple(_METADATA["cases"]) @@ -32,19 +27,14 @@ def _canonical_array_sha256(array: np.ndarray) -> str: digest = hashlib.sha256() digest.update(str(canonical.dtype).encode("ascii")) digest.update(b"\0") - digest.update( - ",".join(str(value) for value in canonical.shape).encode("ascii") - ) + digest.update(",".join(str(value) for value in canonical.shape).encode("ascii")) digest.update(b"\0") digest.update(canonical.tobytes(order="C")) return digest.hexdigest() def _load_fixture() -> dict[str, np.ndarray]: - npz_path = ( - _FIXTURE_DIR - / _METADATA["artifacts"]["npz_filename"] - ) + npz_path = _FIXTURE_DIR / _METADATA["artifacts"]["npz_filename"] assert hashlib.sha256(npz_path.read_bytes()).hexdigest() == ( _METADATA["artifacts"]["npz_sha256"] @@ -59,9 +49,7 @@ def _load_fixture() -> dict[str, np.ndarray]: for name in fixture.files } - expected_hashes = _METADATA["artifacts"][ - "array_canonical_sha256" - ] + expected_hashes = _METADATA["artifacts"]["array_canonical_sha256"] assert set(arrays) == set(expected_hashes) @@ -107,11 +95,7 @@ def test_arc_background_matches_gwyddion_2_71( @pytest.mark.parametrize( "case_name", - [ - name - for name, case in _METADATA["cases"].items() - if case["corrected_reference_valid"] - ], + [name for name, case in _METADATA["cases"].items() if case["corrected_reference_valid"]], ) def test_arc_corrected_matches_valid_gwyddion_2_71_results( case_name: str, @@ -153,21 +137,15 @@ def test_arc_corrected_matches_valid_gwyddion_2_71_results( def test_horizontal_inverted_reference_defect_is_preserved_and_repaired() -> None: fixture = _load_fixture() - defect = _METADATA["known_reference_defects"][ - "horizontal_inverted_corrected_result" - ] + defect = _METADATA["known_reference_defects"]["horizontal_inverted_corrected_result"] acceptance = _METADATA["acceptance"] input_field = fixture["input"].copy() - reference_corrected = fixture[ - "corrected_horizontal_inverted" - ] + reference_corrected = fixture["corrected_horizontal_inverted"] assert defect["classification"] == "KNOWN_REFERENCE_DEFECT" assert defect["reference_result_untouched"] is True - assert np.all( - reference_corrected == defect["sentinel"] - ) + assert np.all(reference_corrected == defect["sentinel"]) background = _gwyddion_arc_background( input_field, @@ -253,16 +231,9 @@ def test_public_arc_result_matches_gwyddion_2_71( rtol=0.0, ) else: - defect = _METADATA["known_reference_defects"][ - "horizontal_inverted_corrected_result" - ] - assert np.all( - fixture[f"corrected_{case_name}"] - == defect["sentinel"] - ) - assert not np.any( - result.corrected.data == defect["sentinel"] - ) + defect = _METADATA["known_reference_defects"]["horizontal_inverted_corrected_result"] + assert np.all(fixture[f"corrected_{case_name}"] == defect["sentinel"]) + assert not np.any(result.corrected.data == defect["sentinel"]) np.testing.assert_allclose( result.corrected.data + result.background.data, From 96cf983090c5194b2148685ede11ccddda00fc46 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:30:33 -0400 Subject: [PATCH 79/82] test(compat): remove local source-tree dependency --- tests/compat/test_gwyddion_reports.py | 1 - tests/compat/test_gwyddion_source_audit.py | 30 +++++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/compat/test_gwyddion_reports.py b/tests/compat/test_gwyddion_reports.py index d1645c5..29c58b8 100644 --- a/tests/compat/test_gwyddion_reports.py +++ b/tests/compat/test_gwyddion_reports.py @@ -32,7 +32,6 @@ def test_canonical_json_is_stable_and_round_trips() -> None: assert report_to_dict(reconstructed) == report_to_dict(first) assert canonical_report_json(reconstructed) == first_json assert "modules/process/report-sample.c" in first_json - assert "/tmp/" not in first_json def test_audit_rejects_non_text_source_without_writing() -> None: diff --git a/tests/compat/test_gwyddion_source_audit.py b/tests/compat/test_gwyddion_source_audit.py index 4249aca..8b54063 100644 --- a/tests/compat/test_gwyddion_source_audit.py +++ b/tests/compat/test_gwyddion_source_audit.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - from spmkit.compat.gwyddion.source_audit import audit_gwyddion_source from spmkit.compat.gwyddion.symbols import ( RegistrationKind, @@ -11,19 +9,33 @@ SymbolSupportStatus, ) -_REPOSITORY = Path(__file__).resolve().parents[2] -_SOURCE_ROOT = _REPOSITORY / ".reference/gwyddion-2.71/source" +_SOURCE_SNIPPETS = { + "modules/tools/pathlevel.c": "\n" * 110 + + ' gwy_tool_func_register("pathlevel", callback);\n' + + "GWY_MODULE_QUERY2(module_info, pathlevel)\n" + + "#include \n" + + "gtk_widget_show(widget);\n" + + "gwy_plain_tool_connect_selection(tool);\n" + + "gwy_params_new_from_settings();\n", + "modules/tools/filter.c": ( + 'gwy_tool_func_register("filter", callback);\n' + "gwy_data_field_area_filter_min_max(field);\n" + ), + "modules/process/median-bg.c": ( + 'gwy_process_func_register("median-bg", callback);\n' "gwy_app_channel_log_add_proc();\n" + ), +} def _audit(relative: str): return audit_gwyddion_source( - (_SOURCE_ROOT / relative).read_text(encoding="utf-8"), + _SOURCE_SNIPPETS[relative], source_path=relative, ) def test_lexical_scanner_ignores_comments_and_literals_and_retains_calls() -> None: - source = ''' + source = """ #include "local-header.h" /* gwy_process_func_register("fake", nope); GWY_MODULE_QUERY2(fake, wrong) */ const char *message = "gwy_tool_func_register(GWY_FAKE)"; @@ -40,7 +52,7 @@ def test_lexical_scanner_ignores_comments_and_literals_and_retains_calls() -> No gwy_custom_func_register(); gtk_widget_show(widget); gwyish_data_field_get_xres(field); -''' +""" report = audit_gwyddion_source(source, source_path="synthetic.c") assert [(item.kind, item.declared_name) for item in report.registrations] == [ (RegistrationKind.UNKNOWN, "synthetic"), @@ -67,7 +79,7 @@ def test_incomplete_source_never_crashes_the_lexical_inventory() -> None: assert len(report.gwyddion_symbols[0].call_occurrences) == 1 -def test_real_path_level_source_facts_are_extracted_with_locations() -> None: +def test_representative_path_level_source_facts_are_extracted_with_locations() -> None: report = _audit("modules/tools/pathlevel.c") assert report.module_path == "modules/tools/pathlevel.c" assert any(item.kind is RegistrationKind.TOOL for item in report.registrations) @@ -83,7 +95,7 @@ def test_real_path_level_source_facts_are_extracted_with_locations() -> None: assert tool_registration.span.start.column == 5 -def test_real_filter_and_median_sources_remain_audit_inventory_only() -> None: +def test_representative_filter_and_median_sources_remain_audit_inventory_only() -> None: filter_report = _audit("modules/tools/filter.c") median_report = _audit("modules/process/median-bg.c") assert any(item.kind is RegistrationKind.TOOL for item in filter_report.registrations) From 710d8f50b1c7f18458d370eddfc5d8f7585485c9 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:30:33 -0400 Subject: [PATCH 80/82] chore(validation): sanitize frozen evidence provenance --- .../gwyddion_2_71_directional.json | 4 +- .../gwyddion_2_71_end_to_end.json | 2 +- .../median_background_reference.json | 20 +++---- .../path_level/path_level_reference.json | 2 - ...est_median_background_fixture_integrity.py | 54 +++++++++++++++---- .../test_path_level_fixture_integrity.py | 15 ++++-- 6 files changed, 67 insertions(+), 30 deletions(-) diff --git a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json index f32a35a..cab1966 100644 --- a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json +++ b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json @@ -91,10 +91,10 @@ "reference": { "operation": "Revolve Arc", "probe_output_sha256": "1f8ee0535ac3b0d93e3b330ec4f96b39436e4da1853f5d3ce9ba45e1f2d0eca3", - "probe_source": ".reference/gwyddion-2.71/arc-revolve-parity/arc_revolve_behavior_probe.c", + "probe_source": "gwyddion-2.71/arc-revolve-parity/arc_revolve_behavior_probe.c", "probe_source_sha256": "27e92376d7955f134a6d76091775dc28fe2e1ba8246936b27e2e924d3ba765f4", "software": "Gwyddion", - "source_file": ".reference/gwyddion-2.71/source/modules/process/arc-revolve.c", + "source_file": "gwyddion-2.71/source/modules/process/arc-revolve.c", "source_sha256": "afb19a2382b0abb46595fa3dabc126ade50ec31c91ec9c96ea2284f42d0a67ac", "version": "2.71" }, diff --git a/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json b/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json index 68a09d0..f0235d9 100644 --- a/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json +++ b/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json @@ -42,7 +42,7 @@ "reference": { "operation": "Flatten Base", "probe_output_sha256": "51ead932b37381b8c0cdd4b0df6b4f9e8962cf638c1cfaad96d0b178118a70a0", - "probe_source": ".reference/gwyddion-2.71/flatten-base-parity/flatten_base_end_to_end_probe.c", + "probe_source": "gwyddion-2.71/flatten-base-parity/flatten_base_end_to_end_probe.c", "software": "Gwyddion", "version": "2.71" }, diff --git a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json index aee5653..8778aa4 100644 --- a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json +++ b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json @@ -1521,49 +1521,49 @@ "source_artifacts": { "campaign_summary": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_probe/campaign-summary.tsv", + "path": "frozen-evidence/median-background/campaign-summary.tsv", "sha256": "7876d9cf3bc61375ecff5ca42c16789e11f4f0cc651f330ed7da57b36fc493b2" }, "median_bg_c": { - "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/source/modules/process/median-bg.c", + "path": "gwyddion-2.71/source/modules/process/median-bg.c", "sha256": "5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729" }, "oracle_log": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle.log", + "path": "frozen-evidence/median-background/oracle.log", "sha256": "87eec8d8509d6d879ab41d748e9c21cc1ae428e6157131320c5fe7de0635d059" }, "oracle_provenance": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle_provenance.json", + "path": "frozen-evidence/median-background/oracle_provenance.json", "sha256": "fe759bafbd7180394f3262da13e19275475ae569c3f79f879a51c6b8025e0d74" }, "oracle_report": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle_report.md", + "path": "frozen-evidence/median-background/oracle_report.md", "sha256": "1a11f8caec6456de78feb9679e3fe0bec81e101058bfd73806d9953665b8dd31" }, "oracle_script": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle.py", + "path": "frozen-evidence/median-background/oracle.py", "sha256": "2696798b180fcce779bbded49131d106cdc8f159c30aa1df873008f04d66084b" }, "oracle_source_npz": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle_arrays.npz", + "path": "frozen-evidence/median-background/oracle_arrays.npz", "sha256": "d56117cb4bbfc182d9fdfb8a9f6d2b400b5d5d8c17e051075342387b98dacc09" }, "oracle_summary": { "note": "ephemeral source artifact; identity frozen by SHA-256", - "path": "/tmp/spmkit_gwyddion_median_background_oracle_summary.tsv", + "path": "frozen-evidence/median-background/oracle_summary.tsv", "sha256": "0325066fd9a13cbc2b70d21473d2aeca783faa469a37d867fc6c11801c51b69f" }, "probe_c": { - "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c", + "path": "gwyddion-2.71/median-background-parity/median_background_behavior_probe.c", "sha256": "8e1956a6dbc69afcf5244098bc930f529c733bdec2768909cb7372ccea260f10" }, "runner": { - "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh", + "path": "gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh", "sha256": "8c1c42dba8fc8a36bb93d1a82e60257dca3ff5044a2987b452608f99115ab594" } } diff --git a/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json b/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json index d480173..fd00959 100644 --- a/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json +++ b/tests/validation/fixtures/gwyddion/path_level/path_level_reference.json @@ -4814,8 +4814,6 @@ }, "external_sha256sums": "8aa6a3e68403d3b67fedc63c725b81bfa4b9f391621681da9decf152c3cdc8d8", "oracle_artifacts": { - "__pycache__/path_level_oracle.cpython-312.pyc": "dad726b661f7ab295a2c4c969d3aaa294d0310bae00b19b02987bff84ce3b4ea", - "__pycache__/regenerate_path_level_cases.cpython-312.pyc": "d2b0a61a13bfe2a08b440a556ae88c808d5ffef3a839252bb8a55ad7e0651722", "compare_external_reference.py": "3240c45edf7295a34b9b71774157cb910eafd4e99b47f2ee1d47ada718bb87d8", "comparison_execution.log": "49a9b02fa0c7edfad9b9098465f06eb3f2129b349a20c8e93200e2289129ff6e", "comparison_ledger.json": "ff6aa434f193263b21fe13a8a76e795565b0838ea6288900eb79c26f056436a8", diff --git a/tests/validation/test_median_background_fixture_integrity.py b/tests/validation/test_median_background_fixture_integrity.py index 8bbb917..c785a78 100644 --- a/tests/validation/test_median_background_fixture_integrity.py +++ b/tests/validation/test_median_background_fixture_integrity.py @@ -1,4 +1,5 @@ """Integrity checks for frozen Gwyddion 2.71 Median Background evidence.""" + from __future__ import annotations import hashlib @@ -11,17 +12,42 @@ _NPZ_PATH = _FIXTURE_DIR / "median_background_reference.npz" _MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" _EXPECTED_CASES = [ - "wide_r1", "wide_r2", "wide_r3", "wide_r4", "wide_r20", - "tall_r1", "tall_r2", "tall_r3", "tall_r4", "tall_r20", - "constant_r1", "constant_r3", "constant_r20", - "signed_r1", "signed_r2", "signed_r3", "signed_r20", - "singleton_1x1_r1", "singleton_1x1_r3", "singleton_1x1_r20", + "wide_r1", + "wide_r2", + "wide_r3", + "wide_r4", + "wide_r20", + "tall_r1", + "tall_r2", + "tall_r3", + "tall_r4", + "tall_r20", + "constant_r1", + "constant_r3", + "constant_r20", + "signed_r1", + "signed_r2", + "signed_r3", + "signed_r20", + "singleton_1x1_r1", + "singleton_1x1_r3", + "singleton_1x1_r20", "singleton_1x1_r1024", - "singleton_row_r1", "singleton_row_r3", "singleton_row_r20", - "singleton_column_r1", "singleton_column_r3", "singleton_column_r20", - "impulse_positive_r1", "impulse_positive_r2", "impulse_positive_r3", - "impulse_negative_r1", "impulse_negative_r2", "impulse_negative_r3", - "monotonic_r1", "monotonic_r2", "monotonic_r3", + "singleton_row_r1", + "singleton_row_r3", + "singleton_row_r20", + "singleton_column_r1", + "singleton_column_r3", + "singleton_column_r20", + "impulse_positive_r1", + "impulse_positive_r2", + "impulse_positive_r3", + "impulse_negative_r1", + "impulse_negative_r2", + "impulse_negative_r3", + "monotonic_r1", + "monotonic_r2", + "monotonic_r3", ] _EXPECTED_RADIUS_INVENTORY = { "1": {"active_count": 9, "backend": "direct", "rank": 4, "resolution": 3}, @@ -87,6 +113,14 @@ def test_manifest_schema_and_identity() -> None: assert manifest["oracle"]["canonical_source_array_hash_count"] == 180 assert len(manifest["oracle"]["canonical_source_array_hashes"]) == 180 assert "manifest_self_hash" not in manifest["fixture"] + serialized = json.dumps(manifest, sort_keys=True) + forbidden_markers = ( + "/" + "tmp/", + "/" + "home/", + "." + "reference", + "_" + "_" + "pycache__", + ) + assert not any(marker in serialized for marker in forbidden_markers) def test_case_order_is_exact() -> None: diff --git a/tests/validation/test_path_level_fixture_integrity.py b/tests/validation/test_path_level_fixture_integrity.py index 3d9b9cf..4db1995 100644 --- a/tests/validation/test_path_level_fixture_integrity.py +++ b/tests/validation/test_path_level_fixture_integrity.py @@ -22,6 +22,14 @@ def _canonical_hash(array: np.ndarray) -> str: def test_path_level_fixture_integrity() -> None: manifest = json.loads((ROOT / "path_level_reference.json").read_text()) + serialized = json.dumps(manifest, sort_keys=True) + forbidden_markers = ( + "/" + "tmp/", + "/" + "home/", + "." + "reference", + "_" + "_" + "pycache__", + ) + assert not any(marker in serialized for marker in forbidden_markers) assert manifest["schema_version"] == 1 assert manifest["capability"] == "gwyddion_path_level" assert len(manifest["bases"]) == 18 @@ -33,8 +41,7 @@ def test_path_level_fixture_integrity() -> None: assert len({case["case_id"] for case in manifest["cases"]}) == 72 assert all(len(case["lines_hex"]) == 4 * case["line_count"] for case in manifest["cases"]) assert all( - len(case["normalized_endpoints"]) == 4 * case["line_count"] - for case in manifest["cases"] + len(case["normalized_endpoints"]) == 4 * case["line_count"] for case in manifest["cases"] ) external_artifacts = manifest["evidence"]["source_hashes"]["external_artifacts"] assert external_artifacts["canonical_reference.json"] == ( @@ -58,6 +65,4 @@ def test_path_level_fixture_integrity() -> None: assert list(archive[base["input_key"]].shape) == base["shape"] for case in manifest["cases"]: assert case["output_key"] in archive.files - assert archive[case["output_key"]].shape == archive[ - f"input__{case['base_id']}" - ].shape + assert archive[case["output_key"]].shape == archive[f"input__{case['base_id']}"].shape From 7b127b52fa026c70e7c6840b18addc8be0543c81 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:30:33 -0400 Subject: [PATCH 81/82] docs(validation): remove stale paths and guidance --- docs/api.md | 2 +- ...WYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md | 2 +- ...WYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md | 2 +- docs/scientific-status.md | 37 ++++++++++--------- docs/validation/index.md | 18 ++++----- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/docs/api.md b/docs/api.md index 247cd5d..72ac101 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,7 +14,7 @@ instrument variant. ```bash python -m pip install spmkit # PyPI 0.1.2 -python -m pip install "spmkit[gwy,hdf5,grains]" # selected optional features +python -m pip install "spmkit[gwy,hdf5]" # selected optional features ``` The current source and GitHub-release options are listed in the diff --git a/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md index aace822..94e321b 100644 --- a/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md +++ b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md @@ -11,7 +11,7 @@ outside the frozen domain. ## 1. Reference identity **SOURCE_CONFIRMED** reference software is Gwyddion 2.71. The frozen module is -`.reference/gwyddion-2.71/source/modules/process/median-bg.c`, SHA-256 +Gwyddion 2.71 source `modules/process/median-bg.c`, SHA-256 `5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729`. The manifest records the probe, runner, oracle, campaign, and fixture identities. diff --git a/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md index 60a7635..08d4ed6 100644 --- a/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md +++ b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md @@ -296,7 +296,7 @@ Evidence hierarchy: ## 14. Independent oracle evidence -An independent Python oracle (`/tmp/spmkit_gwyddion_sphere_oracle.py`) evaluated 20 valid cases (10 original normal, 10 negated normal): +An independent Python oracle (`sphere_revolution_oracle.py`, frozen by its recorded SHA-256) evaluated 20 valid cases (10 original normal, 10 negated normal): - Implemented in pure Python 3 and NumPy without SciPy or SPMKit imports. - Evaluated using direct 2D window loops. - Max $q$ absolute error: `0.0`. diff --git a/docs/scientific-status.md b/docs/scientific-status.md index d1b9569..4e89e35 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -97,9 +97,9 @@ production. **Traceability:** ```text -.reference/gwyddion-2.71/source/modules/process/median-bg.c - → .reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c - → .reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh +Gwyddion 2.71 source: modules/process/median-bg.c + → frozen external probe: median_background_behavior_probe.c + → frozen campaign runner: run_median_background_probe_campaign.sh → independent Python oracle recorded by docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md → tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz → tests/validation/fixtures/gwyddion/median_background/median_background_reference.json @@ -117,7 +117,7 @@ Background, and 72 public Median Background tests; the preceding combined focal 442 tests. These are focal-campaign counts, not a project-wide total. **Non-claims:** no universal equivalence; no guarantee outside the 36 cases; no NaN or infinity -coverage; no reproduction of Gwyddion's internal radixtree; no performance-equivalence claim; +coverage and no reproduction of Gwyddion's internal radixtree; no performance-equivalence claim; no claim for future Gwyddion versions; no claim for every radius or matrix; and no validation of configurable border, shape, or rank parameters because the API exposes none. @@ -145,10 +145,10 @@ evidence; the corrected zero-initialised probe is the valid external record. **Traceability:** ```text -.reference/gwyddion-2.71/source/libprocess/filters-minmax.c - → /tmp/spmkit_flat_disc_probe_v3 - → /tmp/spmkit_flat_disc_reduction_trace_v1 - → /tmp/spmkit_flat_disc_reduction_trace_v1/oracle_v2/flat_disc_morphology_oracle.py +Gwyddion 2.71 source: libprocess/filters-minmax.c + → frozen external probe: flat_disc_probe_v3 + → frozen reduction trace: flat_disc_reduction_trace_v1 + → independent oracle: flat_disc_morphology_oracle.py → tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz → tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json → src/spmkit/core/analysis/_gwyddion_flat_disc_morphology.py @@ -158,8 +158,9 @@ evidence; the corrected zero-initialised probe is the valid external record. → docs/scientific-status.md ``` -Evidence was frozen in `2ba366e`; the private kernel is `05c5ae4`. No public or documentation -commit is claimed here. **Non-claims:** no universal equivalence; no NaN or infinity coverage; +Evidence was frozen in `2ba366e`; the private kernel is `05c5ae4`; the public API and +documentation are recorded in `1b2d081` and `c0de811`. **Non-claims:** no universal equivalence; +no NaN or infinity coverage; no ROI, masks, ASF, tip morphology, physical rolling-ball equivalence, performance parity, other Gwyddion builds or versions, public erosion or dilation, or claim that source-level C tie semantics alone reproduce the audited binary. @@ -167,7 +168,7 @@ tie semantics alone reproduce the audited binary. ### Gwyddion 2.71 Path Level **Claim:** `CROSS_VALIDATED` only within the frozen Path Level campaign against the audited -Gwyddion 2.71 tool `/usr/lib/gwyddion/modules/tool/tools.so` +Gwyddion 2.71 tool module `tools.so` (`4711c360dd42e3e16257bf0e86d8bd41852b43d1d34540bf097736a603146237`, Build ID `600b16d9857946609b567704b406abcc74aea698`). The campaign contains 18 finite, non-empty, full-field base families, thicknesses 1, 2, 3, and 128, 72 logical cases, 144 fresh external @@ -186,10 +187,10 @@ GUI-publication parity. **Traceability:** ```text -.reference/gwyddion-2.71/source/modules/tools/pathlevel.c +Gwyddion 2.71 source: modules/tools/pathlevel.c → installed Gwyddion 2.71 Path Level tool execution - → /tmp/spmkit_path_level_probe_v1 - → /tmp/spmkit_path_level_oracle_v1 + → frozen external probe: path_level_probe_v1 + → independent oracle: path_level_oracle_v1 → tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz → tests/validation/fixtures/gwyddion/path_level/path_level_reference.json → src/spmkit/core/analysis/_gwyddion_path_level.py @@ -218,7 +219,7 @@ new context-preserving `SPMChannel` instances and do not claim Gwyddion GUI, pub or mutation behavior. The secondary `installed_gwyddion_2_71_fast_math_profile` is external executable evidence from -`/usr/lib/gwyddion/modules/process/process.so` +`process.so` (Gwyddion 2.71 installed module) (`c21d52375807ae096e34a3469c2f20c4c66ea3197479e13215a6d7b9d465b451`). It is bitwise exact for `61/64` arrays and `3757/3888` elements. The complete and bounded exception set is three signed-zero-only Median elements in `median__plateaus_signed_zero__10` plus 64 finite elements in @@ -237,9 +238,9 @@ compatibility claim for, the existing generic `align_rows`. **Traceability:** ```text -.reference/gwyddion-2.71/source/modules/process/linematch.c - → /tmp/spmkit_align_rows_probe_v1 - → /tmp/spmkit_align_rows_oracle_stats_v2 +Gwyddion 2.71 source: modules/process/linematch.c + → frozen external probe: align_rows_probe_v1 + → independent oracle: align_rows_oracle_stats_v2 → tests/validation/fixtures/gwyddion/align_rows_statistics/ → src/spmkit/core/analysis/_gwyddion_align_rows_statistics.py → src/spmkit/core/analysis/leveling.py diff --git a/docs/validation/index.md b/docs/validation/index.md index 6cf93e8..3c419d9 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -69,9 +69,9 @@ Gwyddion source → scientific status ``` -The concrete records are `.reference/gwyddion-2.71/source/modules/process/median-bg.c`, -`.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c`, -`.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh`, +The concrete records are Gwyddion 2.71 source `modules/process/median-bg.c`, +the frozen `median_background_behavior_probe.c`, +and the frozen `run_median_background_probe_campaign.sh`, `docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md`, `tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz`, `tests/validation/fixtures/gwyddion/median_background/median_background_reference.json`, @@ -115,8 +115,8 @@ Gwyddion source → CROSS_VALIDATED status ``` -The records are `.reference/gwyddion-2.71/source/libprocess/filters-minmax.c`, -`/tmp/spmkit_flat_disc_probe_v3`, `/tmp/spmkit_flat_disc_reduction_trace_v1`, +The records are Gwyddion 2.71 source `libprocess/filters-minmax.c`, +the frozen `flat_disc_probe_v3` and `flat_disc_reduction_trace_v1`, `docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md`, `tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz`, `tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json`, @@ -148,8 +148,8 @@ Gwyddion source → CROSS_VALIDATED status ``` -The records are `.reference/gwyddion-2.71/source/modules/tools/pathlevel.c`, -`/tmp/spmkit_path_level_probe_v1`, `/tmp/spmkit_path_level_oracle_v1`, +The records are Gwyddion 2.71 source `modules/tools/pathlevel.c`, +the frozen `path_level_probe_v1` and independent `path_level_oracle_v1`, `docs/design/GWYDDION_PATH_LEVEL_COMPATIBILITY.md`, `tests/validation/fixtures/gwyddion/path_level/path_level_reference.npz`, `tests/validation/fixtures/gwyddion/path_level/path_level_reference.json`, @@ -187,8 +187,8 @@ Gwyddion source → CROSS_VALIDATED status ``` -The records are `.reference/gwyddion-2.71/source/modules/process/linematch.c`, -`/tmp/spmkit_align_rows_probe_v1`, `/tmp/spmkit_align_rows_oracle_stats_v2`, +The records are Gwyddion 2.71 source `modules/process/linematch.c`, +the frozen `align_rows_probe_v1` and independent `align_rows_oracle_stats_v2`, `tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.npz`, `tests/validation/fixtures/gwyddion/align_rows_statistics/align_rows_statistics_reference.json`, `docs/design/GWYDDION_ALIGN_ROWS_STATISTICS_COMPATIBILITY.md`, From 6d74d3b7ec90f990c4c6a0bc945c40d355196521 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:15:46 -0400 Subject: [PATCH 82/82] fix(docs): synchronize manual provenance --- docs/manual/artifacts-manifest.json | 8 ++++---- docs/scientific-status.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/manual/artifacts-manifest.json b/docs/manual/artifacts-manifest.json index 7400d4b..96ae5e7 100644 --- a/docs/manual/artifacts-manifest.json +++ b/docs/manual/artifacts-manifest.json @@ -1,14 +1,14 @@ { "artifacts": [ { - "bytes": 56415, + "bytes": 56482, "path": "docs/user-guide.md", - "sha256": "dbbfa1fddffd5f25b3b880c80b33f6b8b1e37b0c15dd85af25c7aacef9f8fe08" + "sha256": "2af676b1a962202ec7c5424a95d7477a6b99621c88ad433720cff46b2e117a43" }, { - "bytes": 39873, + "bytes": 39958, "path": "docs/user-guide.tex", - "sha256": "d11a27d1889510c6beaed2f1055216b54366e737cbfbfc4bbbaaa5e6617bd6db" + "sha256": "9a8de11a62874301eee6a7797149146f5fe8057374b7c8008c1463da1e1e0314" }, { "bytes": 117821, diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 4e89e35..e6694bc 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -63,7 +63,7 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) - [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) - [Sphere Revolution Gwyddion 2.71 frozen fixture](https://github.com/kegouro/spmkit/blob/feat/gwyddion-leveling-parity/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json) -- [Median Background Gwyddion 2.71 frozen manifest](../tests/validation/fixtures/gwyddion/median_background/median_background_reference.json) +- [Median Background Gwyddion 2.71 frozen manifest](https://github.com/kegouro/spmkit/blob/main/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json) ### Gwyddion Sphere Revolution