From 41922e727e96b372b0b6ac1c6326b2a1dfb03e4d Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:09:36 -0400 Subject: [PATCH 01/22] feat(leveling): add Gwyddion facet-level tilt parity --- docs/scientific-status.md | 61 ++ src/spmkit/core/analysis/__init__.py | 2 + .../_gwyddion_align_rows_facet_tilt.py | 238 ++++++ src/spmkit/core/analysis/leveling.py | 63 ++ .../test_gwyddion_align_rows_facet_tilt.py | 775 +++++++++++++++++ .../facet_tilt/facet_tilt_reference.json | 778 ++++++++++++++++++ .../facet_tilt/facet_tilt_reference.npz | Bin 0 -> 15570 bytes .../gwyddion/facet_tilt/generate_fixtures.py | 318 +++++++ .../gwyddion/facet_tilt/oracle_facet_tilt.py | 232 ++++++ ...align_rows_facet_tilt_fixture_integrity.py | 112 +++ ...est_gwyddion_facet_tilt_generator_guard.py | 282 +++++++ 11 files changed, 2861 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_align_rows_facet_tilt.py create mode 100644 tests/core/test_gwyddion_align_rows_facet_tilt.py create mode 100644 tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.json create mode 100644 tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.npz create mode 100644 tests/validation/fixtures/gwyddion/facet_tilt/generate_fixtures.py create mode 100644 tests/validation/fixtures/gwyddion/facet_tilt/oracle_facet_tilt.py create mode 100644 tests/validation/test_gwyddion_align_rows_facet_tilt_fixture_integrity.py create mode 100644 tests/validation/test_gwyddion_facet_tilt_generator_guard.py diff --git a/docs/scientific-status.md b/docs/scientific-status.md index e6694bc..03c6eff 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -44,6 +44,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | 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 | +| Gwyddion 2.71 Align Rows Facet-level tilt | `core.analysis.leveling`, `core.analysis._gwyddion_align_rows_facet_tilt` | Public 15-case finite campaign: 15/15 corrected arrays (377 elements) bitwise exact against independent oracle and compiled Gwyddion 2.71 source-inclusion probe; 3 background arrays verified elementwise; shifts confirmed all-zero with source-correct length (original rows horizontal, original columns vertical, 7-length VERTICAL shifts for the 5x7 case); mask EXCLUDE/INCLUDE/IGNORE predicates, HORIZONTAL/VERTICAL directions, and fractional mask boundary behavior verified | CROSS_VALIDATED within the frozen 15-case campaign | Gwyddion 2.71 source (compiled source-inclusion probe), independent Python oracle, frozen NPZ/JSON fixture | Facet-level tilt method only; finite inputs (NaN/inf rejected at entry); no trim-fraction, degree, or other method-family claim; no universal, performance, other-version/build, or GUI 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 | @@ -252,6 +253,66 @@ Gwyddion 2.71 source: modules/process/linematch.c matrix, performance, ROI/GUI, adapter, or other Align Rows method-family claim. This finite campaign does not establish physical validation or general SPMKit parity. +### Gwyddion 2.71 Align Rows Facet-level tilt + +**Claim:** `CROSS_VALIDATED` within the frozen 15-case public campaign covering zero constant, +exactly linear, nearly linear, curved with outliers, curved with masks (INCLUDE, EXCLUDE, IGNORE), +fractional mask boundaries, horizontal/vertical directions, and two-column rows (both +orientations). The production contract is bitwise exact against both the independent Python +oracle and the compiled Gwyddion 2.71 source-inclusion probe in 15/15 corrected +arrays (377 elements). Background arrays for the three extract-background cases are verified +elementwise (`input - corrected`). Shifts arrays are confirmed all-zero (matching +`gwy_data_line_clear`) with the source-correct length: the operation resamples the shifts line +to the working field's y-resolution (`gwy_data_line_resample` in `linematch.c` `execute()`), +so horizontal processing yields original-row-length shifts while vertical processing yields +original-column-length shifts (7 for the 5x7 VERTICAL case). + +The kernel implements the exact Gwyddion 2.71 `linematch_do_facet_tilt` algorithm: iterative +robust reweighted slope estimation (C=1/200 weighting, exp(q) weights, 30-iteration cap, +`|tilt/dx|<1e-6` convergence), pair-wise mask predicates (INCLUDE mask≥1.0, EXCLUDE mask≤0.0), +2-column mincount guard, transpose/restore for VERTICAL direction, and centre-pivot untilting. + +Known source-confirmed behaviors: constant rows produce NaN (sigma²=0, IEEE 0/0 in exp); exactly +linear rows NaN-propagate after the first correction iteration. Input NaN/inf is rejected at +entry (deliberate defensive validation, diverging from Gwyddion's unchecked IEEE propagation). + +**Repair history:** an earlier closure stored five shifts for the 5x7 VERTICAL case in the kernel, +oracle, and fixture generator while the external probe emitted seven; the generator truncated the +external evidence to the assumed original y-resolution (circular-validation failure). The repair +derived the shifts length from the source (working-field y-resolution), fixed the kernel, oracle, +and generator (which now raises on truncation), added the `two_column_vertical` external case, +re-ran the normal and ASan campaigns (15/15 cases exit 0, ASan clean, normal-vs-ASan stdout +identical), and regenerated the fixtures from fresh probe output. All 14 pre-existing +corrected/background arrays are bitwise identical before and after the repair, confirming the +shifts-length correction did not alter the correction science. + +The kernel implements the exact Gwyddion 2.71 `linematch_do_facet_tilt` algorithm: iterative +robust reweighted slope estimation (C=1/200 weighting, exp(q) weights, 30-iteration cap, +`|tilt/dx|<1e-6` convergence), pair-wise mask predicates (INCLUDE mask≥1.0, EXCLUDE mask≤0.0), +2-column mincount guard, transpose/restore for VERTICAL direction, and centre-pivot untilting. + +Known source-confirmed behaviors: constant rows produce NaN (sigma²=0, IEEE 0/0 in exp); exactly +linear rows NaN-propagate after the first correction iteration. Input NaN/inf is rejected at +entry (deliberate defensive validation, diverging from Gwyddion's unchecked IEEE propagation). + +**Traceability:** + +```text +Gwyddion 2.71 source: modules/process/linematch.c (SHA-256 79b951a1...) + → source-inclusion probe: .reference/gwyddion-2.71/facet-tilt-parity/facet_tilt_behavior_probe.c + → independent oracle: tests/validation/fixtures/gwyddion/facet_tilt/oracle_facet_tilt.py + → frozen fixtures: tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.{json,npz} + → private kernel: src/spmkit/core/analysis/_gwyddion_align_rows_facet_tilt.py + → public API: src/spmkit/core/analysis/leveling.py (gwyddion_align_rows_facet_tilt) + → tests: tests/core/test_gwyddion_align_rows_facet_tilt.py + → fixture integrity: tests/validation/test_gwyddion_align_rows_facet_tilt_fixture_integrity.py +``` + +**Non-claims:** no universal equivalence; no non-finite input propagation (rejected at entry); no +other Align Rows method-family, performance, other Gwyddion version/build, GUI, adapter, or +physical validation claim. The public function is an explicit alternative to, not a compatibility +claim for, the existing generic `align_rows`. + ## Test-count policy The collection total is measured with: diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 0cff264..89ea029 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -55,6 +55,7 @@ from spmkit.core.analysis.leveling import ( GwyddionAlignRowsDirection, GwyddionAlignRowsMaskMode, + gwyddion_align_rows_facet_tilt, gwyddion_align_rows_median, gwyddion_align_rows_median_of_differences, gwyddion_align_rows_trimmed_mean, @@ -99,6 +100,7 @@ "gwyddion_flat_disc_opening", "GwyddionAlignRowsDirection", "GwyddionAlignRowsMaskMode", + "gwyddion_align_rows_facet_tilt", "gwyddion_align_rows_median", "gwyddion_align_rows_median_of_differences", "gwyddion_align_rows_trimmed_mean", diff --git a/src/spmkit/core/analysis/_gwyddion_align_rows_facet_tilt.py b/src/spmkit/core/analysis/_gwyddion_align_rows_facet_tilt.py new file mode 100644 index 0000000..46be8db --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_align_rows_facet_tilt.py @@ -0,0 +1,238 @@ +"""Private portable Gwyddion 2.71 Align Rows facet-tilt kernel. + +This module intentionally implements only the source-confirmed +linematch_do_facet_tilt algorithm from the frozen Gwyddion 2.71 +linematch.c source (lines 625-749). 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 typing import cast + +import numpy as np +from numpy.typing import ArrayLike + +from ._gwyddion_align_rows_statistics import ( + FloatArray, + _GwyddionAlignRowsDirection, + _GwyddionMaskMode, + _minimum_sample_count, + _validated_enum, + _validated_field, + _validated_mask, +) + +_C = 1.0 / 200.0 + + +def _exp(value: float) -> float: + """Return ``exp(value)`` matching C's ``exp()`` which returns HUGE_VAL + (infinity) on overflow without raising an error.""" + try: + return math.exp(value) + except OverflowError: + return math.inf + + +@dataclass(frozen=True) +class _GwyddionFacetTiltResult: + """Corrected field with optional extracted background and zero shifts.""" + + corrected: FloatArray + background: FloatArray | None + shifts: FloatArray + + +def _row_fit_facet_tilt( + drow: FloatArray, + mrow: FloatArray | None, + mode: _GwyddionMaskMode, + dx: float, + mincount: int, +) -> float: + """Compute one facet-tilt estimate for a single row. + + Implements the exact ``row_fit_facet_tilt`` from Gwyddion 2.71 + linematch.c. The FP order of ``sigma2 = C * sigma2 / n`` and + ``return sumvx/sumvz * dx`` is preserved for source parity. + + Note that for sensible inputs the computed tilt is independent of + ``dx`` (the factor cancels in ``sumvx/sumvz * dx``). The convergence + test ``fabs(tilt/dx) < 1e-6`` however *does* depend on ``dx``. + """ + res = drow.size + sigma2 = 0.0 + n = 0 + + if mrow is not None and mode is _GwyddionMaskMode.INCLUDE: + for i in range(res - 1): + if mrow[i] >= 1.0 and mrow[i + 1] >= 1.0: + vx = (drow[i + 1] - drow[i]) / dx + sigma2 += vx * vx + n += 1 + elif mrow is not None and mode is _GwyddionMaskMode.EXCLUDE: + for i in range(res - 1): + if mrow[i] <= 0.0 and mrow[i + 1] <= 0.0: + vx = (drow[i + 1] - drow[i]) / dx + sigma2 += vx * vx + n += 1 + else: + for i in range(res - 1): + vx = (drow[i + 1] - drow[i]) / dx + sigma2 += vx * vx + n = res - 1 + + if n < mincount: + return 0.0 + + # C: sigma2 = c*sigma2/n → ((1.0/200.0) * sigma2) / n + sigma2 = (_C * sigma2) / n + + sumvx = 0.0 + sumvz = 0.0 + if mrow is not None and mode is _GwyddionMaskMode.INCLUDE: + for i in range(res - 1): + if mrow[i] >= 1.0 and mrow[i + 1] >= 1.0: + vx = (drow[i + 1] - drow[i]) / dx + q = _exp(vx * vx / sigma2) + sumvx += vx / q + sumvz += 1.0 / q + elif mrow is not None and mode is _GwyddionMaskMode.EXCLUDE: + for i in range(res - 1): + if mrow[i] <= 0.0 and mrow[i + 1] <= 0.0: + vx = (drow[i + 1] - drow[i]) / dx + q = _exp(vx * vx / sigma2) + sumvx += vx / q + sumvz += 1.0 / q + else: + for i in range(res - 1): + vx = (drow[i + 1] - drow[i]) / dx + q = _exp(vx * vx / sigma2) + sumvx += vx / q + sumvz += 1.0 / q + + # C: return sumvx/sumvz * dx → (sumvx/sumvz) * dx + return (sumvx / sumvz) * dx + + +def _untilt_row(drow: FloatArray, res: int, bx: float) -> None: + """Subtract facet tilt from a row in-place. + + ``bx == 0.0`` is a no-op (matching ``if (!bx) return`` in C). + NaN ``bx`` is truthy, so subtraction proceeds and propagates NaN. + """ + if bx == 0.0: + return + + half = 0.5 * (res - 1) + for i in range(res): + x = i - half + drow[i] -= bx * x + + +def _background_in_c_order(input_data: FloatArray, corrected: FloatArray) -> FloatArray: + """Compute ``input - corrected`` elementwise in row-major C order.""" + background = np.empty_like(input_data, order="C") + for row in range(input_data.shape[0]): + for col in range(input_data.shape[1]): + background[row, col] = input_data[row, col] - corrected[row, col] + return background + + +def _gwyddion_align_rows_facet_tilt( + data: ArrayLike, + *, + masking_mode: object, + direction: object, + dx: object, + mask: ArrayLike | None = None, + extract_background: object = False, +) -> _GwyddionFacetTiltResult: + """Compute the private portable Gwyddion 2.71 facet-tilt result. + + Parameters + ---------- + data : array-like, (yres, xres). + Finite numeric two-dimensional input. + masking_mode : _GwyddionMaskMode. + direction : _GwyddionAlignRowsDirection. + dx : float. + Physical pixel spacing in data units (xreal / xres). Required + for the convergence test ``|tilt/dx| < 1e-6``. + mask : None or (yres, xres) array-like. + extract_background : bool. + When True, the returned ``background`` is ``input - corrected`` + computed in row-major C order. + + Returns + ------- + _GwyddionFacetTiltResult + """ + values = _validated_field(data, label="data") + validated_mask = _validated_mask(mask, values.shape) + selected_mode = cast( + _GwyddionMaskMode, + _validated_enum(masking_mode, _GwyddionMaskMode, "masking_mode"), + ) + selected_direction = cast( + _GwyddionAlignRowsDirection, + _validated_enum(direction, _GwyddionAlignRowsDirection, "direction"), + ) + if isinstance(dx, (bool, np.bool_)) or not isinstance( + dx, (int, float, np.integer, np.floating) + ): + raise TypeError("Gwyddion Align Rows facet_tilt dx must be a real scalar") + dx_value = float(dx) + if not math.isfinite(dx_value): + raise ValueError("Gwyddion Align Rows facet_tilt dx must be finite") + if dx_value <= 0.0: + raise ValueError("Gwyddion Align Rows facet_tilt dx must be positive") + 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.copy(order="C") + working_mask = effective_mask + work_yres, work_xres = values.shape + 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) + ) + work_yres, work_xres = working.shape + + mincount = _minimum_sample_count(work_xres) + + for row_idx in range(work_yres): + drow = working[row_idx] + mrow = working_mask[row_idx] if working_mask is not None else None + for _ in range(30): + tilt = _row_fit_facet_tilt(drow, mrow, selected_mode, dx_value, mincount) + _untilt_row(drow, work_xres, tilt) + if math.fabs(tilt / dx_value) < 1e-6: + break + + corrected = ( + working + if selected_direction is _GwyddionAlignRowsDirection.HORIZONTAL + else np.ascontiguousarray(working.T) + ) + background = ( + _background_in_c_order(values, corrected) if extract_background else None + ) + shifts = np.zeros(work_yres, dtype=np.float64, order="C") + return _GwyddionFacetTiltResult( + corrected=corrected, background=background, shifts=shifts + ) diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index e954a32..c5eb073 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -11,6 +11,9 @@ import numpy as np +from spmkit.core.analysis._gwyddion_align_rows_facet_tilt import ( + _gwyddion_align_rows_facet_tilt, +) from spmkit.core.analysis._gwyddion_align_rows_statistics import ( _gwyddion_align_rows_statistics_result, _GwyddionAlignRowsDirection, @@ -370,6 +373,66 @@ def gwyddion_align_rows_trimmed_mean_of_differences( ) +def gwyddion_align_rows_facet_tilt( + channel: SPMChannel, + *, + mask: np.ndarray | None = None, + mask_mode: GwyddionAlignRowsMaskMode = "ignore", + direction: GwyddionAlignRowsDirection = "horizontal", +) -> SPMChannel: + """Apply Gwyddion 2.71 Align Rows Facet-level tilt correction. + + ``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 algorithm estimates the facet tilt (surface slope) for each row using + iterative robust reweighting and subtracts it about the row centre. Rows + with zero variance propagate IEEE NaN (as in the Gwyddion 2.71 source). + Inputs containing NaN or infinity are rejected at entry (consistent with + the defensive validation shared by all ``gwyddion_align_rows_*`` functions; + the Gwyddion C implementation performs no such pre-filtering). + + The algorithm produces no per-row offset vector: its shifts output is + always all zeros, matching the Gwyddion 2.71 source behaviour (the length + is the working field's y-resolution — original rows for horizontal, + original columns for vertical). + + ``x_range`` must be positive; it determines the physical pixel spacing + ``dx = x_range / columns`` used in the convergence test + ``|tilt/dx| < 1e-6``. + + The result is a new ``SPMChannel`` with the input context preserved. + """ + 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'" + ) + + columns = channel.data.shape[1] + if columns < 2: + raise ValueError( + "Gwyddion Align Rows facet_tilt requires at least two columns" + ) + dx = channel.x_range / float(columns) + + result = _gwyddion_align_rows_facet_tilt( + channel.data, + masking_mode=_GWYDDION_ALIGN_ROWS_MASK_MODES[mask_mode], + direction=_GWYDDION_ALIGN_ROWS_DIRECTIONS[direction], + dx=dx, + mask=mask, + ) + 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_align_rows_facet_tilt.py b/tests/core/test_gwyddion_align_rows_facet_tilt.py new file mode 100644 index 0000000..9a48ae0 --- /dev/null +++ b/tests/core/test_gwyddion_align_rows_facet_tilt.py @@ -0,0 +1,775 @@ +"""Public-contract tests for Gwyddion 2.71 Align Rows facet-tilt.""" + +from __future__ import annotations + +import math +import os +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +from spmkit.core.analysis._gwyddion_align_rows_facet_tilt import ( + _gwyddion_align_rows_facet_tilt, + _GwyddionAlignRowsDirection, + _GwyddionFacetTiltResult, + _GwyddionMaskMode, +) +from spmkit.core.models import SPMChannel + +# ── helpers ────────────────────────────────────────────────────────── + +_ORACLE_PATH = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "facet_tilt" + / "oracle_facet_tilt.py" +) + + +def _import_oracle() -> Any: # pragma: no cover + import importlib.util + + spec = importlib.util.spec_from_file_location( + "oracle_facet_tilt", str(_ORACLE_PATH) + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +def _build_field(yr: int, xr: int, func) -> np.ndarray: + """Build a (yr, xr) float64 field from a callable ``func(row, col)``.""" + data = np.empty((yr, xr), dtype=np.float64) + for row in range(yr): + for col in range(xr): + data[row, col] = func(row, col) + return data + + +def _base_func(row: int, col: int) -> float: + return float( + 2.0 + + 0.12 * col + - 0.07 * row + + 0.015 * col * row + + 0.03 * math.sin(0.9 * col + 0.4 * row) + ) + + +def _spikes(row: int, col: int) -> float: + v = _base_func(row, col) + if row == 1 and col == 4: + v += 3.5 + if row == 3 and col == 2: + v -= 4.0 + if row == 4 and col == 6: + v += 1.2 + return v + + +# The 14 cases from the C probe campaign, keyed by case name +_CASE_PARAMS: dict[str, dict[str, Any]] = { + "wide_curved_nomask": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": _spikes, + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": True, + }, + "wide_curved_includemask": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": _spikes, + "mask_type": "every2nd", + "masking": "include", + "direction": "horizontal", + "do_extract": False, + }, + "wide_curved_excludemask": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": _spikes, + "mask_type": "cols234", + "masking": "exclude", + "direction": "horizontal", + "do_extract": False, + }, + "wide_curved_ignoremask": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": _spikes, + "mask_type": "every2nd", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "constant_rows_5x4": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": lambda r, c: 7.0, + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "constant_rows_nonzero_5x4": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": None, # replaced in _build_case_data + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": True, + }, + "exactly_linear_rows": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": lambda r, c: { + 0: 0.0 + 1.0 * c, + 1: 2.0 + 3.0 * c, + 2: -1.0 - 2.0 * c, + 3: 4.0 + 0.5 * c, + }[r], + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "nearly_linear_rows": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": lambda r, c: { + 0: 0.0 + 1.0 * c, + 1: 2.0 + 3.0 * c, + 2: -1.0 - 2.0 * c, + 3: 4.0 + 0.5 * c, + }[r] + + 1e-13 * math.sin(c + r * 17.0), + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "large_outlier": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": lambda r, c: 1e10 if (r == 2 and c == 3) else 0.0, + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "repeated_outlier": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": lambda r, c: 1e10 + if (r == 2 and c in (1, 2, 6)) or (r == 3 and c == 3) + else 0.0, + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "two_column_row": { + "yres": 3, + "xres": 2, + "xreal": 2.0, + "func": lambda r, c: { + 0: {0: 0.0, 1: 1.0}, + 1: {0: 5.0, 1: 10.0}, + 2: {0: -3.0, 1: 7.0}, + }[r][c], + "mask_type": "none", + "masking": "ignore", + "direction": "horizontal", + "do_extract": False, + }, + "vertical_direction": { + "yres": 5, + "xres": 7, + "xreal": 5.6, + "func": _spikes, + "mask_type": "none", + "masking": "ignore", + "direction": "vertical", + "do_extract": True, + }, + "fractional_mask": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": _spikes, # cropped to 4×5 by taking first 4 rows, first 5 cols + "mask_type": "fractional", + "masking": "exclude", + "direction": "horizontal", + "do_extract": False, + }, + "fractional_mask_include": { + "yres": 4, + "xres": 5, + "xreal": 4.0, + "func": _spikes, # same cropped data + "mask_type": "fractional", + "masking": "include", + "direction": "horizontal", + "do_extract": False, + }, + "two_column_vertical": { + "yres": 2, + "xres": 3, + "xreal": 3.0, + "func": lambda r, c: { + 0: {0: 0.0, 1: 1.0, 2: 2.0}, + 1: {0: 5.0, 1: 10.0, 2: 15.0}, + }[r][c], + "mask_type": "none", + "masking": "ignore", + "direction": "vertical", + "do_extract": False, + }, +} + + +def _build_mask(mask_type: str, yres: int, xres: int) -> np.ndarray | None: + if mask_type == "none": + return None + mask = np.empty((yres, xres), dtype=np.float64) + if mask_type == "every2nd": + for row in range(yres): + for col in range(xres): + mask[row, col] = 1.0 if (row * xres + col) % 2 == 0 else 0.0 + elif mask_type == "cols234": + for row in range(yres): + for col in range(xres): + mask[row, col] = 1.0 if 2 <= col <= 4 else 0.0 + elif mask_type == "fractional": + pattern = [0.0, 0.999999, 1.0, 1.000001, 0.5] + for row in range(yres): + for col in range(xres): + mask[row, col] = pattern[col % 5] + else: + raise ValueError(f"Unknown mask_type: {mask_type}") + return mask + + +def _build_case_data(case_name: str) -> tuple[np.ndarray, np.ndarray | None, dict[str, Any]]: + params = _CASE_PARAMS[case_name] + yres, xres = params["yres"], params["xres"] + + func = params["func"] + if func is not None: + data = _build_field(yres, xres, func) + else: + data = np.empty((yres, xres), dtype=np.float64) + + # For fractional_mask cases, use cropped spikes data (first 4 rows, first 5 cols) + if case_name in ("fractional_mask", "fractional_mask_include"): + # Build the full 7×5 field from wide_curved data and crop to 4×5 + full_data = _build_field(5, 7, _spikes) + data = full_data[:4, :5].copy(order="C") + + # Fix constant_rows_nonzero_5x4 + if case_name == "constant_rows_nonzero_5x4": + row_vals = [-3.5, 0.0, 7.0, 2.5] + for row in range(4): + data[row, :] = row_vals[row] + + mask = _build_mask(params["mask_type"], yres, xres) + return data, mask, params + + +def _run_kernel( + data: np.ndarray, + mask: np.ndarray | None, + params: dict[str, Any], + extract_background: bool | None = None, +) -> _GwyddionFacetTiltResult: + mode_map = { + "ignore": _GwyddionMaskMode.IGNORE, + "include": _GwyddionMaskMode.INCLUDE, + "exclude": _GwyddionMaskMode.EXCLUDE, + } + dir_map = { + "horizontal": _GwyddionAlignRowsDirection.HORIZONTAL, + "vertical": _GwyddionAlignRowsDirection.VERTICAL, + } + dx = params["xreal"] / params["xres"] + do_extract = params["do_extract"] if extract_background is None else extract_background + return _gwyddion_align_rows_facet_tilt( + data, + masking_mode=mode_map[params["masking"]], + direction=dir_map[params["direction"]], + dx=dx, + mask=mask, + extract_background=do_extract, + ) + + +def _max_numeric_diff(a: np.ndarray, b: np.ndarray) -> float: + max_diff = 0.0 + for i in range(a.size): + av = float(a.flat[i]) + bv = float(b.flat[i]) + if np.isnan(av) and np.isnan(bv): + continue + if not (np.isnan(av) or np.isnan(bv)): + diff = abs(av - bv) + if diff > max_diff: + max_diff = diff + return max_diff + + +def _nan_count_match(a: np.ndarray, b: np.ndarray) -> bool: + return np.sum(np.isnan(a)) == np.sum(np.isnan(b)) + + +def _inf_count_match(a: np.ndarray, b: np.ndarray) -> bool: + return np.sum(np.isinf(a)) == np.sum(np.isinf(b)) + + +# --------------------------------------------------------------------------- +# 1. Oracle vs C probe +# --------------------------------------------------------------------------- + +_C_PROBE_ROOT = "/tmp/spmkit_gwyddion_facet_tilt_probe/normal" + + +def _load_probe_corrected(case_name: str, yres: int, xres: int) -> np.ndarray: + stdout_path = os.path.join(_C_PROBE_ROOT, f"{case_name}.stdout") + if not os.path.exists(stdout_path): + pytest.skip("C probe output not available") + with open(stdout_path) as f: + lines = f.read().splitlines() + d: dict[int, float] = {} + prefix = f"{case_name}_corrected_" + for line in lines: + if line.startswith(prefix): + rest = line[len(prefix) :] + if rest and rest[0].isdigit(): + idx_str, val_str = rest.split("=", 1) + d[int(idx_str)] = float(val_str) + result = np.empty((yres, xres), dtype=np.float64) + for row in range(yres): + for col in range(xres): + result[row, col] = d.get(row * xres + col, float("nan")) + return result + + +def _load_probe_background(case_name: str, yres: int, xres: int) -> np.ndarray: + stdout_path = os.path.join(_C_PROBE_ROOT, f"{case_name}.stdout") + if not os.path.exists(stdout_path): + pytest.skip("C probe output not available") + with open(stdout_path) as f: + lines = f.read().splitlines() + d: dict[int, float] = {} + prefix = f"{case_name}_background_" + for line in lines: + if line.startswith(prefix): + rest = line[len(prefix) :] + if rest and rest[0].isdigit(): + idx_str, val_str = rest.split("=", 1) + d[int(idx_str)] = float(val_str) + if not d: + return np.empty((0, 0)) # no background output + result = np.empty((yres, xres), dtype=np.float64) + for row in range(yres): + for col in range(xres): + result[row, col] = d.get(row * xres + col, float("nan")) + return result + + +def _load_probe_shifts(case_name: str) -> np.ndarray | None: + """Parse ALL probe shift values (never truncate).""" + stdout_path = os.path.join(_C_PROBE_ROOT, f"{case_name}.stdout") + if not os.path.exists(stdout_path): + pytest.skip("C probe output not available") + with open(stdout_path) as f: + lines = f.read().splitlines() + d: dict[int, float] = {} + prefix = f"{case_name}_shifts_" + for line in lines: + if line.startswith(prefix): + rest = line[len(prefix):] + if rest and rest[0].isdigit(): + idx_str, val_str = rest.split("=", 1) + d[int(idx_str)] = float(val_str) + if not d: + return None + max_idx = max(d.keys()) + result = np.empty(max_idx + 1, dtype=np.float64) + for i in range(max_idx + 1): + result[i] = d.get(i, np.nan) + return result + + +@pytest.mark.parametrize( + "case_name", + list(_CASE_PARAMS), +) +def test_facet_tilt_oracle_vs_probe(case_name: str) -> None: + """Oracle matches the compiled Gwyddion 2.71 source-inclusion probe output.""" + oracle_mod = _import_oracle() + data, mask, params = _build_case_data(case_name) + if data is None: + return + yres, xres = data.shape + dx = params["xreal"] / params["xres"] + + oracle_corr, oracle_bg, oracle_shifts = oracle_mod.oracle_facet_tilt( + data.copy(order="C"), + mask.copy(order="C") if mask is not None else None, + params["masking"], + dx, + params["direction"], + params["do_extract"], + ) + + probe_corr = _load_probe_corrected(case_name, yres, xres) + max_diff = _max_numeric_diff(oracle_corr, probe_corr) + assert _nan_count_match(oracle_corr, probe_corr) + assert _inf_count_match(oracle_corr, probe_corr) + assert max_diff < 1e-14, ( + f"Oracle vs probe corrected max diff {max_diff:.17g} for {case_name}" + ) + + if params["do_extract"]: + probe_bg = _load_probe_background(case_name, yres, xres) + bg_diff = _max_numeric_diff(oracle_bg, probe_bg) + assert bg_diff < 1e-14, ( + f"Oracle vs probe background max diff {bg_diff:.17g} for {case_name}" + ) + + # Compare shifts: shape AND values against the probe + probe_shifts = _load_probe_shifts(case_name) + assert probe_shifts is not None, f"No probe shifts found for {case_name}" + assert probe_shifts.shape == oracle_shifts.shape, ( + f"Shifts shape mismatch for {case_name}: " + f"probe {probe_shifts.shape} vs oracle {oracle_shifts.shape}" + ) + assert np.all(probe_shifts == 0.0) + assert np.all(oracle_shifts == 0.0) + + +# --------------------------------------------------------------------------- +# 2. Oracle vs kernel (bitwise exact) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("case_name", list(_CASE_PARAMS)) +def test_facet_tilt_oracle_vs_kernel(case_name: str) -> None: + """Private SPMKit kernel matches the oracle bitwise.""" + oracle_mod = _import_oracle() + data, mask, params = _build_case_data(case_name) + if data is None: + return + dx = params["xreal"] / params["xres"] + + kernel_result = _run_kernel(data, mask, params) + oracle_corr, oracle_bg, oracle_shifts = oracle_mod.oracle_facet_tilt( + data.copy(order="C"), + mask.copy(order="C") if mask is not None else None, + params["masking"], + dx, + params["direction"], + params["do_extract"], + ) + + assert _max_numeric_diff(kernel_result.corrected, oracle_corr) == 0.0 + if params["do_extract"]: + assert _max_numeric_diff(kernel_result.background, oracle_bg) == 0.0 + # Shifts: shape and values must match bitwise + assert kernel_result.shifts.shape == oracle_shifts.shape, ( + f"Shifts shape mismatch for {case_name}: " + f"kernel {kernel_result.shifts.shape} vs oracle {oracle_shifts.shape}" + ) + assert np.all(kernel_result.shifts == 0.0) + assert np.all(oracle_shifts == 0.0) + + +# --------------------------------------------------------------------------- +# 3. Constant-row NaN behaviour +# --------------------------------------------------------------------------- + +def test_facet_tilt_constant_row_nan() -> None: + """A uniformly constant row produces NaN (IEEE 0/0 in sigma2).""" + data = np.full((3, 5), 7.0, dtype=np.float64) + result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=1.0, + ) + assert np.isnan(result.corrected).all() + + +# --------------------------------------------------------------------------- +# 4. Two-column row mincount guard +# --------------------------------------------------------------------------- + +def test_facet_tilt_two_column_row() -> None: + """A 2-column row has n = 1, which is below mincount = 2, so stays unchanged.""" + data = np.array([[0.0, 1.0], [5.0, 10.0], [-3.0, 7.0]], dtype=np.float64) + result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=1.0, + ) + assert np.array_equal(result.corrected, data) + assert np.all(result.shifts == 0.0) + + +# --------------------------------------------------------------------------- +# 5. Exactly linear row behaviour +# --------------------------------------------------------------------------- + +def test_facet_tilt_exactly_linear() -> None: + """A perfectly linear row becomes constant after the first untilt, then NaNs. + + Gwyddion source confirmation: the first iteration removes the true + slope exactly, making sigma2 zero in the second iteration, which + triggers an FP NaN chain. + """ + data = np.array( + [ + [0.0, 1.0, 2.0, 3.0, 4.0], # b=1, a=0 + ], + dtype=np.float64, + ) + oracle_mod = _import_oracle() + corrected, bg, shifts = oracle_mod.oracle_facet_tilt( + data.copy(), None, "ignore", 1.0, "horizontal", False + ) + assert np.all(np.isnan(corrected)) + assert np.all(shifts == 0.0) + + +# --------------------------------------------------------------------------- +# 6. Convergence cap at 30 iterations +# --------------------------------------------------------------------------- + +def test_facet_tilt_convergence_cap() -> None: + """NaN-producing rows stop at 30 iterations, never infinite.""" + data = np.full((1, 5), 7.0, dtype=np.float64) + # The function does not raise; it returns NaN result after 30 iterations. + result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=1.0, + ) + assert result.corrected.shape == (1, 5) + + +# --------------------------------------------------------------------------- +# 7. Input non-mutation +# --------------------------------------------------------------------------- + +def test_facet_tilt_nonmutation() -> None: + """Input data is not modified by processing.""" + data, mask, params = _build_case_data("wide_curved_nomask") + original = data.copy(order="C") + _run_kernel(data, mask, params) + assert np.array_equal(data, original) + + +# --------------------------------------------------------------------------- +# 8. Mask semantics +# --------------------------------------------------------------------------- + +def test_facet_tilt_mask_semantics() -> None: + """EXCLUDE uses ``<= 0.0``, INCLUDE uses ``>= 1.0``, tested via fractional mask.""" + data, mask, params = _build_case_data("fractional_mask") + result = _run_kernel(data, mask, params) + # No NaN expected — the fractional mask has enough non-excluded columns + assert result.corrected.shape == data.shape + + data2, mask2, params2 = _build_case_data("fractional_mask_include") + result2 = _run_kernel(data2, mask2, params2) + assert result2.corrected.shape == data2.shape + + +# --------------------------------------------------------------------------- +# 9. Direction transpose consistency +# --------------------------------------------------------------------------- + +def test_facet_tilt_direction_transpose() -> None: + """HORIZONTAL and VERTICAL produce transpose-consistent corrected outputs.""" + data, mask, params = _build_case_data("vertical_direction") + dx = params["xreal"] / params["xres"] + + h_result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=dx, + extract_background=True, + ) + v_result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.VERTICAL, + dx=dx, + extract_background=True, + ) + + # H & V should differ (different processing axis) + assert not np.allclose(h_result.corrected, v_result.corrected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# 10. Background = input - corrected +# --------------------------------------------------------------------------- + +def test_facet_tilt_background_identity() -> None: + """background == input - corrected elementwise in C order.""" + data, mask, params = _build_case_data("wide_curved_nomask") + result = _run_kernel(data, mask, {**params, "do_extract": True}) + expected_bg = np.empty_like(data, order="C") + for row in range(data.shape[0]): + for col in range(data.shape[1]): + expected_bg[row, col] = data[row, col] - result.corrected[row, col] + assert _max_numeric_diff(result.background, expected_bg) == 0.0 + + +# --------------------------------------------------------------------------- +# 11. Shifts are always zero +# --------------------------------------------------------------------------- + +def test_facet_tilt_shifts_zero() -> None: + """shifts output is the zero vector, matching ``gwy_data_line_clear``. + + For HORIZONTAL the shifts length equals yres; for VERTICAL it equals + xres (the working field's y-resolution after transpose). + """ + data, mask, params = _build_case_data("wide_curved_nomask") + result = _run_kernel(data, mask, params) + assert result.shifts.size == data.shape[0] + assert np.all(result.shifts == 0.0) + + # VERTICAL: shifts length = original xres + data2, mask2, params2 = _build_case_data("vertical_direction") + result2 = _run_kernel(data2, mask2, params2) + assert result2.shifts.size == data2.shape[1], ( + f"VERTICAL shifts size {result2.shifts.size} != xres {data2.shape[1]}" + ) + assert np.all(result2.shifts == 0.0) + + +# --------------------------------------------------------------------------- +# 12. Metamorphic: adding a constant to all rows +# --------------------------------------------------------------------------- + +def test_facet_tilt_metamorphic_constant_shift() -> None: + """Adding a constant C to the entire field shifts corrected by C; + the slope-estimation algorithm subtracts tilt only.""" + data, mask, params = _build_case_data("wide_curved_nomask") + dx = params["xreal"] / params["xres"] + + result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=dx, + extract_background=True, + ) + + c = 100.0 + shifted = data + c + result_shifted = _gwyddion_align_rows_facet_tilt( + shifted, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=dx, + extract_background=True, + ) + + corrected_diff = _max_numeric_diff( + result_shifted.corrected, result.corrected + c + ) + # The corrected output should shift by ~C (within a few ULPs) + assert corrected_diff < 1e-13, ( + f"Constant-shift metamorphism failed: diff {corrected_diff:.17g}" + ) + + # Background should be identical (tilt only, no offset) + bg_diff = _max_numeric_diff(result_shifted.background, result.background) + assert bg_diff < 1e-13, ( + f"Background should be constant-shift invariant: diff {bg_diff:.17g}" + ) + + +# --------------------------------------------------------------------------- +# 13. Iteration limit (slow convergence) +# --------------------------------------------------------------------------- + +def test_facet_tilt_iteration_limit() -> None: + """Verify the 30-iteration cap does not loop infinitely.""" + # A row with very large slope needs many iterations but converges within 30. + # row: d[col] = 1000 * col, xres = 7, dx = 1 + data = np.array( + [ + [0.0, 1000.0, 2000.0, 3000.0, 4000.0, 5000.0, 6000.0], + ], + dtype=np.float64, + ) + result = _gwyddion_align_rows_facet_tilt( + data, + masking_mode=_GwyddionMaskMode.IGNORE, + direction=_GwyddionAlignRowsDirection.HORIZONTAL, + dx=1.0, + ) + # After convergence, the row becomes constant (about the centre value). + # It should NOT be all NaN (unlike the exactly-linear case which has + # a different issue — the 2nd iteration becomes constant, producing NaN). + # With large slope, exp(vx^2/sigma2) ≈ exp(200) ≈ huge but finite, + # so the row converges properly after 1-2 iterations. + assert not np.all(np.isnan(result.corrected)) + assert not np.all(np.isinf(result.corrected)) + + +# --------------------------------------------------------------------------- +# 14. Public API type errors +# --------------------------------------------------------------------------- + +def test_facet_tilt_public_api_type_errors() -> None: + """gwyddion_align_rows_facet_tilt rejects invalid inputs.""" + channel = SPMChannel( + data=np.ones((5, 7), dtype=np.float64), + x_range=5.6, + y_range=6.5, + name="test", + unit="m", + ) + + result = analysis.gwyddion_align_rows_facet_tilt(channel) + assert result.data.shape == (5, 7) + assert isinstance(result, SPMChannel) + + # TypeError for non-channel + with pytest.raises(TypeError): + analysis.gwyddion_align_rows_facet_tilt("not a channel") # type: ignore[arg-type] + + # ValueError for bad mask_mode + with pytest.raises(ValueError): + analysis.gwyddion_align_rows_facet_tilt(channel, mask_mode="bogus") # type: ignore[arg-type] + + # ValueError for bad direction + with pytest.raises(ValueError): + analysis.gwyddion_align_rows_facet_tilt(channel, direction="diagonal") # type: ignore[arg-type] diff --git a/tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.json b/tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.json new file mode 100644 index 0000000..64fcd3b --- /dev/null +++ b/tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.json @@ -0,0 +1,778 @@ +{ + "capability": "gwyddion_align_rows_facet_tilt", + "case_count": 15, + "cases": [ + { + "case_identifier": "wide_curved_nomask", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": true, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x400490bd94a81b09", + "0x40059314d35895b8", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x40049aad98b6c732", + "0x4005cef98f406c99", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x4004ae253eb9782f", + "0x40060f5fff276bce", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e", + "0x4004c9af95d951b4", + "0x40065094c30f461d", + "0x3ffbfff29632b952", + "0x3ffeaff0b4afeab9", + "0x40009423b9db33e0", + "0x4001dc30d5f70749", + "0x40034ed72e050102", + "0x4004ea91643c8ae4", + "0x4010142ec0baa61d" + ], + "input_key": "wide_curved_nomask_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": "wide_curved_nomask_probe_background", + "probe_corrected_key": "wide_curved_nomask_probe_corrected", + "probe_shifts_key": "wide_curved_nomask_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "wide_curved_includemask", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": false, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x400490bd94a81b09", + "0x40059314d35895b8", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x40049aad98b6c732", + "0x4005cef98f406c99", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x4004ae253eb9782f", + "0x40060f5fff276bce", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e", + "0x4004c9af95d951b4", + "0x40065094c30f461d", + "0x3ffbfff29632b952", + "0x3ffeaff0b4afeab9", + "0x40009423b9db33e0", + "0x4001dc30d5f70749", + "0x40034ed72e050102", + "0x4004ea91643c8ae4", + "0x4010142ec0baa61d" + ], + "input_key": "wide_curved_includemask_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 1, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "wide_curved_includemask_probe_corrected", + "probe_shifts_key": "wide_curved_includemask_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "wide_curved_excludemask", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": false, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x400490bd94a81b09", + "0x40059314d35895b8", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x40049aad98b6c732", + "0x4005cef98f406c99", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x4004ae253eb9782f", + "0x40060f5fff276bce", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e", + "0x4004c9af95d951b4", + "0x40065094c30f461d", + "0x3ffbfff29632b952", + "0x3ffeaff0b4afeab9", + "0x40009423b9db33e0", + "0x4001dc30d5f70749", + "0x40034ed72e050102", + "0x4004ea91643c8ae4", + "0x4010142ec0baa61d" + ], + "input_key": "wide_curved_excludemask_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "wide_curved_excludemask_probe_corrected", + "probe_shifts_key": "wide_curved_excludemask_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "wide_curved_ignoremask", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": false, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x400490bd94a81b09", + "0x40059314d35895b8", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x40049aad98b6c732", + "0x4005cef98f406c99", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x4004ae253eb9782f", + "0x40060f5fff276bce", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e", + "0x4004c9af95d951b4", + "0x40065094c30f461d", + "0x3ffbfff29632b952", + "0x3ffeaff0b4afeab9", + "0x40009423b9db33e0", + "0x4001dc30d5f70749", + "0x40034ed72e050102", + "0x4004ea91643c8ae4", + "0x4010142ec0baa61d" + ], + "input_key": "wide_curved_ignoremask_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "wide_curved_ignoremask_probe_corrected", + "probe_shifts_key": "wide_curved_ignoremask_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "constant_rows_5x4", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": false, + "input_bits": [ + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000" + ], + "input_key": "constant_rows_5x4_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "constant_rows_5x4_probe_corrected", + "probe_shifts_key": "constant_rows_5x4_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "constant_rows_nonzero_5x4", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": true, + "input_bits": [ + "0xc00c000000000000", + "0xc00c000000000000", + "0xc00c000000000000", + "0xc00c000000000000", + "0xc00c000000000000", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x401c000000000000", + "0x4004000000000000", + "0x4004000000000000", + "0x4004000000000000", + "0x4004000000000000", + "0x4004000000000000" + ], + "input_key": "constant_rows_nonzero_5x4_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": "constant_rows_nonzero_5x4_probe_background", + "probe_corrected_key": "constant_rows_nonzero_5x4_probe_corrected", + "probe_shifts_key": "constant_rows_nonzero_5x4_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "exactly_linear_rows", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x3ff0000000000000", + "0x4000000000000000", + "0x4008000000000000", + "0x4010000000000000", + "0x4000000000000000", + "0x4014000000000000", + "0x4020000000000000", + "0x4026000000000000", + "0x402c000000000000", + "0xbff0000000000000", + "0xc008000000000000", + "0xc014000000000000", + "0xc01c000000000000", + "0xc022000000000000", + "0x4010000000000000", + "0x4012000000000000", + "0x4014000000000000", + "0x4016000000000000", + "0x4018000000000000" + ], + "input_key": "exactly_linear_rows_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "exactly_linear_rows_probe_corrected", + "probe_shifts_key": "exactly_linear_rows_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "nearly_linear_rows", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x3ff000000000017b", + "0x40000000000000cd", + "0x4008000000000020", + "0x400fffffffffff56", + "0x3ffffffffffffe4f", + "0x4013ffffffffffab", + "0x4020000000000008", + "0x4026000000000033", + "0x402c00000000002f", + "0xbfeffffffffffe23", + "0xc008000000000060", + "0xc014000000000070", + "0xc01c000000000048", + "0xc021ffffffffffef", + "0x401000000000004b", + "0x401200000000006f", + "0x401400000000002d", + "0x4015ffffffffffc1", + "0x4017ffffffffff8f" + ], + "input_key": "nearly_linear_rows_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "nearly_linear_rows_probe_corrected", + "probe_shifts_key": "nearly_linear_rows_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "large_outlier", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x4202a05f20000000", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0" + ], + "input_key": "large_outlier_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "large_outlier_probe_corrected", + "probe_shifts_key": "large_outlier_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "repeated_outlier", + "columns": 7, + "direction": 0, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x4202a05f20000000", + "0x4202a05f20000000", + "0x0", + "0x0", + "0x0", + "0x4202a05f20000000", + "0x0", + "0x0", + "0x0", + "0x4202a05f20000000", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x0" + ], + "input_key": "repeated_outlier_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "repeated_outlier_probe_corrected", + "probe_shifts_key": "repeated_outlier_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "two_column_row", + "columns": 2, + "direction": 0, + "dx_hex": "0x3ff0000000000000", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x3ff0000000000000", + "0x4014000000000000", + "0x4024000000000000", + "0xc008000000000000", + "0x401c000000000000" + ], + "input_key": "two_column_row_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "two_column_row_probe_corrected", + "probe_shifts_key": "two_column_row_probe_shifts", + "rows": 3, + "xreal_hex": "0x4000000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "vertical_direction", + "columns": 7, + "direction": 1, + "dx_hex": "0x3fe9999999999999", + "extract_background_request": true, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x400490bd94a81b09", + "0x40059314d35895b8", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x40049aad98b6c732", + "0x4005cef98f406c99", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x4004ae253eb9782f", + "0x40060f5fff276bce", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e", + "0x4004c9af95d951b4", + "0x40065094c30f461d", + "0x3ffbfff29632b952", + "0x3ffeaff0b4afeab9", + "0x40009423b9db33e0", + "0x4001dc30d5f70749", + "0x40034ed72e050102", + "0x4004ea91643c8ae4", + "0x4010142ec0baa61d" + ], + "input_key": "vertical_direction_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": "vertical_direction_probe_background", + "probe_corrected_key": "vertical_direction_probe_corrected", + "probe_shifts_key": "vertical_direction_probe_shifts", + "rows": 5, + "xreal_hex": "0x4016666666666666", + "yreal_hex": "0x401a000000000000" + }, + { + "case_identifier": "fractional_mask", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": false, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e" + ], + "input_key": "fractional_mask_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 0, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "fractional_mask_probe_corrected", + "probe_shifts_key": "fractional_mask_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "fractional_mask_include", + "columns": 5, + "direction": 0, + "dx_hex": "0x3fe999999999999a", + "extract_background_request": false, + "input_bits": [ + "0x4000000000000000", + "0x400125e33a1a4d6c", + "0x4002275a6b3689c4", + "0x4002fb89c8c68996", + "0x4003bbd9fec90455", + "0x3fff1121b8c9ecef", + "0x4000c0522e0884b6", + "0x4001cb46261ccd22", + "0x4002b0a27cbdc054", + "0x4017ca07f168745e", + "0x3ffe1ab56e70f5bd", + "0x400051686b36c1c9", + "0x4001675a372acdce", + "0x40026553efd842d4", + "0x4003739d12d499ef", + "0x3ffd165e7459feba", + "0x3fffb1c04c7a925e", + "0xbffe0323a0de5ab8", + "0x40021d6c74243e03", + "0x40035c654915ab0e" + ], + "input_key": "fractional_mask_include_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 1, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "fractional_mask_include_probe_corrected", + "probe_shifts_key": "fractional_mask_include_probe_shifts", + "rows": 4, + "xreal_hex": "0x4010000000000000", + "yreal_hex": "0x4008000000000000" + }, + { + "case_identifier": "two_column_vertical", + "columns": 3, + "direction": 1, + "dx_hex": "0x3ff0000000000000", + "extract_background_request": false, + "input_bits": [ + "0x0", + "0x3ff0000000000000", + "0x4000000000000000", + "0x4014000000000000", + "0x4024000000000000", + "0x402e000000000000" + ], + "input_key": "two_column_vertical_input", + "mask_bits": null, + "mask_key": null, + "masking_mode": 2, + "method": 7, + "method_name": "Facet-level tilt", + "probe_background_key": null, + "probe_corrected_key": "two_column_vertical_probe_corrected", + "probe_shifts_key": "two_column_vertical_probe_shifts", + "rows": 2, + "xreal_hex": "0x4008000000000000", + "yreal_hex": "0x4000000000000000" + } + ], + "comparison_metrics": { + "corrected": { + "arrays_bitwise_exact": 15, + "elements_bitwise_exact": 377, + "finite_nonzero_mismatch": 0, + "inf_mismatch": 0, + "max_absolute_difference": 0.0, + "nan_mismatch": 0, + "signed_zero_mismatch": 0 + } + }, + "evidence": { + "compiled_probe_diagnosis": [ + "LINEMATCH_SOURCE_MATCHES_FROZEN_REFERENCE" + ], + "non_claims": [ + "Does not cover fast-math reassociation divergence." + ], + "probe_description": "Custom binary compiling modules/process/linematch.c by source inclusion and linking the installed Gwyddion 2.71 shared libraries; the installed GUI executable (/usr/bin/gwyddion) was not invoked.", + "probe_kind": "compiled_gwyddion_2_71_source_inclusion_probe" + }, + "fixture": { + "array_hashes": { + "constant_rows_5x4_input": "ecfb44ee59e74ef1eef8f7f5360e34ffb4b28ff02468c483516e459b5307c118", + "constant_rows_5x4_probe_corrected": "4d6330c7284d412c9f19d1fb95eb8e63d9c338b446e23651ee5d478f7c2608e6", + "constant_rows_5x4_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "constant_rows_nonzero_5x4_input": "6e456e4c4fc76291cc328c105d147dfcfec6c7c355a25bb6ace6ce6932f6189d", + "constant_rows_nonzero_5x4_probe_background": "4d6330c7284d412c9f19d1fb95eb8e63d9c338b446e23651ee5d478f7c2608e6", + "constant_rows_nonzero_5x4_probe_corrected": "4d6330c7284d412c9f19d1fb95eb8e63d9c338b446e23651ee5d478f7c2608e6", + "constant_rows_nonzero_5x4_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "exactly_linear_rows_input": "64f9658f4526f2cc1c75afdf9801d9c33b9b7125dc6b22ba4fa0337040270435", + "exactly_linear_rows_probe_corrected": "4d6330c7284d412c9f19d1fb95eb8e63d9c338b446e23651ee5d478f7c2608e6", + "exactly_linear_rows_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "fractional_mask_include_input": "e6f4acb2bb2272cc824e616bb8a2e3715e85b3a75bf340278779313c3e10ef3a", + "fractional_mask_include_probe_corrected": "e6f4acb2bb2272cc824e616bb8a2e3715e85b3a75bf340278779313c3e10ef3a", + "fractional_mask_include_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "fractional_mask_input": "e6f4acb2bb2272cc824e616bb8a2e3715e85b3a75bf340278779313c3e10ef3a", + "fractional_mask_probe_corrected": "e6f4acb2bb2272cc824e616bb8a2e3715e85b3a75bf340278779313c3e10ef3a", + "fractional_mask_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "large_outlier_input": "6887bc9ab979b55006809a6e9b59d35f170b07bc7a32b97ca3065beda97f795b", + "large_outlier_probe_corrected": "8368e807335481e8d190dc8a55b1db82cce7bf93ce64b311fe641e9c0fecab45", + "large_outlier_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c", + "nearly_linear_rows_input": "6ef71661c686bd2ec31805e5db4749982f512016ac7437fadac25293a1a1f367", + "nearly_linear_rows_probe_corrected": "b9d894a4884ca46f55947fdbcc6fc2d0028e572e3eea9e69f457a99b0ae80e76", + "nearly_linear_rows_probe_shifts": "e4fc37cfbee13b5dfda57a767b296f623d26e8bf67d7f1fdcd27ed8d52d55865", + "repeated_outlier_input": "9434c8f906370c8e11bb26a9d1dc8936a650aa89d40890849aba71ecbe14ec97", + "repeated_outlier_probe_corrected": "dc916497fc09d44f47cb60c2da2d715996dd6fdac509931604d770a1ab8a1891", + "repeated_outlier_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c", + "two_column_row_input": "b31c2b49cb420f4334ed1d47d56b89cb8d85aa244a8793489bbac38105b061ed", + "two_column_row_probe_corrected": "b31c2b49cb420f4334ed1d47d56b89cb8d85aa244a8793489bbac38105b061ed", + "two_column_row_probe_shifts": "e50512deeab1fca1006866d5e38b76dd6886fda73e829b50a91e3f7a82ca86e3", + "two_column_vertical_input": "29ad288a81c4c06c88826cdac7c9eaad2901f1abe81911daef8faee0d6748ff6", + "two_column_vertical_probe_corrected": "29ad288a81c4c06c88826cdac7c9eaad2901f1abe81911daef8faee0d6748ff6", + "two_column_vertical_probe_shifts": "e50512deeab1fca1006866d5e38b76dd6886fda73e829b50a91e3f7a82ca86e3", + "vertical_direction_input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "vertical_direction_probe_background": "560426acd53ffb58b110fbb5eed49064eacdf685afb8528b80ebb1b18596dc9b", + "vertical_direction_probe_corrected": "9eba417d9720ea80f88cf911eb9d70e80a362e58899d7fe613e8fa1485405eb7", + "vertical_direction_probe_shifts": "bd99fc9c63b9a6be2583dd7896656ebb74ebfe1820dd962abb2bcac7726a4c7b", + "wide_curved_excludemask_input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_excludemask_probe_corrected": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_excludemask_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c", + "wide_curved_ignoremask_input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_ignoremask_probe_corrected": "f76b84607258e3b3348985cec05b22f9f8309449545ee34c48f41317060f8440", + "wide_curved_ignoremask_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c", + "wide_curved_includemask_input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_includemask_probe_corrected": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_includemask_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c", + "wide_curved_nomask_input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "wide_curved_nomask_probe_background": "ef11cc05bbcd6616d9662fd2bd799a5bc7db555b80917c6701295f54074cce4c", + "wide_curved_nomask_probe_corrected": "f76b84607258e3b3348985cec05b22f9f8309449545ee34c48f41317060f8440", + "wide_curved_nomask_probe_shifts": "5fc6d0c0aeaa02108907b22aa1f331adaf2d2241ae86749c3dcf92c9f9150f7c" + } + }, + "profiles": { + "compiled_gwyddion_2_71_source_inclusion_profile": { + "canonical_reference_sha256": "79b951a161431ba9822d8d0faba2b512107a5e4822569f78c42201f289e06604", + "module_sha256": "79b951a161431ba9822d8d0faba2b512107a5e4822569f78c42201f289e06604" + } + }, + "schema_version": 1 +} diff --git a/tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.npz b/tests/validation/fixtures/gwyddion/facet_tilt/facet_tilt_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..c3b9f348811e74e5ff666af63dc794d1724ab9ac GIT binary patch literal 15570 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 A(EtDd literal 0 HcmV?d00001 diff --git a/tests/validation/fixtures/gwyddion/facet_tilt/generate_fixtures.py b/tests/validation/fixtures/gwyddion/facet_tilt/generate_fixtures.py new file mode 100644 index 0000000..ce6e4be --- /dev/null +++ b/tests/validation/fixtures/gwyddion/facet_tilt/generate_fixtures.py @@ -0,0 +1,318 @@ +"""Generate facet_tilt_reference.json and .npz from C probe campaign output. + +Run this script from the fixture directory after the C probe campaign +has completed successfully. It parses the campaign stdout files and +produces the frozen reference fixture. + +The external evidence is produced by a *compiled Gwyddion 2.71 +source-inclusion probe*: a custom binary that compiles the frozen +``modules/process/linematch.c`` by source inclusion and links the +installed Gwyddion 2.71 shared libraries. The installed GUI executable +(``/usr/bin/gwyddion``) is never invoked by the campaign. +""" + +import hashlib +import json +import os +import re +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parent +PROBE_ROOT = "/tmp/spmkit_gwyddion_facet_tilt_probe/normal" + +MASKING_ENUM = {"IGNORE": 2, "INCLUDE": 1, "EXCLUDE": 0} +DIRECTION_ENUM = {"HORIZONTAL": 0, "VERTICAL": 1} + +# Element lines have the shape ``=`` immediately after the +# ``_