diff --git a/.github/workflows/leapfrog-parity.yml b/.github/workflows/leapfrog-parity.yml index cc2cf2fa8..91836382e 100644 --- a/.github/workflows/leapfrog-parity.yml +++ b/.github/workflows/leapfrog-parity.yml @@ -2,6 +2,10 @@ name: Leapfrog parity on: push: + branches: + - feature/leapfrog-parity + - feature/automatic-subdomainer + pull_request: branches: - feature/leapfrog-parity workflow_dispatch: @@ -19,6 +23,20 @@ jobs: with: python-version: "3.12" + - name: Validate standalone notebook + run: | + python - <<'PY' + import json + from pathlib import Path + + path = Path("examples/standalone_automatic_lva.ipynb") + notebook = json.loads(path.read_text(encoding="utf-8")) + for index, cell in enumerate(notebook.get("cells", [])): + if cell.get("cell_type") == "code": + compile("".join(cell.get("source", [])), f"{path}:cell-{index}", "exec") + print(f"Validated {path}") + PY + - name: Install native build tools run: | sudo apt-get update @@ -36,5 +54,6 @@ jobs: run: | python -m pip install pytest scipy python -m pytest -q \ + python/tests/test_automatic_domain_builder.py \ python/tests/test_labeled_domain_builder.py \ python/tests/test_leapfrog_values.py diff --git a/.github/workflows/polatory-benchmark.yml b/.github/workflows/polatory-benchmark.yml new file mode 100644 index 000000000..63b6cc3de --- /dev/null +++ b/.github/workflows/polatory-benchmark.yml @@ -0,0 +1,91 @@ +name: Polatory benchmark lab + +on: + workflow_dispatch: + inputs: + surface_resolution: + description: "Polatory surface resolution in metres" + required: false + default: "10" + push: + branches: + - feature/automatic-subdomainer + +jobs: + build-polatory: + runs-on: windows-latest + timeout-minutes: 300 + env: + POLATORY_BENCHMARK_RESOLUTION: ${{ github.event.inputs.surface_resolution || '10' }} + PYTHONPATH: ${{ github.workspace }}\benchmarks\headless_shims + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python build tools + run: | + python -m pip install --upgrade pip + python -m pip install setuptools wheel cmake ninja build + + - name: Bootstrap vcpkg + shell: powershell + run: | + .\vcpkg\bootstrap-vcpkg.bat -disableMetrics + + - name: Build and install Polatory + shell: powershell + env: + VCPKG_ROOT: ${{ github.workspace }}\vcpkg + run: | + python -m pip install . --no-build-isolation --force-reinstall + + - name: Install benchmark dependencies + run: | + python -m pip install scipy scikit-image pandas pyvista PyQt6 matplotlib + + - name: Test Polatory import + run: | + python -c "import polatory; print('Polatory imported successfully')" + + - name: Download Leapfrog gold benchmark release + shell: powershell + run: | + $archive = "benchmark-data\leapfrog-benchmark-data-v1.zip" + $folder = "benchmark-data\leapfrog-benchmark-data-v1" + New-Item -ItemType Directory -Force -Path "benchmark-data" | Out-Null + Invoke-WebRequest ` + -Uri "https://github.com/Mining-Geologist/RBF/releases/download/leapfrog-benchmark-data-v1/leapfrog-benchmark-data-v1.zip" ` + -OutFile $archive + New-Item -ItemType Directory -Force -Path $folder | Out-Null + Expand-Archive -Path $archive -DestinationPath $folder -Force + Get-ChildItem $folder + + - name: Run synthetic structural-LVA suite + env: + QT_QPA_PLATFORM: offscreen + run: | + python benchmarks/leapfrog_blending/run_headless_suite.py + + - name: Run real Leapfrog gold suite + env: + QT_QPA_PLATFORM: offscreen + LEAPFROG_GOLD_DIR: ${{ github.workspace }}\benchmark-data\leapfrog-benchmark-data-v1 + run: | + python benchmarks/leapfrog_gold/run_real_gold_suite_ci.py + + - name: Upload benchmark meshes, overlays, and metrics + if: always() + uses: actions/upload-artifact@v4 + with: + name: polatory-leapfrog-benchmark-results + path: benchmark-results/ + if-no-files-found: warn + retention-days: 30 diff --git a/benchmarks/headless_shims/polatory_lva_pyqt_app_v2.py b/benchmarks/headless_shims/polatory_lva_pyqt_app_v2.py new file mode 100644 index 000000000..ad88e06c0 --- /dev/null +++ b/benchmarks/headless_shims/polatory_lva_pyqt_app_v2.py @@ -0,0 +1,108 @@ +"""Minimal non-interactive compatibility surface for CI benchmark imports. + +The production launcher chain historically imports the original PyQt v2 application +at module import time, even when a benchmark only needs the automatic domain builder +and chunk-safe mesher. The full v2 GUI is intentionally not duplicated in the +repository. GitHub Actions prepends this directory to ``PYTHONPATH`` so the launcher +patch modules can be imported headlessly without creating widgets. + +No benchmark calls these UI methods. They exist only so v3-v8 can capture and patch +the same attributes they patch in the interactive application. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pandas as pd +import pyvista as pv +from PyQt6 import QtCore, QtWidgets + + +APP_TITLE = "Polatory Automatic Structural LVA" +ROLE_INSIDE = "Inside" +ROLE_OUTSIDE = "Outside" +ROLE_IGNORE = "Ignore" +ROLE_OPTIONS = (ROLE_INSIDE, ROLE_OUTSIDE, ROLE_IGNORE) + + +def category_keys(series: pd.Series) -> pd.Series: + return series.astype("string").fillna("").astype(str) + + +def default_role_for_category(value: str) -> str: + text = str(value).strip().lower() + try: + number = float(text) + except ValueError: + number = np.nan + if np.isfinite(number): + if number > 0.0: + return ROLE_OUTSIDE + if number < 0.0: + return ROLE_INSIDE + return ROLE_IGNORE + if any(token in text for token in ("inside", "ore", "deposit", "target")): + return ROLE_INSIDE + if any(token in text for token in ("outside", "waste", "background")): + return ROLE_OUTSIDE + return ROLE_IGNORE + + +def structured_points( + minimum: np.ndarray, + maximum: np.ndarray, + dimensions: tuple[int, int, int], +) -> np.ndarray: + """Return the same regular Fortran-ordered point grid used by the GUI worker.""" + minimum = np.asarray(minimum, dtype=float) + maximum = np.asarray(maximum, dtype=float) + dimensions = tuple(int(value) for value in dimensions) + axes = [ + np.linspace(minimum[index], maximum[index], dimensions[index]) + for index in range(3) + ] + x, y, z = np.meshgrid(*axes, indexing="ij") + return np.column_stack( + [x.ravel(order="F"), y.ravel(order="F"), z.ravel(order="F")] + ) + + +class ModelWorker: + def run(self) -> None: + raise RuntimeError("The CI compatibility worker must be patched before use.") + + +class MainWindow: + """Attribute shell required only while the launcher patch modules are imported.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + + def model_parameters(self) -> dict[str, Any]: + return {} + + def model_finished(self, result: dict[str, Any]) -> None: + del result + + def load_csv(self) -> None: + return None + + def run_model(self) -> None: + return None + + def populate_category_roles(self, column: str) -> None: + del column + + def _mapped_arrays(self): + raise RuntimeError("The headless compatibility shell has no mapped UI data.") + + def apply_data_mapping(self) -> None: + return None + + +def main() -> int: + raise RuntimeError( + "This module is a CI import shim, not an interactive application launcher." + ) diff --git a/benchmarks/leapfrog_blending/01_two_domain_blend_binary.csv b/benchmarks/leapfrog_blending/01_two_domain_blend_binary.csv new file mode 100644 index 000000000..b8a0ada59 --- /dev/null +++ b/benchmarks/leapfrog_blending/01_two_domain_blend_binary.csv @@ -0,0 +1,25 @@ +X,Y,Z,Category,Indicator,DomainHint,ExpectedBoundaryZ +-160,-60,60,Inside,-1,Left,80 +-160,-60,100,Outside,1,Left,80 +-160,0,60,Inside,-1,Left,80 +-160,0,100,Outside,1,Left,80 +-160,60,60,Inside,-1,Left,80 +-160,60,100,Outside,1,Left,80 +-80,-60,20,Inside,-1,Left,40 +-80,-60,60,Outside,1,Left,40 +-80,0,20,Inside,-1,Left,40 +-80,0,60,Outside,1,Left,40 +-80,60,20,Inside,-1,Left,40 +-80,60,60,Outside,1,Left,40 +80,-60,120,Inside,-1,Right,140 +80,-60,160,Outside,1,Right,140 +80,0,120,Inside,-1,Right,140 +80,0,160,Outside,1,Right,140 +80,60,120,Inside,-1,Right,140 +80,60,160,Outside,1,Right,140 +160,-60,160,Inside,-1,Right,180 +160,-60,200,Outside,1,Right,180 +160,0,160,Inside,-1,Right,180 +160,0,200,Outside,1,Right,180 +160,60,160,Inside,-1,Right,180 +160,60,200,Outside,1,Right,180 diff --git a/benchmarks/leapfrog_blending/01_two_domain_blend_points.csv b/benchmarks/leapfrog_blending/01_two_domain_blend_points.csv new file mode 100644 index 000000000..ce9b0a278 --- /dev/null +++ b/benchmarks/leapfrog_blending/01_two_domain_blend_points.csv @@ -0,0 +1,37 @@ +X,Y,Z,Role,Indicator,DomainHint,ContactSurfaceZ +-160,-60,60,Inside,-1,Left,80 +-160,-60,80,Contact,0,Left,80 +-160,-60,100,Outside,1,Left,80 +-160,0,60,Inside,-1,Left,80 +-160,0,80,Contact,0,Left,80 +-160,0,100,Outside,1,Left,80 +-160,60,60,Inside,-1,Left,80 +-160,60,80,Contact,0,Left,80 +-160,60,100,Outside,1,Left,80 +-80,-60,20,Inside,-1,Left,40 +-80,-60,40,Contact,0,Left,40 +-80,-60,60,Outside,1,Left,40 +-80,0,20,Inside,-1,Left,40 +-80,0,40,Contact,0,Left,40 +-80,0,60,Outside,1,Left,40 +-80,60,20,Inside,-1,Left,40 +-80,60,40,Contact,0,Left,40 +-80,60,60,Outside,1,Left,40 +80,-60,120,Inside,-1,Right,140 +80,-60,140,Contact,0,Right,140 +80,-60,160,Outside,1,Right,140 +80,0,120,Inside,-1,Right,140 +80,0,140,Contact,0,Right,140 +80,0,160,Outside,1,Right,140 +80,60,120,Inside,-1,Right,140 +80,60,140,Contact,0,Right,140 +80,60,160,Outside,1,Right,140 +160,-60,160,Inside,-1,Right,180 +160,-60,180,Contact,0,Right,180 +160,-60,200,Outside,1,Right,180 +160,0,160,Inside,-1,Right,180 +160,0,180,Contact,0,Right,180 +160,0,200,Outside,1,Right,180 +160,60,160,Inside,-1,Right,180 +160,60,180,Contact,0,Right,180 +160,60,200,Outside,1,Right,180 diff --git a/benchmarks/leapfrog_blending/02_background_decay_binary.csv b/benchmarks/leapfrog_blending/02_background_decay_binary.csv new file mode 100644 index 000000000..0385938b7 --- /dev/null +++ b/benchmarks/leapfrog_blending/02_background_decay_binary.csv @@ -0,0 +1,31 @@ +X,Y,Z,Category,Indicator,ExpectedBoundaryZ +-160,-60,60,Inside,-1,80 +-160,-60,100,Outside,1,80 +-160,0,60,Inside,-1,80 +-160,0,100,Outside,1,80 +-160,60,60,Inside,-1,80 +-160,60,100,Outside,1,80 +-80,-60,20,Inside,-1,40 +-80,-60,60,Outside,1,40 +-80,0,20,Inside,-1,40 +-80,0,60,Outside,1,40 +-80,60,20,Inside,-1,40 +-80,60,60,Outside,1,40 +0,-60,-20,Inside,-1,0 +0,-60,20,Outside,1,0 +0,0,-20,Inside,-1,0 +0,0,20,Outside,1,0 +0,60,-20,Inside,-1,0 +0,60,20,Outside,1,0 +80,-60,20,Inside,-1,40 +80,-60,60,Outside,1,40 +80,0,20,Inside,-1,40 +80,0,60,Outside,1,40 +80,60,20,Inside,-1,40 +80,60,60,Outside,1,40 +160,-60,60,Inside,-1,80 +160,-60,100,Outside,1,80 +160,0,60,Inside,-1,80 +160,0,100,Outside,1,80 +160,60,60,Inside,-1,80 +160,60,100,Outside,1,80 diff --git a/benchmarks/leapfrog_blending/02_background_decay_points.csv b/benchmarks/leapfrog_blending/02_background_decay_points.csv new file mode 100644 index 000000000..9516deec0 --- /dev/null +++ b/benchmarks/leapfrog_blending/02_background_decay_points.csv @@ -0,0 +1,46 @@ +X,Y,Z,Role,Indicator,ContactSurfaceZ +-160,-60,60,Inside,-1,80 +-160,-60,80,Contact,0,80 +-160,-60,100,Outside,1,80 +-160,0,60,Inside,-1,80 +-160,0,80,Contact,0,80 +-160,0,100,Outside,1,80 +-160,60,60,Inside,-1,80 +-160,60,80,Contact,0,80 +-160,60,100,Outside,1,80 +-80,-60,20,Inside,-1,40 +-80,-60,40,Contact,0,40 +-80,-60,60,Outside,1,40 +-80,0,20,Inside,-1,40 +-80,0,40,Contact,0,40 +-80,0,60,Outside,1,40 +-80,60,20,Inside,-1,40 +-80,60,40,Contact,0,40 +-80,60,60,Outside,1,40 +0,-60,-20,Inside,-1,0 +0,-60,0,Contact,0,0 +0,-60,20,Outside,1,0 +0,0,-20,Inside,-1,0 +0,0,0,Contact,0,0 +0,0,20,Outside,1,0 +0,60,-20,Inside,-1,0 +0,60,0,Contact,0,0 +0,60,20,Outside,1,0 +80,-60,20,Inside,-1,40 +80,-60,40,Contact,0,40 +80,-60,60,Outside,1,40 +80,0,20,Inside,-1,40 +80,0,40,Contact,0,40 +80,0,60,Outside,1,40 +80,60,20,Inside,-1,40 +80,60,40,Contact,0,40 +80,60,60,Outside,1,40 +160,-60,60,Inside,-1,80 +160,-60,80,Contact,0,80 +160,-60,100,Outside,1,80 +160,0,60,Inside,-1,80 +160,0,80,Contact,0,80 +160,0,100,Outside,1,80 +160,60,60,Inside,-1,80 +160,60,80,Contact,0,80 +160,60,100,Outside,1,80 diff --git a/benchmarks/leapfrog_blending/README_RUN_IN_LEAPFROG.txt b/benchmarks/leapfrog_blending/README_RUN_IN_LEAPFROG.txt new file mode 100644 index 000000000..9d3ef2c46 --- /dev/null +++ b/benchmarks/leapfrog_blending/README_RUN_IN_LEAPFROG.txt @@ -0,0 +1,93 @@ +LEAPFROG LVA / SUBDOMAIN BLENDING BENCHMARKS + +PURPOSE +------- +These synthetic files are designed to reveal how Leapfrog blends local structural +RBF domains and how the field behaves far from data. + +FILES +----- +folded_trend_mesh.obj + One connected folded structural surface. Its left and right limbs have opposite dip. + +01_two_domain_blend_points.csv + Left contact sheet: Z = -0.5 X + Right contact sheet: Z = 0.5 X + 100 + Inside points are 20 m below each sheet, Contacts are on the sheet, and Outside + points are 20 m above. + +02_background_decay_points.csv + One continuous folded contact sheet: Z = 0.5 |X|. + This isolates Leapfrog's far-field/background behaviour. + +IMPORTANT +--------- +Use the same Leapfrog workflow you used to create your S5_R100 benchmark from your +current Inside / Outside / Contact CSV. Do not change hidden or advanced defaults. +Only Strength and Range should be changed as normal structural controls. + +TEST 1 - TWO-DOMAIN BLEND +------------------------- +1. Import 01_two_domain_blend_points.csv as point data. +2. Map X, Y and Z to the coordinate columns. +3. Use Role as the categorical role column: + Inside -> Inside + Outside -> Outside + Contact -> Contact / zero constraint + The Indicator column is only a check: + Inside=-1, Contact=0, Outside=+1 +4. Import folded_trend_mesh.obj as the structural/LVA input mesh. +5. Build the same categorical RBF / intrusion / indicator volume workflow used for + the S5_R100 benchmark. +6. Structural trend mode: Strongest along inputs. +7. Strength: 5 +8. Range: 100 +9. Leave every other Leapfrog setting at its default. +10. Use this exact model extent: + X min = -300 X max = 300 + Y min = -150 Y max = 150 + Z min = -150 Z max = 350 +11. Use surface resolution 5 m, or the closest available Leapfrog setting. +12. Export the zero/contact surface as: + LF_test1_tight_S5_R100.obj + +TEST 1B - SAME MODEL WITH DEEP EXTENT +------------------------------------- +Duplicate Test 1. Change only the model extent: + X min = -300 X max = 300 + Y min = -150 Y max = 150 + Z min = -500 Z max = 350 +Export as: + LF_test1_deep_S5_R100.obj + +TEST 2 - BACKGROUND / FAR-FIELD DECAY +-------------------------------------- +1. Import 02_background_decay_points.csv. +2. Use the same folded_trend_mesh.obj. +3. Use the same Inside / Outside / Contact mapping. +4. Structural trend mode: Strongest along inputs. +5. Strength: 5 +6. Range: 100 +7. Leave every other setting at the Leapfrog default. +8. Use this exact model extent: + X min = -300 X max = 300 + Y min = -150 Y max = 150 + Z min = -500 Z max = 350 +9. Export as: + LF_test2_background_S5_R100.obj + +SEND BACK +--------- +Send these three OBJ files: + LF_test1_tight_S5_R100.obj + LF_test1_deep_S5_R100.obj + LF_test2_background_S5_R100.obj + +Also send one section-view screenshot of Test 1 at Y = 0 with the points and generated +surface visible. + +WHY THESE RUNS ARE ENOUGH +------------------------- +Test 1 reveals the transition between two conflicting local zero surfaces. +Comparing Test 1 tight and deep reveals whether blending depends on model extent. +Test 2 separates local-domain blending from global/background closure. diff --git a/benchmarks/leapfrog_blending/folded_trend_mesh.obj b/benchmarks/leapfrog_blending/folded_trend_mesh.obj new file mode 100644 index 000000000..7262680d5 --- /dev/null +++ b/benchmarks/leapfrog_blending/folded_trend_mesh.obj @@ -0,0 +1,20 @@ +# Synthetic folded structural trend mesh +# z = 0.5 * abs(x) +v -250 -150 125 +v -250 150 125 +v -125 -150 62.5 +v -125 150 62.5 +v 0 -150 0 +v 0 150 0 +v 125 -150 62.5 +v 125 150 62.5 +v 250 -150 125 +v 250 150 125 +f 1 3 4 +f 1 4 2 +f 3 5 6 +f 3 6 4 +f 5 7 8 +f 5 8 6 +f 7 9 10 +f 7 10 8 diff --git a/benchmarks/leapfrog_blending/run_headless_suite.py b/benchmarks/leapfrog_blending/run_headless_suite.py new file mode 100644 index 000000000..76fc44fbb --- /dev/null +++ b/benchmarks/leapfrog_blending/run_headless_suite.py @@ -0,0 +1,285 @@ +"""Run the synthetic Leapfrog structural-LVA benchmarks without the GUI. + +This script intentionally uses the same automatic finite LVA-geodesic builder and +native structural interpolant used by the process-isolated application. It writes +OBJ meshes and a JSON report suitable for GitHub Actions artifacts. +""" + +from __future__ import annotations + +import csv +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +import polatory +from polatory import three as p3 + + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLES = ROOT / "examples" +if str(EXAMPLES) not in sys.path: + sys.path.insert(0, str(EXAMPLES)) + +# The builder module currently lives beside the GUI launchers. Importing it here +# applies no GUI actions; it only exposes the exact finite geodesic domain builder. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import polatory_lva_worker_process_v3 as lva_worker # noqa: E402 + + +@dataclass(frozen=True) +class Case: + name: str + csv_name: str + bbox_min: tuple[float, float, float] + bbox_max: tuple[float, float, float] + base_range: float + + +def read_points(path: Path) -> tuple[np.ndarray, np.ndarray]: + points: list[list[float]] = [] + indicators: list[float] = [] + with path.open("r", encoding="utf-8-sig", newline="") as stream: + reader = csv.DictReader(stream) + required = {"X", "Y", "Z", "Category"} + missing = required.difference(reader.fieldnames or ()) + if missing: + raise ValueError(f"{path} is missing columns: {sorted(missing)}") + for row in reader: + category = str(row["Category"]).strip().lower() + if category == "inside": + indicator = -1.0 + elif category == "outside": + indicator = 1.0 + else: + raise ValueError(f"Unsupported category {row['Category']!r} in {path}") + points.append([float(row["X"]), float(row["Y"]), float(row["Z"])]) + indicators.append(indicator) + return np.asarray(points, dtype=float), np.asarray(indicators, dtype=float) + + +def read_obj(path: Path) -> tuple[np.ndarray, np.ndarray]: + vertices: list[list[float]] = [] + faces: list[list[int]] = [] + with path.open("r", encoding="utf-8", errors="ignore") as stream: + for line in stream: + if line.startswith("v "): + fields = line.split() + vertices.append([float(fields[1]), float(fields[2]), float(fields[3])]) + elif line.startswith("f "): + fields = line.split()[1:] + polygon = [int(field.split("/", 1)[0]) - 1 for field in fields] + if len(polygon) < 3: + continue + for index in range(1, len(polygon) - 1): + faces.append([polygon[0], polygon[index], polygon[index + 1]]) + vertex_array = np.asarray(vertices, dtype=float) + face_array = np.asarray(faces, dtype=np.int64) + if vertex_array.ndim != 2 or vertex_array.shape[1] != 3 or len(vertex_array) == 0: + raise ValueError(f"No OBJ vertices found in {path}") + if face_array.ndim != 2 or face_array.shape[1] != 3 or len(face_array) == 0: + raise ValueError(f"No OBJ triangles found in {path}") + return vertex_array, face_array + + +def mesh_metrics(path: Path) -> dict[str, Any]: + vertices, faces = read_obj(path) + suspicious: dict[str, list[dict[str, float | int]]] = {} + for axis, name in enumerate(("x", "y", "z")): + rounded = np.round(vertices[:, axis], decimals=6) + values, counts = np.unique(rounded, return_counts=True) + threshold = max(20, int(np.ceil(0.01 * len(vertices)))) + order = np.argsort(counts)[::-1] + suspicious[name] = [ + {"coordinate": float(values[index]), "vertices": int(counts[index])} + for index in order[:10] + if int(counts[index]) >= threshold + ] + return { + "vertices": int(len(vertices)), + "triangles": int(len(faces)), + "bounds_min": vertices.min(axis=0).tolist(), + "bounds_max": vertices.max(axis=0).tolist(), + "suspicious_coordinate_planes": suspicious, + } + + +def symmetric_vertex_distance(first_path: Path, second_path: Path) -> dict[str, float]: + from scipy.spatial import cKDTree + + first, _ = read_obj(first_path) + second, _ = read_obj(second_path) + first_to_second = np.asarray(cKDTree(second).query(first, k=1)[0], dtype=float) + second_to_first = np.asarray(cKDTree(first).query(second, k=1)[0], dtype=float) + combined = np.concatenate([first_to_second, second_to_first]) + return { + "mean": float(np.mean(combined)), + "median": float(np.median(combined)), + "p95": float(np.percentile(combined, 95.0)), + "p99": float(np.percentile(combined, 99.0)), + "maximum": float(np.max(combined)), + } + + +def run_case(case: Case, trend_vertices: np.ndarray, trend_faces: np.ndarray, output: Path) -> dict[str, Any]: + data_path = Path(__file__).resolve().parent / case.csv_name + points, indicators = read_points(data_path) + bbox_min = np.asarray(case.bbox_min, dtype=float) + bbox_max = np.asarray(case.bbox_max, dtype=float) + + value_info = polatory.leapfrog_indicator_values3( + points, + indicators, + fit_accuracy=0.0, + ) + values = np.asarray(value_info.values, dtype=float) + + trend_input = polatory.StructuralTrendInput3( + trend_vertices, + trend_faces, + 5.0, + 100.0, + ) + rbf = p3.CovSpheroidal3([10.0, float(case.base_range)]) + model = p3.Model(rbf, 0) + model.nugget = 0.0 + model_parameters = np.asarray(model.parameters, dtype=float).reshape(-1).tolist() + + # Use the same finite LVA-geodesic automatic SubDomainer as the current app. + builder = lva_worker.FiniteLvaGeodesicAutomaticBuilder( + centroid_count=6000, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.10, + consistency_threshold=0.60, + base_range=float(case.base_range), + support_multiplier=5, + minimum_support_points=1, + ) + domains = builder.build_from_inputs( + points, + [trend_input], + model_parameters=model_parameters, + trend_type=polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) + diagnostics = builder.diagnostics_ + + structural = polatory.StructuralInterpolant3( + model, + -1.0, + 1.0, + 0.0, + True, + ) + structural.fit( + points, + values, + domains, + tolerance=float(value_info.fit_accuracy), + max_iter=100, + ) + predictions = np.asarray(structural.evaluate(points), dtype=float) + errors = predictions - values + + field = polatory.StructuralRbfFieldFunction(structural) + bbox = p3.Bbox(bbox_min.reshape(1, 3), bbox_max.reshape(1, 3)) + result = polatory.Isosurface(bbox, 5.0, np.eye(3)).generate( + field, + isovalue=0.0, + refine=0, + ) + output.parent.mkdir(parents=True, exist_ok=True) + result.export_obj(str(output)) + + metrics = mesh_metrics(output) + metrics.update( + { + "case": case.name, + "input_points": int(len(points)), + "domain_count": int(len(domains)), + "training_rmse": float(np.sqrt(np.mean(errors**2))), + "training_max_abs": float(np.max(np.abs(errors))), + "parameters": { + "strength": 5.0, + "trend_range": 100.0, + "base_range": float(case.base_range), + "sill": 10.0, + "blend_power": 1.0, + "background_blending": True, + "centroid_count": 6000, + "minimum_cluster_fraction": 0.001, + "maximum_cluster_fraction": 0.10, + "consistency_threshold": 0.60, + "support_multiplier": 5, + "minimum_support_points": 1, + "surface_resolution": 5.0, + }, + "centroid_grid_shape": ( + list(diagnostics.centroid_grid_shape) + if diagnostics is not None + else None + ), + } + ) + return metrics + + +def main() -> int: + benchmark_dir = Path(__file__).resolve().parent + output_dir = ROOT / "benchmark-results" + output_dir.mkdir(parents=True, exist_ok=True) + + trend_vertices, trend_faces = read_obj(benchmark_dir / "folded_trend_mesh.obj") + cases = ( + Case( + "test1_tight_s5_r100", + "01_two_domain_blend_binary.csv", + (-300.0, -150.0, -150.0), + (300.0, 150.0, 350.0), + 80.0, + ), + Case( + "test1_deep_s5_r100", + "01_two_domain_blend_binary.csv", + (-300.0, -150.0, -500.0), + (300.0, 150.0, 350.0), + 80.0, + ), + Case( + "test2_background_s5_r100", + "02_background_decay_binary.csv", + (-300.0, -150.0, -500.0), + (300.0, 150.0, 350.0), + 70.0, + ), + ) + + report: dict[str, Any] = {"cases": {}} + output_paths: dict[str, Path] = {} + for case in cases: + print(f"Running {case.name}...", flush=True) + output_path = output_dir / f"{case.name}.obj" + report["cases"][case.name] = run_case( + case, + trend_vertices, + trend_faces, + output_path, + ) + output_paths[case.name] = output_path + + report["extent_invariance"] = symmetric_vertex_distance( + output_paths["test1_tight_s5_r100"], + output_paths["test1_deep_s5_r100"], + ) + report_path = output_dir / "metrics.json" + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(json.dumps(report, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/LEAPFROG_EMBEDDED_RUNTIME_PROBE.md b/benchmarks/leapfrog_gold/LEAPFROG_EMBEDDED_RUNTIME_PROBE.md new file mode 100644 index 000000000..315a54b2e --- /dev/null +++ b/benchmarks/leapfrog_gold/LEAPFROG_EMBEDDED_RUNTIME_PROBE.md @@ -0,0 +1,102 @@ +# Leapfrog embedded runtime probe + +This workflow inspects the installed Leapfrog runtime and captures the exact inputs, +outputs, and object state used by the real-location `SubDomainer`. It does not modify +Leapfrog projects or replace the original domaining functions. + +## 1. Pull the probe + +```bat +git pull +``` + +## 2. Inventory the installed binaries and archives + +Run this from the repository virtual environment: + +```bat +python benchmarks\leapfrog_gold\inspect_leapfrog_embedded_runtime.py ^ + --mode inventory ^ + --install-root "D:\Program files\Seequent\Leapfrog 2026.1\bin" ^ + --output benchmark-results\leapfrog-runtime-inventory.json +``` + +Optional PE export/import parsing is enabled by installing `pefile`: + +```bat +python -m pip install pefile +``` + +The string and `PyInit_*` scan works even without `pefile`. + +## 3. Activate the in-process probe + +Use the existing Leapfrog `sitecustomize.py` that is already loaded by the +instrumented Leapfrog process. Append the following block, adjusting the repository +path only when necessary: + +```python +import os +import sys + +_REPO = r"D:\Polatory_LVA\RBF-LVA" +sys.path.insert(0, _REPO + r"\benchmarks\leapfrog_gold") +os.environ["LEAPFROG_RE_PROBE_DIR"] = ( + _REPO + r"\benchmark-results\leapfrog-embedded-runtime" +) +os.environ["LEAPFROG_RE_PROBE"] = "1" + +# Leave this disabled for the first real-project capture. +os.environ["LEAPFROG_RE_MICROCASES"] = "0" + +import activate_leapfrog_embedded_probe # noqa: F401,E402 +``` + +Restart Leapfrog after editing `sitecustomize.py`, open the benchmark project, and +trigger the automatic structural-domaining recompute once. + +Expected output directory: + +```text +benchmark-results\leapfrog-embedded-runtime\ +``` + +Important files: + +- `runtime-reflection.json`: module names, signatures, docs, source when available, + and Python 3.12 bytecode disassembly. +- `runtime-events.jsonl`: ordered calls into `SubDomainer`, `GridSeededDomainer`, and + anisotropy methods. +- `call-*.npz`: exact NumPy arrays passed into or retained by those calls, including + real locations, anisotropies, strengths, parent labels, and result arrays when + exposed by the Python objects. + +## 4. Run controlled SubDomainer micro-cases + +After the real-project capture works, change: + +```python +os.environ["LEAPFROG_RE_MICROCASES"] = "1" +os.environ["LEAPFROG_RE_MICROCASE_DELAY"] = "15" +``` + +Restart Leapfrog. The probe waits before constructing small synthetic point sets with: + +- identical tensors; +- two sharply different orientation groups; +- a gradual orientation ramp; +- connected and spatially separated point layouts; +- thresholds `0.0`, `0.6`, `0.9`, `0.99`, and `0.999`. + +The result is written to: + +```text +subdomainer-microcases.json +``` + +Disable the micro-cases again after the file is produced. + +## 5. Restore normal Leapfrog startup + +Remove or comment out the bootstrap block from `sitecustomize.py`, then restart +Leapfrog. No project data is changed by the probe. diff --git a/benchmarks/leapfrog_gold/activate_leapfrog_embedded_probe.py b/benchmarks/leapfrog_gold/activate_leapfrog_embedded_probe.py new file mode 100644 index 000000000..57f01cdf1 --- /dev/null +++ b/benchmarks/leapfrog_gold/activate_leapfrog_embedded_probe.py @@ -0,0 +1,58 @@ +"""Bootstrap ``inspect_leapfrog_embedded_runtime`` from Leapfrog sitecustomize. + +Add this module's directory to ``sys.path`` and import this module from the existing +Leapfrog ``sitecustomize.py``. The import installs read-only wrappers. Optional +micro-cases are delayed so Leapfrog can finish starting its embedded Python runtime. +""" +from __future__ import annotations + +import os +import threading +import time +import traceback + +import numpy as np + +import inspect_leapfrog_embedded_runtime as probe + + +def _correct_spd_for_angle(angle: float, ratio: float = 5.0) -> np.ndarray: + """Return determinant-one SPD tensor whose unique axis tilts in the X-Z plane.""" + radians = np.deg2rad(float(angle)) + normal = np.array([np.sin(radians), 0.0, np.cos(radians)], dtype=float) + normal /= np.linalg.norm(normal) + projector = normal[:, None] * normal[None, :] + tangent = ratio ** (-1.0 / 3.0) + axial = ratio ** (2.0 / 3.0) + return tangent * (np.eye(3) - projector) + axial * projector + + +# Correct the micro-case orientation generator before any synthetic run. +probe._spd_for_angle = _correct_spd_for_angle +probe.install_import_hook(reflect=True) + + +def _run_delayed_microcases() -> None: + delay = float(os.environ.get("LEAPFROG_RE_MICROCASE_DELAY", "15")) + time.sleep(max(delay, 0.0)) + try: + path = probe.run_subdomainer_microcases() + probe._append_event( + {"label": "delayed_microcases_complete", "path": str(path)} + ) + except Exception as exc: + probe._append_event( + { + "label": "delayed_microcases_error", + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + ) + + +if os.environ.get("LEAPFROG_RE_MICROCASES", "").strip() == "1": + threading.Thread( + target=_run_delayed_microcases, + name="Leapfrog RE microcases", + daemon=True, + ).start() diff --git a/benchmarks/leapfrog_gold/analyse_matrix_reassignment_boundary_scores.py b/benchmarks/leapfrog_gold/analyse_matrix_reassignment_boundary_scores.py new file mode 100644 index 000000000..0febb73a5 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_matrix_reassignment_boundary_scores.py @@ -0,0 +1,286 @@ +"""Analyse boundary-level evidence for merges after one real-point reassignment. + +Whole-domain matrix consistency does not select the oracle-improving merge in the +S3_R100 and S3_R300 diagnostics. This script therefore measures the shared boundary +between every Delaunay-adjacent reassigned-domain pair: cross-boundary edge count, +unique boundary-point support, edge lengths, and point-pair matrix consistency. It +reports oracle metrics only as diagnostics and ranks several model-only scores to see +whether the same rule can identify the useful merge across cases. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_matrix_reassignment_pair_merges as pair_analysis # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_matrix_reassignment as reassignment # noqa: E402 + + +def _delaunay_point_edges(points: np.ndarray) -> np.ndarray: + from scipy.spatial import Delaunay + + simplices = np.asarray(Delaunay(points, qhull_options="QJ").simplices, dtype=np.int64) + edges: list[tuple[int, int]] = [] + for simplex in simplices: + edges.extend(combinations((int(value) for value in simplex), 2)) + array = np.asarray(edges, dtype=np.int64).reshape(-1, 2) + array.sort(axis=1) + array = array[array[:, 0] != array[:, 1]] + return np.unique(array, axis=0) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--nearest-domains", type=int, default=2) + parser.add_argument("--iterations", type=int, default=1) + parser.add_argument("--spatial-penalty", type=float, default=0.0) + parser.add_argument("--top", type=int, default=20) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + + labels, changes, history = reassignment._run_reassignment( + points, + point_matrices, + coarse_labels, + nearest_domains=args.nearest_domains, + spatial_penalty=args.spatial_penalty, + iterations=args.iterations, + threshold=args.threshold, + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + labels = reassignment._relabel(labels) + + def metrics(candidate: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, candidate) + return ( + float(adjusted_rand_score(oracle_labels, candidate)), + float(adjusted_mutual_info_score(oracle_labels, candidate)), + float(accuracy), + int(matched), + ) + + coarse_metrics = metrics(coarse_labels) + reassigned_metrics = metrics(labels) + values, domain_matrices, domain_centroids, sizes = reassignment._domain_statistics( + points, point_matrices, labels, _normalise_determinant + ) + positions = {int(value): index for index, value in enumerate(values)} + + boundary_edges: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list) + for first_index, second_index in _delaunay_point_edges(points): + first_label = int(labels[first_index]) + second_label = int(labels[second_index]) + if first_label == second_label: + continue + pair = tuple(sorted((first_label, second_label))) + boundary_edges[pair].append((int(first_index), int(second_index))) + + rows: list[dict[str, object]] = [] + for pair, edges in boundary_edges.items(): + first_label, second_label = pair + first_position = positions[first_label] + second_position = positions[second_label] + first_size = int(sizes[first_position]) + second_size = int(sizes[second_position]) + + whole_merged, whole_consistency = _merged_matrix_and_consistency( + domain_matrices[first_position], + first_size, + domain_matrices[second_position], + second_size, + ) + del whole_merged + + lengths: list[float] = [] + local_consistencies: list[float] = [] + first_boundary: set[int] = set() + second_boundary: set[int] = set() + for first_index, second_index in edges: + if int(labels[first_index]) == second_label: + first_index, second_index = second_index, first_index + first_boundary.add(first_index) + second_boundary.add(second_index) + lengths.append(float(np.linalg.norm(points[first_index] - points[second_index]))) + _, local_consistency = _merged_matrix_and_consistency( + point_matrices[first_index], 1, point_matrices[second_index], 1 + ) + local_consistencies.append(float(local_consistency)) + + local = np.asarray(local_consistencies, dtype=float) + edge_lengths = np.asarray(lengths, dtype=float) + edge_count = len(edges) + first_support = len(first_boundary) + second_support = len(second_boundary) + support_min_fraction = min( + first_support / max(first_size, 1), + second_support / max(second_size, 1), + ) + support_geomean_fraction = float( + np.sqrt( + (first_support / max(first_size, 1)) + * (second_support / max(second_size, 1)) + ) + ) + edge_density = edge_count / max(np.sqrt(first_size * second_size), 1.0) + mean_local = float(np.mean(local)) + median_local = float(np.median(local)) + p10_local = float(np.quantile(local, 0.10)) + excess_mean = float(np.mean(np.maximum(local - args.threshold, 0.0))) + mean_length = float(np.mean(edge_lengths)) + centroid_distance = float( + np.linalg.norm(domain_centroids[first_position] - domain_centroids[second_position]) + ) + + merged_labels = pair_analysis._merge_pair(labels, first_label, second_label) + ari, ami, match, matched = metrics(merged_labels) + rows.append( + { + "pair": pair, + "sizes": (first_size, second_size), + "combined": first_size + second_size, + "whole": float(whole_consistency), + "edges": edge_count, + "support": (first_support, second_support), + "support_min_fraction": support_min_fraction, + "support_geomean_fraction": support_geomean_fraction, + "edge_density": edge_density, + "mean_local": mean_local, + "median_local": median_local, + "p10_local": p10_local, + "excess_mean": excess_mean, + "mean_length": mean_length, + "centroid_distance": centroid_distance, + "score_contact": support_geomean_fraction * mean_local, + "score_edges": edge_density * mean_local, + "score_excess": support_geomean_fraction * excess_mean, + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + def format_row(row: dict[str, object]) -> str: + first, second = row["pair"] + first_size, second_size = row["sizes"] + first_support, second_support = row["support"] + return ( + f"pair=({first},{second}) sizes=({first_size},{second_size}) " + f"edges={int(row['edges']):4d} support=({first_support},{second_support}) " + f"support_min={float(row['support_min_fraction']):.3f} " + f"edge_density={float(row['edge_density']):.3f} " + f"local[mean={float(row['mean_local']):.6f},p10={float(row['p10_local']):.6f}] " + f"whole={float(row['whole']):.6f} mean_len={float(row['mean_length']):.2f} " + f"score_contact={float(row['score_contact']):.6f} " + f"score_edges={float(row['score_edges']):.6f} " + f"ARI={float(row['ari']):.5f} match={float(row['match']):.5f} " + f"({int(row['matched'])}/{len(points)})" + ) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_metrics[0]:.5f} " + f"AMI={coarse_metrics[1]:.5f} match={coarse_metrics[2]:.5f} " + f"({coarse_metrics[3]}/{len(points)})" + ) + print( + f"reassigned: changes={changes} history={history} domains={len(values)} " + f"ARI={reassigned_metrics[0]:.5f} AMI={reassigned_metrics[1]:.5f} " + f"match={reassigned_metrics[2]:.5f} ({reassigned_metrics[3]}/{len(points)})" + ) + + top = max(args.top, 1) + rankings = ( + ("oracle ARI diagnostic", "ari"), + ("boundary contact score", "score_contact"), + ("boundary edge-density score", "score_edges"), + ("boundary excess-consistency score", "score_excess"), + ("whole-domain consistency", "whole"), + ("boundary support", "support_geomean_fraction"), + ) + for title, key in rankings: + print(f"\nTop {top} by {title}:") + for row in sorted(rows, key=lambda item: float(item[key]), reverse=True)[:top]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_matrix_reassignment_pair_merges.py b/benchmarks/leapfrog_gold/analyse_matrix_reassignment_pair_merges.py new file mode 100644 index 000000000..64e819ba8 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_matrix_reassignment_pair_merges.py @@ -0,0 +1,254 @@ +"""Analyse one-step matrix reassignment followed by a single domain merge. + +The best standalone diagnostic so far is one reassignment iteration using the two +nearest coarse-domain centroids and no spatial penalty. It improves S3_R100 from +ARI 0.47915 to about 0.50083 but preserves ten domains while Leapfrog has nine. +This script tests every possible pair merge after that reassignment and reports +whether the missing count correction could plausibly be one final merge. Oracle +metrics are diagnostic only; model-side adjacency and matrix consistency are also +reported so the result is not interpreted as a production tuning rule. +""" +from __future__ import annotations + +import argparse +import os +import sys +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_matrix_reassignment as reassignment # noqa: E402 + + +def _merge_pair(labels: np.ndarray, first: int, second: int) -> np.ndarray: + merged = np.asarray(labels, dtype=np.int64).copy() + merged[merged == second] = first + return reassignment._relabel(merged) + + +def _delaunay_domain_pairs(points: np.ndarray, labels: np.ndarray) -> set[tuple[int, int]]: + from scipy.spatial import Delaunay, QhullError + + pairs: set[tuple[int, int]] = set() + try: + simplices = Delaunay(points, qhull_options="QJ").simplices + except QhullError: + return pairs + for simplex in simplices: + simplex_labels = sorted(set(int(labels[int(index)]) for index in simplex)) + for first, second in combinations(simplex_labels, 2): + pairs.add((first, second)) + return pairs + + +def _knn_domain_pairs(points: np.ndarray, labels: np.ndarray, k: int) -> set[tuple[int, int]]: + from scipy.spatial import cKDTree + + count = len(points) + actual_k = min(max(int(k), 1), max(count - 1, 1)) + neighbours = np.asarray(cKDTree(points).query(points, k=actual_k + 1)[1])[:, 1:] + pairs: set[tuple[int, int]] = set() + for first_index, row in enumerate(neighbours): + first_label = int(labels[first_index]) + for second_index in row: + second_label = int(labels[int(second_index)]) + if first_label != second_label: + pairs.add(tuple(sorted((first_label, second_label)))) + return pairs + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--nearest-domains", type=int, default=2) + parser.add_argument("--iterations", type=int, default=1) + parser.add_argument("--spatial-penalty", type=float, default=0.0) + parser.add_argument("--knn", type=int, default=6) + parser.add_argument("--top", type=int, default=20) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + + reassigned, changes, history = reassignment._run_reassignment( + points, + point_matrices, + coarse_labels, + nearest_domains=args.nearest_domains, + spatial_penalty=args.spatial_penalty, + iterations=args.iterations, + threshold=args.threshold, + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + reassigned = reassignment._relabel(reassigned) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + coarse_metrics = metrics(coarse_labels) + reassigned_metrics = metrics(reassigned) + values, matrices, domain_centroids, sizes = reassignment._domain_statistics( + points, point_matrices, reassigned, _normalise_determinant + ) + value_to_position = {int(value): index for index, value in enumerate(values)} + + delaunay_pairs = _delaunay_domain_pairs(points, reassigned) + knn_pairs = _knn_domain_pairs(points, reassigned, args.knn) + spatial_extent = max(float(np.linalg.norm(np.ptp(points, axis=0))), np.finfo(float).eps) + + rows: list[dict[str, object]] = [] + for first_value, second_value in combinations((int(value) for value in values), 2): + first_position = value_to_position[first_value] + second_position = value_to_position[second_value] + _, consistency = _merged_matrix_and_consistency( + matrices[first_position], + int(sizes[first_position]), + matrices[second_position], + int(sizes[second_position]), + ) + distance = float( + np.linalg.norm(domain_centroids[first_position] - domain_centroids[second_position]) + ) + merged_labels = _merge_pair(reassigned, first_value, second_value) + ari, ami, match, matched = metrics(merged_labels) + pair = tuple(sorted((first_value, second_value))) + rows.append( + { + "pair": pair, + "sizes": (int(sizes[first_position]), int(sizes[second_position])), + "combined": int(sizes[first_position] + sizes[second_position]), + "consistency": float(consistency), + "distance": distance, + "distance_fraction": distance / spatial_extent, + "delaunay": pair in delaunay_pairs, + "knn": pair in knn_pairs, + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + def format_row(row: dict[str, object]) -> str: + first, second = row["pair"] + first_size, second_size = row["sizes"] + return ( + f"merge=({first},{second}) sizes=({first_size},{second_size}) " + f"combined={int(row['combined']):3d} consistency={float(row['consistency']):.6f} " + f"distance={float(row['distance']):.3f} " + f"adj[delaunay={bool(row['delaunay'])},knn{args.knn}={bool(row['knn'])}] " + f"ARI={float(row['ari']):.5f} AMI={float(row['ami']):.5f} " + f"match={float(row['match']):.5f} ({int(row['matched'])}/{len(points)})" + ) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_metrics[0]:.5f} " + f"AMI={coarse_metrics[1]:.5f} match={coarse_metrics[2]:.5f} " + f"({coarse_metrics[3]}/{len(points)})" + ) + print( + f"reassigned: near={args.nearest_domains} penalty={args.spatial_penalty:.3f} " + f"iterations={args.iterations} changes={changes} history={history} " + f"domains={len(values)} ARI={reassigned_metrics[0]:.5f} " + f"AMI={reassigned_metrics[1]:.5f} match={reassigned_metrics[2]:.5f} " + f"({reassigned_metrics[3]}/{len(points)})" + ) + + top = max(args.top, 1) + print(f"\nTop {top} pair merges by ARI:") + for row in sorted(rows, key=lambda item: float(item["ari"]), reverse=True)[:top]: + print(format_row(row)) + + adjacent_rows = [row for row in rows if bool(row["delaunay"]) or bool(row["knn"])] + print(f"\nTop {top} adjacent pair merges by ARI:") + for row in sorted(adjacent_rows, key=lambda item: float(item["ari"]), reverse=True)[:top]: + print(format_row(row)) + + print(f"\nTop {top} adjacent pair merges by model consistency:") + for row in sorted( + adjacent_rows, + key=lambda item: (float(item["consistency"]), -float(item["distance_fraction"])), + reverse=True, + )[:top]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_oracle_partition_overlap.py b/benchmarks/leapfrog_gold/analyse_oracle_partition_overlap.py new file mode 100644 index 000000000..ac52c8443 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_oracle_partition_overlap.py @@ -0,0 +1,288 @@ +"""Analyse how corrected automatic domains overlap decoded Leapfrog domains. + +This diagnostic determines whether the missing Leapfrog real-location SubDomainer stage +could be represented as a simple split or merge of the current grid-derived point labels. +It reports the full oracle/predicted contingency matrix, per-domain purity, and pairwise +false-split/false-merge counts. No RBF fitting or surface meshing is performed. +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing this module patches the strict coordinate/label loader in comparison. +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 + + +def _choose2(values: np.ndarray) -> int: + values = np.asarray(values, dtype=np.int64) + return int(np.sum(values * (values - 1) // 2)) + + +def _contingency( + oracle: np.ndarray, + predicted: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + oracle_values, oracle_inverse = np.unique(oracle, return_inverse=True) + predicted_values, predicted_inverse = np.unique(predicted, return_inverse=True) + matrix = np.zeros((len(oracle_values), len(predicted_values)), dtype=np.int64) + np.add.at(matrix, (oracle_inverse, predicted_inverse), 1) + return oracle_values, predicted_values, matrix + + +def _print_matrix( + oracle_values: np.ndarray, + predicted_values: np.ndarray, + matrix: np.ndarray, +) -> None: + width = max(6, max(len(str(int(value))) for value in predicted_values) + 2) + header = "oracle\\pred".rjust(12) + "".join( + f"P{int(value)}".rjust(width) for value in predicted_values + ) + " | total" + print(header) + print("-" * len(header)) + for row_index, oracle_value in enumerate(oracle_values): + row = matrix[row_index] + print( + f"O{int(oracle_value)}".rjust(12) + + "".join(f"{int(value):>{width}d}" for value in row) + + f" | {int(row.sum())}" + ) + totals = matrix.sum(axis=0) + print("-" * len(header)) + print( + "pred total".rjust(12) + + "".join(f"{int(value):>{width}d}" for value in totals) + + f" | {int(matrix.sum())}" + ) + + +def _domain_rows( + source_values: np.ndarray, + target_values: np.ndarray, + matrix: np.ndarray, + source_name: str, + target_name: str, +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for index, source_value in enumerate(source_values): + counts = matrix[index] + total = int(counts.sum()) + nonzero = np.flatnonzero(counts) + dominant_index = int(np.argmax(counts)) + dominant_count = int(counts[dominant_index]) + rows.append( + { + f"{source_name}_domain": int(source_value), + "point_count": total, + f"dominant_{target_name}_domain": int(target_values[dominant_index]), + "dominant_count": dominant_count, + "purity": dominant_count / float(total), + f"overlapping_{target_name}_domains": int(len(nonzero)), + } + ) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--output-dir", type=Path, default=Path("benchmark-results") + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory # noqa: E402 + import run_selected_exact_leapfrog_lva as exact # noqa: E402 + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score # noqa: E402 + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( # noqa: SLF001 + args.decoded_root, case_name + ) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) # noqa: SLF001 + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) # noqa: SLF001 + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + predicted_labels, _, minimum_points, maximum_points, merge_count = ( + builder._automatic_labels( # noqa: SLF001 + np.asarray(points, dtype=np.float64), + np.asarray(centroid_anisotropies, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + ) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + predicted_labels = np.asarray(predicted_labels, dtype=np.int64) + + oracle_values, predicted_values, matrix = _contingency( + oracle_labels, predicted_labels + ) + oracle_rows = _domain_rows( + oracle_values, predicted_values, matrix, "oracle", "predicted" + ) + predicted_rows = _domain_rows( + predicted_values, oracle_values, matrix.T, "predicted", "oracle" + ) + + oracle_sizes = matrix.sum(axis=1) + predicted_sizes = matrix.sum(axis=0) + intersection_same_pairs = _choose2(matrix.ravel()) + oracle_same_pairs = _choose2(oracle_sizes) + predicted_same_pairs = _choose2(predicted_sizes) + false_split_pairs = oracle_same_pairs - intersection_same_pairs + false_merge_pairs = predicted_same_pairs - intersection_same_pairs + + oracle_refines_predicted = all( + int(row["overlapping_predicted_domains"]) == 1 for row in oracle_rows + ) + predicted_refines_oracle = all( + int(row["overlapping_oracle_domains"]) == 1 for row in predicted_rows + ) + weighted_oracle_purity = sum( + int(row["dominant_count"]) for row in oracle_rows + ) / float(len(points)) + weighted_predicted_purity = sum( + int(row["dominant_count"]) for row in predicted_rows + ) / float(len(points)) + optimal_accuracy, matched_points = comparison._optimal_label_accuracy( # noqa: SLF001 + oracle_labels, predicted_labels + ) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(oracle_values)} predicted_domains={len(predicted_values)}" + ) + print( + f"grid_internal_limits: min={minimum_points} max={maximum_points}; " + f"merges={merge_count}" + ) + print( + f"ARI={adjusted_rand_score(oracle_labels, predicted_labels):.5f} " + f"AMI={adjusted_mutual_info_score(oracle_labels, predicted_labels):.5f} " + f"optimal_match={optimal_accuracy:.5f} ({matched_points}/{len(points)})" + ) + print() + _print_matrix(oracle_values, predicted_values, matrix) + + print("\nOracle domains split across predicted domains:") + print(f"{'oracle':>8} {'size':>7} {'best_pred':>10} {'best_n':>8} {'purity':>9} {'pred_overlap':>13}") + for row in oracle_rows: + print( + f"{int(row['oracle_domain']):>8d} {int(row['point_count']):>7d} " + f"{int(row['dominant_predicted_domain']):>10d} " + f"{int(row['dominant_count']):>8d} {float(row['purity']):>9.4f} " + f"{int(row['overlapping_predicted_domains']):>13d}" + ) + + print("\nPredicted domains mixing oracle domains:") + print(f"{'pred':>8} {'size':>7} {'best_oracle':>11} {'best_n':>8} {'purity':>9} {'oracle_overlap':>14}") + for row in predicted_rows: + print( + f"{int(row['predicted_domain']):>8d} {int(row['point_count']):>7d} " + f"{int(row['dominant_oracle_domain']):>11d} " + f"{int(row['dominant_count']):>8d} {float(row['purity']):>9.4f} " + f"{int(row['overlapping_oracle_domains']):>14d}" + ) + + print("\nPartition diagnosis:") + print(f"oracle_is_refinement_of_predicted={oracle_refines_predicted}") + print(f"predicted_is_refinement_of_oracle={predicted_refines_oracle}") + print(f"weighted_oracle_to_predicted_purity={weighted_oracle_purity:.5f}") + print(f"weighted_predicted_to_oracle_purity={weighted_predicted_purity:.5f}") + print( + f"false_split_pairs={false_split_pairs} " + f"({false_split_pairs / float(max(oracle_same_pairs, 1)):.5f} of oracle-same pairs)" + ) + print( + f"false_merge_pairs={false_merge_pairs} " + f"({false_merge_pairs / float(max(predicted_same_pairs, 1)):.5f} of predicted-same pairs)" + ) + + args.output_dir.mkdir(parents=True, exist_ok=True) + stem = f"oracle-partition-overlap-{case_name.lower()}" + matrix_path = args.output_dir / f"{stem}-matrix.csv" + with matrix_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["oracle_domain", *[f"predicted_{int(v)}" for v in predicted_values], "total"]) + for index, oracle_value in enumerate(oracle_values): + writer.writerow( + [int(oracle_value), *[int(v) for v in matrix[index]], int(matrix[index].sum())] + ) + writer.writerow(["total", *[int(v) for v in predicted_sizes], int(matrix.sum())]) + + summary = { + "case": case_name, + "point_count": int(len(points)), + "grid_shape": [int(value) for value in shape], + "oracle_domain_count": int(len(oracle_values)), + "predicted_domain_count": int(len(predicted_values)), + "oracle_domain_sizes": [int(value) for value in oracle_sizes], + "predicted_domain_sizes": [int(value) for value in predicted_sizes], + "adjusted_rand_index": float(adjusted_rand_score(oracle_labels, predicted_labels)), + "adjusted_mutual_information": float( + adjusted_mutual_info_score(oracle_labels, predicted_labels) + ), + "optimal_label_accuracy": float(optimal_accuracy), + "matched_points": int(matched_points), + "oracle_is_refinement_of_predicted": bool(oracle_refines_predicted), + "predicted_is_refinement_of_oracle": bool(predicted_refines_oracle), + "weighted_oracle_to_predicted_purity": float(weighted_oracle_purity), + "weighted_predicted_to_oracle_purity": float(weighted_predicted_purity), + "oracle_same_pairs": int(oracle_same_pairs), + "predicted_same_pairs": int(predicted_same_pairs), + "intersection_same_pairs": int(intersection_same_pairs), + "false_split_pairs": int(false_split_pairs), + "false_merge_pairs": int(false_merge_pairs), + "oracle_domains": oracle_rows, + "predicted_domains": predicted_rows, + } + json_path = args.output_dir / f"{stem}.json" + json_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(f"\nwrote {matrix_path}") + print(f"wrote {json_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_dynamic_cluster_merging.py b/benchmarks/leapfrog_gold/analyse_real_stage_dynamic_cluster_merging.py new file mode 100644 index 000000000..1404bcd87 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_dynamic_cluster_merging.py @@ -0,0 +1,621 @@ +"""Test cluster-level consistency merging for Leapfrog's real SubDomainer stage. + +Pairwise point-matrix scores are compressed near one, but Leapfrog's consistency +threshold may be evaluated after neighbouring point matrices have been accumulated +inside growing clusters. This diagnostic keeps the within-coarse-domain Delaunay +topology and dynamically recomputes consistency after every cluster merge. It also +tests endpoint-relative similarity backbones. Oracle labels are used only for +evaluation; production code is unchanged. +""" +from __future__ import annotations + +import argparse +import heapq +import os +import sys +from collections import defaultdict +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_real_stage_edge_consistency as edge_base # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _pair_consistency( + first_matrix: np.ndarray, + first_count: int, + second_matrix: np.ndarray, + second_count: int, +) -> tuple[np.ndarray, float]: + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + ) + + merged, consistency = _merged_matrix_and_consistency( + np.asarray(first_matrix, dtype=float), + int(first_count), + np.asarray(second_matrix, dtype=float), + int(second_count), + ) + return np.asarray(merged, dtype=float), float(consistency) + + +def _endpoint_topk_edges( + matrices: np.ndarray, + edges: list[tuple[int, int]], + k: int, +) -> list[tuple[int, int]]: + incident: dict[int, list[tuple[float, int]]] = defaultdict(list) + for edge_index, (first, second) in enumerate(edges): + _, score = _pair_consistency( + matrices[int(first)], 1, matrices[int(second)], 1 + ) + incident[int(first)].append((score, edge_index)) + incident[int(second)].append((score, edge_index)) + + chosen: dict[int, set[int]] = {} + for node, values in incident.items(): + ordered = sorted(values, key=lambda item: item[0], reverse=True) + chosen[node] = { + edge_index for _, edge_index in ordered[: min(max(int(k), 1), len(ordered))] + } + + kept: list[tuple[int, int]] = [] + for edge_index, (first, second) in enumerate(edges): + if edge_index in chosen[int(first)] or edge_index in chosen[int(second)]: + kept.append((int(first), int(second))) + return kept + + +class _DynamicAgglomerator: + def __init__( + self, + matrices: np.ndarray, + edges: list[tuple[int, int]], + ) -> None: + self.count = len(matrices) + self.parent = np.arange(self.count, dtype=np.int64) + self.active = np.ones(self.count, dtype=bool) + self.versions = np.zeros(self.count, dtype=np.int64) + self.cluster_counts = np.ones(self.count, dtype=np.int64) + self.cluster_matrices = [ + np.asarray(matrix, dtype=float).copy() for matrix in matrices + ] + self.neighbours: list[set[int]] = [set() for _ in range(self.count)] + for first, second in edges: + first_i, second_i = int(first), int(second) + if first_i == second_i: + continue + self.neighbours[first_i].add(second_i) + self.neighbours[second_i].add(first_i) + + def find(self, value: int) -> int: + value_i = int(value) + while int(self.parent[value_i]) != value_i: + self.parent[value_i] = self.parent[int(self.parent[value_i])] + value_i = int(self.parent[value_i]) + return value_i + + def score(self, first: int, second: int) -> tuple[np.ndarray, float]: + return _pair_consistency( + self.cluster_matrices[first], + int(self.cluster_counts[first]), + self.cluster_matrices[second], + int(self.cluster_counts[second]), + ) + + def merge(self, first: int, second: int, merged_matrix: np.ndarray) -> int: + first_i, second_i = self.find(first), self.find(second) + if first_i == second_i: + return first_i + if int(self.cluster_counts[first_i]) < int(self.cluster_counts[second_i]): + first_i, second_i = second_i, first_i + + merged_neighbours = ( + self.neighbours[first_i].union(self.neighbours[second_i]) + - {first_i, second_i} + ) + self.parent[second_i] = first_i + self.active[second_i] = False + self.cluster_counts[first_i] += self.cluster_counts[second_i] + self.cluster_matrices[first_i] = np.asarray(merged_matrix, dtype=float) + self.versions[first_i] += 1 + + self.neighbours[first_i] = set() + self.neighbours[second_i].clear() + for neighbour in merged_neighbours: + neighbour_root = self.find(neighbour) + if neighbour_root == first_i or not self.active[neighbour_root]: + continue + self.neighbours[neighbour_root].discard(first_i) + self.neighbours[neighbour_root].discard(second_i) + self.neighbours[neighbour_root].add(first_i) + self.neighbours[first_i].add(neighbour_root) + return first_i + + def labels(self) -> np.ndarray: + roots = np.asarray([self.find(index) for index in range(self.count)], dtype=np.int64) + _, labels = np.unique(roots, return_inverse=True) + return labels.astype(np.int64) + + +def _global_best_merge( + matrices: np.ndarray, + edges: list[tuple[int, int]], + threshold: float, +) -> tuple[np.ndarray, list[float], int]: + state = _DynamicAgglomerator(matrices, edges) + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + first_i, second_i = state.find(first), state.find(second) + if first_i == second_i or not state.active[first_i] or not state.active[second_i]: + return + if second_i not in state.neighbours[first_i]: + return + _, score = state.score(first_i, second_i) + low, high = sorted((first_i, second_i)) + heapq.heappush( + heap, + ( + -float(score), + low, + high, + int(state.versions[low]), + int(state.versions[high]), + ), + ) + + for first, second in edges: + push(int(first), int(second)) + + accepted: list[float] = [] + stale_pops = 0 + while heap: + negative_score, first, second, first_version, second_version = heapq.heappop(heap) + first_i, second_i = state.find(first), state.find(second) + if first_i != first or second_i != second: + stale_pops += 1 + continue + if not state.active[first_i] or not state.active[second_i]: + stale_pops += 1 + continue + if ( + int(state.versions[first_i]) != first_version + or int(state.versions[second_i]) != second_version + or second_i not in state.neighbours[first_i] + ): + stale_pops += 1 + continue + + merged_matrix, current_score = state.score(first_i, second_i) + if abs(current_score + negative_score) > 1e-12: + push(first_i, second_i) + stale_pops += 1 + continue + if current_score < threshold: + break + + accepted.append(float(current_score)) + root = state.merge(first_i, second_i, merged_matrix) + for neighbour in list(state.neighbours[root]): + push(root, neighbour) + + return state.labels(), accepted, stale_pops + + +def _mutual_best_merge( + matrices: np.ndarray, + edges: list[tuple[int, int]], + threshold: float, +) -> tuple[np.ndarray, list[float], int]: + state = _DynamicAgglomerator(matrices, edges) + accepted: list[float] = [] + rounds = 0 + + while True: + rounds += 1 + best: dict[int, tuple[float, int, np.ndarray]] = {} + for first in range(state.count): + if not state.active[first]: + continue + for second in state.neighbours[first]: + if first >= second or not state.active[second]: + continue + merged, score = state.score(first, second) + if score > best.get(first, (-np.inf, -1, merged))[0]: + best[first] = (score, second, merged) + if score > best.get(second, (-np.inf, -1, merged))[0]: + best[second] = (score, first, merged) + + pairs: list[tuple[float, int, int, np.ndarray]] = [] + for first, (score, second, merged) in best.items(): + if first >= second or score < threshold: + continue + reverse = best.get(second) + if reverse is not None and int(reverse[1]) == first: + pairs.append((float(score), int(first), int(second), merged)) + + if not pairs: + break + + used: set[int] = set() + merged_this_round = 0 + for score, first, second, _ in sorted(pairs, key=lambda item: item[0], reverse=True): + first_i, second_i = state.find(first), state.find(second) + if first_i == second_i or first_i in used or second_i in used: + continue + if second_i not in state.neighbours[first_i]: + continue + merged_matrix, current_score = state.score(first_i, second_i) + if current_score < threshold: + continue + root = state.merge(first_i, second_i, merged_matrix) + used.add(root) + used.add(second_i if root == first_i else first_i) + accepted.append(float(current_score)) + merged_this_round += 1 + if merged_this_round == 0: + break + + return state.labels(), accepted, rounds + + +def _comb2(values: np.ndarray) -> int: + values_i = np.asarray(values, dtype=np.int64) + return int(np.sum(values_i * (values_i - 1) // 2)) + + +def _evaluate( + truth_groups: list[np.ndarray], + predicted_groups: list[np.ndarray], +) -> dict[str, float | int]: + from sklearn.metrics import adjusted_rand_score + + truth_all: list[np.ndarray] = [] + predicted_all: list[np.ndarray] = [] + truth_offset = 0 + predicted_offset = 0 + fragment_total = 0 + fragment_connected = 0 + split_excess = 0 + largest_numerator = 0 + total_points = 0 + predicted_domains = 0 + impure_domains = 0 + weighted_pure_points = 0 + true_pairs = 0 + predicted_pairs = 0 + true_positive_pairs = 0 + + for truth_raw, predicted_raw in zip(truth_groups, predicted_groups): + _, truth = np.unique(np.asarray(truth_raw, dtype=np.int64), return_inverse=True) + _, predicted = np.unique( + np.asarray(predicted_raw, dtype=np.int64), return_inverse=True + ) + + truth_all.append(truth + truth_offset) + predicted_all.append(predicted + predicted_offset) + truth_offset += int(truth.max()) + 1 if len(truth) else 0 + predicted_offset += int(predicted.max()) + 1 if len(predicted) else 0 + + truth_values = np.unique(truth) + predicted_values = np.unique(predicted) + fragment_total += len(truth_values) + predicted_domains += len(predicted_values) + total_points += len(truth) + + for truth_value in truth_values: + indices = np.flatnonzero(truth == truth_value) + parts, counts = np.unique(predicted[indices], return_counts=True) + fragment_connected += int(len(parts) == 1) + split_excess += max(len(parts) - 1, 0) + largest_numerator += int(counts.max()) if len(counts) else 0 + true_pairs += _comb2(np.asarray([len(indices)])) + + for predicted_value in predicted_values: + indices = np.flatnonzero(predicted == predicted_value) + labels, counts = np.unique(truth[indices], return_counts=True) + weighted_pure_points += int(counts.max()) if len(counts) else 0 + impure_domains += int(len(labels) > 1) + predicted_pairs += _comb2(np.asarray([len(indices)])) + true_positive_pairs += _comb2(counts) + + truth_vector = np.concatenate(truth_all) if truth_all else np.empty(0, dtype=np.int64) + predicted_vector = ( + np.concatenate(predicted_all) if predicted_all else np.empty(0, dtype=np.int64) + ) + pair_precision = true_positive_pairs / max(predicted_pairs, 1) + pair_recall = true_positive_pairs / max(true_pairs, 1) + pair_f1 = ( + 2.0 * pair_precision * pair_recall / max(pair_precision + pair_recall, 1e-15) + ) + return { + "ari": float(adjusted_rand_score(truth_vector, predicted_vector)), + "predicted_domains": predicted_domains, + "oracle_fragments": fragment_total, + "weighted_purity": weighted_pure_points / max(total_points, 1), + "fragments_connected": fragment_connected / max(fragment_total, 1), + "largest_fraction": largest_numerator / max(total_points, 1), + "split_excess": split_excess, + "impure_domains": impure_domains, + "pair_precision": pair_precision, + "pair_recall": pair_recall, + "pair_f1": pair_f1, + } + + +def _parse_thresholds(text: str) -> list[float]: + values = sorted( + { + float(item.strip()) + for item in text.split(",") + if item.strip() + } + ) + if not values: + raise ValueError("At least one threshold is required.") + if any(not 0.0 <= value <= 1.0 for value in values): + raise ValueError("Thresholds must lie in [0, 1].") + return values + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--coarse-threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--thresholds", + default="0.60,0.70,0.80,0.90,0.95,0.97,0.99,0.995", + help="Comma-separated real-stage cluster-consistency thresholds.", + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import _normalise_determinant + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.coarse_threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + groups: list[dict[str, object]] = [] + for coarse_value in np.unique(coarse_labels): + global_indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[global_indices] + local_matrices = point_matrices[global_indices] + delaunay = edge_base._delaunay_edges(local_points) + groups.append( + { + "coarse": int(coarse_value), + "truth": oracle_labels[global_indices], + "matrices": local_matrices, + "graphs": { + "delaunay": delaunay, + "similarity_top6_either": _endpoint_topk_edges( + local_matrices, delaunay, 6 + ), + "similarity_top8_either": _endpoint_topk_edges( + local_matrices, delaunay, 8 + ), + "similarity_top10_either": _endpoint_topk_edges( + local_matrices, delaunay, 10 + ), + }, + } + ) + + thresholds = _parse_thresholds(args.thresholds) + rows: list[dict[str, object]] = [] + for graph_name in ( + "delaunay", + "similarity_top6_either", + "similarity_top8_either", + "similarity_top10_either", + ): + for policy in ("global_best", "mutual_best"): + for threshold in thresholds: + predicted_groups: list[np.ndarray] = [] + accepted_scores: list[float] = [] + work_units = 0 + for group in groups: + matrices = np.asarray(group["matrices"], dtype=float) + edges = list(group["graphs"][graph_name]) + if policy == "global_best": + labels, accepted, stale = _global_best_merge( + matrices, edges, threshold + ) + work_units += stale + else: + labels, accepted, rounds = _mutual_best_merge( + matrices, edges, threshold + ) + work_units += rounds + predicted_groups.append(labels) + accepted_scores.extend(accepted) + + metrics = _evaluate( + [np.asarray(group["truth"], dtype=np.int64) for group in groups], + predicted_groups, + ) + metrics.update( + { + "graph": graph_name, + "policy": policy, + "threshold": threshold, + "accepted_merges": len(accepted_scores), + "accepted_min": min(accepted_scores) if accepted_scores else np.nan, + "accepted_median": ( + float(np.median(accepted_scores)) + if accepted_scores + else np.nan + ), + "work_units": work_units, + } + ) + rows.append(metrics) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + oracle_fragments = int(rows[0]["oracle_fragments"]) if rows else 0 + print( + f"Evaluation target: {oracle_fragments} oracle fragments inside coarse domains. " + "Unlike the previous edge test, this evaluates final dynamically merged labels." + ) + + print("\nLiteral 0.60 cluster-level threshold:") + print( + " graph policy domains purity connected largest split " + "impure pairP pairR pairF1 ARI merges accepted[min/median]" + ) + literal = [row for row in rows if abs(float(row["threshold"]) - 0.60) < 1e-12] + for row in sorted( + literal, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + float(item["weighted_purity"]), + ), + reverse=True, + ): + print( + f" {str(row['graph']):<24s} {str(row['policy']):<11s} " + f"{int(row['predicted_domains']):>7d} " + f"{float(row['weighted_purity']):>6.3f} " + f"{float(row['fragments_connected']):>9.3f} " + f"{float(row['largest_fraction']):>7.3f} " + f"{int(row['split_excess']):>5d} " + f"{int(row['impure_domains']):>6d} " + f"{float(row['pair_precision']):>5.3f} " + f"{float(row['pair_recall']):>5.3f} " + f"{float(row['pair_f1']):>6.3f} " + f"{float(row['ari']):>5.3f} " + f"{int(row['accepted_merges']):>6d} " + f"{float(row['accepted_min']):.3f}/{float(row['accepted_median']):.3f}" + ) + + print("\nTop dynamic-merging candidates:") + print( + " graph policy thr domains purity connected largest split " + "impure pairP pairR pairF1 ARI" + ) + for row in sorted( + rows, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + float(item["weighted_purity"]), + float(item["fragments_connected"]), + -abs(int(item["predicted_domains"]) - int(item["oracle_fragments"])), + ), + reverse=True, + )[:20]: + print( + f" {str(row['graph']):<24s} {str(row['policy']):<11s} " + f"{float(row['threshold']):>4.2f} " + f"{int(row['predicted_domains']):>7d} " + f"{float(row['weighted_purity']):>6.3f} " + f"{float(row['fragments_connected']):>9.3f} " + f"{float(row['largest_fraction']):>7.3f} " + f"{int(row['split_excess']):>5d} " + f"{int(row['impure_domains']):>6d} " + f"{float(row['pair_precision']):>5.3f} " + f"{float(row['pair_recall']):>5.3f} " + f"{float(row['pair_f1']):>6.3f} " + f"{float(row['ari']):>5.3f}" + ) + + print("\nBest candidate with high fragment preservation:") + feasible = [ + row + for row in rows + if float(row["fragments_connected"]) >= 0.90 + and float(row["largest_fraction"]) >= 0.99 + ] + if not feasible: + print(" none") + else: + best = max( + feasible, + key=lambda item: ( + float(item["pair_f1"]), + float(item["weighted_purity"]), + float(item["ari"]), + -int(item["impure_domains"]), + ), + ) + print( + f" graph={best['graph']} policy={best['policy']} " + f"threshold={float(best['threshold']):.3f} " + f"domains={int(best['predicted_domains'])}/{int(best['oracle_fragments'])} " + f"purity={float(best['weighted_purity']):.4f} " + f"connected={float(best['fragments_connected']):.4f} " + f"largest={float(best['largest_fraction']):.4f} " + f"split={int(best['split_excess'])} impure={int(best['impure_domains'])} " + f"pair_precision={float(best['pair_precision']):.4f} " + f"pair_recall={float(best['pair_recall']):.4f} " + f"pair_f1={float(best['pair_f1']):.4f} ARI={float(best['ari']):.4f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_edge_consistency.py b/benchmarks/leapfrog_gold/analyse_real_stage_edge_consistency.py new file mode 100644 index 000000000..667c2701e --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_edge_consistency.py @@ -0,0 +1,309 @@ +"""Measure whether the recovered point-pair consistency separates oracle fragments. + +Runtime evidence and connectivity tests make a within-coarse-domain Delaunay graph the +most plausible candidate graph for Leapfrog's second SubDomainer. This diagnostic +scores every such edge with the currently recovered matrix-consistency equation and +checks whether any threshold can simultaneously preserve connectivity inside decoded +oracle fragments and reject edges crossing between them. Production code is unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +class UnionFind: + def __init__(self, count: int) -> None: + self.parent = np.arange(count, dtype=np.int64) + self.rank = np.zeros(count, dtype=np.int8) + + def find(self, value: int) -> int: + value = int(value) + while self.parent[value] != value: + self.parent[value] = self.parent[self.parent[value]] + value = int(self.parent[value]) + return value + + def union(self, first: int, second: int) -> None: + first_root = self.find(first) + second_root = self.find(second) + if first_root == second_root: + return + if self.rank[first_root] < self.rank[second_root]: + first_root, second_root = second_root, first_root + self.parent[second_root] = first_root + if self.rank[first_root] == self.rank[second_root]: + self.rank[first_root] += 1 + + +def _delaunay_edges(points: np.ndarray) -> list[tuple[int, int]]: + from scipy.spatial import Delaunay, QhullError + + if len(points) < 2: + return [] + if len(points) == 2: + return [(0, 1)] + try: + simplices = Delaunay(points, qhull_options="QJ").simplices + except QhullError: + return [] + edges: set[tuple[int, int]] = set() + for simplex in simplices: + for first, second in combinations((int(value) for value in simplex), 2): + if first != second: + edges.add(tuple(sorted((first, second)))) + return sorted(edges) + + +def _component_count(indices: np.ndarray, edges: list[tuple[int, int]]) -> int: + if len(indices) == 0: + return 0 + mapping = {int(value): position for position, value in enumerate(indices)} + uf = UnionFind(len(indices)) + for first, second in edges: + if first in mapping and second in mapping: + uf.union(mapping[first], mapping[second]) + return len({uf.find(index) for index in range(len(indices))}) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import roc_auc_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + records: list[dict[str, object]] = [] + local_groups: list[dict[str, object]] = [] + for coarse_value in np.unique(coarse_labels): + global_indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[global_indices] + local_oracle = oracle_labels[global_indices] + local_edges = _delaunay_edges(local_points) + scored_edges: list[tuple[int, int, float, bool]] = [] + for first, second in local_edges: + _, consistency = _merged_matrix_and_consistency( + point_matrices[global_indices[first]], + 1, + point_matrices[global_indices[second]], + 1, + ) + same = bool(local_oracle[first] == local_oracle[second]) + score = float(consistency) + scored_edges.append((first, second, score, same)) + records.append( + { + "coarse": int(coarse_value), + "first": int(first), + "second": int(second), + "score": score, + "same": same, + } + ) + local_groups.append( + { + "coarse": int(coarse_value), + "indices": global_indices, + "oracle": local_oracle, + "edges": scored_edges, + } + ) + + scores = np.asarray([float(record["score"]) for record in records], dtype=float) + same_flags = np.asarray([bool(record["same"]) for record in records], dtype=bool) + same_scores = scores[same_flags] + cross_scores = scores[~same_flags] + auc = float(roc_auc_score(same_flags.astype(np.int8), scores)) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + print( + f"Delaunay edges={len(scores)} same={len(same_scores)} cross={len(cross_scores)} " + f"same_fraction={len(same_scores) / max(len(scores), 1):.5f} ROC_AUC={auc:.5f}" + ) + + quantiles = (0.0, 0.01, 0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99, 1.0) + print("\nConsistency quantiles:") + print(" quantile same cross") + for quantile in quantiles: + same_value = float(np.quantile(same_scores, quantile)) if len(same_scores) else np.nan + cross_value = float(np.quantile(cross_scores, quantile)) if len(cross_scores) else np.nan + print(f" {quantile:>7.2f} {same_value:>10.7f} {cross_value:>10.7f}") + + fixed_thresholds = [ + 0.60, + 0.80, + 0.90, + 0.95, + 0.97, + 0.98, + 0.99, + 0.995, + 0.997, + 0.999, + 0.9995, + 0.9999, + ] + data_thresholds = [] + for values in (same_scores, cross_scores): + if len(values): + data_thresholds.extend(float(np.quantile(values, q)) for q in (0.10, 0.25, 0.50, 0.75, 0.90)) + thresholds = sorted(set(fixed_thresholds + data_thresholds)) + + print("\nThreshold sweep on within-coarse Delaunay edges:") + print( + " threshold kept purity same_recall fragments_connected split_excess components cross" + ) + rows: list[dict[str, float | int]] = [] + for threshold in thresholds: + kept_total = 0 + kept_same = 0 + kept_cross = 0 + fragment_total = 0 + fragment_connected = 0 + split_excess = 0 + component_total = 0 + + for group in local_groups: + local_oracle = np.asarray(group["oracle"], dtype=np.int64) + kept_edges = [ + (int(first), int(second)) + for first, second, score, _ in group["edges"] + if float(score) >= threshold + ] + kept_total += len(kept_edges) + for first, second, score, same in group["edges"]: + if float(score) >= threshold: + if bool(same): + kept_same += 1 + else: + kept_cross += 1 + + all_indices = np.arange(len(local_oracle), dtype=np.int64) + component_total += _component_count(all_indices, kept_edges) + for oracle_value in np.unique(local_oracle): + fragment_indices = np.flatnonzero(local_oracle == oracle_value) + fragment_edges = [ + (first, second) + for first, second in kept_edges + if local_oracle[first] == oracle_value + and local_oracle[second] == oracle_value + ] + components = _component_count(fragment_indices, fragment_edges) + fragment_total += 1 + fragment_connected += int(components == 1) + split_excess += max(components - 1, 0) + + purity = kept_same / max(kept_total, 1) + recall = kept_same / max(len(same_scores), 1) + connected_fraction = fragment_connected / max(fragment_total, 1) + rows.append( + { + "threshold": threshold, + "kept": kept_total, + "purity": purity, + "recall": recall, + "connected": connected_fraction, + "split": split_excess, + "components": component_total, + "cross": kept_cross, + } + ) + print( + f" {threshold:>9.7f} {kept_total:>5d} {purity:>7.4f} {recall:>12.4f} " + f"{connected_fraction:>20.4f} {split_excess:>13d} " + f"{component_total:>11d} {kept_cross:>6d}" + ) + + feasible = [row for row in rows if float(row["connected"]) >= 0.90] + print("\nBest edge purity while retaining at least 90% fragment connectivity:") + if feasible: + best = max(feasible, key=lambda row: (float(row["purity"]), -int(row["cross"]))) + print( + f"threshold={float(best['threshold']):.7f} purity={float(best['purity']):.5f} " + f"same_recall={float(best['recall']):.5f} " + f"fragments_connected={float(best['connected']):.5f} " + f"split_excess={int(best['split'])} cross={int(best['cross'])}" + ) + else: + print("none") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_graph_partitioning.py b/benchmarks/leapfrog_gold/analyse_real_stage_graph_partitioning.py new file mode 100644 index 000000000..ad419e019 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_graph_partitioning.py @@ -0,0 +1,418 @@ +"""Test global weighted-graph partitioning at the real SubDomainer stage. + +The pairwise and dynamically accumulated determinant-consistency values remain too +close to one for a literal threshold to split the real-location graph. This diagnostic +tests whether the same signal becomes useful when the whole within-coarse-domain +Delaunay graph is partitioned jointly. + +Tests suffixed ``oracle_k`` use the decoded number of oracle intersections only as a +recoverability upper bound. ``spectral_eigengap`` chooses its own cluster count. +Production code is unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_real_stage_dynamic_cluster_merging as dynamic # noqa: E402 +import analyse_real_stage_edge_consistency as edge_base # noqa: E402 +import analyse_real_stage_structural_edge_ranking as ranking # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _robust_scale(values: np.ndarray) -> np.ndarray: + values = np.asarray(values, dtype=float) + low, high = np.quantile(values, [0.05, 0.95]) + if high - low <= 1e-15: + low, high = float(np.min(values)), float(np.max(values)) + if high - low <= 1e-15: + return np.ones_like(values) + return np.clip((values - low) / (high - low), 0.0, 1.0) + + +def _weights(rows: list[dict[str, float | int]]) -> dict[str, np.ndarray]: + similarity = np.asarray([float(row["similarity"]) for row in rows], dtype=float) + first = np.asarray( + [float(row["similarity_pct_first"]) for row in rows], dtype=float + ) + second = np.asarray( + [float(row["similarity_pct_second"]) for row in rows], dtype=float + ) + both = np.minimum(first, second) + either = np.maximum(first, second) + mean = 0.5 * (first + second) + + incident: dict[int, list[int]] = {} + for edge_index, row in enumerate(rows): + incident.setdefault(int(row["first"]), []).append(edge_index) + incident.setdefault(int(row["second"]), []).append(edge_index) + chosen: dict[int, set[int]] = {} + for node, edge_indices in incident.items(): + ordered = sorted( + edge_indices, + key=lambda index: float(rows[index]["similarity"]), + reverse=True, + ) + chosen[node] = set(ordered[: min(6, len(ordered))]) + top6 = np.asarray( + [ + float( + edge_index in chosen[int(row["first"])] + or edge_index in chosen[int(row["second"])] + ) + for edge_index, row in enumerate(rows) + ], + dtype=float, + ) + + relative = _robust_scale(similarity) + return { + "raw_similarity": similarity, + "relative_similarity": relative, + "endpoint_both": both, + "endpoint_mean": mean, + "endpoint_either": either, + "top6_binary": top6, + "top6_relative": top6 * relative, + } + + +def _affinity( + count: int, + rows: list[dict[str, float | int]], + weights: np.ndarray, +) -> np.ndarray: + result = np.zeros((count, count), dtype=float) + for row, weight in zip(rows, np.asarray(weights, dtype=float)): + first, second = int(row["first"]), int(row["second"]) + value = max(float(weight), 1e-9) + result[first, second] = max(result[first, second], value) + result[second, first] = result[first, second] + return result + + +def _eigensystem(affinity: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + count = len(affinity) + degrees = np.sum(affinity, axis=1) + inverse = np.zeros_like(degrees) + valid = degrees > np.finfo(float).eps + inverse[valid] = degrees[valid] ** -0.5 + normalised = inverse[:, None] * affinity * inverse[None, :] + laplacian = np.eye(count) - normalised + values, vectors = np.linalg.eigh(0.5 * (laplacian + laplacian.T)) + order = np.argsort(values) + return values[order], vectors[:, order] + + +def _spectral_labels( + affinity: np.ndarray, + clusters: int, +) -> tuple[np.ndarray, np.ndarray]: + from sklearn.cluster import KMeans + + count = len(affinity) + clusters = min(max(int(clusters), 1), count) + values, vectors = _eigensystem(affinity) + if clusters == 1: + return np.zeros(count, dtype=np.int64), values + + embedding = np.asarray(vectors[:, :clusters], dtype=float) + norms = np.linalg.norm(embedding, axis=1) + valid = norms > np.finfo(float).eps + embedding[valid] /= norms[valid, None] + labels = KMeans( + n_clusters=clusters, + n_init=30, + random_state=0, + ).fit_predict(embedding) + return np.asarray(labels, dtype=np.int64), values + + +def _auto_k(values: np.ndarray, maximum: int) -> tuple[int, float]: + if len(values) <= 1: + return 1, 0.0 + maximum = min(max(int(maximum), 1), len(values) - 1) + gaps = values[1 : maximum + 1] - values[:maximum] + best = int(np.argmax(gaps)) + return best + 1, float(gaps[best]) + + +def _matrix_features(matrices: np.ndarray) -> np.ndarray: + features: list[np.ndarray] = [] + indices = np.triu_indices(3) + for matrix in matrices: + values, vectors = np.linalg.eigh( + 0.5 * (np.asarray(matrix, dtype=float) + np.asarray(matrix, dtype=float).T) + ) + values = np.maximum(values, np.finfo(float).eps) + log_matrix = (vectors * np.log(values)) @ vectors.T + features.append(log_matrix[indices]) + result = np.asarray(features, dtype=float) + std = np.std(result, axis=0) + std[std <= 1e-12] = 1.0 + return (result - np.mean(result, axis=0)) / std + + +def _format(row: dict[str, object]) -> str: + return ( + f"{str(row['method']):<22s} {str(row['weight']):<20s} " + f"{int(row['predicted_domains']):>7d} " + f"{float(row['weighted_purity']):>6.3f} " + f"{float(row['fragments_connected']):>9.3f} " + f"{float(row['largest_fraction']):>7.3f} " + f"{int(row['split_excess']):>5d} " + f"{int(row['impure_domains']):>6d} " + f"{float(row['pair_precision']):>5.3f} " + f"{float(row['pair_recall']):>5.3f} " + f"{float(row['pair_f1']):>6.3f} " + f"{float(row['ari']):>5.3f}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--coarse-threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--maximum-auto-clusters", type=int, default=8) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import _normalise_determinant + from sklearn.cluster import KMeans + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.coarse_threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + groups: list[dict[str, object]] = [] + for coarse_value in np.unique(coarse_labels): + indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[indices] + local_matrices = point_matrices[indices] + truth = oracle_labels[indices] + edges = edge_base._delaunay_edges(local_points) + rows = ranking._edge_features(local_points, local_matrices, edges) + groups.append( + { + "coarse": int(coarse_value), + "truth": truth, + "matrices": local_matrices, + "rows": rows, + "weights": _weights(rows), + "oracle_k": int(len(np.unique(truth))), + } + ) + + truths = [np.asarray(group["truth"], dtype=np.int64) for group in groups] + weight_names = tuple(groups[0]["weights"].keys()) if groups else () + upper: list[dict[str, object]] = [] + automatic: list[dict[str, object]] = [] + auto_details: dict[str, list[tuple[int, int, int, float]]] = {} + + for weight_name in weight_names: + oracle_predictions: list[np.ndarray] = [] + auto_predictions: list[np.ndarray] = [] + details: list[tuple[int, int, int, float]] = [] + + for group in groups: + count = len(group["truth"]) + affinity = _affinity( + count, + list(group["rows"]), + np.asarray(group["weights"][weight_name], dtype=float), + ) + oracle_k = int(group["oracle_k"]) + oracle_labels_local, eigenvalues = _spectral_labels(affinity, oracle_k) + selected_k, gap = _auto_k(eigenvalues, args.maximum_auto_clusters) + automatic_labels_local, _ = _spectral_labels(affinity, selected_k) + oracle_predictions.append(oracle_labels_local) + auto_predictions.append(automatic_labels_local) + details.append( + (int(group["coarse"]), oracle_k, selected_k, gap) + ) + + metrics = dynamic._evaluate(truths, oracle_predictions) + metrics.update({"method": "spectral_oracle_k", "weight": weight_name}) + upper.append(metrics) + + metrics = dynamic._evaluate(truths, auto_predictions) + metrics.update({"method": "spectral_eigengap", "weight": weight_name}) + automatic.append(metrics) + auto_details[weight_name] = details + + matrix_predictions: list[np.ndarray] = [] + for group in groups: + features = _matrix_features(np.asarray(group["matrices"], dtype=float)) + oracle_k = int(group["oracle_k"]) + if oracle_k == 1: + labels = np.zeros(len(features), dtype=np.int64) + else: + labels = KMeans( + n_clusters=oracle_k, + n_init=30, + random_state=0, + ).fit_predict(features) + matrix_predictions.append(np.asarray(labels, dtype=np.int64)) + matrix_metrics = dynamic._evaluate(truths, matrix_predictions) + matrix_metrics.update({"method": "matrix_kmeans_oracle_k", "weight": "log_SPD"}) + upper.append(matrix_metrics) + + baseline = dynamic._evaluate( + truths, + [np.zeros(len(truth), dtype=np.int64) for truth in truths], + ) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + target = int(upper[0]["oracle_fragments"]) if upper else 0 + print(f"Evaluation target: {target} oracle intersections inside coarse domains.") + print( + "Coarse baseline: " + f"domains={int(baseline['predicted_domains'])} " + f"purity={float(baseline['weighted_purity']):.4f} " + f"pairF1={float(baseline['pair_f1']):.4f} " + f"ARI={float(baseline['ari']):.4f}" + ) + + header = ( + " method weight domains purity connected largest split " + "impure pairP pairR pairF1 ARI" + ) + print("\nOracle-k recoverability upper bounds:") + print(header) + for row in sorted( + upper, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + float(item["weighted_purity"]), + ), + reverse=True, + ): + print(" " + _format(row)) + + print("\nAutomatic spectral eigengap partitions:") + print(header) + for row in sorted( + automatic, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + float(item["weighted_purity"]), + ), + reverse=True, + ): + print(" " + _format(row)) + + if automatic: + best = max( + automatic, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + ), + ) + weight_name = str(best["weight"]) + print("\nBest automatic candidate per coarse domain:") + print(" coarse oracle_k predicted_k eigengap") + for coarse, oracle_k, predicted_k, gap in auto_details[weight_name]: + print( + f" {coarse:>6d} {oracle_k:>8d} {predicted_k:>11d} {gap:>9.6f}" + ) + + best_upper = max( + upper, + key=lambda item: ( + float(item["ari"]), + float(item["pair_f1"]), + ), + ) + print("\nInterpretation gate:") + print( + f" best_upper={best_upper['method']}/{best_upper['weight']} " + f"ARI={float(best_upper['ari']):.4f} " + f"pairF1={float(best_upper['pair_f1']):.4f} " + f"purity={float(best_upper['weighted_purity']):.4f}" + ) + if float(best_upper["ari"]) >= 0.85: + print( + " graph or matrix structure is sufficient when k is known; automatic " + "model selection is the main missing mechanism." + ) + elif float(best_upper["ari"]) >= 0.70: + print( + " the recovered signal is partially sufficient, but the partition " + "objective or edge weighting still differs from Leapfrog." + ) + else: + print( + " even oracle-k partitioning is weak; the recovered point matrices or " + "candidate topology are missing a stronger Leapfrog signal." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_matrix_similarity_metrics.py b/benchmarks/leapfrog_gold/analyse_real_stage_matrix_similarity_metrics.py new file mode 100644 index 000000000..f775646e4 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_matrix_similarity_metrics.py @@ -0,0 +1,284 @@ +"""Compare candidate SPD-matrix similarity metrics for the real SubDomainer stage. + +The recovered determinant consistency has useful ordering but values are compressed +near one, so Leapfrog's 0.60 threshold cannot act directly on point-pair scores. This +diagnostic evaluates several affine-invariant, log-Euclidean, spectral, and powered +determinant similarities on the same within-coarse-domain Delaunay graph. It reports +ROC AUC, behaviour at the literal 0.60 threshold, and the best threshold that retains +at least 90% decoded-fragment connectivity. Production code is unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_real_stage_edge_consistency as edge_base # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _spd_eigh(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + values, vectors = np.linalg.eigh(0.5 * (matrix + matrix.T)) + values = np.maximum(values, np.finfo(float).eps) + return values, vectors + + +def _matrix_log(matrix: np.ndarray) -> np.ndarray: + values, vectors = _spd_eigh(matrix) + return (vectors * np.log(values)) @ vectors.T + + +def _pair_scores( + first: np.ndarray, + second: np.ndarray, + current_consistency: float, +) -> dict[str, float]: + first_values, first_vectors = _spd_eigh(first) + first_inverse_sqrt = (first_vectors * (first_values ** -0.5)) @ first_vectors.T + relative = first_inverse_sqrt @ second @ first_inverse_sqrt + relative_values, _ = _spd_eigh(relative) + relative_logs = np.log(relative_values) + + affine_distance = float(np.linalg.norm(relative_logs)) + log_distance = float(np.linalg.norm(_matrix_log(first) - _matrix_log(second))) + spectral_spread = float(np.max(relative_logs) - np.min(relative_logs)) + current = float(np.clip(current_consistency, 0.0, 1.0)) + + return { + "det_current": current, + "det_sqrt": current ** 0.5, + "det_pow16": current ** 16.0, + "det_pow32": current ** 32.0, + "det_pow64": current ** 64.0, + "affine_exp": float(np.exp(-affine_distance)), + "affine_exp_sq": float(np.exp(-0.5 * affine_distance * affine_distance)), + "affine_inverse": 1.0 / (1.0 + affine_distance), + "log_exp": float(np.exp(-log_distance)), + "log_exp_sq": float(np.exp(-0.5 * log_distance * log_distance)), + "log_inverse": 1.0 / (1.0 + log_distance), + "spectral_ratio": float(np.exp(-spectral_spread)), + } + + +def _evaluate_threshold( + groups: list[dict[str, object]], + metric: str, + threshold: float, + total_same: int, +) -> dict[str, float | int]: + kept_total = 0 + kept_same = 0 + kept_cross = 0 + fragment_total = 0 + fragment_connected = 0 + split_excess = 0 + component_total = 0 + + for group in groups: + local_oracle = np.asarray(group["oracle"], dtype=np.int64) + kept_edges: list[tuple[int, int]] = [] + for first, second, scores, same in group["edges"]: + if float(scores[metric]) >= threshold: + kept_edges.append((int(first), int(second))) + kept_total += 1 + if bool(same): + kept_same += 1 + else: + kept_cross += 1 + + all_indices = np.arange(len(local_oracle), dtype=np.int64) + component_total += edge_base._component_count(all_indices, kept_edges) + for oracle_value in np.unique(local_oracle): + fragment_indices = np.flatnonzero(local_oracle == oracle_value) + fragment_edges = [ + (first, second) + for first, second in kept_edges + if local_oracle[first] == oracle_value and local_oracle[second] == oracle_value + ] + components = edge_base._component_count(fragment_indices, fragment_edges) + fragment_total += 1 + fragment_connected += int(components == 1) + split_excess += max(components - 1, 0) + + return { + "threshold": float(threshold), + "kept": kept_total, + "purity": kept_same / max(kept_total, 1), + "recall": kept_same / max(total_same, 1), + "connected": fragment_connected / max(fragment_total, 1), + "split": split_excess, + "components": component_total, + "cross": kept_cross, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import roc_auc_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + groups: list[dict[str, object]] = [] + flat_scores: dict[str, list[float]] = {} + flat_same: list[bool] = [] + for coarse_value in np.unique(coarse_labels): + global_indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[global_indices] + local_oracle = oracle_labels[global_indices] + local_edges = edge_base._delaunay_edges(local_points) + scored_edges: list[tuple[int, int, dict[str, float], bool]] = [] + for first, second in local_edges: + first_matrix = point_matrices[global_indices[first]] + second_matrix = point_matrices[global_indices[second]] + _, current = _merged_matrix_and_consistency( + first_matrix, 1, second_matrix, 1 + ) + scores = _pair_scores(first_matrix, second_matrix, float(current)) + same = bool(local_oracle[first] == local_oracle[second]) + scored_edges.append((first, second, scores, same)) + for name, value in scores.items(): + flat_scores.setdefault(name, []).append(float(value)) + flat_same.append(same) + groups.append( + { + "coarse": int(coarse_value), + "oracle": local_oracle, + "edges": scored_edges, + } + ) + + same_flags = np.asarray(flat_same, dtype=bool) + total_same = int(np.count_nonzero(same_flags)) + total_cross = int(len(same_flags) - total_same) + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + print( + f"Delaunay edges={len(same_flags)} same={total_same} cross={total_cross} " + f"same_fraction={total_same / max(len(same_flags), 1):.5f}" + ) + print("\nMetric comparison:") + print( + " metric AUC score_same50 score_cross50 | " + "at0.60[purity recall connected split cross] | " + "best90[threshold purity recall connected split cross]" + ) + + rows: list[tuple[float, str, str]] = [] + for metric, values_list in flat_scores.items(): + values = np.asarray(values_list, dtype=float) + auc = float(roc_auc_score(same_flags.astype(np.int8), values)) + same_values = values[same_flags] + cross_values = values[~same_flags] + literal = _evaluate_threshold(groups, metric, 0.60, total_same) + + quantiles = np.linspace(0.0, 1.0, 101) + thresholds = sorted( + set([0.60] + [float(np.quantile(values, q)) for q in quantiles]) + ) + candidates = [ + _evaluate_threshold(groups, metric, threshold, total_same) + for threshold in thresholds + ] + feasible = [row for row in candidates if float(row["connected"]) >= 0.90] + if feasible: + best = max( + feasible, + key=lambda row: ( + float(row["purity"]), + -int(row["cross"]), + float(row["recall"]), + ), + ) + else: + best = max(candidates, key=lambda row: float(row["connected"])) + + text = ( + f" {metric:<18s} {auc:>5.3f} " + f"{float(np.median(same_values)):>12.6f} {float(np.median(cross_values)):>13.6f} | " + f"{float(literal['purity']):.3f} {float(literal['recall']):.3f} " + f"{float(literal['connected']):.3f} {int(literal['split']):>3d} {int(literal['cross']):>4d} | " + f"{float(best['threshold']):.6f} {float(best['purity']):.3f} " + f"{float(best['recall']):.3f} {float(best['connected']):.3f} " + f"{int(best['split']):>3d} {int(best['cross']):>4d}" + ) + rows.append((auc, metric, text)) + + for _, _, text in sorted(rows, key=lambda item: item[0], reverse=True): + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_oracle_connectivity.py b/benchmarks/leapfrog_gold/analyse_real_stage_oracle_connectivity.py new file mode 100644 index 000000000..3d288e1b8 --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_oracle_connectivity.py @@ -0,0 +1,290 @@ +"""Diagnose the real-location SubDomainer graph inside each coarse domain. + +Recovered runtime evidence shows Leapfrog constructs a second SubDomainer for real +locations grouped by a preceding coarse-domain id. This diagnostic therefore avoids +post-hoc global merge tuning and asks a more direct question: for each candidate local +point graph, are the decoded oracle fragments inside every coarse domain connected, +and how many graph edges cross between different oracle fragments? + +A plausible graph should make same-oracle fragments internally connected while +presenting relatively few cross-oracle candidate edges. Production code is unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict, deque +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _unique_edges(edges: list[tuple[int, int]]) -> np.ndarray: + if not edges: + return np.empty((0, 2), dtype=np.int64) + values = np.asarray([tuple(sorted(edge)) for edge in edges if edge[0] != edge[1]], dtype=np.int64) + if len(values) == 0: + return np.empty((0, 2), dtype=np.int64) + return np.unique(values, axis=0) + + +def _delaunay_edges(points: np.ndarray) -> np.ndarray: + from scipy.spatial import Delaunay, QhullError + + if len(points) < 2: + return np.empty((0, 2), dtype=np.int64) + if len(points) == 2: + return np.asarray([[0, 1]], dtype=np.int64) + try: + simplices = Delaunay(points, qhull_options="QJ").simplices + except QhullError: + return np.empty((0, 2), dtype=np.int64) + edges: list[tuple[int, int]] = [] + for simplex in simplices: + for first, second in combinations((int(value) for value in simplex), 2): + edges.append((first, second)) + return _unique_edges(edges) + + +def _knn_edges(points: np.ndarray, k: int, mutual: bool) -> np.ndarray: + from scipy.spatial import cKDTree + + count = len(points) + if count < 2: + return np.empty((0, 2), dtype=np.int64) + actual_k = min(max(int(k), 1), count - 1) + neighbours = np.asarray(cKDTree(points).query(points, k=actual_k + 1)[1])[:, 1:] + directed = {(int(index), int(other)) for index, row in enumerate(neighbours) for other in row} + edges: list[tuple[int, int]] = [] + for first, second in directed: + if mutual and (second, first) not in directed: + continue + edges.append((first, second)) + return _unique_edges(edges) + + +def _component_sizes(indices: np.ndarray, edges: np.ndarray) -> list[int]: + if len(indices) == 0: + return [] + allowed = set(int(value) for value in indices) + adjacency: dict[int, list[int]] = defaultdict(list) + for first, second in edges: + first_i, second_i = int(first), int(second) + if first_i in allowed and second_i in allowed: + adjacency[first_i].append(second_i) + adjacency[second_i].append(first_i) + remaining = set(allowed) + sizes: list[int] = [] + while remaining: + start = remaining.pop() + queue: deque[int] = deque([start]) + size = 1 + while queue: + current = queue.popleft() + for neighbour in adjacency.get(current, []): + if neighbour in remaining: + remaining.remove(neighbour) + queue.append(neighbour) + size += 1 + sizes.append(size) + return sorted(sizes, reverse=True) + + +def _evaluate_graph(oracle: np.ndarray, edges: np.ndarray) -> dict[str, float | int]: + same_edges = 0 + cross_edges = 0 + for first, second in edges: + if int(oracle[int(first)]) == int(oracle[int(second)]): + same_edges += 1 + else: + cross_edges += 1 + + fragments = 0 + fully_connected = 0 + split_excess = 0 + weighted_largest = 0 + total_fragment_points = 0 + singleton_fragments = 0 + for label in np.unique(oracle): + indices = np.flatnonzero(oracle == label) + fragments += 1 + total_fragment_points += len(indices) + if len(indices) == 1: + singleton_fragments += 1 + component_sizes = _component_sizes(indices, edges) + if len(component_sizes) == 1: + fully_connected += 1 + split_excess += max(len(component_sizes) - 1, 0) + if component_sizes: + weighted_largest += component_sizes[0] + + edge_count = len(edges) + return { + "edges": edge_count, + "same_edges": same_edges, + "cross_edges": cross_edges, + "edge_purity": same_edges / edge_count if edge_count else 0.0, + "fragments": fragments, + "fully_connected": fully_connected, + "fully_connected_fraction": fully_connected / fragments if fragments else 1.0, + "split_excess": split_excess, + "largest_component_fraction": weighted_largest / total_fragment_points if total_fragment_points else 1.0, + "singleton_fragments": singleton_fragments, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument("--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark")) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--top", type=int, default=20) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs(args.decoded_root, case_name) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + graph_builders = { + "delaunay": lambda local_points: _delaunay_edges(local_points), + "knn4": lambda local_points: _knn_edges(local_points, 4, False), + "knn6": lambda local_points: _knn_edges(local_points, 6, False), + "knn8": lambda local_points: _knn_edges(local_points, 8, False), + "knn12": lambda local_points: _knn_edges(local_points, 12, False), + "mutual4": lambda local_points: _knn_edges(local_points, 4, True), + "mutual6": lambda local_points: _knn_edges(local_points, 6, True), + "mutual8": lambda local_points: _knn_edges(local_points, 8, True), + "mutual12": lambda local_points: _knn_edges(local_points, 12, True), + } + + aggregate: dict[str, dict[str, float]] = { + name: defaultdict(float) for name in graph_builders + } + coarse_rows: list[dict[str, object]] = [] + + for coarse_value in np.unique(coarse_labels): + global_indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[global_indices] + local_oracle = oracle_labels[global_indices] + oracle_values, oracle_counts = np.unique(local_oracle, return_counts=True) + purity = float(oracle_counts.max() / len(global_indices)) + row: dict[str, object] = { + "coarse": int(coarse_value), + "size": int(len(global_indices)), + "oracle_fragments": int(len(oracle_values)), + "purity": purity, + "oracle_counts": tuple(int(value) for value in sorted(oracle_counts, reverse=True)), + } + for name, build_graph in graph_builders.items(): + edges = build_graph(local_points) + metrics = _evaluate_graph(local_oracle, edges) + row[name] = metrics + weights = aggregate[name] + weights["edges"] += float(metrics["edges"]) + weights["same_edges"] += float(metrics["same_edges"]) + weights["cross_edges"] += float(metrics["cross_edges"]) + weights["fragments"] += float(metrics["fragments"]) + weights["fully_connected"] += float(metrics["fully_connected"]) + weights["split_excess"] += float(metrics["split_excess"]) + weights["largest_numerator"] += float(metrics["largest_component_fraction"]) * len(global_indices) + weights["points"] += len(global_indices) + coarse_rows.append(row) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} coarse_domains={len(np.unique(coarse_labels))} " + f"coarse_merges={coarse_merges}" + ) + print("\nAggregate candidate-graph diagnostics inside coarse domains:") + ranking_rows: list[tuple[str, float, float, float, int, int, int]] = [] + for name, values in aggregate.items(): + edges = int(values["edges"]) + same = int(values["same_edges"]) + cross = int(values["cross_edges"]) + fragments = int(values["fragments"]) + connected = int(values["fully_connected"]) + split_excess = int(values["split_excess"]) + purity = same / edges if edges else 0.0 + connected_fraction = connected / fragments if fragments else 1.0 + largest_fraction = values["largest_numerator"] / values["points"] if values["points"] else 1.0 + ranking_rows.append((name, connected_fraction, largest_fraction, purity, split_excess, cross, edges)) + for name, connected_fraction, largest_fraction, purity, split_excess, cross, edges in sorted( + ranking_rows, + key=lambda item: (item[1], item[2], item[3], -item[4], -item[5]), + reverse=True, + ): + print( + f"{name:>9s}: fragments_connected={connected_fraction:.4f} " + f"largest_fraction={largest_fraction:.4f} edge_purity={purity:.4f} " + f"split_excess={split_excess:3d} cross_edges={cross:5d} edges={edges:5d}" + ) + + print(f"\nTop {max(args.top, 1)} mixed coarse domains by lowest oracle purity:") + for row in sorted(coarse_rows, key=lambda item: (float(item["purity"]), -int(item["size"])))[: max(args.top, 1)]: + print( + f"coarse={int(row['coarse']):2d} size={int(row['size']):3d} " + f"oracle_fragments={int(row['oracle_fragments']):2d} purity={float(row['purity']):.4f} " + f"counts={row['oracle_counts']}" + ) + for name in graph_builders: + metrics = row[name] + print( + f" {name:>9s}: connected={int(metrics['fully_connected'])}/{int(metrics['fragments'])} " + f"largest={float(metrics['largest_component_fraction']):.4f} " + f"purity={float(metrics['edge_purity']):.4f} split={int(metrics['split_excess']):2d} " + f"cross={int(metrics['cross_edges']):4d} edges={int(metrics['edges']):4d}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_proximity_graphs.py b/benchmarks/leapfrog_gold/analyse_real_stage_proximity_graphs.py new file mode 100644 index 000000000..92e29956d --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_proximity_graphs.py @@ -0,0 +1,326 @@ +"""Compare sparse proximity graphs for Leapfrog's real-location SubDomainer stage. + +The within-coarse-domain Delaunay graph preserves decoded oracle fragments well but +contains many cross-fragment edges, while fixed-k nearest-neighbour graphs fragment +valid domains. This diagnostic evaluates two classical Delaunay subgraphs (Gabriel +and relative-neighbourhood graphs) plus locally scaled Delaunay edge filters. It asks +whether a sparser geometry-only graph can reduce cross-fragment candidate edges while +retaining at least 90 percent decoded-fragment connectivity. Production code is unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +class UnionFind: + def __init__(self, count: int) -> None: + self.parent = np.arange(count, dtype=np.int64) + self.rank = np.zeros(count, dtype=np.int8) + + def find(self, value: int) -> int: + value = int(value) + while self.parent[value] != value: + self.parent[value] = self.parent[self.parent[value]] + value = int(self.parent[value]) + return value + + def union(self, first: int, second: int) -> None: + first_root = self.find(first) + second_root = self.find(second) + if first_root == second_root: + return + if self.rank[first_root] < self.rank[second_root]: + first_root, second_root = second_root, first_root + self.parent[second_root] = first_root + if self.rank[first_root] == self.rank[second_root]: + self.rank[first_root] += 1 + + +def _delaunay_edges(points: np.ndarray) -> list[tuple[int, int]]: + from scipy.spatial import Delaunay, QhullError + + count = len(points) + if count < 2: + return [] + if count == 2: + return [(0, 1)] + try: + simplices = Delaunay(points, qhull_options="QJ").simplices + except QhullError: + return [] + edges: set[tuple[int, int]] = set() + for simplex in simplices: + for first, second in combinations((int(value) for value in simplex), 2): + if first != second: + edges.add(tuple(sorted((first, second)))) + return sorted(edges) + + +def _gabriel_edges(points: np.ndarray, edges: list[tuple[int, int]]) -> list[tuple[int, int]]: + kept: list[tuple[int, int]] = [] + tolerance = 1.0e-12 + for first, second in edges: + midpoint = 0.5 * (points[first] + points[second]) + radius_sq = 0.25 * float(np.sum((points[first] - points[second]) ** 2)) + distances_sq = np.sum((points - midpoint) ** 2, axis=1) + mask = np.ones(len(points), dtype=bool) + mask[[first, second]] = False + if not np.any(distances_sq[mask] < radius_sq - tolerance): + kept.append((first, second)) + return kept + + +def _rng_edges(points: np.ndarray, edges: list[tuple[int, int]]) -> list[tuple[int, int]]: + kept: list[tuple[int, int]] = [] + tolerance = 1.0e-12 + for first, second in edges: + length_sq = float(np.sum((points[first] - points[second]) ** 2)) + first_sq = np.sum((points - points[first]) ** 2, axis=1) + second_sq = np.sum((points - points[second]) ** 2, axis=1) + mask = np.ones(len(points), dtype=bool) + mask[[first, second]] = False + lune_inside = np.maximum(first_sq[mask], second_sq[mask]) < length_sq - tolerance + if not np.any(lune_inside): + kept.append((first, second)) + return kept + + +def _local_scales(points: np.ndarray, k: int) -> np.ndarray: + from scipy.spatial import cKDTree + + count = len(points) + if count <= 1: + return np.ones(count, dtype=float) + actual_k = min(max(int(k), 1), count - 1) + distances = np.asarray(cKDTree(points).query(points, k=actual_k + 1)[0], dtype=float) + scales = distances[:, actual_k] + positive = scales[scales > 0.0] + fallback = float(np.median(positive)) if len(positive) else 1.0 + return np.where(scales > 0.0, scales, fallback) + + +def _scaled_edges( + points: np.ndarray, + edges: list[tuple[int, int]], + scales: np.ndarray, + multiplier: float, + mode: str, +) -> list[tuple[int, int]]: + kept: list[tuple[int, int]] = [] + for first, second in edges: + length = float(np.linalg.norm(points[first] - points[second])) + if mode == "max": + reference = max(float(scales[first]), float(scales[second])) + elif mode == "mean": + reference = 0.5 * (float(scales[first]) + float(scales[second])) + else: + reference = float(np.sqrt(float(scales[first]) * float(scales[second]))) + if length <= multiplier * max(reference, np.finfo(float).eps): + kept.append((first, second)) + return kept + + +def _component_count(indices: np.ndarray, edges: list[tuple[int, int]]) -> int: + if len(indices) == 0: + return 0 + mapping = {int(value): position for position, value in enumerate(indices)} + uf = UnionFind(len(indices)) + for first, second in edges: + if first in mapping and second in mapping: + uf.union(mapping[first], mapping[second]) + return len({uf.find(index) for index in range(len(indices))}) + + +def _evaluate(groups: list[dict[str, object]], graph_name: str) -> dict[str, float | int | str]: + edge_total = 0 + same_total = 0 + cross_total = 0 + fragment_total = 0 + fragment_connected = 0 + split_excess = 0 + largest_weighted = 0.0 + point_weight = 0 + component_total = 0 + + for group in groups: + oracle = np.asarray(group["oracle"], dtype=np.int64) + edges = list(group[graph_name]) + edge_total += len(edges) + for first, second in edges: + if oracle[first] == oracle[second]: + same_total += 1 + else: + cross_total += 1 + all_indices = np.arange(len(oracle), dtype=np.int64) + component_total += _component_count(all_indices, edges) + for oracle_value in np.unique(oracle): + indices = np.flatnonzero(oracle == oracle_value) + fragment_edges = [ + (first, second) + for first, second in edges + if oracle[first] == oracle_value and oracle[second] == oracle_value + ] + components = _component_count(indices, fragment_edges) + fragment_total += 1 + fragment_connected += int(components == 1) + split_excess += max(components - 1, 0) + if len(indices): + mapping = {int(value): position for position, value in enumerate(indices)} + uf = UnionFind(len(indices)) + for first, second in fragment_edges: + uf.union(mapping[first], mapping[second]) + counts: dict[int, int] = {} + for position in range(len(indices)): + root = uf.find(position) + counts[root] = counts.get(root, 0) + 1 + largest = max(counts.values(), default=0) + largest_weighted += largest + point_weight += len(indices) + + return { + "graph": graph_name, + "edges": edge_total, + "purity": same_total / max(edge_total, 1), + "same_recall": same_total, + "cross": cross_total, + "connected": fragment_connected / max(fragment_total, 1), + "largest": largest_weighted / max(point_weight, 1), + "split": split_excess, + "components": component_total, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--scale-k", type=int, default=6) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + multipliers = (1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0, 6.0) + graph_names = ["delaunay", "gabriel", "rng"] + for mode in ("geom", "mean", "max"): + for multiplier in multipliers: + graph_names.append(f"scaled_{mode}_{multiplier:g}") + + groups: list[dict[str, object]] = [] + for coarse_value in np.unique(coarse_labels): + indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[indices] + local_oracle = oracle_labels[indices] + delaunay = _delaunay_edges(local_points) + scales = _local_scales(local_points, args.scale_k) + group: dict[str, object] = { + "coarse": int(coarse_value), + "oracle": local_oracle, + "delaunay": delaunay, + "gabriel": _gabriel_edges(local_points, delaunay), + "rng": _rng_edges(local_points, delaunay), + } + for mode in ("geom", "mean", "max"): + for multiplier in multipliers: + group[f"scaled_{mode}_{multiplier:g}"] = _scaled_edges( + local_points, delaunay, scales, multiplier, mode + ) + groups.append(group) + + rows = [_evaluate(groups, name) for name in graph_names] + baseline_same = int(next(row for row in rows if row["graph"] == "delaunay")["same_recall"]) + for row in rows: + row["same_recall"] = int(row["same_recall"]) / max(baseline_same, 1) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + print(f"local scale: k={args.scale_k}") + print("\nGraph comparison:") + print(" graph edges purity same_recall connected largest split components cross") + for row in sorted(rows, key=lambda value: (float(value["connected"]), float(value["purity"])), reverse=True): + print( + f" {str(row['graph']):<24s} {int(row['edges']):>5d} " + f"{float(row['purity']):>6.3f} {float(row['same_recall']):>11.3f} " + f"{float(row['connected']):>9.3f} {float(row['largest']):>7.3f} " + f"{int(row['split']):>5d} {int(row['components']):>10d} {int(row['cross']):>5d}" + ) + + feasible = [row for row in rows if float(row["connected"]) >= 0.90] + print("\nBest purity while retaining at least 90% fragment connectivity:") + if feasible: + best = max(feasible, key=lambda row: (float(row["purity"]), -int(row["cross"]))) + print( + f"graph={best['graph']} purity={float(best['purity']):.5f} " + f"same_recall={float(best['same_recall']):.5f} " + f"fragments_connected={float(best['connected']):.5f} " + f"largest_fraction={float(best['largest']):.5f} " + f"split_excess={int(best['split'])} cross={int(best['cross'])}" + ) + else: + print("none") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_real_stage_structural_edge_ranking.py b/benchmarks/leapfrog_gold/analyse_real_stage_structural_edge_ranking.py new file mode 100644 index 000000000..250d34daf --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_real_stage_structural_edge_ranking.py @@ -0,0 +1,582 @@ +"""Diagnose endpoint-relative structural ranking on real-stage Delaunay edges. + +The preceding diagnostics established two constraints: + +* the within-coarse-domain Delaunay graph preserves decoded oracle-fragment + connectivity, but contains many cross-fragment edges; and +* replacing it with Euclidean k-NN or applying a single global matrix-similarity + threshold destroys too much valid connectivity. + +This diagnostic therefore keeps the Delaunay candidate graph and evaluates local, +endpoint-relative edge rules. It compares structural-similarity ranks, anisotropic +edge-distance ranks, local score percentiles, tensor-direction alignment, and +Delaunay-neighbourhood agreement. Production code is unchanged; decoded oracle +labels are used only to measure each diagnostic graph. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_real_stage_edge_consistency as edge_base # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _safe_unit(vector: np.ndarray) -> np.ndarray: + norm = float(np.linalg.norm(vector)) + if not np.isfinite(norm) or norm <= np.finfo(float).eps: + return np.zeros_like(vector, dtype=float) + return np.asarray(vector, dtype=float) / norm + + +def _normalised_rank(values: np.ndarray, descending: bool) -> np.ndarray: + """Return endpoint-local percentile in [0, 1], where one is best.""" + count = len(values) + if count == 0: + return np.empty(0, dtype=float) + order = np.argsort(values, kind="mergesort") + if descending: + order = order[::-1] + result = np.empty(count, dtype=float) + if count == 1: + result[order[0]] = 1.0 + return result + result[order] = 1.0 - np.arange(count, dtype=float) / float(count - 1) + return result + + +def _edge_features( + points: np.ndarray, + matrices: np.ndarray, + edges: list[tuple[int, int]], +) -> list[dict[str, float | int]]: + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + ) + + adjacency: dict[int, set[int]] = defaultdict(set) + incident: dict[int, list[int]] = defaultdict(list) + rows: list[dict[str, float | int]] = [] + + for edge_index, (first, second) in enumerate(edges): + first_i, second_i = int(first), int(second) + adjacency[first_i].add(second_i) + adjacency[second_i].add(first_i) + incident[first_i].append(edge_index) + incident[second_i].append(edge_index) + + delta = np.asarray(points[second_i] - points[first_i], dtype=float) + direction = _safe_unit(delta) + euclidean = float(np.linalg.norm(delta)) + + first_matrix = np.asarray(matrices[first_i], dtype=float) + second_matrix = np.asarray(matrices[second_i], dtype=float) + merged_matrix, consistency = _merged_matrix_and_consistency( + first_matrix, 1, second_matrix, 1 + ) + merged_matrix = 0.5 * ( + np.asarray(merged_matrix, dtype=float) + + np.asarray(merged_matrix, dtype=float).T + ) + + direct_q = float(max(delta @ merged_matrix @ delta, 0.0)) + direct_distance = float(np.sqrt(direct_q)) + try: + inverse_matrix = np.linalg.inv(merged_matrix) + inverse_q = float(max(delta @ inverse_matrix @ delta, 0.0)) + inverse_distance = float(np.sqrt(inverse_q)) + except np.linalg.LinAlgError: + inverse_distance = float("inf") + + eigenvalues, eigenvectors = np.linalg.eigh(merged_matrix) + least_penalised = _safe_unit(eigenvectors[:, int(np.argmin(eigenvalues))]) + most_penalised = _safe_unit(eigenvectors[:, int(np.argmax(eigenvalues))]) + least_alignment = float(abs(direction @ least_penalised)) + most_alignment = float(abs(direction @ most_penalised)) + + rows.append( + { + "first": first_i, + "second": second_i, + "similarity": float(np.clip(consistency, 0.0, 1.0)), + "euclidean": euclidean, + "tensor_direct": direct_distance, + "tensor_inverse": inverse_distance, + "align_least": least_alignment, + "align_most": most_alignment, + } + ) + + for row in rows: + first_i, second_i = int(row["first"]), int(row["second"]) + common = adjacency[first_i].intersection(adjacency[second_i]) + union = adjacency[first_i].union(adjacency[second_i]) + union.discard(first_i) + union.discard(second_i) + row["common_count"] = int(len(common)) + row["common_jaccard"] = float(len(common) / max(len(union), 1)) + + rank_fields = ( + ("similarity", True, "similarity_pct"), + ("euclidean", False, "euclidean_pct"), + ("tensor_direct", False, "tensor_direct_pct"), + ("tensor_inverse", False, "tensor_inverse_pct"), + ("align_least", True, "align_least_pct"), + ("align_most", True, "align_most_pct"), + ("common_jaccard", True, "common_pct"), + ) + for node, edge_indices in incident.items(): + for field, descending, output in rank_fields: + values = np.asarray( + [float(rows[index][field]) for index in edge_indices], dtype=float + ) + ranks = _normalised_rank(values, descending=descending) + for index, rank in zip(edge_indices, ranks): + if node == int(rows[index]["first"]): + rows[index][f"{output}_first"] = float(rank) + else: + rows[index][f"{output}_second"] = float(rank) + + for row in rows: + for output in ( + "similarity_pct", + "euclidean_pct", + "tensor_direct_pct", + "tensor_inverse_pct", + "align_least_pct", + "align_most_pct", + "common_pct", + ): + first_value = float(row[f"{output}_first"]) + second_value = float(row[f"{output}_second"]) + row[f"{output}_either"] = max(first_value, second_value) + row[f"{output}_both"] = min(first_value, second_value) + + row["score_similarity_alignment"] = ( + 0.70 * float(row["similarity_pct_either"]) + + 0.30 * float(row["align_least_pct_either"]) + ) + row["score_similarity_tensor"] = ( + 0.65 * float(row["similarity_pct_either"]) + + 0.35 * float(row["tensor_direct_pct_either"]) + ) + row["score_similarity_common"] = ( + 0.70 * float(row["similarity_pct_either"]) + + 0.30 * float(row["common_pct_either"]) + ) + row["score_structural_combined"] = ( + 0.50 * float(row["similarity_pct_either"]) + + 0.25 * float(row["tensor_direct_pct_either"]) + + 0.15 * float(row["align_least_pct_either"]) + + 0.10 * float(row["common_pct_either"]) + ) + return rows + + +def _evaluate_graph( + groups: list[dict[str, object]], + selected: dict[int, set[int]], +) -> dict[str, float | int]: + edge_count = 0 + same_edges = 0 + cross_edges = 0 + fragment_total = 0 + fragment_connected = 0 + split_excess = 0 + largest_numerator = 0 + total_points = 0 + + for group_index, group in enumerate(groups): + local_oracle = np.asarray(group["oracle"], dtype=np.int64) + rows = group["rows"] + kept_indices = selected.get(group_index, set()) + kept_edges: list[tuple[int, int]] = [] + + for edge_index in kept_indices: + row = rows[edge_index] + first, second = int(row["first"]), int(row["second"]) + kept_edges.append((first, second)) + edge_count += 1 + if int(local_oracle[first]) == int(local_oracle[second]): + same_edges += 1 + else: + cross_edges += 1 + + for oracle_value in np.unique(local_oracle): + fragment_indices = np.flatnonzero(local_oracle == oracle_value) + fragment_edges = [ + (first, second) + for first, second in kept_edges + if int(local_oracle[first]) == int(oracle_value) + and int(local_oracle[second]) == int(oracle_value) + ] + components = edge_base._component_count(fragment_indices, fragment_edges) + fragment_total += 1 + fragment_connected += int(components == 1) + split_excess += max(components - 1, 0) + + if len(fragment_indices): + mapping = { + int(value): position + for position, value in enumerate(fragment_indices) + } + uf = edge_base.UnionFind(len(fragment_indices)) + for first, second in fragment_edges: + uf.union(mapping[first], mapping[second]) + component_sizes: dict[int, int] = defaultdict(int) + for position in range(len(fragment_indices)): + component_sizes[uf.find(position)] += 1 + largest_numerator += max(component_sizes.values(), default=0) + total_points += len(fragment_indices) + + return { + "edges": edge_count, + "same_edges": same_edges, + "cross_edges": cross_edges, + "purity": same_edges / max(edge_count, 1), + "same_recall": 0.0, + "fragments_connected": fragment_connected / max(fragment_total, 1), + "split_excess": split_excess, + "largest_fraction": largest_numerator / max(total_points, 1), + } + + +def _select_all(groups: list[dict[str, object]]) -> dict[int, set[int]]: + return { + group_index: set(range(len(group["rows"]))) + for group_index, group in enumerate(groups) + } + + +def _select_threshold( + groups: list[dict[str, object]], + field: str, + threshold: float, +) -> dict[int, set[int]]: + selected: dict[int, set[int]] = {} + for group_index, group in enumerate(groups): + selected[group_index] = { + edge_index + for edge_index, row in enumerate(group["rows"]) + if float(row[field]) >= threshold + } + return selected + + +def _select_endpoint_topk( + groups: list[dict[str, object]], + field: str, + k: int, + descending: bool, + mutual: bool, +) -> dict[int, set[int]]: + selected: dict[int, set[int]] = {} + for group_index, group in enumerate(groups): + rows = group["rows"] + incident: dict[int, list[int]] = defaultdict(list) + for edge_index, row in enumerate(rows): + incident[int(row["first"])].append(edge_index) + incident[int(row["second"])].append(edge_index) + + chosen_by_node: dict[int, set[int]] = {} + for node, edge_indices in incident.items(): + ordered = sorted( + edge_indices, + key=lambda index: float(rows[index][field]), + reverse=descending, + ) + chosen_by_node[node] = set( + ordered[: min(max(int(k), 1), len(ordered))] + ) + + kept: set[int] = set() + for edge_index, row in enumerate(rows): + first = int(row["first"]) + second = int(row["second"]) + selected_first = edge_index in chosen_by_node[first] + selected_second = edge_index in chosen_by_node[second] + if ( + selected_first and selected_second + if mutual + else selected_first or selected_second + ): + kept.add(edge_index) + selected[group_index] = kept + return selected + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--minimum-connectivity", type=float, default=0.90) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import _normalise_determinant + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + coarse_labels = np.asarray(coarse_labels, dtype=np.int64) + + groups: list[dict[str, object]] = [] + for coarse_value in np.unique(coarse_labels): + global_indices = np.flatnonzero(coarse_labels == coarse_value) + local_points = points[global_indices] + local_matrices = point_matrices[global_indices] + local_oracle = oracle_labels[global_indices] + local_edges = edge_base._delaunay_edges(local_points) + groups.append( + { + "coarse": int(coarse_value), + "oracle": local_oracle, + "rows": _edge_features(local_points, local_matrices, local_edges), + } + ) + + candidates: list[tuple[str, dict[int, set[int]]]] = [ + ("delaunay", _select_all(groups)), + ] + + for k in (2, 3, 4, 5, 6, 8, 10, 12): + candidates.append( + ( + f"similarity_top{k}_either", + _select_endpoint_topk( + groups, "similarity", k, descending=True, mutual=False + ), + ) + ) + candidates.append( + ( + f"similarity_top{k}_both", + _select_endpoint_topk( + groups, "similarity", k, descending=True, mutual=True + ), + ) + ) + candidates.append( + ( + f"tensor_top{k}_either", + _select_endpoint_topk( + groups, "tensor_direct", k, descending=False, mutual=False + ), + ) + ) + candidates.append( + ( + f"tensor_top{k}_both", + _select_endpoint_topk( + groups, "tensor_direct", k, descending=False, mutual=True + ), + ) + ) + + threshold_fields = ( + "similarity_pct_either", + "similarity_pct_both", + "tensor_direct_pct_either", + "tensor_direct_pct_both", + "tensor_inverse_pct_either", + "align_least_pct_either", + "common_pct_either", + "score_similarity_alignment", + "score_similarity_tensor", + "score_similarity_common", + "score_structural_combined", + ) + for field in threshold_fields: + for threshold in (0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90): + candidates.append( + ( + f"{field}@{threshold:.2f}", + _select_threshold(groups, field, threshold), + ) + ) + + baseline = _evaluate_graph(groups, candidates[0][1]) + total_same = int(baseline["same_edges"]) + rows: list[dict[str, float | int | str]] = [] + for name, selected in candidates: + metrics = _evaluate_graph(groups, selected) + metrics["name"] = name + metrics["same_recall"] = int(metrics["same_edges"]) / max(total_same, 1) + rows.append(metrics) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} " + f"coarse_domains={len(np.unique(coarse_labels))} coarse_merges={coarse_merges}" + ) + print( + f"Delaunay edges={int(baseline['edges'])} " + f"same={int(baseline['same_edges'])} " + f"cross={int(baseline['cross_edges'])} " + f"fragments_connected={float(baseline['fragments_connected']):.4f} " + f"largest_fraction={float(baseline['largest_fraction']):.4f}" + ) + + print("\nTop candidates retaining requested fragment connectivity:") + print( + " rule edges purity recall connected " + "largest split cross" + ) + feasible = [ + row + for row in rows + if float(row["fragments_connected"]) >= args.minimum_connectivity + ] + ranking = sorted( + feasible, + key=lambda row: ( + int(row["cross_edges"]), + -float(row["purity"]), + -float(row["same_recall"]), + int(row["split_excess"]), + ), + ) + for row in ranking[:30]: + print( + f" {str(row['name']):<41s} " + f"{int(row['edges']):>5d} {float(row['purity']):>6.3f} " + f"{float(row['same_recall']):>6.3f} " + f"{float(row['fragments_connected']):>9.3f} " + f"{float(row['largest_fraction']):>7.3f} " + f"{int(row['split_excess']):>5d} {int(row['cross_edges']):>5d}" + ) + + print("\nBest candidate at each connectivity floor:") + print( + " floor rule purity recall largest " + "split cross" + ) + for floor in (0.95, 0.90, 0.85, 0.80, 0.70): + floor_rows = [ + row for row in rows if float(row["fragments_connected"]) >= floor + ] + if not floor_rows: + print(f" {floor:>4.2f} none") + continue + best = min( + floor_rows, + key=lambda row: ( + int(row["cross_edges"]), + -float(row["purity"]), + -float(row["same_recall"]), + int(row["split_excess"]), + ), + ) + print( + f" {floor:>4.2f} {str(best['name']):<41s} " + f"{float(best['purity']):>6.3f} " + f"{float(best['same_recall']):>6.3f} " + f"{float(best['largest_fraction']):>7.3f} " + f"{int(best['split_excess']):>5d} " + f"{int(best['cross_edges']):>5d}" + ) + + print("\nSignal diagnostics (ROC AUC; larger means stronger separation):") + from sklearn.metrics import roc_auc_score + + flat_same: list[int] = [] + flat_features: dict[str, list[float]] = defaultdict(list) + signal_fields = ( + "similarity", + "similarity_pct_either", + "similarity_pct_both", + "tensor_direct_pct_either", + "tensor_inverse_pct_either", + "align_least", + "align_most", + "common_jaccard", + "score_similarity_alignment", + "score_similarity_tensor", + "score_similarity_common", + "score_structural_combined", + ) + for group in groups: + local_oracle = np.asarray(group["oracle"], dtype=np.int64) + for row in group["rows"]: + first, second = int(row["first"]), int(row["second"]) + flat_same.append(int(local_oracle[first] == local_oracle[second])) + for field in signal_fields: + flat_features[field].append(float(row[field])) + + labels = np.asarray(flat_same, dtype=np.int8) + auc_rows = [] + for field in signal_fields: + values = np.asarray(flat_features[field], dtype=float) + finite = np.isfinite(values) + if np.count_nonzero(finite) == 0 or len(np.unique(labels[finite])) < 2: + auc = float("nan") + else: + auc = float(roc_auc_score(labels[finite], values[finite])) + auc_rows.append((auc, field)) + for auc, field in sorted(auc_rows, reverse=True): + print(f" {field:<34s} {auc:.5f}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_reassignment_boundary_merge_features.py b/benchmarks/leapfrog_gold/analyse_reassignment_boundary_merge_features.py new file mode 100644 index 000000000..fdbea4b8f --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_reassignment_boundary_merge_features.py @@ -0,0 +1,370 @@ +"""Analyse boundary-level features for the post-reassignment domain merge. + +The best oracle merge after one local matrix reassignment is not consistently the pair +with the highest whole-domain matrix consistency. This diagnostic measures the +actual interface between adjacent reassigned domains using Delaunay and k-nearest +point graphs: cross-edge counts, boundary support, edge lengths, and boundary-only +matrix consistency. It reports where the oracle-best merge ranks under each +model-side feature without changing production code. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import analyse_matrix_reassignment_pair_merges as pair_diag # noqa: E402 +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_matrix_reassignment as reassignment # noqa: E402 + + +def _unique_delaunay_edges(points: np.ndarray) -> np.ndarray: + from scipy.spatial import Delaunay, QhullError + + try: + simplices = np.asarray(Delaunay(points, qhull_options="QJ").simplices, dtype=np.int64) + except QhullError: + return np.empty((0, 2), dtype=np.int64) + edges: set[tuple[int, int]] = set() + for simplex in simplices: + for first, second in combinations((int(value) for value in simplex), 2): + edges.add((first, second) if first < second else (second, first)) + return np.asarray(sorted(edges), dtype=np.int64) + + +def _unique_knn_edges(points: np.ndarray, k: int) -> np.ndarray: + from scipy.spatial import cKDTree + + count = len(points) + if count < 2: + return np.empty((0, 2), dtype=np.int64) + actual_k = min(max(int(k), 1), count - 1) + neighbours = np.asarray(cKDTree(points).query(points, k=actual_k + 1)[1])[:, 1:] + edges: set[tuple[int, int]] = set() + for first, row in enumerate(neighbours): + for second in row: + second = int(second) + edges.add((first, second) if first < second else (second, first)) + return np.asarray(sorted(edges), dtype=np.int64) + + +def _interface_records( + points: np.ndarray, + labels: np.ndarray, + matrices: np.ndarray, + edges: np.ndarray, + *, + normalise, + merged_score, +) -> dict[tuple[int, int], dict[str, object]]: + grouped: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list) + for first_index, second_index in np.asarray(edges, dtype=np.int64): + first_label = int(labels[int(first_index)]) + second_label = int(labels[int(second_index)]) + if first_label == second_label: + continue + pair = tuple(sorted((first_label, second_label))) + if first_label == pair[0]: + grouped[pair].append((int(first_index), int(second_index))) + else: + grouped[pair].append((int(second_index), int(first_index))) + + output: dict[tuple[int, int], dict[str, object]] = {} + for pair, pair_edges in grouped.items(): + edge_array = np.asarray(pair_edges, dtype=np.int64) + first_points = np.unique(edge_array[:, 0]) + second_points = np.unique(edge_array[:, 1]) + lengths = np.linalg.norm( + points[edge_array[:, 0]] - points[edge_array[:, 1]], axis=1 + ) + first_matrix = normalise(np.asarray(matrices[first_points]).mean(axis=0)) + second_matrix = normalise(np.asarray(matrices[second_points]).mean(axis=0)) + _, boundary_consistency = merged_score( + first_matrix, + len(first_points), + second_matrix, + len(second_points), + ) + edge_consistencies: list[float] = [] + for first_index, second_index in edge_array: + _, value = merged_score( + matrices[int(first_index)], 1, matrices[int(second_index)], 1 + ) + if np.isfinite(value): + edge_consistencies.append(float(value)) + output[pair] = { + "edges": int(len(edge_array)), + "first_boundary_points": int(len(first_points)), + "second_boundary_points": int(len(second_points)), + "boundary_points": int(len(first_points) + len(second_points)), + "length_min": float(np.min(lengths)), + "length_median": float(np.median(lengths)), + "length_mean": float(np.mean(lengths)), + "boundary_consistency": float(boundary_consistency), + "edge_consistency_mean": float(np.mean(edge_consistencies)) + if edge_consistencies + else float("-inf"), + "edge_consistency_min": float(np.min(edge_consistencies)) + if edge_consistencies + else float("-inf"), + } + return output + + +def _rank(rows: list[dict[str, object]], key: str, pair: tuple[int, int], reverse: bool) -> int: + ordered = sorted(rows, key=lambda row: float(row[key]), reverse=reverse) + for index, row in enumerate(ordered, start=1): + if row["pair"] == pair: + return index + return -1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--nearest-domains", type=int, default=2) + parser.add_argument("--iterations", type=int, default=1) + parser.add_argument("--spatial-penalty", type=float, default=0.0) + parser.add_argument("--knn", type=int, default=6) + parser.add_argument("--top", type=int, default=12) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + reassigned, changes, history = reassignment._run_reassignment( + points, + point_matrices, + coarse_labels, + nearest_domains=args.nearest_domains, + spatial_penalty=args.spatial_penalty, + iterations=args.iterations, + threshold=args.threshold, + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + reassigned = reassignment._relabel(reassigned) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + match, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(match), + int(matched), + ) + + values, domain_matrices, domain_centroids, sizes = reassignment._domain_statistics( + points, point_matrices, reassigned, _normalise_determinant + ) + position = {int(value): index for index, value in enumerate(values)} + delaunay = _interface_records( + points, + reassigned, + point_matrices, + _unique_delaunay_edges(points), + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + knn = _interface_records( + points, + reassigned, + point_matrices, + _unique_knn_edges(points, args.knn), + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + + rows: list[dict[str, object]] = [] + for pair in sorted(set(delaunay) | set(knn)): + first, second = pair + first_position = position[first] + second_position = position[second] + _, whole_consistency = _merged_matrix_and_consistency( + domain_matrices[first_position], + int(sizes[first_position]), + domain_matrices[second_position], + int(sizes[second_position]), + ) + merged_labels = pair_diag._merge_pair(reassigned, first, second) + ari, ami, match, matched = metrics(merged_labels) + d = delaunay.get(pair, {}) + k = knn.get(pair, {}) + rows.append( + { + "pair": pair, + "sizes": (int(sizes[first_position]), int(sizes[second_position])), + "whole_consistency": float(whole_consistency), + "centroid_distance": float( + np.linalg.norm( + domain_centroids[first_position] - domain_centroids[second_position] + ) + ), + "d_edges": int(d.get("edges", 0)), + "d_boundary_points": int(d.get("boundary_points", 0)), + "d_length_median": float(d.get("length_median", float("inf"))), + "d_boundary_consistency": float( + d.get("boundary_consistency", float("-inf")) + ), + "d_edge_consistency_mean": float( + d.get("edge_consistency_mean", float("-inf")) + ), + "k_edges": int(k.get("edges", 0)), + "k_boundary_points": int(k.get("boundary_points", 0)), + "k_length_median": float(k.get("length_median", float("inf"))), + "k_boundary_consistency": float( + k.get("boundary_consistency", float("-inf")) + ), + "k_edge_consistency_mean": float( + k.get("edge_consistency_mean", float("-inf")) + ), + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + coarse_metrics = metrics(coarse_labels) + reassigned_metrics = metrics(reassigned) + best = max(rows, key=lambda row: float(row["ari"])) + best_pair = best["pair"] + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_metrics[0]:.5f} " + f"AMI={coarse_metrics[1]:.5f} match={coarse_metrics[2]:.5f} " + f"({coarse_metrics[3]}/{len(points)})" + ) + print( + f"reassigned: changes={changes} history={history} domains={len(values)} " + f"ARI={reassigned_metrics[0]:.5f} AMI={reassigned_metrics[1]:.5f} " + f"match={reassigned_metrics[2]:.5f} ({reassigned_metrics[3]}/{len(points)})" + ) + + print("\nOracle-best adjacent merge and model-side feature ranks:") + print( + f"pair={best_pair} sizes={best['sizes']} ARI={best['ari']:.5f} " + f"AMI={best['ami']:.5f} match={best['match']:.5f} ({best['matched']}/{len(points)})" + ) + feature_specs = ( + ("whole_consistency", True), + ("centroid_distance", False), + ("d_edges", True), + ("d_boundary_points", True), + ("d_length_median", False), + ("d_boundary_consistency", True), + ("d_edge_consistency_mean", True), + ("k_edges", True), + ("k_boundary_points", True), + ("k_length_median", False), + ("k_boundary_consistency", True), + ("k_edge_consistency_mean", True), + ) + for key, reverse in feature_specs: + print( + f" {key:24s} value={best[key]!s:>12s} " + f"rank={_rank(rows, key, best_pair, reverse)}/{len(rows)}" + ) + + def format_row(row: dict[str, object]) -> str: + return ( + f"pair={row['pair']} sizes={row['sizes']} whole={float(row['whole_consistency']):.6f} " + f"D[edges={int(row['d_edges']):4d},pts={int(row['d_boundary_points']):3d}," + f"med={float(row['d_length_median']):7.2f},bc={float(row['d_boundary_consistency']):.6f}," + f"ec={float(row['d_edge_consistency_mean']):.6f}] " + f"K[edges={int(row['k_edges']):4d},pts={int(row['k_boundary_points']):3d}," + f"med={float(row['k_length_median']):7.2f},bc={float(row['k_boundary_consistency']):.6f}," + f"ec={float(row['k_edge_consistency_mean']):.6f}] " + f"ARI={float(row['ari']):.5f} match={float(row['match']):.5f}" + ) + + top = max(args.top, 1) + print(f"\nTop {top} adjacent pairs by Delaunay cross-edge count:") + for row in sorted(rows, key=lambda item: int(item["d_edges"]), reverse=True)[:top]: + print(format_row(row)) + + print(f"\nTop {top} adjacent pairs by Delaunay boundary consistency:") + for row in sorted( + rows, key=lambda item: float(item["d_boundary_consistency"]), reverse=True + )[:top]: + print(format_row(row)) + + print(f"\nTop {top} adjacent pairs by oracle ARI:") + for row in sorted(rows, key=lambda item: float(item["ari"]), reverse=True)[:top]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyse_reassignment_grid_interface_features.py b/benchmarks/leapfrog_gold/analyse_reassignment_grid_interface_features.py new file mode 100644 index 000000000..448344eec --- /dev/null +++ b/benchmarks/leapfrog_gold/analyse_reassignment_grid_interface_features.py @@ -0,0 +1,360 @@ +"""Analyse coarse-grid interface features for the post-reassignment merge. + +Real-point Delaunay boundary statistics do not uniquely identify the oracle-best merge. +The recovered Leapfrog architecture, however, retains a six-connected centroid grid +from the first GridSeededDomainer stage. This diagnostic tests whether the final +merge is ranked more naturally by the shared interface on that original grid. +Production code is unchanged and oracle metrics are used only to identify the pair +whose merge best matches the decoded partition. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict +from itertools import combinations +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_matrix_reassignment as reassignment # noqa: E402 + + +def _merge_pair(labels: np.ndarray, first: int, second: int) -> np.ndarray: + merged = np.asarray(labels, dtype=np.int64).copy() + merged[merged == second] = first + return reassignment._relabel(merged) + + +def _grid_edges_with_axis(shape: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]: + grid = np.arange(np.prod(shape), dtype=np.int64).reshape(shape) + edge_blocks: list[np.ndarray] = [] + axis_blocks: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1: + continue + left = [slice(None), slice(None), slice(None)] + right = [slice(None), slice(None), slice(None)] + left[axis] = slice(0, size - 1) + right[axis] = slice(1, size) + block = np.column_stack([grid[tuple(left)].ravel(), grid[tuple(right)].ravel()]) + edge_blocks.append(block) + axis_blocks.append(np.full(len(block), axis, dtype=np.int64)) + if not edge_blocks: + return np.empty((0, 2), dtype=np.int64), np.empty(0, dtype=np.int64) + return np.vstack(edge_blocks), np.concatenate(axis_blocks) + + +def _rank(rows: list[dict[str, object]], target: dict[str, object], key: str, *, reverse: bool) -> int: + ordered = sorted(rows, key=lambda row: float(row[key]), reverse=reverse) + target_pair = tuple(target["pair"]) + return next(index for index, row in enumerate(ordered, start=1) if tuple(row["pair"]) == target_pair) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--nearest-domains", type=int, default=2) + parser.add_argument("--iterations", type=int, default=1) + parser.add_argument("--spatial-penalty", type=float, default=0.0) + parser.add_argument("--top", type=int, default=15) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + shape = tuple(int(value) for value in shape) + minimum = np.asarray(minimum, dtype=float) + maximum = np.asarray(maximum, dtype=float) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + centroid_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in centroid_matrices], dtype=float + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + + coarse_labels, centroid_labels, _, _, coarse_merges = builder._automatic_labels( + points, + centroid_matrices, + minimum, + maximum, + shape, + ) + coarse_labels = reassignment._relabel(np.asarray(coarse_labels, dtype=np.int64)) + centroid_labels = reassignment._relabel(np.asarray(centroid_labels, dtype=np.int64)) + + reassigned_labels, changes, history = reassignment._run_reassignment( + points, + point_matrices, + coarse_labels, + nearest_domains=args.nearest_domains, + spatial_penalty=args.spatial_penalty, + iterations=args.iterations, + threshold=args.threshold, + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + reassigned_labels = reassignment._relabel(reassigned_labels) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + coarse_metrics = metrics(coarse_labels) + reassigned_metrics = metrics(reassigned_labels) + + values, domain_matrices, domain_centroids, point_sizes = reassignment._domain_statistics( + points, point_matrices, reassigned_labels, _normalise_determinant + ) + values = np.asarray(values, dtype=np.int64) + value_to_position = {int(value): index for index, value in enumerate(values)} + centroid_sizes = np.asarray( + [np.count_nonzero(centroid_labels == value) for value in values], dtype=np.int64 + ) + + edges, edge_axes = _grid_edges_with_axis(shape) + spans = maximum - minimum + cell_sizes = np.divide(spans, np.asarray(shape, dtype=float), out=np.ones(3), where=np.asarray(shape) > 0) + face_areas = np.asarray( + [cell_sizes[1] * cell_sizes[2], cell_sizes[0] * cell_sizes[2], cell_sizes[0] * cell_sizes[1]], + dtype=float, + ) + + pair_edges: dict[tuple[int, int], list[tuple[int, int, int]]] = defaultdict(list) + for (first_index, second_index), axis in zip(edges, edge_axes, strict=True): + first_label = int(centroid_labels[int(first_index)]) + second_label = int(centroid_labels[int(second_index)]) + if first_label == second_label: + continue + pair = tuple(sorted((first_label, second_label))) + pair_edges[pair].append((int(first_index), int(second_index), int(axis))) + + rows: list[dict[str, object]] = [] + for first_value, second_value in combinations((int(value) for value in values), 2): + first_position = value_to_position[first_value] + second_position = value_to_position[second_value] + pair = tuple(sorted((first_value, second_value))) + interface = pair_edges.get(pair, []) + + _, whole_consistency = _merged_matrix_and_consistency( + domain_matrices[first_position], + int(point_sizes[first_position]), + domain_matrices[second_position], + int(point_sizes[second_position]), + ) + distance = float( + np.linalg.norm(domain_centroids[first_position] - domain_centroids[second_position]) + ) + + if interface: + first_boundary: list[int] = [] + second_boundary: list[int] = [] + edge_consistencies: list[float] = [] + axis_counts = np.zeros(3, dtype=np.int64) + shared_area = 0.0 + for first_index, second_index, axis in interface: + if int(centroid_labels[first_index]) == first_value: + first_boundary.append(first_index) + second_boundary.append(second_index) + else: + first_boundary.append(second_index) + second_boundary.append(first_index) + axis_counts[axis] += 1 + shared_area += float(face_areas[axis]) + _, consistency = _merged_matrix_and_consistency( + centroid_matrices[first_index], 1, centroid_matrices[second_index], 1 + ) + edge_consistencies.append(float(consistency)) + + first_unique = np.unique(np.asarray(first_boundary, dtype=np.int64)) + second_unique = np.unique(np.asarray(second_boundary, dtype=np.int64)) + first_matrix = _normalise_determinant(centroid_matrices[first_unique].mean(axis=0)) + second_matrix = _normalise_determinant(centroid_matrices[second_unique].mean(axis=0)) + _, boundary_consistency = _merged_matrix_and_consistency( + first_matrix, len(first_unique), second_matrix, len(second_unique) + ) + first_fraction = len(first_unique) / max(int(centroid_sizes[first_position]), 1) + second_fraction = len(second_unique) / max(int(centroid_sizes[second_position]), 1) + boundary_fraction_min = min(first_fraction, second_fraction) + boundary_fraction_hmean = ( + 0.0 + if first_fraction <= 0.0 or second_fraction <= 0.0 + else 2.0 * first_fraction * second_fraction / (first_fraction + second_fraction) + ) + edge_mean = float(np.mean(edge_consistencies)) + edge_min = float(np.min(edge_consistencies)) + edge_median = float(np.median(edge_consistencies)) + else: + axis_counts = np.zeros(3, dtype=np.int64) + shared_area = 0.0 + boundary_consistency = float("-inf") + boundary_fraction_min = 0.0 + boundary_fraction_hmean = 0.0 + edge_mean = float("-inf") + edge_min = float("-inf") + edge_median = float("-inf") + + merged_labels = _merge_pair(reassigned_labels, first_value, second_value) + ari, ami, match, matched = metrics(merged_labels) + rows.append( + { + "pair": pair, + "point_sizes": (int(point_sizes[first_position]), int(point_sizes[second_position])), + "centroid_sizes": (int(centroid_sizes[first_position]), int(centroid_sizes[second_position])), + "whole_consistency": float(whole_consistency), + "centroid_distance": distance, + "grid_faces": int(len(interface)), + "grid_area": float(shared_area), + "axis_x": int(axis_counts[0]), + "axis_y": int(axis_counts[1]), + "axis_z": int(axis_counts[2]), + "grid_boundary_consistency": float(boundary_consistency), + "grid_edge_consistency_mean": edge_mean, + "grid_edge_consistency_median": edge_median, + "grid_edge_consistency_min": edge_min, + "boundary_fraction_min": float(boundary_fraction_min), + "boundary_fraction_hmean": float(boundary_fraction_hmean), + "area_consistency": float(shared_area) * max(float(boundary_consistency), 0.0), + "faces_consistency": float(len(interface)) * max(edge_mean, 0.0), + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + adjacent_rows = [row for row in rows if int(row["grid_faces"]) > 0] + oracle_best = max(rows, key=lambda row: float(row["ari"])) + + def format_row(row: dict[str, object]) -> str: + first_size, second_size = row["point_sizes"] + first_grid, second_grid = row["centroid_sizes"] + return ( + f"pair={row['pair']} points=({first_size},{second_size}) grid=({first_grid},{second_grid}) " + f"whole={float(row['whole_consistency']):.6f} distance={float(row['centroid_distance']):.2f} " + f"faces={int(row['grid_faces']):4d} area={float(row['grid_area']):10.2f} " + f"axes=({int(row['axis_x'])},{int(row['axis_y'])},{int(row['axis_z'])}) " + f"bfmin={float(row['boundary_fraction_min']):.4f} " + f"bfhm={float(row['boundary_fraction_hmean']):.4f} " + f"bc={float(row['grid_boundary_consistency']):.6f} " + f"ecmean={float(row['grid_edge_consistency_mean']):.6f} " + f"ecmin={float(row['grid_edge_consistency_min']):.6f} " + f"ARI={float(row['ari']):.5f} match={float(row['match']):.5f}" + ) + + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_metrics[0]:.5f} " + f"AMI={coarse_metrics[1]:.5f} match={coarse_metrics[2]:.5f} " + f"({coarse_metrics[3]}/{len(points)})" + ) + print( + f"reassigned: changes={changes} history={history} domains={len(values)} " + f"ARI={reassigned_metrics[0]:.5f} AMI={reassigned_metrics[1]:.5f} " + f"match={reassigned_metrics[2]:.5f} ({reassigned_metrics[3]}/{len(points)})" + ) + + print("\nOracle-best pair and coarse-grid feature ranks:") + print(format_row(oracle_best)) + if int(oracle_best["grid_faces"]) == 0: + print(" not adjacent on the six-connected coarse centroid grid") + else: + rank_specs = ( + ("whole_consistency", True), + ("grid_faces", True), + ("grid_area", True), + ("boundary_fraction_min", True), + ("boundary_fraction_hmean", True), + ("grid_boundary_consistency", True), + ("grid_edge_consistency_mean", True), + ("grid_edge_consistency_min", True), + ("area_consistency", True), + ("faces_consistency", True), + ("centroid_distance", False), + ) + for key, reverse in rank_specs: + print( + f" {key:31s} value={float(oracle_best[key]):12.6f} " + f"rank={_rank(adjacent_rows, oracle_best, key, reverse=reverse)}/{len(adjacent_rows)}" + ) + + top = max(args.top, 1) + reports = ( + ("grid shared-face count", "grid_faces", True), + ("grid shared area", "grid_area", True), + ("grid boundary consistency", "grid_boundary_consistency", True), + ("grid edge-consistency mean", "grid_edge_consistency_mean", True), + ("grid boundary-fraction harmonic mean", "boundary_fraction_hmean", True), + ("oracle ARI", "ari", True), + ) + for title, key, reverse in reports: + print(f"\nTop {top} grid-adjacent pairs by {title}:") + for row in sorted(adjacent_rows, key=lambda item: float(item[key]), reverse=reverse)[:top]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/analyze_basal_artifact.py b/benchmarks/leapfrog_gold/analyze_basal_artifact.py new file mode 100644 index 000000000..9a4150daa --- /dev/null +++ b/benchmarks/leapfrog_gold/analyze_basal_artifact.py @@ -0,0 +1,163 @@ +"""Analyze the detached/necked basal sheet in an existing benchmark mesh. + +This is intentionally a fast, post-meshing diagnostic. It does not rebuild the RBF or +modify the OBJ. It reports whether the low flat feature is an independent connected +component or part of the main body, together with its bounds, aspect ratio, area, and +nearest distance to the input data. The implementation avoids extracting one VTK mesh +per component because that path can terminate the Python process on some Windows/VTK +builds. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import numpy as np +import pandas as pd +import pyvista as pv +from scipy.spatial import cKDTree + +ROOT = Path(__file__).resolve().parents[2] +CASE = os.environ.get("POLATORY_BENCHMARK_CASE", "S5_R300").strip().upper() +DATA_DIR = Path( + os.environ.get( + "LEAPFROG_GOLD_DIR", + str(ROOT / "benchmark-data" / "leapfrog-benchmark-data-v1"), + ) +) +MESH_PATH = ( + ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-zero-plateau-padded-bottom" + / "meshes" + / f"{CASE}_polatory.obj" +) +REFERENCE_PATH = DATA_DIR / f"{CASE}.obj" +OUTPUT_PATH = MESH_PATH.with_name(f"{CASE}_basal_artifact_analysis.json") + + +def _surface(path: Path) -> pv.PolyData: + if not path.exists(): + raise FileNotFoundError(path) + mesh = pv.read(path) + if not isinstance(mesh, pv.PolyData): + mesh = mesh.extract_surface(algorithm="dataset_surface") + mesh = mesh.triangulate().clean() + if mesh.n_points == 0 or mesh.n_cells == 0: + raise ValueError(f"Mesh is empty: {path}") + return mesh + + +def _bounds(points: np.ndarray) -> tuple[list[float], list[float], np.ndarray]: + minimum_array = np.min(points, axis=0) + maximum_array = np.max(points, axis=0) + span = maximum_array - minimum_array + return minimum_array.tolist(), maximum_array.tolist(), span + + +def _triangle_area(points: np.ndarray, triangles: np.ndarray) -> float: + a = points[triangles[:, 0]] + b = points[triangles[:, 1]] + c = points[triangles[:, 2]] + return float(0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1).sum()) + + +def main() -> int: + print(f"Reading generated mesh: {MESH_PATH}", flush=True) + generated = _surface(MESH_PATH) + print(f"Reading Leapfrog reference: {REFERENCE_PATH}", flush=True) + reference = _surface(REFERENCE_PATH) + + frame = pd.read_csv(DATA_DIR / "Used Data(1).csv") + data_points = frame[["xe", "ye", "ze"]].to_numpy(dtype=float) + tree = cKDTree(data_points) + + print("Labelling connected surface regions…", flush=True) + labelled = generated.connectivity(extraction_mode="all", label_regions=True) + if "RegionId" not in labelled.cell_data: + raise RuntimeError("PyVista connectivity did not produce cell RegionId labels.") + + points = np.asarray(labelled.points, dtype=float) + face_stream = np.asarray(labelled.faces, dtype=np.int64) + if face_stream.size % 4 != 0: + raise RuntimeError("Expected a triangulated PolyData face stream.") + packed_faces = face_stream.reshape(-1, 4) + if not np.all(packed_faces[:, 0] == 3): + raise RuntimeError("Expected only triangular cells after triangulation.") + triangles = packed_faces[:, 1:] + + region_labels = np.asarray(labelled.cell_data["RegionId"], dtype=np.int64) + if len(region_labels) != len(triangles): + raise RuntimeError("Region labels do not match the triangulated cell count.") + region_ids = np.unique(region_labels) + + components: list[dict[str, object]] = [] + for region_id in region_ids: + triangle_ids = np.flatnonzero(region_labels == region_id) + region_triangles = triangles[triangle_ids] + point_ids = np.unique(region_triangles) + region_points = points[point_ids] + minimum, maximum, span = _bounds(region_points) + horizontal_span = float(np.hypot(span[0], span[1])) + flatness = float(span[2] / max(horizontal_span, 1.0e-12)) + + stride = max(1, len(region_points) // 20_000) + sampled = region_points[::stride] + distances, _ = tree.query(sampled, k=1) + components.append( + { + "region_id": int(region_id), + "vertices": int(len(point_ids)), + "triangles": int(len(region_triangles)), + "area": _triangle_area(points, region_triangles), + "bounds_min": minimum, + "bounds_max": maximum, + "span": span.tolist(), + "vertical_to_horizontal_span": flatness, + "nearest_input_distance_min": float(np.min(distances)), + "nearest_input_distance_median": float(np.median(distances)), + "nearest_input_distance_p95": float(np.percentile(distances, 95.0)), + } + ) + + components.sort(key=lambda item: float(item["area"]), reverse=True) + for rank, component in enumerate(components, start=1): + component["area_rank"] = rank + + generated_min, generated_max, generated_span = _bounds( + np.asarray(generated.points, dtype=float) + ) + reference_min, reference_max, _ = _bounds(np.asarray(reference.points, dtype=float)) + low_threshold = float(reference_min[2]) + point_z = np.asarray(generated.points[:, 2], dtype=float) + low_fraction = float(np.mean(point_z < low_threshold)) + + cell_centers = points[triangles].mean(axis=1) + low_cells = cell_centers[:, 2] < low_threshold + low_region_ids = sorted(np.unique(region_labels[low_cells]).astype(int).tolist()) + + report = { + "case": CASE, + "generated_obj": str(MESH_PATH), + "reference_obj": str(REFERENCE_PATH), + "generated_bounds_min": generated_min, + "generated_bounds_max": generated_max, + "generated_span": generated_span.tolist(), + "reference_bounds_min": reference_min, + "reference_bounds_max": reference_max, + "input_bounds_min": np.min(data_points, axis=0).tolist(), + "input_bounds_max": np.max(data_points, axis=0).tolist(), + "connectivity_component_count": int(len(components)), + "generated_vertex_fraction_below_reference_min_z": low_fraction, + "regions_with_cell_centres_below_reference_min_z": low_region_ids, + "components_by_area": components, + } + OUTPUT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(json.dumps(report, indent=2), flush=True) + print(f"Saved diagnostic: {OUTPUT_PATH}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/capture_merge_locals_parallel.py b/benchmarks/leapfrog_gold/capture_merge_locals_parallel.py new file mode 100644 index 000000000..cef82c12b --- /dev/null +++ b/benchmarks/leapfrog_gold/capture_merge_locals_parallel.py @@ -0,0 +1,214 @@ +"""Capture Leapfrog domaining locals with minimal interference. + +Run this while Leapfrog is open, then immediately trigger an automatic-domaining +recompute. The script never injects code into Leapfrog. + +The earlier implementation launched many simultaneous ``py-spy dump --locals`` +processes. On Windows, each dump briefly suspends the target process, so overlapping +dumps could keep Leapfrog almost continuously paused. This version uses one sampler. + +Use ``--stage real`` to follow the coarse ``GridSeededDomainer`` into the second, +real-location ``SubDomainer`` stage. Real mode starts its single locals sampler as +soon as coarse region growing is seen, skips the SubDomainer-construction snapshot, +and waits for the real SubDomainer's own region-growing or merge frame. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import time +from pathlib import Path + + +REGION_TERM = "set_domains_by_region_growing (domaining.py" +MERGE_TERM = "merge_domains (domaining.py" +REAL_STAGE_TERM = "subdomain_with_real_locations (domaining.py" +SUBDOMAINER_TERM = "self: int | None: + command = [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -eq 'Leapfrog.exe' -and $_.CommandLine -match '--background' } | " + "Sort-Object CreationDate -Descending | Select-Object -First 1 -ExpandProperty ProcessId", + ] + completed = subprocess.run(command, capture_output=True, text=True, check=False) + text = completed.stdout.strip() + try: + return int(text.splitlines()[-1]) if text else None + except (ValueError, IndexError): + return None + + +def default_py_spy() -> Path: + local_appdata = os.environ.get("LOCALAPPDATA", "") + return Path(local_appdata) / "Programs" / "Python" / "Python312" / "Scripts" / "py-spy.exe" + + +def run_dump(py_spy: Path, pid: int, *, locals_: bool) -> str: + command = [str(py_spy), "dump", "--pid", str(pid)] + if locals_: + command.append("--locals") + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + errors="replace", + timeout=8, + check=False, + ) + except subprocess.TimeoutExpired: + return "" + return (completed.stdout or "") + (completed.stderr or "") + + +def process_is_gone(output: str) -> bool: + lowered = output.lower() + return "no such process" in lowered or "os error 87" in lowered + + +def in_region_growing(output: str) -> bool: + return REGION_TERM in output or MERGE_TERM in output + + +def in_real_stage(output: str) -> bool: + return REAL_STAGE_TERM in output or SUBDOMAINER_TERM in output + + +def trigger_matches(output: str, stage: str) -> bool: + """Return True when it is safe to begin the single locals-capture burst.""" + if stage == "real": + # The real pass can be extremely short. Start following during the coarse + # pass rather than trying to observe the real parent frame first. + return in_region_growing(output) or in_real_stage(output) + if stage == "coarse": + return in_region_growing(output) and not in_real_stage(output) + return in_region_growing(output) + + +def capture_matches(output: str, stage: str) -> bool: + if stage == "real": + # Do not stop on SubDomainer construction/get_anisotropies. We need the + # second-stage grower itself, identified by a SubDomainer frame together + # with set_domains_by_region_growing or merge_domains. + return SUBDOMAINER_TERM in output and in_region_growing(output) + if MERGE_TERM not in output: + return False + if stage == "coarse": + return not in_real_stage(output) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--duration", type=float, default=300.0) + parser.add_argument("--idle-interval", type=float, default=0.5) + parser.add_argument("--burst-duration", type=float, default=180.0) + parser.add_argument("--burst-interval", type=float, default=0.02) + parser.add_argument( + "--stage", + choices=("any", "coarse", "real"), + default="any", + help="Domaining stage to capture. 'real' targets the second SubDomainer stage.", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/leapfrog-merge-locals.txt"), + ) + parser.add_argument("--py-spy", type=Path, default=default_py_spy()) + args = parser.parse_args() + + if not args.py_spy.is_file(): + raise SystemExit(f"py-spy was not found: {args.py_spy}") + + pid = find_background_pid() + if pid is None: + raise SystemExit("No Leapfrog --background process was found.") + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.unlink(missing_ok=True) + + print( + f"Watching Leapfrog background PID {pid} for the {args.stage} domaining stage " + "with one sampler." + ) + print("Trigger the automatic-domaining recompute now.", flush=True) + + overall_deadline = time.monotonic() + max(args.duration, 1.0) + trigger_seen = False + light_samples = 0 + + while time.monotonic() < overall_deadline: + output = run_dump(args.py_spy, pid, locals_=False) + light_samples += 1 + if process_is_gone(output): + raise SystemExit("The Leapfrog background process exited or restarted.") + if trigger_matches(output, args.stage): + trigger_seen = True + if args.stage == "real" and not in_real_stage(output): + print( + f"Coarse region growing detected after {light_samples} lightweight " + "samples; following it into the real SubDomainer grower.", + flush=True, + ) + else: + print( + f"Capture trigger detected after {light_samples} lightweight samples; " + "switching to one locals sampler.", + flush=True, + ) + break + time.sleep(max(args.idle_interval, 0.05)) + + if not trigger_seen: + print("The requested domaining stage was not observed before the timeout.") + return 1 + + burst_deadline = min( + overall_deadline, + time.monotonic() + max(args.burst_duration, 1.0), + ) + local_samples = 0 + real_stage_snapshots = 0 + while time.monotonic() < burst_deadline: + output = run_dump(args.py_spy, pid, locals_=True) + local_samples += 1 + if process_is_gone(output): + raise SystemExit("The Leapfrog background process exited or restarted.") + if args.stage == "real" and in_real_stage(output): + real_stage_snapshots += 1 + if capture_matches(output, args.stage): + args.output.write_text(output, encoding="utf-8") + capture_name = ( + "real SubDomainer region-growing locals" + if args.stage == "real" + else "merge_domains locals" + ) + print( + f"Captured {capture_name} after {local_samples} locals samples: " + f"{args.output}" + ) + return 0 + time.sleep(max(args.burst_interval, 0.01)) + + if args.stage == "real" and real_stage_snapshots: + print( + f"Observed {real_stage_snapshots} real-stage snapshots, but none contained " + "the SubDomainer region-growing frame. Leapfrog was left running normally." + ) + else: + print( + "The capture trigger was detected, but no matching stage frame was captured. " + "Leapfrog was left running normally." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle.py b/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle.py new file mode 100644 index 000000000..b561fa5d2 --- /dev/null +++ b/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle.py @@ -0,0 +1,415 @@ +"""Compare reconstructed automatic domains directly with decoded Leapfrog labels. + +This is the clustering benchmark that should drive SubDomainer recovery. It uses the +real WolfPass input points, the real structural trend mesh, and the exact decoded +``point_clusters.csv`` labels extracted from Leapfrog's serialized SubDomainer. +No RBF fitting or surface meshing is performed. + +For each determinant-to-consistency candidate, the script reports: + +* predicted and Leapfrog domain counts; +* adjusted Rand index (ARI); +* adjusted mutual information (AMI); +* optimal one-to-one label-matching accuracy (Hungarian assignment); +* merge count and runtime. + +The default decoded benchmark root is ``Leapfrog_LVA_decoded_benchmark``. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import os +import re +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +import numpy as np +import pandas as pd +from scipy.optimize import linear_sum_assignment + +try: + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score +except ImportError as exc: # pragma: no cover - environment guidance + raise ImportError( + "This benchmark requires scikit-learn. Install it with: " + "python -m pip install scikit-learn" + ) from exc + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import sweep_exact_domainer_consistency as exact_sweep # noqa: E402 + + +@dataclass(frozen=True) +class OracleComparison: + case: str + formula: str + threshold: float + point_count: int + centroid_count: int + grid_shape: tuple[int, int, int] + oracle_domains: int + predicted_domains: int + merge_count: int + adjusted_rand_index: float + adjusted_mutual_information: float + optimal_label_accuracy: float + matched_points: int + elapsed_seconds: float + + +def _optimal_label_accuracy( + truth: np.ndarray, + predicted: np.ndarray, +) -> tuple[float, int]: + """Return accuracy after optimal one-to-one relabelling of predicted clusters.""" + truth_values, truth_inverse = np.unique(truth, return_inverse=True) + predicted_values, predicted_inverse = np.unique(predicted, return_inverse=True) + contingency = np.zeros( + (len(truth_values), len(predicted_values)), + dtype=np.int64, + ) + np.add.at(contingency, (truth_inverse, predicted_inverse), 1) + rows, columns = linear_sum_assignment(-contingency) + matched = int(contingency[rows, columns].sum()) + return matched / float(len(truth)), matched + + +def _parse_case_parameters(case_name: str) -> tuple[float, float]: + match = re.fullmatch( + r"S(?P[0-9]+(?:\.[0-9]+)?)_R(?P[0-9]+(?:\.[0-9]+)?)", + case_name, + flags=re.IGNORECASE, + ) + if match is None: + raise ValueError( + f"Case {case_name!r} must follow the S_R convention." + ) + return float(match.group("strength")), float(match.group("range")) + + +def _available_cases(decoded_root: Path) -> list[str]: + return sorted( + path.name + for path in decoded_root.iterdir() + if path.is_dir() + and re.fullmatch(r"S[0-9]+(?:\.[0-9]+)?_R[0-9]+(?:\.[0-9]+)?", path.name) + and (path / "point_clusters.csv").is_file() + ) + + +def _load_oracle_inputs( + decoded_root: Path, + case_name: str, +) -> tuple[np.ndarray, np.ndarray, Path]: + points_path = decoded_root / "Used Data.csv" + mesh_path = decoded_root / "Reference Mesh.obj" + labels_path = decoded_root / case_name / "point_clusters.csv" + missing = [ + path + for path in (points_path, mesh_path, labels_path) + if not path.is_file() + ] + if missing: + details = "\n".join(f"- {path}" for path in missing) + raise FileNotFoundError(f"Required decoded benchmark files are missing:\n{details}") + + points_frame = pd.read_csv(points_path) + coordinate_columns = ["xe", "ye", "ze"] + missing_columns = [name for name in coordinate_columns if name not in points_frame] + if missing_columns: + raise ValueError( + f"{points_path} is missing coordinate columns {missing_columns}." + ) + points = points_frame[coordinate_columns].to_numpy(dtype=np.float64) + + labels_frame = pd.read_csv(labels_path) + if "cluster" not in labels_frame: + raise ValueError(f"{labels_path} does not contain a 'cluster' column.") + oracle_labels = labels_frame["cluster"].to_numpy(dtype=np.int64) + if len(oracle_labels) != len(points): + raise ValueError( + f"Point/label length mismatch: {len(points)} points versus " + f"{len(oracle_labels)} labels." + ) + return points, oracle_labels, mesh_path + + +def _compare_formula( + *, + case_name: str, + formula_name: str, + transform: Callable[[float], float] | None, + builder_class: type, + builder_module: object, + original_merge_function: Callable[..., tuple[np.ndarray, float]], + determinant_function: Callable[[np.ndarray], float], + points: np.ndarray, + oracle_labels: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + threshold: float, + centroid_count: int, + minimum_fraction: float, + maximum_fraction: float, +) -> OracleComparison: + started = time.perf_counter() + replacement = ( + original_merge_function + if transform is None + else exact_sweep._candidate_merge_function( # noqa: SLF001 + determinant_function, + transform, + ) + ) + setattr(builder_module, "_merged_matrix_and_consistency", replacement) + + builder = builder_class( + centroid_count=centroid_count, + minimum_cluster_fraction=minimum_fraction, + maximum_cluster_fraction=maximum_fraction, + consistency_threshold=threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + predicted, _, _, _, merge_count = builder._automatic_labels( # noqa: SLF001 + points, + centroid_anisotropies, + minimum, + maximum, + shape, + ) + predicted = np.asarray(predicted, dtype=np.int64) + accuracy, matched = _optimal_label_accuracy(oracle_labels, predicted) + + return OracleComparison( + case=case_name, + formula=formula_name, + threshold=float(threshold), + point_count=int(len(points)), + centroid_count=int(np.prod(shape)), + grid_shape=shape, + oracle_domains=int(len(np.unique(oracle_labels))), + predicted_domains=int(len(np.unique(predicted))), + merge_count=int(merge_count), + adjusted_rand_index=float(adjusted_rand_score(oracle_labels, predicted)), + adjusted_mutual_information=float( + adjusted_mutual_info_score(oracle_labels, predicted) + ), + optimal_label_accuracy=float(accuracy), + matched_points=int(matched), + elapsed_seconds=float(time.perf_counter() - started), + ) + + +def _run_case( + *, + case_name: str, + decoded_root: Path, + threshold: float, + centroid_count: int, + minimum_fraction: float, + maximum_fraction: float, +) -> list[OracleComparison]: + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + import run_selected_exact_leapfrog_lva as exact + + points, oracle_labels, mesh_path = _load_oracle_inputs(decoded_root, case_name) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = _parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder_class = polatory.AutomaticStructuralDomainBuilder3 + required_methods = ("_prepare_grid", "_automatic_labels") + missing = [name for name in required_methods if not hasattr(builder_class, name)] + if missing: + raise RuntimeError( + "The installed top-level AutomaticStructuralDomainBuilder3 is not the " + f"reconstructed Leapfrog builder; missing {missing}." + ) + + builder_module = importlib.import_module(builder_class.__module__) + original_merge_function = getattr( + builder_module, + "_merged_matrix_and_consistency", + None, + ) + determinant_function = getattr(builder_module, "_symmetric_determinant", None) + if original_merge_function is None or determinant_function is None: + raise RuntimeError( + f"{builder_class.__module__} does not expose the recovered merge helpers." + ) + + preparation_builder = builder_class( + centroid_count=centroid_count, + minimum_cluster_fraction=minimum_fraction, + maximum_cluster_fraction=maximum_fraction, + consistency_threshold=threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = preparation_builder._prepare_grid(points) # noqa: SLF001 + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, + trend_input, + non_decaying=False, + ) + + print( + f"case={case_name} points={len(points)} oracle_domains=" + f"{len(np.unique(oracle_labels))} centroids={len(centroids)} grid={shape}", + flush=True, + ) + + results: list[OracleComparison] = [] + try: + for formula_name, transform in exact_sweep._formulae().items(): # noqa: SLF001 + results.append( + _compare_formula( + case_name=case_name, + formula_name=formula_name, + transform=transform, + builder_class=builder_class, + builder_module=builder_module, + original_merge_function=original_merge_function, + determinant_function=determinant_function, + points=points, + oracle_labels=oracle_labels, + centroid_anisotropies=np.asarray( + centroid_anisotropies, + dtype=np.float64, + ), + minimum=np.asarray(minimum, dtype=np.float64), + maximum=np.asarray(maximum, dtype=np.float64), + shape=tuple(int(value) for value in shape), + threshold=threshold, + centroid_count=centroid_count, + minimum_fraction=minimum_fraction, + maximum_fraction=maximum_fraction, + ) + ) + finally: + setattr( + builder_module, + "_merged_matrix_and_consistency", + original_merge_function, + ) + + results.sort( + key=lambda item: ( + -item.adjusted_rand_index, + -item.optimal_label_accuracy, + abs(item.predicted_domains - item.oracle_domains), + item.formula, + ) + ) + print( + f"{'formula':>29} {'oracle':>7} {'pred':>7} {'ARI':>9} " + f"{'AMI':>9} {'match_acc':>10} {'merges':>8} {'seconds':>9}", + flush=True, + ) + for item in results: + print( + f"{item.formula:>29} {item.oracle_domains:>7d} " + f"{item.predicted_domains:>7d} {item.adjusted_rand_index:>9.5f} " + f"{item.adjusted_mutual_information:>9.5f} " + f"{item.optimal_label_accuracy:>10.5f} " + f"{item.merge_count:>8d} {item.elapsed_seconds:>9.3f}", + flush=True, + ) + return results + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--case", + default="S3_R100", + help="Decoded case name, or ALL to compare every recovered case.", + ) + parser.add_argument( + "--decoded-root", + type=Path, + default=Path("Leapfrog_LVA_decoded_benchmark"), + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/automatic-domain-oracle-comparison.json"), + ) + args = parser.parse_args() + + if not args.decoded_root.is_dir(): + parser.error(f"Decoded benchmark root was not found: {args.decoded_root}") + if not 0.0 < args.threshold <= 1.0: + parser.error("--threshold must be in (0, 1]") + if args.centroid_count <= 0: + parser.error("--centroid-count must be positive") + if not 0.0 < args.minimum_fraction <= args.maximum_fraction <= 1.0: + parser.error("cluster fractions must satisfy 0 < minimum <= maximum <= 1") + + requested = args.case.strip().upper() + available = _available_cases(args.decoded_root) + if requested == "ALL": + cases = available + else: + matches = [name for name in available if name.upper() == requested] + if len(matches) != 1: + parser.error( + f"Case {requested!r} was not found. Available cases: {available}" + ) + cases = matches + + all_results: list[OracleComparison] = [] + for case_name in cases: + all_results.extend( + _run_case( + case_name=case_name, + decoded_root=args.decoded_root, + threshold=args.threshold, + centroid_count=args.centroid_count, + minimum_fraction=args.minimum_fraction, + maximum_fraction=args.maximum_fraction, + ) + ) + + payload = { + "decoded_root": str(args.decoded_root), + "threshold": float(args.threshold), + "centroid_count_requested": int(args.centroid_count), + "minimum_fraction": float(args.minimum_fraction), + "maximum_fraction": float(args.maximum_fraction), + "cases": cases, + "results": [asdict(item) for item in all_results], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle_robust.py b/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle_robust.py new file mode 100644 index 000000000..abff3b1d9 --- /dev/null +++ b/benchmarks/leapfrog_gold/compare_automatic_domains_to_oracle_robust.py @@ -0,0 +1,128 @@ +"""Run the automatic-domain oracle comparison with robust CSV schema detection. + +This wrapper fixes decoded benchmark CSVs whose coordinate headers differ in case, +spacing, punctuation, BOM encoding, or naming convention. It patches only the input +loader and delegates all clustering and metric calculations to +``compare_automatic_domains_to_oracle.py``. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import numpy as np +import pandas as pd + +import compare_automatic_domains_to_oracle as comparison + + +def _normalise_header(value: object) -> str: + return re.sub(r"[^a-z0-9]+", "", str(value).lstrip("\ufeff").strip().lower()) + + +def _read_csv_flexible(path: Path) -> pd.DataFrame: + frame = pd.read_csv(path, encoding="utf-8-sig") + if len(frame.columns) > 1: + return frame + # A single parsed column often means the file uses semicolons or tabs. + sniffed = pd.read_csv(path, encoding="utf-8-sig", sep=None, engine="python") + return sniffed if len(sniffed.columns) > len(frame.columns) else frame + + +def _resolve_column( + frame: pd.DataFrame, + aliases: tuple[str, ...], + *, + description: str, + path: Path, +) -> str: + normalised: dict[str, list[str]] = {} + for column in frame.columns: + normalised.setdefault(_normalise_header(column), []).append(str(column)) + + for alias in aliases: + matches = normalised.get(_normalise_header(alias), []) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"{path} has ambiguous {description} columns for alias {alias!r}: {matches}." + ) + + raise ValueError( + f"Could not identify the {description} column in {path}. " + f"Available columns: {list(frame.columns)!r}." + ) + + +def _load_oracle_inputs( + decoded_root: Path, + case_name: str, +) -> tuple[np.ndarray, np.ndarray, Path]: + points_path = decoded_root / "Used Data.csv" + mesh_path = decoded_root / "Reference Mesh.obj" + labels_path = decoded_root / case_name / "point_clusters.csv" + missing = [ + path + for path in (points_path, mesh_path, labels_path) + if not path.is_file() + ] + if missing: + details = "\n".join(f"- {path}" for path in missing) + raise FileNotFoundError(f"Required decoded benchmark files are missing:\n{details}") + + points_frame = _read_csv_flexible(points_path) + x_column = _resolve_column( + points_frame, + ("xe", "x", "easting", "east", "xcoord", "coordx", "xcoordinate", "pointx"), + description="X/easting coordinate", + path=points_path, + ) + y_column = _resolve_column( + points_frame, + ("ye", "y", "northing", "north", "ycoord", "coordy", "ycoordinate", "pointy"), + description="Y/northing coordinate", + path=points_path, + ) + z_column = _resolve_column( + points_frame, + ("ze", "z", "elevation", "elev", "rl", "zcoord", "coordz", "zcoordinate", "pointz"), + description="Z/elevation coordinate", + path=points_path, + ) + coordinate_columns = [x_column, y_column, z_column] + points = points_frame[coordinate_columns].apply(pd.to_numeric, errors="raise").to_numpy( + dtype=np.float64 + ) + if not np.all(np.isfinite(points)): + raise ValueError(f"{points_path} contains non-finite coordinates.") + + labels_frame = _read_csv_flexible(labels_path) + label_column = _resolve_column( + labels_frame, + ("cluster", "clusterid", "clusternumber", "domain", "domainid", "rawdomainid"), + description="Leapfrog cluster label", + path=labels_path, + ) + oracle_labels = pd.to_numeric(labels_frame[label_column], errors="raise").to_numpy( + dtype=np.int64 + ) + if len(oracle_labels) != len(points): + raise ValueError( + f"Point/label length mismatch: {len(points)} points versus " + f"{len(oracle_labels)} labels. Coordinate columns were {coordinate_columns!r}; " + f"label column was {label_column!r}." + ) + + print( + f"Decoded CSV schema: coordinates={coordinate_columns}; label={label_column!r}", + flush=True, + ) + return points, oracle_labels, mesh_path + + +comparison._load_oracle_inputs = _load_oracle_inputs + + +if __name__ == "__main__": + raise SystemExit(comparison.main()) diff --git a/benchmarks/leapfrog_gold/compare_oracle_heap_invalidation.py b/benchmarks/leapfrog_gold/compare_oracle_heap_invalidation.py new file mode 100644 index 000000000..9e63ba7b4 --- /dev/null +++ b/benchmarks/leapfrog_gold/compare_oracle_heap_invalidation.py @@ -0,0 +1,378 @@ +"""Compare the current heap invalidation with a corrected lazy-heap update. + +The reconstructed builder increments the version of every neighbouring domain when two +other domains merge. That invalidates the neighbour's still-valid heap entries to all of +its unchanged neighbours, but only the new merged-domain edge is pushed again. The heap +therefore silently loses valid adjacency candidates and region growing can stop early. + +This diagnostic reproduces the current behaviour, verifies it matches the installed +builder, then reruns the same exact WolfPass oracle comparison while preserving unaffected +heap entries. No RBF fitting or surface meshing is performed. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import os +import sys +import time +from dataclasses import asdict, dataclass +from heapq import heappop, heappush +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing the robust wrapper patches the oracle CSV loader used by comparison. +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 + + +@dataclass(frozen=True) +class HeapComparison: + mode: str + oracle_domains: int + predicted_domains: int + surviving_grid_domains: int + merge_count: int + stale_heap_pops: int + adjusted_rand_index: float + adjusted_mutual_information: float + optimal_label_accuracy: float + matched_points: int + elapsed_seconds: float + + +def _automatic_labels_with_heap_mode( + *, + builder_module: object, + points: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + minimum_fraction: float, + maximum_fraction: float, + threshold: float, + invalidate_unchanged_neighbours: bool, +) -> tuple[np.ndarray, np.ndarray, int, int]: + """Run the recovered merge loop with either current or corrected invalidation.""" + point_cell_indices = getattr(builder_module, "_point_cell_indices") + normalise = getattr(builder_module, "_normalise_determinant") + grid_edges = getattr(builder_module, "_grid_edges") + merge_matrix = getattr(builder_module, "_merged_matrix_and_consistency") + + total = int(len(centroid_anisotropies)) + point_cells = point_cell_indices(points, minimum, maximum, shape) + minimum_points = max(1, int(np.floor(minimum_fraction * total))) + maximum_points = max(minimum_points, int(np.floor(maximum_fraction * total))) + + capacity = 2 * total + 1 + active = np.zeros(capacity, dtype=bool) + active[:total] = True + version = np.zeros(capacity, dtype=np.int64) + sizes = np.zeros(capacity, dtype=np.int64) + sizes[:total] = 1 + matrices = np.zeros((capacity, 3, 3), dtype=np.float64) + matrices[:total] = np.asarray( + [normalise(matrix) for matrix in centroid_anisotropies], dtype=np.float64 + ) + leaves: list[list[int]] = [[index] for index in range(total)] + [ + [] for _ in range(total + 1) + ] + neighbours: list[set[int]] = [set() for _ in range(capacity)] + edges = grid_edges(shape) + for first, second in edges: + first_i, second_i = int(first), int(second) + neighbours[first_i].add(second_i) + neighbours[second_i].add(first_i) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if first == second or not active[first] or not active[second]: + return + if int(sizes[first] + sizes[second]) > maximum_points: + return + _, consistency = merge_matrix( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + if not np.isfinite(consistency): + return + low, high = sorted((int(first), int(second))) + heappush( + heap, + (-float(consistency), low, high, int(version[low]), int(version[high])), + ) + + for first, second in edges: + push(int(first), int(second)) + + next_id = total + merge_count = 0 + stale_heap_pops = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if not active[first] or not active[second]: + stale_heap_pops += 1 + continue + if version[first] != first_version or version[second] != second_version: + stale_heap_pops += 1 + continue + if second not in neighbours[first] or first not in neighbours[second]: + stale_heap_pops += 1 + continue + + consistency = -negative + if consistency < threshold: + break + if int(sizes[first] + sizes[second]) > maximum_points: + continue + + merged_matrix, _ = merge_matrix( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + merged_neighbours = (neighbours[first] | neighbours[second]) - {first, second} + + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + + active[next_id] = True + sizes[next_id] = sizes[first] + sizes[second] + matrices[next_id] = merged_matrix + leaves[next_id] = leaves[first] + leaves[second] + + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + # Current implementation increments this version, which invalidates every + # still-valid edge from this neighbour to unrelated active neighbours. + if invalidate_unchanged_neighbours: + version[neighbour] += 1 + neighbours[next_id].add(neighbour) + + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + + next_id += 1 + merge_count += 1 + + owner = np.empty(total, dtype=np.int64) + for domain_id in range(next_id): + if active[domain_id]: + owner[np.asarray(leaves[domain_id], dtype=np.int64)] = domain_id + + point_domains = owner[point_cells] + populated = np.unique(point_domains) + ordering: list[tuple[tuple[float, float, float], int]] = [] + for domain_id in populated: + owned = points[point_domains == domain_id] + ordering.append((tuple(float(value) for value in owned.mean(axis=0)), int(domain_id))) + ordering.sort() + labels_by_id = { + domain_id: label for label, (_, domain_id) in enumerate(ordering) + } + labels = np.asarray( + [labels_by_id[int(domain_id)] for domain_id in point_domains], dtype=np.int64 + ) + centroid_labels = np.full(total, -1, dtype=np.int64) + for index, domain_id in enumerate(owner): + label = labels_by_id.get(int(domain_id)) + if label is not None: + centroid_labels[index] = label + + return labels, centroid_labels, merge_count, stale_heap_pops + + +def _metrics( + *, + mode: str, + oracle_labels: np.ndarray, + labels: np.ndarray, + centroid_labels: np.ndarray, + merge_count: int, + stale_heap_pops: int, + elapsed_seconds: float, +) -> HeapComparison: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) # noqa: SLF001 + return HeapComparison( + mode=mode, + oracle_domains=int(len(np.unique(oracle_labels))), + predicted_domains=int(len(np.unique(labels))), + surviving_grid_domains=int(len(np.unique(centroid_labels[centroid_labels >= 0]))), + merge_count=int(merge_count), + stale_heap_pops=int(stale_heap_pops), + adjusted_rand_index=float(comparison.adjusted_rand_score(oracle_labels, labels)), + adjusted_mutual_information=float( + comparison.adjusted_mutual_info_score(oracle_labels, labels) + ), + optimal_label_accuracy=float(accuracy), + matched_points=int(matched), + elapsed_seconds=float(elapsed_seconds), + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/oracle-heap-invalidation-comparison.json"), + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory # noqa: E402 + import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( # noqa: SLF001 + args.decoded_root, case_name + ) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) # noqa: SLF001 + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder_class = polatory.AutomaticStructuralDomainBuilder3 + builder_module = importlib.import_module(builder_class.__module__) + builder = builder_class( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) # noqa: SLF001 + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + + production_labels, production_centroid_labels, _, _, production_merges = ( + builder._automatic_labels( # noqa: SLF001 + points, + centroid_anisotropies, + minimum, + maximum, + shape, + ) + ) + + print( + f"case={case_name} points={len(points)} oracle_domains={len(np.unique(oracle_labels))} " + f"centroids={len(centroids)} grid={shape}", + flush=True, + ) + + results: list[HeapComparison] = [] + current_labels: np.ndarray | None = None + current_centroid_labels: np.ndarray | None = None + for mode, invalidate in ( + ("current_neighbour_invalidation", True), + ("preserve_unaffected_heap_edges", False), + ): + started = time.perf_counter() + labels, centroid_labels, merges, stale_pops = _automatic_labels_with_heap_mode( + builder_module=builder_module, + points=np.asarray(points, dtype=np.float64), + centroid_anisotropies=np.asarray(centroid_anisotropies, dtype=np.float64), + minimum=np.asarray(minimum, dtype=np.float64), + maximum=np.asarray(maximum, dtype=np.float64), + shape=tuple(int(value) for value in shape), + minimum_fraction=args.minimum_fraction, + maximum_fraction=args.maximum_fraction, + threshold=args.threshold, + invalidate_unchanged_neighbours=invalidate, + ) + if invalidate: + current_labels = labels + current_centroid_labels = centroid_labels + results.append( + _metrics( + mode=mode, + oracle_labels=oracle_labels, + labels=labels, + centroid_labels=centroid_labels, + merge_count=merges, + stale_heap_pops=stale_pops, + elapsed_seconds=time.perf_counter() - started, + ) + ) + + if current_labels is None or current_centroid_labels is None: + raise AssertionError("Current-mode diagnostic did not execute.") + if not np.array_equal(current_labels, np.asarray(production_labels, dtype=np.int64)): + raise RuntimeError( + "Diagnostic current-mode labels do not reproduce the installed production builder." + ) + if not np.array_equal( + current_centroid_labels, np.asarray(production_centroid_labels, dtype=np.int64) + ): + raise RuntimeError( + "Diagnostic current-mode centroid labels do not reproduce the installed builder." + ) + if results[0].merge_count != int(production_merges): + raise RuntimeError( + "Diagnostic current-mode merge count does not reproduce the installed builder." + ) + + print( + f"{'mode':>33} {'oracle':>7} {'pred':>7} {'grid':>7} {'ARI':>9} " + f"{'AMI':>9} {'match':>9} {'merges':>8} {'stale':>9} {'seconds':>9}", + flush=True, + ) + for item in results: + print( + f"{item.mode:>33} {item.oracle_domains:>7d} {item.predicted_domains:>7d} " + f"{item.surviving_grid_domains:>7d} {item.adjusted_rand_index:>9.5f} " + f"{item.adjusted_mutual_information:>9.5f} " + f"{item.optimal_label_accuracy:>9.5f} {item.merge_count:>8d} " + f"{item.stale_heap_pops:>9d} {item.elapsed_seconds:>9.3f}", + flush=True, + ) + + payload = { + "case": case_name, + "point_count": int(len(points)), + "oracle_domains": int(len(np.unique(oracle_labels))), + "centroid_count": int(len(centroids)), + "grid_shape": [int(value) for value in shape], + "threshold": float(args.threshold), + "minimum_fraction": float(args.minimum_fraction), + "maximum_fraction": float(args.maximum_fraction), + "production_control_validated": True, + "results": [asdict(item) for item in results], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/inspect_decoded_subdomainer_payloads.py b/benchmarks/leapfrog_gold/inspect_decoded_subdomainer_payloads.py new file mode 100644 index 000000000..ac82b7353 --- /dev/null +++ b/benchmarks/leapfrog_gold/inspect_decoded_subdomainer_payloads.py @@ -0,0 +1,348 @@ +"""Inventory decoded Leapfrog SubDomainer payloads and candidate label vectors. + +Recent real-stage diagnostics grouped the real locations using the reconstructed +coarse labels. Before changing the clustering algorithm again, this script checks +whether the decoded benchmark already contains Leapfrog's original coarse, parent, +group, centroid, or intermediate assignments. + +The script is read-only. It inventories the selected case plus shared root-level +files, inspects CSV/JSON/NPY/NPZ payloads, and reports one-dimensional integer-like +vectors that could represent point or centroid assignments. Point-length vectors +are compared with the decoded final ``point_clusters.csv`` labels. +""" +from __future__ import annotations + +import argparse +import json +from collections import Counter +from pathlib import Path +from typing import Any, Iterable + +import numpy as np +import pandas as pd + + +LABEL_TERMS = ( + "cluster", + "domain", + "label", + "group", + "partition", + "component", + "parent", + "coarse", + "assignment", + "membership", +) + + +def _looks_labelled(name: str) -> bool: + lowered = name.lower() + return any(term in lowered for term in LABEL_TERMS) + + +def _integer_like(values: np.ndarray) -> bool: + values = np.asarray(values) + if values.ndim != 1 or len(values) == 0: + return False + if values.dtype.kind in "biu": + return True + if values.dtype.kind != "f": + return False + finite = values[np.isfinite(values)] + if len(finite) != len(values): + return False + return bool(np.all(np.abs(finite - np.round(finite)) <= 1e-9)) + + +def _normalise_labels(values: np.ndarray) -> np.ndarray: + values = np.asarray(values).reshape(-1) + if values.dtype.kind == "f": + values = np.round(values) + _, inverse = np.unique(values, return_inverse=True) + return inverse.astype(np.int64) + + +def _weighted_purity(reference: np.ndarray, candidate: np.ndarray) -> float: + reference = _normalise_labels(reference) + candidate = _normalise_labels(candidate) + correct = 0 + for value in np.unique(candidate): + indices = np.flatnonzero(candidate == value) + if len(indices): + counts = np.bincount(reference[indices]) + correct += int(counts.max()) + return correct / max(len(reference), 1) + + +def _comparison(reference: np.ndarray, candidate: np.ndarray) -> dict[str, float]: + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + reference_i = _normalise_labels(reference) + candidate_i = _normalise_labels(candidate) + intersections = len(np.unique(np.column_stack((reference_i, candidate_i)), axis=0)) + return { + "ari": float(adjusted_rand_score(reference_i, candidate_i)), + "ami": float(adjusted_mutual_info_score(reference_i, candidate_i)), + "candidate_to_final_purity": _weighted_purity(reference_i, candidate_i), + "final_to_candidate_purity": _weighted_purity(candidate_i, reference_i), + "intersections": float(intersections), + } + + +def _top_counts(values: np.ndarray, limit: int = 8) -> str: + counts = Counter(_normalise_labels(values).tolist()) + return ",".join(f"{label}:{count}" for label, count in counts.most_common(limit)) + + +def _summarise_vector( + *, + source: str, + key: str, + values: np.ndarray, + point_count: int, + centroid_count: int | None, + final_labels: np.ndarray, +) -> dict[str, Any] | None: + values = np.asarray(values) + if values.ndim != 1 or not _integer_like(values): + return None + count = len(values) + if count == 0: + return None + labels = _normalise_labels(values) + row: dict[str, Any] = { + "source": source, + "key": key, + "length": count, + "unique": int(len(np.unique(labels))), + "role": ( + "point" + if count == point_count + else "centroid" + if centroid_count is not None and count == centroid_count + else "other" + ), + "top_counts": _top_counts(labels), + } + if count == point_count: + row.update(_comparison(final_labels, labels)) + row["exact_final"] = bool(np.array_equal(labels, _normalise_labels(final_labels))) + return row + + +def _walk_json(value: Any, prefix: str = "$") -> Iterable[tuple[str, np.ndarray]]: + if isinstance(value, dict): + for key, child in value.items(): + yield from _walk_json(child, f"{prefix}.{key}") + elif isinstance(value, list): + array = np.asarray(value) + if array.ndim == 1: + yield prefix, array + else: + for index, child in enumerate(value): + if isinstance(child, (dict, list)): + yield from _walk_json(child, f"{prefix}[{index}]") + + +def _candidate_files(root: Path, case_dir: Path) -> list[Path]: + files: set[Path] = set() + for path in root.iterdir(): + if path.is_file(): + files.add(path) + if case_dir.is_dir(): + files.update(path for path in case_dir.rglob("*") if path.is_file()) + return sorted(files, key=lambda path: str(path).lower()) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument( + "--centroid-count", + type=int, + default=6000, + help="Expected first-stage centroid-vector length; use 0 to disable.", + ) + args = parser.parse_args() + + root = args.decoded_root + case_name = args.case.strip().upper() + case_dir = root / case_name + points_path = root / "Used Data.csv" + labels_path = case_dir / "point_clusters.csv" + if not points_path.is_file() or not labels_path.is_file(): + raise FileNotFoundError( + f"Expected {points_path} and {labels_path}; decoded benchmark is incomplete." + ) + + points_frame = pd.read_csv(points_path) + final_frame = pd.read_csv(labels_path) + if "cluster" not in final_frame: + raise ValueError(f"{labels_path} has no 'cluster' column") + point_count = len(points_frame) + final_labels = final_frame["cluster"].to_numpy() + centroid_count = args.centroid_count if args.centroid_count > 0 else None + + files = _candidate_files(root, case_dir) + print( + f"decoded_root={root} case={case_name} files={len(files)} " + f"points={point_count} final_domains={len(np.unique(final_labels))} " + f"expected_centroids={centroid_count if centroid_count is not None else 'disabled'}" + ) + print("\nDecoded file inventory:") + for path in files: + relative = path.relative_to(root) + print(f" {str(relative):<72s} {path.stat().st_size:>12d} bytes") + + rows: list[dict[str, Any]] = [] + errors: list[str] = [] + for path in files: + relative = str(path.relative_to(root)) + suffix = path.suffix.lower() + try: + if suffix == ".csv": + frame = pd.read_csv(path) + print( + f"\nCSV {relative}: rows={len(frame)} columns={list(frame.columns)}" + ) + for column in frame.columns: + values = frame[column].to_numpy() + row = _summarise_vector( + source=relative, + key=str(column), + values=values, + point_count=point_count, + centroid_count=centroid_count, + final_labels=final_labels, + ) + if row is not None and ( + _looks_labelled(str(column)) + or row["role"] in {"point", "centroid"} + or int(row["unique"]) <= 128 + ): + rows.append(row) + elif suffix == ".json": + payload = json.loads(path.read_text(encoding="utf-8")) + for key, values in _walk_json(payload): + row = _summarise_vector( + source=relative, + key=key, + values=values, + point_count=point_count, + centroid_count=centroid_count, + final_labels=final_labels, + ) + if row is not None and ( + _looks_labelled(key) + or row["role"] in {"point", "centroid"} + or int(row["unique"]) <= 128 + ): + rows.append(row) + elif suffix == ".npy": + values = np.load(path, allow_pickle=False) + row = _summarise_vector( + source=relative, + key="array", + values=values, + point_count=point_count, + centroid_count=centroid_count, + final_labels=final_labels, + ) + if row is not None: + rows.append(row) + elif suffix == ".npz": + with np.load(path, allow_pickle=False) as payload: + for key in payload.files: + row = _summarise_vector( + source=relative, + key=key, + values=payload[key], + point_count=point_count, + centroid_count=centroid_count, + final_labels=final_labels, + ) + if row is not None: + rows.append(row) + except Exception as exc: # diagnostic should continue through malformed files + errors.append(f"{relative}: {type(exc).__name__}: {exc}") + + print("\nCandidate assignment vectors:") + if not rows: + print(" none") + else: + rows.sort( + key=lambda row: ( + {"point": 0, "centroid": 1, "other": 2}[str(row["role"])], + -float(row.get("ari", -1.0)), + str(row["source"]), + str(row["key"]), + ) + ) + print( + " role length unique source::key " + "ARI AMI candPur finalPur intersections exact top_counts" + ) + for row in rows: + identifier = f"{row['source']}::{row['key']}" + if row["role"] == "point": + print( + f" {str(row['role']):<8s} {int(row['length']):>6d} {int(row['unique']):>6d} " + f"{identifier:<48.48s} {float(row['ari']):>6.3f} " + f"{float(row['ami']):>6.3f} " + f"{float(row['candidate_to_final_purity']):>7.3f} " + f"{float(row['final_to_candidate_purity']):>8.3f} " + f"{int(row['intersections']):>13d} " + f"{str(bool(row['exact_final'])):<5s} {row['top_counts']}" + ) + else: + print( + f" {str(row['role']):<8s} {int(row['length']):>6d} {int(row['unique']):>6d} " + f"{identifier:<48.48s} {'-':>6s} {'-':>6s} {'-':>7s} " + f"{'-':>8s} {'-':>13s} {'-':<5s} {row['top_counts']}" + ) + + point_candidates = [ + row + for row in rows + if row["role"] == "point" and not bool(row.get("exact_final", False)) + ] + centroid_candidates = [row for row in rows if row["role"] == "centroid"] + print("\nInterpretation gate:") + if point_candidates: + best = max(point_candidates, key=lambda row: float(row.get("ari", -1.0))) + print( + " found non-final point-length assignments; strongest candidate is " + f"{best['source']}::{best['key']} with {best['unique']} groups, " + f"ARI={float(best['ari']):.4f}, intersections={int(best['intersections'])}." + ) + print( + " Use this vector as the real-stage parent/coarse grouping before testing " + "any further partition algorithm." + ) + elif centroid_candidates: + print( + " found centroid-length assignments but no alternative point-length vector. " + "The next step is to reproduce Leapfrog's centroid-to-point parent mapping " + "from these decoded centroid labels." + ) + else: + print( + " no alternative point- or centroid-length assignment vector was found. " + "The decoded payload does not expose the preceding grouping directly; next " + "test nearest-source vertex/face identity, distance and mesh topology because " + "the current SPD matrix discards those fields." + ) + + if errors: + print("\nInspection errors:") + for error in errors: + print(f" {error}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/inspect_leapfrog_embedded_runtime.py b/benchmarks/leapfrog_gold/inspect_leapfrog_embedded_runtime.py new file mode 100644 index 000000000..927511812 --- /dev/null +++ b/benchmarks/leapfrog_gold/inspect_leapfrog_embedded_runtime.py @@ -0,0 +1,674 @@ +"""Inspect Leapfrog's embedded structural-domaining runtime. + +This diagnostic has two complementary modes. + +1. ``inventory`` runs from a normal Python interpreter and scans the Leapfrog + installation for PE modules, embedded ``PyInit_*`` exports, algorithm-related + strings, Python archives, and structural/domaining files. +2. ``runtime`` is imported inside Leapfrog's own Python process (normally from an + existing ``sitecustomize.py``). It reflects and disassembles the archived Python + modules, instruments the real-location domaining calls, saves their arrays, and + can run synthetic SubDomainer micro-cases using Leapfrog's actual implementation. + +The probe is read-only with respect to Leapfrog projects. Runtime hooks call the +original functions unchanged and only write diagnostics to a separate directory. +""" +from __future__ import annotations + +import argparse +import builtins +import dis +import functools +import hashlib +import importlib +import inspect +import io +import json +import mmap +import os +import re +import sys +import threading +import time +import traceback +from pathlib import Path +from typing import Any, Callable + +import numpy as np + +DEFAULT_INSTALL_ROOT = Path(r"D:\Program files\Seequent\Leapfrog 2026.1\bin") +DEFAULT_INVENTORY = Path("benchmark-results/leapfrog-runtime-inventory.json") +MODULE_CANDIDATES = ( + "structural_fitter", + "structural_fitter.domaining", + "structural_fitter.anisotropy", + "rangeTree", + "triangle_tree", + "turbo_rbf_spaces", + "_isosurfacer", +) +MEMBER_HINTS = ( + "domain", + "region", + "merge", + "consisten", + "anisotrop", + "normal", + "matrix", + "determinant", + "tree", + "neigh", + "adjacen", + "location", +) +BINARY_HINTS = ( + b"SubDomainer", + b"GridSeededDomainer", + b"set_domains_by_region_growing", + b"subdomain_with_real_locations", + b"merge_domains", + b"consistency_thresh", + b"affine_matrix_and_consistency", + b"symmetric_determinant", + b"region_growing", + b"delaunay", + b"nearest", + b"adjacency", + b"RangeTree", +) +RUNTIME_MODULE_TRIGGERS = { + "structural_fitter", + "structural_fitter.domaining", + "structural_fitter.anisotropy", +} +_LOCK = threading.RLock() +_INSTALLED = False +_ORIGINAL_IMPORT: Callable[..., Any] | None = None +_PATCHED: set[tuple[int, str]] = set() +_CALL_COUNTER = 0 + + +def _output_root() -> Path: + configured = os.environ.get("LEAPFROG_RE_PROBE_DIR", "").strip() + if configured: + root = Path(configured) + else: + root = Path(os.environ.get("TEMP", ".")) / "leapfrog-re-probe" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _jsonable(value: Any, *, depth: int = 0) -> Any: + if depth > 3: + return repr(value) + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, np.generic): + return value.item() + if isinstance(value, np.ndarray): + return { + "type": "ndarray", + "shape": list(value.shape), + "dtype": str(value.dtype), + "min": float(np.nanmin(value)) if value.size and np.issubdtype(value.dtype, np.number) else None, + "max": float(np.nanmax(value)) if value.size and np.issubdtype(value.dtype, np.number) else None, + } + if isinstance(value, (list, tuple)): + return [_jsonable(item, depth=depth + 1) for item in value[:50]] + if isinstance(value, dict): + items = list(value.items())[:100] + return {str(key): _jsonable(item, depth=depth + 1) for key, item in items} + return { + "type": type(value).__name__, + "module": type(value).__module__, + "repr": repr(value)[:500], + } + + +def _write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + + +def _append_event(payload: dict[str, Any]) -> None: + payload = dict(payload) + payload.setdefault("time", time.time()) + path = _output_root() / "runtime-events.jsonl" + with path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(payload, default=str) + "\n") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _pe_details(path: Path) -> dict[str, Any]: + details: dict[str, Any] = { + "path": str(path), + "size": path.stat().st_size, + "sha256": _sha256(path), + "pyinit_symbols": [], + "hint_hits": [], + "exports": [], + "imports": [], + } + try: + with path.open("rb") as stream: + with mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as mapped: + details["pyinit_symbols"] = sorted( + { + match.group(1).decode("ascii", errors="replace") + for match in re.finditer(rb"PyInit_([A-Za-z0-9_]+)", mapped) + } + ) + details["hint_hits"] = [ + hint.decode("ascii", errors="replace") + for hint in BINARY_HINTS + if mapped.find(hint) >= 0 + ] + except (OSError, ValueError) as exc: + details["binary_scan_error"] = f"{type(exc).__name__}: {exc}" + + try: + import pefile # type: ignore + + pe = pefile.PE(str(path), fast_load=True) + pe.parse_data_directories( + directories=[ + pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_EXPORT"], + pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"], + ] + ) + if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"): + details["exports"] = sorted( + symbol.name.decode("utf-8", errors="replace") + for symbol in pe.DIRECTORY_ENTRY_EXPORT.symbols + if symbol.name + ) + if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"): + details["imports"] = sorted( + entry.dll.decode("utf-8", errors="replace") + for entry in pe.DIRECTORY_ENTRY_IMPORT + ) + except ImportError: + details["pefile"] = "not installed; binary string scan still completed" + except Exception as exc: + details["pe_error"] = f"{type(exc).__name__}: {exc}" + return details + + +def inventory_installation(root: Path, output: Path) -> dict[str, Any]: + root = root.resolve() + suffixes = {".exe", ".dll", ".pyd", ".py", ".pyc", ".zip", ".zpy", ".pydc"} + files: list[Path] = [] + for path in root.rglob("*"): + if path.is_file() and path.suffix.lower() in suffixes: + files.append(path) + + pe_rows: list[dict[str, Any]] = [] + archive_rows: list[dict[str, Any]] = [] + source_rows: list[dict[str, Any]] = [] + for path in sorted(files): + suffix = path.suffix.lower() + if suffix in {".exe", ".dll", ".pyd"}: + row = _pe_details(path) + if row["pyinit_symbols"] or row["hint_hits"] or "leapfrog" in path.name.lower(): + pe_rows.append(row) + elif suffix in {".zip", ".zpy", ".pydc"}: + archive_rows.append( + {"path": str(path), "size": path.stat().st_size, "sha256": _sha256(path)} + ) + elif suffix in {".py", ".pyc"}: + lowered = str(path).lower() + if any(term in lowered for term in ("domain", "anisotrop", "structural", "rbf")): + source_rows.append({"path": str(path), "size": path.stat().st_size}) + + report = { + "install_root": str(root), + "python": sys.version, + "pe_modules": pe_rows, + "archives": archive_rows, + "structural_source_files": source_rows, + "summary": { + "all_candidate_files": len(files), + "interesting_pe_modules": len(pe_rows), + "archives": len(archive_rows), + "structural_source_files": len(source_rows), + }, + } + _write_json(output, report) + return report + + +def _safe_signature(value: Any) -> str | None: + try: + return str(inspect.signature(value)) + except Exception: + return None + + +def _disassemble(value: Any, limit: int = 120_000) -> str | None: + try: + target = value + if inspect.ismethod(target): + target = target.__func__ + if not hasattr(target, "__code__"): + return None + stream = io.StringIO() + dis.dis(target, file=stream, show_caches=True, adaptive=True) + return stream.getvalue()[:limit] + except Exception as exc: + return f"" + + +def _describe_member(name: str, value: Any) -> dict[str, Any]: + result: dict[str, Any] = { + "name": name, + "type": type(value).__name__, + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", None), + "signature": _safe_signature(value), + "doc": inspect.getdoc(value)[:4000] if inspect.getdoc(value) else None, + } + try: + source = inspect.getsource(value) + result["source"] = source[:200_000] + except Exception: + result["source"] = None + result["disassembly"] = _disassemble(value) + if inspect.isclass(value): + methods: dict[str, Any] = {} + for child_name, child in sorted(vars(value).items()): + if child_name.startswith("__") and child_name not in {"__init__"}: + continue + if callable(child) and any(term in child_name.lower() for term in MEMBER_HINTS): + methods[child_name] = { + "signature": _safe_signature(child), + "doc": inspect.getdoc(child)[:2000] if inspect.getdoc(child) else None, + "disassembly": _disassemble(child), + } + result["methods"] = methods + return result + + +def reflect_module(module_name: str) -> dict[str, Any]: + try: + module = importlib.import_module(module_name) + except BaseException as exc: + return { + "module": module_name, + "imported": False, + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + + names = sorted(name for name in dir(module) if not name.startswith("__")) + interesting: dict[str, Any] = {} + for name in names: + if any(term in name.lower() for term in MEMBER_HINTS): + try: + interesting[name] = _describe_member(name, getattr(module, name)) + except Exception as exc: + interesting[name] = {"error": f"{type(exc).__name__}: {exc}"} + + code_disassembly = None + try: + loader = getattr(module, "__loader__", None) + get_code = getattr(loader, "get_code", None) + code = get_code(module_name) if callable(get_code) else None + if code is not None: + stream = io.StringIO() + dis.dis(code, file=stream, show_caches=True, adaptive=True) + code_disassembly = stream.getvalue()[:500_000] + except Exception as exc: + code_disassembly = f"" + + return { + "module": module_name, + "imported": True, + "file": getattr(module, "__file__", None), + "loader": repr(getattr(module, "__loader__", None)), + "spec": repr(getattr(module, "__spec__", None)), + "public_names": names, + "interesting_members": interesting, + "module_disassembly": code_disassembly, + } + + +def dump_runtime_reflection() -> Path: + payload = { + "executable": sys.executable, + "version": sys.version, + "modules": [reflect_module(name) for name in MODULE_CANDIDATES], + } + path = _output_root() / "runtime-reflection.json" + _write_json(path, payload) + return path + + +def _array_payload(prefix: str, value: Any, arrays: dict[str, np.ndarray]) -> Any: + if isinstance(value, np.ndarray): + key = prefix.replace(".", "_").replace("[", "_").replace("]", "") + arrays[key] = np.asarray(value) + return {"array_key": key, "shape": list(value.shape), "dtype": str(value.dtype)} + if isinstance(value, dict): + return { + str(key): _array_payload(f"{prefix}_{key}", item, arrays) + for key, item in list(value.items())[:500] + } + if isinstance(value, (list, tuple)): + return [ + _array_payload(f"{prefix}_{index}", item, arrays) + for index, item in enumerate(value[:500]) + ] + return _jsonable(value) + + +def _capture_call(label: str, args: tuple[Any, ...], kwargs: dict[str, Any], result: Any = None) -> None: + global _CALL_COUNTER + with _LOCK: + _CALL_COUNTER += 1 + call_id = _CALL_COUNTER + arrays: dict[str, np.ndarray] = {} + payload: dict[str, Any] = { + "call_id": call_id, + "label": label, + "args": [_array_payload(f"arg{index}", value, arrays) for index, value in enumerate(args)], + "kwargs": { + key: _array_payload(f"kw_{key}", value, arrays) for key, value in kwargs.items() + }, + } + if result is not None: + payload["result"] = _array_payload("result", result, arrays) + if args: + self_value = args[0] + state = getattr(self_value, "__dict__", None) + if isinstance(state, dict): + payload["self_state"] = { + key: _array_payload(f"self_{key}", value, arrays) + for key, value in state.items() + if not callable(value) + } + if arrays: + np.savez_compressed(_output_root() / f"call-{call_id:06d}-{label.replace('.', '_')}.npz", **arrays) + _append_event(payload) + + +def _patch_method(owner: Any, name: str, label: str) -> None: + key = (id(owner), name) + if key in _PATCHED or not hasattr(owner, name): + return + original = getattr(owner, name) + if not callable(original): + return + + @functools.wraps(original) + def wrapped(*args: Any, **kwargs: Any) -> Any: + _capture_call(f"{label}.enter", args, kwargs) + try: + result = original(*args, **kwargs) + except BaseException as exc: + _append_event( + { + "label": f"{label}.error", + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + ) + raise + _capture_call(f"{label}.exit", args, kwargs, result) + return result + + try: + setattr(owner, name, wrapped) + _PATCHED.add(key) + _append_event({"label": "patch", "target": label, "status": "installed"}) + except Exception as exc: + _append_event( + {"label": "patch", "target": label, "status": "failed", "error": str(exc)} + ) + + +def instrument_loaded_modules() -> None: + domaining = sys.modules.get("structural_fitter.domaining") + if domaining is not None: + sub = getattr(domaining, "SubDomainer", None) + grid = getattr(domaining, "GridSeededDomainer", None) + if sub is not None: + for method in ( + "__init__", + "set_domains_by_region_growing", + "merge_domains", + "get_domains", + ): + _patch_method(sub, method, f"SubDomainer.{method}") + if grid is not None: + for method in ( + "__init__", + "subdomain_with_real_locations", + "set_domains_by_region_growing", + "merge_domains", + ): + _patch_method(grid, method, f"GridSeededDomainer.{method}") + + anisotropy = sys.modules.get("structural_fitter.anisotropy") + if anisotropy is not None: + for _, cls in inspect.getmembers(anisotropy, inspect.isclass): + for method in ( + "get_primary_normals", + "get_anisotropies", + "get_anisotropies_and_strength", + ): + _patch_method(cls, method, f"{cls.__name__}.{method}") + + +def install_import_hook(*, reflect: bool = True) -> None: + global _INSTALLED, _ORIGINAL_IMPORT + with _LOCK: + if _INSTALLED: + instrument_loaded_modules() + return + _INSTALLED = True + _ORIGINAL_IMPORT = builtins.__import__ + original = _ORIGINAL_IMPORT + + def hooked_import(name: str, globals_: Any = None, locals_: Any = None, fromlist: Any = (), level: int = 0) -> Any: + module = original(name, globals_, locals_, fromlist, level) + if name in RUNTIME_MODULE_TRIGGERS or name.startswith("structural_fitter"): + try: + instrument_loaded_modules() + except Exception as exc: + _append_event({"label": "instrument_error", "error": str(exc)}) + return module + + builtins.__import__ = hooked_import + _append_event( + { + "label": "probe_installed", + "executable": sys.executable, + "version": sys.version, + } + ) + instrument_loaded_modules() + if reflect: + try: + dump_runtime_reflection() + except Exception as exc: + _append_event({"label": "reflection_error", "error": str(exc)}) + + +def _rotation_matrix(axis: np.ndarray, angle_degrees: float) -> np.ndarray: + axis = np.asarray(axis, dtype=float) + axis /= np.linalg.norm(axis) + angle = np.deg2rad(angle_degrees) + x, y, z = axis + c, s = np.cos(angle), np.sin(angle) + one = 1.0 - c + return np.array( + [ + [c + x * x * one, x * y * one - z * s, x * z * one + y * s], + [y * x * one + z * s, c + y * y * one, y * z * one - x * s], + [z * x * one - y * s, z * y * one + x * s, c + z * z * one], + ], + dtype=float, + ) + + +def _spd_for_angle(angle: float, ratio: float = 5.0) -> np.ndarray: + rotation = _rotation_matrix(np.array([0.0, 0.0, 1.0]), angle) + eigenvalues = np.array([ratio ** (-1.0 / 3.0), ratio ** (-1.0 / 3.0), ratio ** (2.0 / 3.0)]) + base = np.diag(eigenvalues) + return rotation @ base @ rotation.T + + +class _StaticAnisotropyField: + def __init__(self, matrices: np.ndarray, strengths: np.ndarray | None = None) -> None: + self.matrices = np.asarray(matrices, dtype=float) + self.strengths = ( + np.ones(len(self.matrices), dtype=float) + if strengths is None + else np.asarray(strengths, dtype=float) + ) + + def _select(self, locations: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + count = len(np.asarray(locations)) + if len(self.matrices) == count: + return self.matrices.copy(), self.strengths.copy() + if len(self.matrices) == 1: + return ( + np.repeat(self.matrices, count, axis=0), + np.repeat(self.strengths, count), + ) + raise ValueError(f"Expected {len(self.matrices)} locations, received {count}") + + def get_anisotropies(self, locations: np.ndarray) -> np.ndarray: + return self._select(locations)[0] + + def get_anisotropies_and_strength(self, locations: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + return self._select(locations) + + +def _candidate_label_vectors(value: Any, point_count: int) -> dict[str, list[int]]: + found: dict[str, list[int]] = {} + state = getattr(value, "__dict__", {}) + if not isinstance(state, dict): + return found + for name, item in state.items(): + array = np.asarray(item) if isinstance(item, (list, tuple, np.ndarray)) else None + if array is None or array.ndim != 1 or len(array) != point_count: + continue + if np.issubdtype(array.dtype, np.integer) or np.all(np.isfinite(array) & (array == np.round(array))): + found[name] = array.astype(np.int64).tolist() + return found + + +def run_subdomainer_microcases() -> Path: + domaining = importlib.import_module("structural_fitter.domaining") + subdomainer = getattr(domaining, "SubDomainer") + cases: list[dict[str, Any]] = [] + + point_sets = { + "line6": np.column_stack((np.arange(6, dtype=float), np.zeros(6), np.zeros(6))), + "two_triads": np.array( + [[0, 0, 0], [1, 0, 0], [0, 1, 0], [10, 0, 0], [11, 0, 0], [10, 1, 0]], + dtype=float, + ), + } + matrix_sets = { + "constant": np.repeat(_spd_for_angle(0.0)[None, :, :], 6, axis=0), + "two_orientations": np.asarray( + [_spd_for_angle(0.0)] * 3 + [_spd_for_angle(90.0)] * 3, + dtype=float, + ), + "orientation_ramp": np.asarray([_spd_for_angle(angle) for angle in (0, 10, 20, 60, 70, 80)]), + } + + for point_name, points in point_sets.items(): + for matrix_name, matrices in matrix_sets.items(): + for threshold in (0.0, 0.6, 0.9, 0.99, 0.999): + record: dict[str, Any] = { + "points": point_name, + "matrices": matrix_name, + "threshold": threshold, + } + try: + field = _StaticAnisotropyField(matrices) + obj = subdomainer( + points, + field, + bbox=None, + consistency_thresh=threshold, + min_points=1, + max_points=len(points), + ) + record["object"] = _jsonable(obj) + record["state"] = _jsonable(getattr(obj, "__dict__", {})) + record["candidate_labels"] = _candidate_label_vectors(obj, len(points)) + for method_name in ("get_domains", "domains", "labels", "get_labels"): + method = getattr(obj, method_name, None) + if callable(method): + try: + output = method() + record[f"method_{method_name}"] = _jsonable(output) + except Exception as exc: + record[f"method_{method_name}_error"] = str(exc) + except BaseException as exc: + record["error"] = f"{type(exc).__name__}: {exc}" + record["traceback"] = traceback.format_exc() + cases.append(record) + + path = _output_root() / "subdomainer-microcases.json" + _write_json(path, cases) + return path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=("inventory", "runtime-reflect", "microcases"), default="inventory") + parser.add_argument("--install-root", type=Path, default=DEFAULT_INSTALL_ROOT) + parser.add_argument("--output", type=Path, default=DEFAULT_INVENTORY) + args = parser.parse_args() + + if args.mode == "inventory": + if not args.install_root.is_dir(): + parser.error(f"Leapfrog installation was not found: {args.install_root}") + report = inventory_installation(args.install_root, args.output) + print(json.dumps(report["summary"], indent=2)) + print(f"wrote {args.output}") + return 0 + + if args.mode == "runtime-reflect": + install_import_hook(reflect=False) + path = dump_runtime_reflection() + print(f"wrote {path}") + return 0 + + install_import_hook(reflect=True) + path = run_subdomainer_microcases() + print(f"wrote {path}") + return 0 + + +if os.environ.get("LEAPFROG_RE_PROBE", "").strip() == "1": + try: + install_import_hook(reflect=True) + if os.environ.get("LEAPFROG_RE_MICROCASES", "").strip() == "1": + run_subdomainer_microcases() + except Exception as exc: + _append_event( + { + "label": "auto_activation_error", + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/leapfrog_grid_seeded_domainer.py b/benchmarks/leapfrog_gold/leapfrog_grid_seeded_domainer.py new file mode 100644 index 000000000..922ec8026 --- /dev/null +++ b/benchmarks/leapfrog_gold/leapfrog_grid_seeded_domainer.py @@ -0,0 +1,356 @@ +"""Leapfrog-style grid-seeded structural domaining reference implementation. + +This module mirrors the algorithm recovered from Leapfrog Geo 2026.1 runtime +profiles. It stays dependency-light (NumPy only) so it can be used as an +executable specification while the native Polatory builder is brought to exact +parity. + +Recovered behaviour implemented here: + +* approximately ``num_seeds`` structured grid locations; +* one initial domain per grid location; +* 6-connected face adjacency; +* point-count-weighted arithmetic matrix merging; +* determinant-based affine consistency; +* global greedy best-neighbour merging with lazy heap invalidation; +* deterministic sequential IDs for merged domains; +* a hard maximum merged-domain size; +* transfer of real locations to final grid domains after region growing. + +The exact Leapfrog return expression around the determinant has not yet been +observed as bytecode. The default reciprocal-determinant metric is the +best-supported reconstruction and is isolated behind ``consistency_fn``. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +import heapq +import itertools +from typing import Callable, Mapping, Sequence + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +FloatArray = NDArray[np.float64] +IntArray = NDArray[np.int64] +ConsistencyFunction = Callable[[FloatArray], float] + + +def symmetric_determinant(matrix: ArrayLike) -> float: + """Return the determinant of a symmetric 3x3 matrix from its six terms.""" + m = np.asarray(matrix, dtype=np.float64) + if m.shape != (3, 3): + raise ValueError(f"matrix must have shape (3, 3); got {m.shape}") + a, b, c = float(m[0, 0]), float(m[0, 1]), float(m[0, 2]) + e, f, i = float(m[1, 1]), float(m[1, 2]), float(m[2, 2]) + return a * e * i + 2.0 * b * c * f - a * f * f - e * c * c - i * b * b + + +def reciprocal_determinant_consistency(matrix: ArrayLike) -> float: + """Candidate Leapfrog consistency: ``1 / det(mean affine matrix)``.""" + determinant = symmetric_determinant(matrix) + if not np.isfinite(determinant) or determinant <= 0.0: + return float("-inf") + return float(1.0 / determinant) + + +def weighted_mean_matrix( + first: ArrayLike, + first_size: int, + second: ArrayLike, + second_size: int, +) -> FloatArray: + """Return Leapfrog's point-count-weighted arithmetic matrix mean.""" + if first_size <= 0 or second_size <= 0: + raise ValueError("domain sizes must be positive") + a = np.asarray(first, dtype=np.float64) + b = np.asarray(second, dtype=np.float64) + if a.shape != (3, 3) or b.shape != (3, 3): + raise ValueError("both matrices must have shape (3, 3)") + weight = first_size / float(first_size + second_size) + merged = weight * a + (1.0 - weight) * b + return 0.5 * (merged + merged.T) + + +def calculate_grid_shape( + bbox_min: ArrayLike, + bbox_max: ArrayLike, + num_seeds: int, +) -> tuple[int, int, int]: + """Choose a near-isotropic structured grid with at least ``num_seeds`` nodes.""" + if num_seeds <= 0: + raise ValueError("num_seeds must be positive") + minimum = np.asarray(bbox_min, dtype=np.float64) + maximum = np.asarray(bbox_max, dtype=np.float64) + if minimum.shape != (3,) or maximum.shape != (3,): + raise ValueError("bbox_min and bbox_max must each have shape (3,)") + span = maximum - minimum + if np.any(~np.isfinite(span)) or np.any(span <= 0.0): + raise ValueError("bounding box must be finite with positive side lengths") + + density = (float(num_seeds) / float(np.prod(span))) ** (1.0 / 3.0) + counts = np.maximum(np.rint(span * density).astype(np.int64), 2) + while int(np.prod(counts)) < num_seeds: + cell_sizes = span / np.maximum(counts - 1, 1) + axis = int(np.argmax(cell_sizes)) + counts[axis] += 1 + return tuple(int(value) for value in counts) + + +def create_grid_locations( + bbox_min: ArrayLike, + bbox_max: ArrayLike, + shape: Sequence[int], +) -> FloatArray: + """Create C-order grid locations, with the last axis varying fastest.""" + minimum = np.asarray(bbox_min, dtype=np.float64) + maximum = np.asarray(bbox_max, dtype=np.float64) + if len(shape) != 3 or any(int(value) < 2 for value in shape): + raise ValueError("shape must contain three integers >= 2") + axes = [ + np.linspace(minimum[axis], maximum[axis], int(shape[axis]), dtype=np.float64) + for axis in range(3) + ] + mesh = np.meshgrid(*axes, indexing="ij") + return np.column_stack([component.ravel(order="C") for component in mesh]) + + +def six_connected_neighbours(index: int, shape: Sequence[int]) -> tuple[int, ...]: + """Return face neighbours for a flattened C-order 3-D grid index.""" + nx, ny, nz = (int(value) for value in shape) + if not 0 <= index < nx * ny * nz: + raise IndexError(index) + i, remainder = divmod(index, ny * nz) + j, k = divmod(remainder, nz) + neighbours: list[int] = [] + if i > 0: + neighbours.append(index - ny * nz) + if i + 1 < nx: + neighbours.append(index + ny * nz) + if j > 0: + neighbours.append(index - nz) + if j + 1 < ny: + neighbours.append(index + nz) + if k > 0: + neighbours.append(index - 1) + if k + 1 < nz: + neighbours.append(index + 1) + return tuple(neighbours) + + +@dataclass(slots=True) +class Domain: + id: int + matrix: FloatArray + leaves: tuple[int, ...] + neighbours: set[int] = field(default_factory=set) + generation: int = 0 + active: bool = True + + @property + def size(self) -> int: + return len(self.leaves) + + +@dataclass(frozen=True, slots=True) +class DomainingResult: + shape: tuple[int, int, int] + grid_locations: FloatArray + grid_domain_ids: IntArray + domains: Mapping[int, Domain] + merge_count: int + real_domain_ids: IntArray | None = None + + +class GridSeededDomainer: + """Deterministic Leapfrog-style adjacency-constrained region grower.""" + + def __init__( + self, + consistency_thresh: float = 0.6, + max_points: int | None = None, + consistency_fn: ConsistencyFunction = reciprocal_determinant_consistency, + ) -> None: + if not np.isfinite(consistency_thresh): + raise ValueError("consistency_thresh must be finite") + if max_points is not None and max_points <= 0: + raise ValueError("max_points must be positive when provided") + self.consistency_thresh = float(consistency_thresh) + self.max_points = max_points + self.consistency_fn = consistency_fn + + def fit( + self, + grid_matrices: ArrayLike, + shape: Sequence[int], + grid_locations: ArrayLike | None = None, + real_locations: ArrayLike | None = None, + ) -> DomainingResult: + matrices = np.asarray(grid_matrices, dtype=np.float64) + grid_shape = tuple(int(value) for value in shape) + expected = int(np.prod(grid_shape)) + if matrices.shape != (expected, 3, 3): + raise ValueError( + f"grid_matrices must have shape ({expected}, 3, 3); got {matrices.shape}" + ) + matrices = 0.5 * (matrices + np.swapaxes(matrices, 1, 2)) + + if grid_locations is None: + locations = np.column_stack( + np.unravel_index(np.arange(expected), grid_shape) + ).astype(np.float64) + else: + locations = np.asarray(grid_locations, dtype=np.float64) + if locations.shape != (expected, 3): + raise ValueError( + f"grid_locations must have shape ({expected}, 3); got {locations.shape}" + ) + + domains: dict[int, Domain] = {} + leaf_owner = np.arange(expected, dtype=np.int64) + for index in range(expected): + domains[index] = Domain( + id=index, + matrix=matrices[index].copy(), + leaves=(index,), + neighbours=set(six_connected_neighbours(index, grid_shape)), + ) + + # (-consistency, low_id, high_id, gen_low, gen_high). IDs provide + # deterministic tie handling while generations invalidate stale pairs. + heap: list[tuple[float, int, int, int, int]] = [] + for domain_id, domain in domains.items(): + for neighbour_id in domain.neighbours: + if domain_id < neighbour_id: + self._push_pair(heap, domain, domains[neighbour_id]) + + next_domain_id = expected + merge_count = 0 + while heap: + neg_consistency, first_id, second_id, first_gen, second_gen = heapq.heappop(heap) + first = domains.get(first_id) + second = domains.get(second_id) + if first is None or second is None or not first.active or not second.active: + continue + if first.generation != first_gen or second.generation != second_gen: + continue + if second_id not in first.neighbours or first_id not in second.neighbours: + continue + consistency = -neg_consistency + if consistency < self.consistency_thresh: + break + if self.max_points is not None and first.size + second.size > self.max_points: + continue + + merged_matrix = weighted_mean_matrix( + first.matrix, first.size, second.matrix, second.size + ) + merged_neighbours = (first.neighbours | second.neighbours) - { + first_id, + second_id, + } + merged = Domain( + id=next_domain_id, + matrix=merged_matrix, + leaves=tuple(itertools.chain(first.leaves, second.leaves)), + neighbours=set(), + ) + next_domain_id += 1 + merge_count += 1 + + first.active = False + second.active = False + first.generation += 1 + second.generation += 1 + + for neighbour_id in sorted(merged_neighbours): + neighbour = domains.get(neighbour_id) + if neighbour is None or not neighbour.active: + continue + neighbour.neighbours.discard(first_id) + neighbour.neighbours.discard(second_id) + neighbour.neighbours.add(merged.id) + neighbour.generation += 1 + merged.neighbours.add(neighbour_id) + + domains[merged.id] = merged + leaf_owner[np.asarray(merged.leaves, dtype=np.int64)] = merged.id + for neighbour_id in sorted(merged.neighbours): + self._push_pair(heap, merged, domains[neighbour_id]) + + active_domains = {key: value for key, value in domains.items() if value.active} + real_domain_ids: IntArray | None = None + if real_locations is not None: + real_domain_ids = assign_real_locations( + np.asarray(real_locations, dtype=np.float64), locations, leaf_owner + ) + return DomainingResult( + shape=grid_shape, + grid_locations=locations, + grid_domain_ids=leaf_owner, + domains=active_domains, + merge_count=merge_count, + real_domain_ids=real_domain_ids, + ) + + def _push_pair( + self, + heap: list[tuple[float, int, int, int, int]], + first: Domain, + second: Domain, + ) -> None: + if self.max_points is not None and first.size + second.size > self.max_points: + return + merged_matrix = weighted_mean_matrix( + first.matrix, first.size, second.matrix, second.size + ) + consistency = self.consistency_fn(merged_matrix) + if not np.isfinite(consistency): + return + low, high = (first, second) if first.id < second.id else (second, first) + heapq.heappush( + heap, + (-float(consistency), low.id, high.id, low.generation, high.generation), + ) + + +def assign_real_locations( + real_locations: ArrayLike, + grid_locations: ArrayLike, + grid_domain_ids: ArrayLike, + *, + chunk_size: int = 4096, +) -> IntArray: + """Assign real locations to the nearest grid seed's final domain.""" + real = np.asarray(real_locations, dtype=np.float64) + grid = np.asarray(grid_locations, dtype=np.float64) + owners = np.asarray(grid_domain_ids, dtype=np.int64) + if real.ndim != 2 or real.shape[1] != 3: + raise ValueError("real_locations must have shape (n, 3)") + if grid.ndim != 2 or grid.shape[1] != 3 or owners.shape != (len(grid),): + raise ValueError("grid_locations/grid_domain_ids shapes are inconsistent") + if chunk_size <= 0: + raise ValueError("chunk_size must be positive") + + result = np.empty(len(real), dtype=np.int64) + for start in range(0, len(real), chunk_size): + stop = min(start + chunk_size, len(real)) + delta = real[start:stop, None, :] - grid[None, :, :] + distances_sq = np.einsum("mni,mni->mn", delta, delta, optimize=True) + nearest = np.argmin(distances_sq, axis=1) + result[start:stop] = owners[nearest] + return result + + +__all__ = [ + "Domain", + "DomainingResult", + "GridSeededDomainer", + "assign_real_locations", + "calculate_grid_shape", + "create_grid_locations", + "reciprocal_determinant_consistency", + "six_connected_neighbours", + "symmetric_determinant", + "weighted_mean_matrix", +] diff --git a/benchmarks/leapfrog_gold/probe_leapfrog_runtime.py b/benchmarks/leapfrog_gold/probe_leapfrog_runtime.py new file mode 100644 index 000000000..6d1e5a889 --- /dev/null +++ b/benchmarks/leapfrog_gold/probe_leapfrog_runtime.py @@ -0,0 +1,321 @@ +"""Probe Leapfrog's installed Python runtime and structural domaining API. + +This script is intentionally conservative. It does not modify Leapfrog projects and does +not construct the full GridSeededDomainer yet. It first discovers the Python ABI, import +paths, relevant modules, public call signatures, and a few safe matrix helper outputs. + +Run it from the repository with a normal Python interpreter. It will search the supplied +Leapfrog installation for an embedded Python executable. When found, it re-executes itself +under that interpreter so Windows ``.pyd`` modules are loaded with the correct ABI. +""" +from __future__ import annotations + +import argparse +import importlib +import inspect +import json +import os +import platform +import subprocess +import sys +import traceback +from pathlib import Path +from typing import Any + +import numpy as np + + +DEFAULT_BIN = Path(r"D:\Program files\Seequent\Leapfrog 2026.1\bin") +DEFAULT_OUTPUT = Path("benchmark-results/leapfrog-runtime-probe.json") +MODULE_CANDIDATES = ( + "structural_fitter", + "structural_fitter.domaining", + "structural_fitter.anisotropy", +) +NAME_HINTS = ( + "SubDomainer", + "GridSeededDomainer", + "DomainInfo", + "affine_matrix_and_consistency", + "symmetric_determinant", +) + + +def _jsonable(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + return repr(value) + + +def _safe_signature(value: Any) -> str | None: + try: + return str(inspect.signature(value)) + except Exception: + return None + + +def _safe_doc(value: Any, limit: int = 1200) -> str | None: + try: + text = inspect.getdoc(value) + except Exception: + return None + if not text: + return None + return text[:limit] + + +def _candidate_python_executables(root: Path) -> list[Path]: + names = {"python.exe", "pythonw.exe"} + candidates: list[Path] = [] + search_roots = [root, root.parent, root.parent.parent] + seen: set[Path] = set() + for base in search_roots: + if not base.exists(): + continue + try: + iterator = base.rglob("*.exe") + except OSError: + continue + for path in iterator: + if path.name.lower() not in names: + continue + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + candidates.append(resolved) + return sorted(candidates, key=lambda path: (len(path.parts), str(path).lower())) + + +def _runtime_files(root: Path) -> dict[str, list[str]]: + patterns = { + "python_dlls": "python*.dll", + "pyd_files": "*.pyd", + "domaining_files": "*domain*", + "anisotropy_files": "*anisotrop*", + "structural_fitter_files": "*structural_fitter*", + } + result: dict[str, list[str]] = {} + for key, pattern in patterns.items(): + matches: list[str] = [] + if root.exists(): + try: + for path in root.rglob(pattern): + if path.is_file(): + matches.append(str(path)) + except OSError as exc: + matches.append(f"") + result[key] = sorted(matches)[:500] + return result + + +def _dll_and_sys_path_setup(root: Path) -> dict[str, Any]: + added_dll_dirs: list[str] = [] + added_sys_paths: list[str] = [] + directories: list[Path] = [] + if root.exists(): + directories.append(root) + try: + directories.extend(path for path in root.rglob("*") if path.is_dir()) + except OSError: + pass + seen: set[str] = set() + for directory in directories: + text = str(directory) + key = text.lower() + if key in seen: + continue + seen.add(key) + if text not in sys.path: + sys.path.insert(0, text) + added_sys_paths.append(text) + if os.name == "nt" and hasattr(os, "add_dll_directory"): + try: + os.add_dll_directory(text) + added_dll_dirs.append(text) + except (FileNotFoundError, OSError): + pass + return { + "added_dll_directories": added_dll_dirs, + "added_sys_paths": added_sys_paths, + } + + +def _describe_member(name: str, value: Any) -> dict[str, Any]: + return { + "name": name, + "type": type(value).__name__, + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", None), + "signature": _safe_signature(value), + "doc": _safe_doc(value), + "is_class": inspect.isclass(value), + "is_function": inspect.isfunction(value) or inspect.isbuiltin(value), + } + + +def _probe_module(module_name: str) -> dict[str, Any]: + result: dict[str, Any] = {"module": module_name} + try: + module = importlib.import_module(module_name) + except BaseException as exc: # import failures can include DLL loader exceptions + result.update( + { + "imported": False, + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + ) + return result + + result.update( + { + "imported": True, + "file": getattr(module, "__file__", None), + "package": getattr(module, "__package__", None), + } + ) + public_names = sorted(name for name in dir(module) if not name.startswith("__")) + result["public_names"] = public_names + members: dict[str, Any] = {} + for name in public_names: + if any(hint.lower() in name.lower() for hint in NAME_HINTS): + try: + members[name] = _describe_member(name, getattr(module, name)) + except BaseException as exc: + members[name] = {"error": f"{type(exc).__name__}: {exc}"} + result["interesting_members"] = members + return result + + +def _safe_matrix_tests(imported_modules: dict[str, Any]) -> dict[str, Any]: + tests: dict[str, Any] = {} + anisotropy_module = imported_modules.get("structural_fitter.anisotropy") + if anisotropy_module is None: + return tests + + identity = np.eye(3, dtype=np.float64) + rotated = np.array( + [[2.0, 0.25, 0.0], [0.25, 0.75, 0.0], [0.0, 0.0, 2.0 / 1.4375]], + dtype=np.float64, + ) + for name in ("symmetric_determinant", "affine_matrix_and_consistency"): + value = getattr(anisotropy_module, name, None) + if value is None: + continue + cases: list[dict[str, Any]] = [] + try: + if name == "symmetric_determinant": + for label, matrix in (("identity", identity), ("rotated", rotated)): + try: + output = value(matrix) + cases.append({"case": label, "output": _jsonable(output)}) + except BaseException as exc: + cases.append({"case": label, "error": f"{type(exc).__name__}: {exc}"}) + else: + for weight in (0.0, 0.25, 0.5, 0.75, 1.0): + try: + output = value(identity, rotated, weight) + cases.append({"weight": weight, "output": _jsonable(output)}) + except BaseException as exc: + cases.append({"weight": weight, "error": f"{type(exc).__name__}: {exc}"}) + except BaseException as exc: + cases.append({"error": f"{type(exc).__name__}: {exc}"}) + tests[name] = cases + return tests + + +def _run_probe(args: argparse.Namespace) -> int: + install_root = args.leapfrog_bin.resolve() + path_setup = _dll_and_sys_path_setup(install_root) + report: dict[str, Any] = { + "install_root": str(install_root), + "runtime": { + "executable": sys.executable, + "version": sys.version, + "implementation": platform.python_implementation(), + "architecture": platform.architecture(), + "platform": platform.platform(), + "process_id": os.getpid(), + }, + "path_setup": path_setup, + "runtime_files": _runtime_files(install_root), + "modules": {}, + } + + imported_objects: dict[str, Any] = {} + for module_name in MODULE_CANDIDATES: + module_report = _probe_module(module_name) + report["modules"][module_name] = module_report + if module_report.get("imported"): + imported_objects[module_name] = importlib.import_module(module_name) + + report["safe_matrix_tests"] = _safe_matrix_tests(imported_objects) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(_jsonable(report), indent=2), encoding="utf-8") + + print(f"python={sys.executable}") + print(f"version={sys.version.split()[0]} architecture={platform.architecture()[0]}") + for module_name, module_report in report["modules"].items(): + status = "OK" if module_report.get("imported") else "FAILED" + print(f"{module_name}: {status}") + if not module_report.get("imported"): + print(f" {module_report.get('error')}") + else: + interesting = sorted(module_report.get("interesting_members", {})) + print(f" file={module_report.get('file')}") + print(f" interesting={interesting}") + print(f"wrote {args.output}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--leapfrog-bin", type=Path, default=DEFAULT_BIN) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument( + "--inside-leapfrog-python", + action="store_true", + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--no-reexec", + action="store_true", + help="Do not search for and re-execute under Leapfrog's embedded Python.", + ) + args = parser.parse_args() + + if not args.leapfrog_bin.is_dir(): + parser.error(f"Leapfrog bin directory was not found: {args.leapfrog_bin}") + + if not args.inside_leapfrog_python and not args.no_reexec: + candidates = _candidate_python_executables(args.leapfrog_bin) + current = Path(sys.executable).resolve() + embedded = next((path for path in candidates if path != current), None) + if embedded is not None: + command = [ + str(embedded), + str(Path(__file__).resolve()), + "--leapfrog-bin", + str(args.leapfrog_bin), + "--output", + str(args.output), + "--inside-leapfrog-python", + ] + print(f"re-executing with candidate Leapfrog Python: {embedded}", flush=True) + completed = subprocess.run(command, check=False) + return int(completed.returncode) + print("No separate embedded python.exe was found; probing with the current interpreter.") + + return _run_probe(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/profile_real_subdomainer.py b/benchmarks/leapfrog_gold/profile_real_subdomainer.py new file mode 100644 index 000000000..c5dfd5b65 --- /dev/null +++ b/benchmarks/leapfrog_gold/profile_real_subdomainer.py @@ -0,0 +1,120 @@ +"""High-frequency, read-only profile of Leapfrog's real-location SubDomainer stage. + +This uses ``py-spy record`` without locals. Unlike repeated ``dump --locals`` calls, +it samples continuously with much less stop/resume interference and can catch the +very short second-stage call stack. Run it, then immediately trigger an automatic- +domaining recompute in Leapfrog. + +Pass ``--native`` to include native extension frames. This is useful when the Python +profile reaches ``SubDomainer.__init__`` but does not expose a Python region-growing +loop, suggesting that the missing work occurs inside a compiled extension. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +from pathlib import Path + + +def find_background_pid() -> int | None: + command = [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -eq 'Leapfrog.exe' -and $_.CommandLine -match '--background' } | " + "Sort-Object CreationDate -Descending | Select-Object -First 1 -ExpandProperty ProcessId", + ] + completed = subprocess.run(command, capture_output=True, text=True, check=False) + text = completed.stdout.strip() + try: + return int(text.splitlines()[-1]) if text else None + except (ValueError, IndexError): + return None + + +def default_py_spy() -> Path: + local_appdata = os.environ.get("LOCALAPPDATA", "") + return Path(local_appdata) / "Programs" / "Python" / "Python312" / "Scripts" / "py-spy.exe" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--duration", type=int, default=45) + parser.add_argument("--rate", type=int, default=500) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/leapfrog-real-subdomainer-profile.txt"), + ) + parser.add_argument( + "--native", + action="store_true", + help="Include native extension frames in the profile.", + ) + parser.add_argument("--py-spy", type=Path, default=default_py_spy()) + args = parser.parse_args() + + if not args.py_spy.is_file(): + raise SystemExit(f"py-spy was not found: {args.py_spy}") + pid = find_background_pid() + if pid is None: + raise SystemExit("No Leapfrog --background process was found.") + + args.output.parent.mkdir(parents=True, exist_ok=True) + raw_output = args.output.with_suffix(".raw.txt") + raw_output.unlink(missing_ok=True) + args.output.unlink(missing_ok=True) + + mode = " with native frames" if args.native else "" + print( + f"Profiling Leapfrog background PID {pid} at {args.rate} Hz for " + f"{args.duration} seconds{mode}." + ) + print("Trigger the automatic-domaining recompute now.", flush=True) + + command = [ + str(args.py_spy), + "record", + "--pid", + str(pid), + "--rate", + str(max(args.rate, 1)), + "--duration", + str(max(args.duration, 1)), + "--format", + "raw", + "--output", + str(raw_output), + ] + if args.native: + command.append("--native") + + completed = subprocess.run(command, text=True, check=False) + if completed.returncode != 0: + raise SystemExit(f"py-spy record failed with exit code {completed.returncode}.") + if not raw_output.is_file(): + raise SystemExit(f"py-spy did not create {raw_output}") + + lines = raw_output.read_text(encoding="utf-8", errors="replace").splitlines() + real_lines = [ + line + for line in lines + if "subdomain_with_real_locations (domaining.py" in line + or "SubDomainer" in line + ] + args.output.write_text("\n".join(real_lines) + ("\n" if real_lines else ""), encoding="utf-8") + + if real_lines: + print(f"Captured {len(real_lines)} real-stage stack signatures: {args.output}") + print(f"Full raw profile: {raw_output}") + return 0 + + print("No real-stage stack signature was found in the profile.") + print(f"Full raw profile: {raw_output}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/run_all_no_background_blending_smooth_support_decay.py b/benchmarks/leapfrog_gold/run_all_no_background_blending_smooth_support_decay.py new file mode 100644 index 000000000..2cac9efbb --- /dev/null +++ b/benchmarks/leapfrog_gold/run_all_no_background_blending_smooth_support_decay.py @@ -0,0 +1,167 @@ +"""Run every Leapfrog gold strength/range case with the best smooth-support field. + +This batch runner applies the same settings that produced the successful S5_R300 result: + +* automatic finite LVA-geodesic domains; +* StructuralInterpolant3 background_blending=False; +* the fitted structural field unchanged near input data; +* cubic nearest-data support decay toward OUTSIDE_VALUE from 0.60 to 1.00 of + BASE_RANGE; and +* the aligned global scalar-grid mesher. + +By default every available S_R.obj case is generated. To run only a +subset, set POLATORY_BENCHMARK_CASES to a comma-separated list such as +``S2_R50,S3_R300,S5_R500``. The support-decay fractions can still be overridden with +POLATORY_SUPPORT_DECAY_START_FRACTION and POLATORY_SUPPORT_DECAY_END_FRACTION. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import polatory +from scipy.spatial import cKDTree + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Installs StructuralInterpolant3 background_blending=False and exposes the unfiltered +# real-gold suite. Unlike the selected-case runner, this retains all available cases. +import run_no_background_blending_diagnostic as diagnostic # noqa: E402 + +suite = diagnostic.suite +_BASE_FACTORY = polatory.StructuralInterpolant3 +_START_FRACTION = float( + os.environ.get("POLATORY_SUPPORT_DECAY_START_FRACTION", "0.60") +) +_END_FRACTION = float( + os.environ.get("POLATORY_SUPPORT_DECAY_END_FRACTION", "1.00") +) + +if not 0.0 <= _START_FRACTION < _END_FRACTION: + raise ValueError( + "Support-decay fractions must satisfy 0 <= start < end; got " + f"{_START_FRACTION:g} and {_END_FRACTION:g}." + ) + +_DECAY_START = _START_FRACTION * float(suite.BASE_RANGE) +_DECAY_END = _END_FRACTION * float(suite.BASE_RANGE) +_OUTSIDE_VALUE = float(suite.OUTSIDE_VALUE) +_ORIGINAL_AVAILABLE_CASES = suite.available_cases + + +class _SmoothSupportDecayInterpolant: + """Delegate fitting while smoothly completing unsupported field regions.""" + + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + self._tree: cKDTree | None = None + + def fit(self, points: Any, *args: Any, **kwargs: Any): + fit_points = np.asarray(points, dtype=np.float64) + if fit_points.ndim != 2 or fit_points.shape[1] != 3: + raise ValueError("Structural fit points must have shape (n, 3).") + self._tree = cKDTree(fit_points) + return self._wrapped.fit(points, *args, **kwargs) + + def evaluate(self, points: Any, *args: Any, **kwargs: Any): + query = np.asarray(points, dtype=np.float64) + values = np.asarray( + self._wrapped.evaluate(points, *args, **kwargs), + dtype=np.float64, + ) + original_shape = values.shape + flat_values = values.reshape(-1) + + if self._tree is None: + return values + if query.ndim != 2 or query.shape[1] != 3 or len(query) != len(flat_values): + raise ValueError( + "Structural evaluation points must have shape (m, 3) and match the " + "number of returned values." + ) + + distances = np.asarray(self._tree.query(query, k=1)[0], dtype=np.float64) + t = np.clip( + (distances - _DECAY_START) / (_DECAY_END - _DECAY_START), + 0.0, + 1.0, + ) + # Cubic smoothstep gives zero slope at both support-transition limits. + weight = t * t * (3.0 - 2.0 * t) + adjusted = (1.0 - weight) * flat_values + weight * _OUTSIDE_VALUE + + # Exact zero is ambiguous for marching cubes. Bias exact zeros only; all nonzero + # values remain governed by the fitted field and smooth support completion. + exact_zero = adjusted == 0.0 + if np.any(exact_zero): + nonzero = np.abs(adjusted[~exact_zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + adjusted = adjusted.copy() + adjusted[exact_zero] = -1.0e-6 * scale + + return adjusted.reshape(original_shape) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _smooth_support_factory(*args: Any, **kwargs: Any): + return _SmoothSupportDecayInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +def _requested_cases(): + """Return all cases, or an exact comma-separated subset when requested.""" + previous_max_cases = suite.MAX_CASES + suite.MAX_CASES = 0 + try: + cases = _ORIGINAL_AVAILABLE_CASES() + finally: + suite.MAX_CASES = previous_max_cases + + requested_text = os.environ.get("POLATORY_BENCHMARK_CASES", "").strip() + if not requested_text: + return cases + + requested = { + name.strip().upper() + for name in requested_text.split(",") + if name.strip() + } + matches = [case for case in cases if case.name.upper() in requested] + found = {case.name.upper() for case in matches} + missing = sorted(requested.difference(found)) + if missing: + available = ", ".join(case.name for case in cases) + raise ValueError( + f"Unknown Leapfrog benchmark case(s): {', '.join(missing)}. " + f"Available cases: {available}" + ) + return matches + + +polatory.StructuralInterpolant3 = _smooth_support_factory +suite.available_cases = _requested_cases +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "all-no-background-blending-smooth-support-decay" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tBatch mode: background_blending=False with smooth nearest-data " + f"support decay from {_DECAY_START:g} m to {_DECAY_END:g} m; all requested " + "strength/range cases use identical settings apart from their encoded strength and " + "trend range.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_basal_range_diagnostic.py b/benchmarks/leapfrog_gold/run_basal_range_diagnostic.py new file mode 100644 index 000000000..ed02767bd --- /dev/null +++ b/benchmarks/leapfrog_gold/run_basal_range_diagnostic.py @@ -0,0 +1,522 @@ +"""Diagnose the range-driven flat basal closure in the Leapfrog LVA benchmark. + +This runner keeps the current topology-local automatic-support V2 model unchanged and +adds measurements only. It runs one or more requested benchmark cases, captures the +fitted structural interpolant and automatic domains, samples vertical scalar profiles, +and records where the lowest raw/final zero crossings occur relative to finite-domain +coverage. + +The default comparison is S3_R100 versus S3_R500. Override it with either +``POLATORY_BASAL_CASES`` (comma separated) or the existing +``POLATORY_BENCHMARK_CASE`` variable. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, Callable, Sequence + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from scipy.spatial import cKDTree + +import polatory + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Installs the current V2 topology-local correction and exposes the benchmark suite. +import run_selected_no_background_blending_auto_support_decay_v2 as current # noqa: E402 + +suite = current.suite +_ORIGINAL_AVAILABLE_CASES = current.v1.selected._ORIGINAL_AVAILABLE_CASES +_ORIGINAL_BUILD_CASE = suite.build_case +_ORIGINAL_BUILDER = suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder +_ORIGINAL_STRUCTURAL_FACTORY = polatory.StructuralInterpolant3 + +_CAPTURE: dict[str, Any] = { + "structural": None, + "domains": None, + "builder": None, +} + +suite.OUTPUT_DIR = suite.ROOT / "benchmark-results" / "diagnostic-basal-range-v3" +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" +DIAGNOSTIC_DIR = suite.OUTPUT_DIR / "basal-diagnostics" + +XY_COUNT = max(7, int(os.environ.get("POLATORY_BASAL_XY_COUNT", "25"))) +Z_STEP = float( + os.environ.get( + "POLATORY_BASAL_Z_STEP", + str(max(float(suite.SURFACE_RESOLUTION) * 0.5, 1.0)), + ) +) +EVALUATION_BATCH_SIZE = max( + 1, int(os.environ.get("POLATORY_BASAL_EVALUATION_BATCH_SIZE", "100000")) +) +if not np.isfinite(Z_STEP) or Z_STEP <= 0.0: + raise ValueError("POLATORY_BASAL_Z_STEP must be a positive finite distance.") + + +def _requested_cases(): + requested = ( + os.environ.get("POLATORY_BASAL_CASES", "").strip() + or os.environ.get("POLATORY_BENCHMARK_CASES", "").strip() + or os.environ.get("POLATORY_BENCHMARK_CASE", "").strip() + or "S3_R100,S3_R500" + ) + names = [item.strip().upper() for item in requested.split(",") if item.strip()] + if not names: + raise ValueError("No benchmark cases were requested.") + + previous_max_cases = suite.MAX_CASES + suite.MAX_CASES = 0 + try: + available = _ORIGINAL_AVAILABLE_CASES() + finally: + suite.MAX_CASES = previous_max_cases + + by_name = {case.name.upper(): case for case in available} + missing = [name for name in names if name not in by_name] + if missing: + raise ValueError( + f"Unknown benchmark case(s) {missing}. Available cases: " + + ", ".join(sorted(by_name)) + ) + return [by_name[name] for name in names] + + +suite.available_cases = _requested_cases + + +class _CapturingBuilder: + """Delegate to the production builder while retaining its returned domains.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._wrapped = _ORIGINAL_BUILDER(*args, **kwargs) + _CAPTURE["builder"] = self._wrapped + + def build_from_inputs(self, *args: Any, **kwargs: Any): + domains = list(self._wrapped.build_from_inputs(*args, **kwargs)) + _CAPTURE["domains"] = domains + return domains + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _capturing_structural_factory(*args: Any, **kwargs: Any): + structural = _ORIGINAL_STRUCTURAL_FACTORY(*args, **kwargs) + _CAPTURE["structural"] = structural + return structural + + +suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder = _CapturingBuilder +polatory.StructuralInterpolant3 = _capturing_structural_factory + + +def _evaluate_batched( + evaluator: Callable[[np.ndarray], Any], + query: np.ndarray, +) -> np.ndarray: + values = np.empty(len(query), dtype=np.float64) + for start in range(0, len(query), EVALUATION_BATCH_SIZE): + stop = min(start + EVALUATION_BATCH_SIZE, len(query)) + values[start:stop] = np.asarray( + evaluator(query[start:stop]), dtype=np.float64 + ).reshape(-1) + if not np.all(np.isfinite(values)): + raise RuntimeError("Structural field returned non-finite diagnostic values.") + return values + + +def _zero_crossings(volume: np.ndarray, z_coordinates: np.ndarray) -> dict[str, np.ndarray]: + lower = np.asarray(volume[:, :, :-1], dtype=np.float64) + upper = np.asarray(volume[:, :, 1:], dtype=np.float64) + scale = max(1.0, float(np.max(np.abs(volume)))) + tolerance = np.finfo(np.float64).eps * scale + lower_sign = np.where(np.abs(lower) <= tolerance, -tolerance, lower) + upper_sign = np.where(np.abs(upper) <= tolerance, -tolerance, upper) + crossing = ((lower_sign < 0.0) & (upper_sign > 0.0)) | ( + (lower_sign > 0.0) & (upper_sign < 0.0) + ) + crossing_count = np.sum(crossing, axis=2).astype(np.int64) + has_crossing = crossing_count > 0 + + first_index = np.argmax(crossing, axis=2) + last_index = crossing.shape[2] - 1 - np.argmax(crossing[:, :, ::-1], axis=2) + + def interpolate(indices: np.ndarray) -> np.ndarray: + gathered_lower = np.take_along_axis(lower, indices[:, :, None], axis=2)[:, :, 0] + gathered_upper = np.take_along_axis(upper, indices[:, :, None], axis=2)[:, :, 0] + denominator = gathered_upper - gathered_lower + fraction = np.divide( + -gathered_lower, + denominator, + out=np.full_like(gathered_lower, 0.5), + where=np.abs(denominator) > tolerance, + ) + fraction = np.clip(fraction, 0.0, 1.0) + base = z_coordinates[indices] + return base + fraction * (z_coordinates[indices + 1] - base) + + lowest = interpolate(first_index) + highest = interpolate(last_index) + lowest[~has_crossing] = np.nan + highest[~has_crossing] = np.nan + return { + "count": crossing_count, + "lowest": lowest, + "highest": highest, + "has": has_crossing, + } + + +def _mode_summary(values: np.ndarray, bin_width: float) -> dict[str, Any]: + finite = np.asarray(values, dtype=np.float64) + finite = finite[np.isfinite(finite)] + if len(finite) == 0: + return { + "count": 0, + "mode_z": None, + "mode_count": 0, + "mode_fraction": 0.0, + "minimum": None, + "p10": None, + "median": None, + "p90": None, + "maximum": None, + "standard_deviation": None, + } + + rounded = np.round(finite / bin_width) * bin_width + coordinates, counts = np.unique(rounded, return_counts=True) + mode_index = int(np.argmax(counts)) + return { + "count": int(len(finite)), + "mode_z": float(coordinates[mode_index]), + "mode_count": int(counts[mode_index]), + "mode_fraction": float(counts[mode_index] / len(finite)), + "minimum": float(np.min(finite)), + "p10": float(np.percentile(finite, 10.0)), + "median": float(np.median(finite)), + "p90": float(np.percentile(finite, 90.0)), + "maximum": float(np.max(finite)), + "standard_deviation": float(np.std(finite)), + } + + +def _domain_coverage(points: np.ndarray, domains: Sequence[Any]) -> np.ndarray: + points = np.asarray(points, dtype=np.float64) + counts = np.zeros(len(points), dtype=np.int64) + for domain in domains: + minimum = np.asarray(domain.bbox_min, dtype=np.float64) + maximum = np.asarray(domain.bbox_max, dtype=np.float64) + counts += np.all(points >= minimum[None, :], axis=1) & np.all( + points <= maximum[None, :], axis=1 + ) + return counts + + +def _save_crossing_map( + path: Path, + values: np.ndarray, + x_coordinates: np.ndarray, + y_coordinates: np.ndarray, + title: str, +) -> None: + figure = plt.figure(figsize=(8, 6)) + image = plt.imshow( + values.T, + origin="lower", + extent=( + float(x_coordinates[0]), + float(x_coordinates[-1]), + float(y_coordinates[0]), + float(y_coordinates[-1]), + ), + aspect="equal", + ) + plt.colorbar(image, label="Lowest zero-crossing Z") + plt.xlabel("X") + plt.ylabel("Y") + plt.title(title) + figure.tight_layout() + figure.savefig(path, dpi=180) + plt.close(figure) + + +def _save_crossing_histogram( + path: Path, + lowest_crossings: np.ndarray, + domain_minimum_z: np.ndarray, + title: str, +) -> None: + finite = np.asarray(lowest_crossings, dtype=np.float64) + finite = finite[np.isfinite(finite)] + figure = plt.figure(figsize=(8, 5)) + if len(finite): + bin_count = max(10, min(80, int(np.ceil((finite.max() - finite.min()) / Z_STEP)))) + plt.hist(finite, bins=bin_count) + for value in np.asarray(domain_minimum_z, dtype=np.float64): + plt.axvline(float(value), linewidth=0.4, alpha=0.15) + plt.xlabel("Lowest zero-crossing Z") + plt.ylabel("Vertical probe count") + plt.title(title) + figure.tight_layout() + figure.savefig(path, dpi=180) + plt.close(figure) + + +def _diagnose_case( + case: Any, + result: dict[str, Any], + points: np.ndarray, +) -> dict[str, Any]: + structural = _CAPTURE.get("structural") + domains = _CAPTURE.get("domains") + builder = _CAPTURE.get("builder") + if structural is None or not domains: + raise RuntimeError("The diagnostic did not capture the fitted structural model.") + + generated_path = suite.ROOT / result["generated_obj"] + generated = suite.polydata(generated_path) + bounds = generated.bounds + x_coordinates = np.linspace(float(bounds[0]), float(bounds[1]), XY_COUNT) + y_coordinates = np.linspace(float(bounds[2]), float(bounds[3]), XY_COUNT) + z_coordinates = np.arange( + float(suite.MODEL_MIN[2]), + float(suite.MODEL_MAX[2]) + 0.5 * Z_STEP, + Z_STEP, + dtype=np.float64, + ) + + x_grid, y_grid, z_grid = np.meshgrid( + x_coordinates, + y_coordinates, + z_coordinates, + indexing="ij", + ) + query = np.column_stack( + [x_grid.ravel(order="C"), y_grid.ravel(order="C"), z_grid.ravel(order="C")] + ) + print( + f"[{case.name}] Basal diagnostic sampling {len(query):,} scalar nodes " + f"({XY_COUNT} x {XY_COUNT} vertical probes, dz={Z_STEP:g} m)…", + flush=True, + ) + + raw_evaluator = getattr(structural, "_raw_evaluate", structural.evaluate) + raw_values = _evaluate_batched(raw_evaluator, query) + final_values = _evaluate_batched(structural.evaluate, query) + shape = (len(x_coordinates), len(y_coordinates), len(z_coordinates)) + raw_volume = raw_values.reshape(shape, order="C") + final_volume = final_values.reshape(shape, order="C") + raw_crossings = _zero_crossings(raw_volume, z_coordinates) + final_crossings = _zero_crossings(final_volume, z_coordinates) + + xy_grid_x, xy_grid_y = np.meshgrid(x_coordinates, y_coordinates, indexing="ij") + final_has = final_crossings["has"] + final_points = np.column_stack( + [ + xy_grid_x[final_has], + xy_grid_y[final_has], + final_crossings["lowest"][final_has], + ] + ) + final_domain_counts = np.full(final_has.shape, -1, dtype=np.int64) + final_nearest_data = np.full(final_has.shape, np.nan, dtype=np.float64) + if len(final_points): + final_domain_counts[final_has] = _domain_coverage(final_points, domains) + final_nearest_data[final_has] = np.asarray( + cKDTree(np.asarray(points, dtype=np.float64)).query(final_points, k=1)[0], + dtype=np.float64, + ) + + domain_minimum = np.vstack( + [np.asarray(domain.bbox_min, dtype=np.float64) for domain in domains] + ) + domain_maximum = np.vstack( + [np.asarray(domain.bbox_max, dtype=np.float64) for domain in domains] + ) + internal_radii: list[float] = [] + diagnostics = getattr(builder, "diagnostics_", None) + if diagnostics is not None: + internal_radii = [ + float(item.internal_radius) for item in getattr(diagnostics, "postcluster", []) + ] + + raw_summary = _mode_summary(raw_crossings["lowest"], Z_STEP) + final_summary = _mode_summary(final_crossings["lowest"], Z_STEP) + domain_min_z_summary = _mode_summary(domain_minimum[:, 2], Z_STEP) + mode_z = final_summary.get("mode_z") + alignment = ( + float(np.min(np.abs(domain_minimum[:, 2] - float(mode_z)))) + if mode_z is not None + else None + ) + + calibration = getattr(structural, "calibration_", None) + summary: dict[str, Any] = { + "case": case.name, + "strength": float(case.strength), + "trend_range": float(case.trend_range), + "xy_probe_count_per_axis": int(XY_COUNT), + "vertical_step": float(Z_STEP), + "scalar_nodes": int(len(query)), + "domain_count": int(len(domains)), + "domain_bbox_min_z": domain_minimum[:, 2].tolist(), + "domain_bbox_max_z": domain_maximum[:, 2].tolist(), + "domain_minimum_z_summary": domain_min_z_summary, + "internal_radii": internal_radii, + "raw_lowest_crossing": raw_summary, + "final_lowest_crossing": final_summary, + "final_mode_to_nearest_domain_min_z": alignment, + "final_crossing_domain_count": _mode_summary( + final_domain_counts[final_domain_counts >= 0].astype(np.float64), 1.0 + ), + "final_crossing_nearest_data_distance": _mode_summary( + final_nearest_data, max(Z_STEP, 1.0) + ), + "support_calibration": calibration, + } + + DIAGNOSTIC_DIR.mkdir(parents=True, exist_ok=True) + frame = pd.DataFrame( + { + "x": xy_grid_x.ravel(order="C"), + "y": xy_grid_y.ravel(order="C"), + "raw_crossing_count": raw_crossings["count"].ravel(order="C"), + "raw_lowest_z": raw_crossings["lowest"].ravel(order="C"), + "raw_highest_z": raw_crossings["highest"].ravel(order="C"), + "final_crossing_count": final_crossings["count"].ravel(order="C"), + "final_lowest_z": final_crossings["lowest"].ravel(order="C"), + "final_highest_z": final_crossings["highest"].ravel(order="C"), + "active_domain_boxes_at_final_lowest": final_domain_counts.ravel(order="C"), + "nearest_data_distance_at_final_lowest": final_nearest_data.ravel(order="C"), + } + ) + frame.to_csv(DIAGNOSTIC_DIR / f"{case.name}_vertical_crossings.csv", index=False) + (DIAGNOSTIC_DIR / f"{case.name}_basal_diagnostic.json").write_text( + json.dumps(summary, indent=2), encoding="utf-8" + ) + + _save_crossing_map( + DIAGNOSTIC_DIR / f"{case.name}_raw_lowest_crossing.png", + raw_crossings["lowest"], + x_coordinates, + y_coordinates, + f"{case.name}: raw lowest zero crossing", + ) + _save_crossing_map( + DIAGNOSTIC_DIR / f"{case.name}_final_lowest_crossing.png", + final_crossings["lowest"], + x_coordinates, + y_coordinates, + f"{case.name}: V2 final lowest zero crossing", + ) + _save_crossing_histogram( + DIAGNOSTIC_DIR / f"{case.name}_final_lowest_crossing_histogram.png", + final_crossings["lowest"], + domain_minimum[:, 2], + f"{case.name}: lowest crossing distribution and domain minima", + ) + + print( + f"[{case.name}] Basal diagnostic: raw mode={raw_summary['mode_z']}, " + f"raw mode fraction={raw_summary['mode_fraction']:.3f}; " + f"final mode={final_summary['mode_z']}, " + f"final mode fraction={final_summary['mode_fraction']:.3f}; " + f"nearest domain-min alignment={alignment}.", + flush=True, + ) + return summary + + +def _diagnostic_build_case( + case: Any, + points: np.ndarray, + indicators: np.ndarray, + trend_vertices: np.ndarray, + trend_faces: np.ndarray, +) -> dict[str, Any]: + _CAPTURE.update({"structural": None, "domains": None, "builder": None}) + result = _ORIGINAL_BUILD_CASE( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + result["basal_range_diagnostic"] = _diagnose_case(case, result, points) + return result + + +suite.build_case = _diagnostic_build_case + + +def _write_cross_case_comparison() -> None: + summaries: list[dict[str, Any]] = [] + for path in sorted(DIAGNOSTIC_DIR.glob("*_basal_diagnostic.json")): + summaries.append(json.loads(path.read_text(encoding="utf-8"))) + if not summaries: + return + summaries.sort(key=lambda item: (float(item["strength"]), float(item["trend_range"]))) + comparison = { + "cases": [ + { + "case": item["case"], + "strength": item["strength"], + "trend_range": item["trend_range"], + "raw_mode_z": item["raw_lowest_crossing"]["mode_z"], + "raw_mode_fraction": item["raw_lowest_crossing"]["mode_fraction"], + "raw_p10_p90_span": ( + item["raw_lowest_crossing"]["p90"] + - item["raw_lowest_crossing"]["p10"] + if item["raw_lowest_crossing"]["p90"] is not None + else None + ), + "final_mode_z": item["final_lowest_crossing"]["mode_z"], + "final_mode_fraction": item["final_lowest_crossing"]["mode_fraction"], + "final_p10_p90_span": ( + item["final_lowest_crossing"]["p90"] + - item["final_lowest_crossing"]["p10"] + if item["final_lowest_crossing"]["p90"] is not None + else None + ), + "mode_to_nearest_domain_min_z": item[ + "final_mode_to_nearest_domain_min_z" + ], + } + for item in summaries + ] + } + (DIAGNOSTIC_DIR / "cross_case_basal_comparison.json").write_text( + json.dumps(comparison, indent=2), encoding="utf-8" + ) + print( + "PROGRESS\tWrote cross-case basal comparison to " + f"{DIAGNOSTIC_DIR / 'cross_case_basal_comparison.json'}", + flush=True, + ) + + +print( + "PROGRESS\tDiagnostic mode: compare range-driven lowest zero crossings, current " + "topology-local V2 field, finite-domain minima, and active domain coverage.", + flush=True, +) + + +if __name__ == "__main__": + exit_code = suite.main() + _write_cross_case_comparison() + raise SystemExit(exit_code) diff --git a/benchmarks/leapfrog_gold/run_exact_lva_fast_inside_fold_sweep.py b/benchmarks/leapfrog_gold/run_exact_lva_fast_inside_fold_sweep.py new file mode 100644 index 000000000..0bc690db4 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_exact_lva_fast_inside_fold_sweep.py @@ -0,0 +1,292 @@ +"""Run the fold LVA sweep with local domains and inside-only component filtering. + +This is the faster alternative to ``run_exact_lva_full_model_fold_sweep.py``. +It keeps the confirmed lower-Z full-depth correction from the fold runner, but does +not extend every automatic domain across the complete 5x model box. The generated +mesh is then split into connected components and unsupported enclosing shells are +removed. Only the cleaned data-supported OBJ is retained. + +The common benchmark defaults remain 50,000,000 base cells and 256 scalar slabs. +For this fold runner only, either limit can be disabled by setting its environment +variable to zero before Python starts: + +- POLATORY_FOLD_MAX_TOTAL_BASE_CELLS=0 +- POLATORY_FOLD_MAX_CHUNKS=0 + +A positive value replaces the corresponding default with that explicit limit. +Use the same remaining environment variables as +``run_exact_lva_full_depth_fold_sweep.py``. +""" +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np + +os.environ.setdefault( + "POLATORY_FOLD_OUTPUT_NAME", + "fold-fast-inside-only-sweep-5x-extent", +) + +import run_exact_lva_full_depth_fold_sweep as fold # noqa: E402 + +suite = fold.suite +diagnostic = fold.diagnostic + +_BASE_BUILD_CASE = suite.build_case +_BASE_GENERATE_ISOSURFACE = suite.SAFE_MESHER.generate_safe_isosurface +_CURRENT_POINTS: np.ndarray | None = None +_LIMIT_MESSAGE_PRINTED = False + + +def _configured_limit(name: str, current: int) -> int: + """Read a positive integer limit; zero means no practical Python-side limit.""" + text = os.environ.get(name, "").strip() + if not text: + return int(current) + value = int(text) + if value < 0: + raise ValueError(f"{name} must be zero or a positive integer") + return int(sys.maxsize if value == 0 else value) + + +def _apply_fold_meshing_limits() -> None: + """Override limits after the common benchmark resets its safe defaults.""" + global _LIMIT_MESSAGE_PRINTED + cells = _configured_limit( + "POLATORY_FOLD_MAX_TOTAL_BASE_CELLS", + int(suite.SAFE_MESHER.MAX_TOTAL_BASE_CELLS), + ) + chunks = _configured_limit( + "POLATORY_FOLD_MAX_CHUNKS", + int(suite.SAFE_MESHER.MAX_CHUNKS), + ) + suite.SAFE_MESHER.MAX_TOTAL_BASE_CELLS = cells + suite.SAFE_MESHER.MAX_CHUNKS = chunks + + if not _LIMIT_MESSAGE_PRINTED: + cell_text = "unlimited" if cells == sys.maxsize else f"{cells:,}" + chunk_text = "unlimited" if chunks == sys.maxsize else f"{chunks:,}" + print( + "PROGRESS\tFold meshing safety limits: " + f"base cells={cell_text}, scalar slabs={chunk_text}. " + "The scalar field remains slab-streamed; disabling these guards does not " + "make the computation small.", + flush=True, + ) + _LIMIT_MESSAGE_PRINTED = True + + +def _support_distance(points: np.ndarray) -> tuple[float, float]: + points = np.asarray(points, dtype=np.float64) + if len(points) < 2: + median_spacing = float(suite.SURFACE_RESOLUTION) + else: + nearest = np.asarray( + suite.cKDTree(points).query(points, k=2)[0][:, 1], + dtype=np.float64, + ) + nearest = nearest[np.isfinite(nearest) & (nearest > 0.0)] + median_spacing = ( + float(np.median(nearest)) + if len(nearest) + else float(suite.SURFACE_RESOLUTION) + ) + + explicit = os.environ.get( + "POLATORY_FOLD_COMPONENT_SUPPORT_DISTANCE", "" + ).strip() + if explicit: + threshold = float(explicit) + if not np.isfinite(threshold) or threshold <= 0.0: + raise ValueError( + "POLATORY_FOLD_COMPONENT_SUPPORT_DISTANCE must be positive and finite" + ) + else: + multiplier = float( + os.environ.get("POLATORY_FOLD_COMPONENT_SPACING_MULTIPLIER", "2") + ) + if not np.isfinite(multiplier) or multiplier <= 0.0: + raise ValueError( + "POLATORY_FOLD_COMPONENT_SPACING_MULTIPLIER must be positive and finite" + ) + threshold = max( + 3.0 * float(suite.SURFACE_RESOLUTION), + multiplier * median_spacing, + ) + return float(threshold), float(median_spacing) + + +def _component_from_region(connected: Any, region_id: int) -> Any: + region_values = np.asarray(connected.cell_data["RegionId"], dtype=np.int64) + selected = connected.extract_cells(region_values == int(region_id)) + return selected.extract_surface().triangulate().clean() + + +def _merge_components(components: list[Any]) -> Any: + merged = components[0].copy(deep=True) + for component in components[1:]: + merged = merged.merge(component, merge_points=False) + return merged.extract_surface().triangulate().clean() + + +def _filter_supported_components(output_obj: Path, points: np.ndarray) -> dict[str, Any]: + output_obj = Path(output_obj) + points = np.asarray(points, dtype=np.float64) + mesh = suite.pv.read(output_obj).extract_surface().triangulate().clean() + connected = mesh.connectivity() + if "RegionId" not in connected.cell_data: + return { + "raw_component_count": 1, + "kept_component_count": 1, + "support_filter_applied": False, + } + + region_ids = sorted( + int(value) + for value in np.unique( + np.asarray(connected.cell_data["RegionId"], dtype=np.int64) + ) + ) + threshold, median_spacing = _support_distance(points) + min_points = max( + int(os.environ.get("POLATORY_FOLD_COMPONENT_MIN_SUPPORT_POINTS", "3")), + int( + math.ceil( + float( + os.environ.get( + "POLATORY_FOLD_COMPONENT_MIN_SUPPORT_FRACTION", "0.01" + ) + ) + * len(points) + ) + ), + ) + + point_cloud = suite.pv.PolyData(points) + components: list[Any] = [] + records: list[dict[str, Any]] = [] + for region_id in region_ids: + component = _component_from_region(connected, region_id) + measured = point_cloud.compute_implicit_distance(component) + distances = np.abs( + np.asarray(measured["implicit_distance"], dtype=np.float64) + ) + support_count = int(np.count_nonzero(distances <= threshold)) + record = { + "region_id": int(region_id), + "vertices": int(component.n_points), + "triangles": int(component.n_cells), + "bounds": [float(value) for value in component.bounds], + "minimum_data_distance": float(np.min(distances)), + "median_data_distance": float(np.median(distances)), + "p90_data_distance": float(np.percentile(distances, 90.0)), + "support_distance": float(threshold), + "support_point_count": support_count, + "support_fraction": float(support_count / len(points)), + "kept": bool(support_count >= min_points), + } + records.append(record) + components.append(component) + + kept_indices = [index for index, record in enumerate(records) if record["kept"]] + if not kept_indices: + best = int( + np.argmin([record["median_data_distance"] for record in records]) + ) + records[best]["kept"] = True + records[best]["fallback_closest_component"] = True + kept_indices = [best] + + cleaned = _merge_components([components[index] for index in kept_indices]) + cleaned.save(output_obj) + + stale_raw = output_obj.with_name(output_obj.stem + "_all_components.obj") + if stale_raw.exists(): + stale_raw.unlink() + + report = { + "cleaned_obj": str(output_obj), + "raw_component_count": int(len(records)), + "kept_component_count": int(len(kept_indices)), + "median_input_spacing": float(median_spacing), + "support_distance": float(threshold), + "minimum_support_points": int(min_points), + "components": records, + } + report_path = output_obj.with_name(output_obj.stem + "_components.json") + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + + print( + f"PROGRESS\tInside-only component filter kept {len(kept_indices)}/{len(records)} " + f"components; threshold={threshold:g}, median input spacing={median_spacing:g}.", + flush=True, + ) + return report + + +def _filtered_generate_isosurface(*args: Any, **kwargs: Any): + # run_real_gold_suite.py resets its conservative defaults immediately before + # calling us. Apply fold-specific overrides here so zero/unlimited is honoured. + _apply_fold_meshing_limits() + result = _BASE_GENERATE_ISOSURFACE(*args, **kwargs) + output_obj = kwargs.get("output_obj") + if output_obj is None: + raise RuntimeError("The fold mesher did not provide output_obj") + if _CURRENT_POINTS is None: + raise RuntimeError("Fold component filtering has no current input points") + _filter_supported_components(Path(output_obj), _CURRENT_POINTS) + return result + + +suite.SAFE_MESHER.generate_safe_isosurface = _filtered_generate_isosurface + + +def _inside_only_build_case( + case: Any, + points: np.ndarray, + indicators: np.ndarray, + trend_vertices: np.ndarray, + trend_faces: np.ndarray, +) -> dict[str, Any]: + global _CURRENT_POINTS + _CURRENT_POINTS = np.asarray(points, dtype=np.float64) + try: + result = _BASE_BUILD_CASE( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + finally: + _CURRENT_POINTS = None + + output_path = suite.ROOT / result["generated_obj"] + component_report_path = output_path.with_name( + output_path.stem + "_components.json" + ) + if component_report_path.is_file(): + result["component_filter"] = json.loads( + component_report_path.read_text(encoding="utf-8") + ) + return result + + +suite.build_case = _inside_only_build_case + +print( + "PROGRESS\tFast inside-only fold mode enabled: automatic domains remain local " + "except for the confirmed lower-Z extension, and unsupported disconnected outer " + "shells are removed after meshing. Only cleaned OBJs are retained.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(fold.main()) diff --git a/benchmarks/leapfrog_gold/run_exact_lva_full_depth_fold_sweep.py b/benchmarks/leapfrog_gold/run_exact_lva_full_depth_fold_sweep.py new file mode 100644 index 000000000..c25e797b4 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_exact_lva_full_depth_fold_sweep.py @@ -0,0 +1,294 @@ +"""Run the full-depth LVA parameter sweep on a categorical fold dataset. + +Required environment variables: +- POLATORY_SWEEP_DATASET: CSV containing labeled sample coordinates. +- POLATORY_SWEEP_TREND_OBJ: structural trend mesh in OBJ format. + +The default CSV mapping matches Fold_dataset.csv: +- coordinates: xm,ym,zm +- category column: Geology +- inside -> +1, outside -> -1 + +By default, the model bounding-box span is five times the combined data/trend +extent along X, Y and Z, centred on that extent. This prevents generated surfaces +from being clipped against a tight model box. Override with: +- POLATORY_FOLD_MODEL_EXTENT_FACTOR=5 +- POLATORY_FOLD_MODEL_PADDING= or X,Y,Z + +All strength/range cases are generated even when no Leapfrog oracle mesh exists. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +# Keep the normal 4 x 10 sweep unless the command overrides it. +os.environ.setdefault("POLATORY_SWEEP_STRENGTHS", "2,3,4,5") +os.environ.setdefault( + "POLATORY_SWEEP_RANGES", + "50,100,150,200,250,300,350,400,450,500", +) + +import run_exact_lva_full_depth_parameter_sweep as sweep # noqa: E402 + +suite = sweep.suite +diagnostic = sweep.diagnostic +full_depth = sweep.full_depth + +DATASET_PATH = Path(os.environ.get("POLATORY_SWEEP_DATASET", "")).expanduser() +TREND_PATH = Path(os.environ.get("POLATORY_SWEEP_TREND_OBJ", "")).expanduser() + +LABEL_COLUMN = os.environ.get("POLATORY_FOLD_LABEL_COLUMN", "Geology").strip() +XYZ_COLUMNS = tuple( + item.strip() + for item in os.environ.get("POLATORY_FOLD_XYZ_COLUMNS", "xm,ym,zm").split(",") + if item.strip() +) +INSIDE_LABEL = os.environ.get("POLATORY_FOLD_INSIDE_LABEL", "inside").strip().casefold() +OUTSIDE_LABEL = os.environ.get("POLATORY_FOLD_OUTSIDE_LABEL", "outside").strip().casefold() +MODEL_EXTENT_FACTOR = float( + os.environ.get("POLATORY_FOLD_MODEL_EXTENT_FACTOR", "5") +) +if not np.isfinite(MODEL_EXTENT_FACTOR) or MODEL_EXTENT_FACTOR < 1.0: + raise ValueError( + "POLATORY_FOLD_MODEL_EXTENT_FACTOR must be a finite number >= 1" + ) + +if len(XYZ_COLUMNS) != 3: + raise ValueError( + "POLATORY_FOLD_XYZ_COLUMNS must contain exactly three comma-separated columns" + ) + + +def _required_path(path: Path, variable: str) -> Path: + if not str(path): + raise ValueError(f"{variable} must point to an existing file") + path = path.resolve() + if not path.is_file(): + raise FileNotFoundError(f"{variable} does not exist: {path}") + return path + + +def _parse_padding(spans: np.ndarray) -> np.ndarray: + """Return padding on each side of the raw extent. + + An explicit absolute padding keeps the previous behavior. Otherwise the + requested extent factor is interpreted as the final box span divided by the + raw span, so factor 5 means two raw spans of padding on each side: + + final_span = raw_span + 2 * padding = 5 * raw_span + """ + text = os.environ.get("POLATORY_FOLD_MODEL_PADDING", "").strip() + if not text: + return 0.5 * (MODEL_EXTENT_FACTOR - 1.0) * np.asarray( + spans, dtype=np.float64 + ) + + values = [float(item.strip()) for item in text.split(",") if item.strip()] + if len(values) == 1: + values *= 3 + if len(values) != 3 or any( + not np.isfinite(value) or value < 0.0 for value in values + ): + raise ValueError( + "POLATORY_FOLD_MODEL_PADDING must be one non-negative finite number or X,Y,Z" + ) + return np.asarray(values, dtype=np.float64) + + +def _read_fold_dataset(path: Path) -> tuple[np.ndarray, np.ndarray, pd.DataFrame]: + frame = pd.read_csv(path) + required = set(XYZ_COLUMNS) | {LABEL_COLUMN} + missing = sorted(required.difference(frame.columns)) + if missing: + raise ValueError(f"{path} is missing required columns: {missing}") + + points = frame[list(XYZ_COLUMNS)].to_numpy(dtype=np.float64) + if len(points) == 0 or not np.all(np.isfinite(points)): + raise ValueError("Fold dataset coordinates must be non-empty and finite") + + labels = frame[LABEL_COLUMN].astype(str).str.strip().str.casefold() + known = labels.isin([INSIDE_LABEL, OUTSIDE_LABEL]) + if not bool(known.all()): + unknown = sorted(labels.loc[~known].unique().tolist()) + raise ValueError( + f"Unexpected {LABEL_COLUMN} values {unknown}; expected " + f"{INSIDE_LABEL!r} and {OUTSIDE_LABEL!r}" + ) + + # The structural interpolant uses -1 as its outside/background value. + indicators = np.where( + labels.to_numpy() == INSIDE_LABEL, + 1.0, + -1.0, + ).astype(np.float64) + return points, indicators, frame + + +def _extent_points(frame: pd.DataFrame, samples: np.ndarray) -> np.ndarray: + groups: list[np.ndarray] = [np.asarray(samples, dtype=np.float64)] + for columns in (("xb", "yb", "zb"), ("xm", "ym", "zm"), ("xe", "ye", "ze")): + if set(columns).issubset(frame.columns): + values = frame[list(columns)].to_numpy(dtype=np.float64) + if np.all(np.isfinite(values)): + groups.append(values) + return np.vstack(groups) + + +def _factor_text(value: float) -> str: + rounded = round(value) + if abs(value - rounded) < 1.0e-12: + return str(int(rounded)) + return f"{value:g}".replace(".", "p") + + +def _configure_paths_and_bounds( + points: np.ndarray, + frame: pd.DataFrame, + trend_vertices: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + extent = np.vstack([_extent_points(frame, points), trend_vertices]) + raw_minimum = np.min(extent, axis=0) + raw_maximum = np.max(extent, axis=0) + spans = raw_maximum - raw_minimum + if np.any(spans <= 0.0): + raise ValueError( + f"Degenerate model extent: minimum={raw_minimum}, maximum={raw_maximum}" + ) + + padding = _parse_padding(spans) + model_min = raw_minimum - padding + model_max = raw_maximum + padding + + suite.MODEL_MIN = model_min + suite.MODEL_MAX = model_max + # The confirmed fix reads this module global while each domain is constructed. + full_depth._MODEL_MIN_Z = float(model_min[2]) + + default_output_name = ( + "fold-exact-lva-full-depth-sweep-" + f"{_factor_text(MODEL_EXTENT_FACTOR)}x-extent" + ) + output_name = os.environ.get( + "POLATORY_FOLD_OUTPUT_NAME", + default_output_name, + ).strip() + if not output_name: + raise ValueError("POLATORY_FOLD_OUTPUT_NAME cannot be empty") + output_dir = suite.ROOT / "benchmark-results" / output_name + suite.OUTPUT_DIR = output_dir + suite.MESH_DIR = output_dir / "meshes" + suite.PLOT_DIR = output_dir / "overlays" + diagnostic.DIAGNOSTIC_DIR = output_dir / "basal-diagnostics" + full_depth.baseline.CSV_DIR = output_dir / "inspection-csv" + + # Synthetic sweep case references are resolved beneath DATA_DIR. This folder + # contains no Sx_Ry oracle meshes unless the user deliberately adds them. + suite.DATA_DIR = DATASET_PATH.parent + return model_min, model_max, padding, raw_minimum, raw_maximum + + +def main() -> int: + dataset_path = _required_path(DATASET_PATH, "POLATORY_SWEEP_DATASET") + trend_path = _required_path(TREND_PATH, "POLATORY_SWEEP_TREND_OBJ") + + points, indicators, frame = _read_fold_dataset(dataset_path) + trend_vertices, trend_faces = suite.read_obj(trend_path) + model_min, model_max, padding, raw_minimum, raw_maximum = ( + _configure_paths_and_bounds( + points, + frame, + trend_vertices, + ) + ) + + cases = sweep._sweep_cases() + suite.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + report: dict[str, Any] = { + "dataset": str(dataset_path), + "trend_mesh": str(trend_path), + "xyz_columns": list(XYZ_COLUMNS), + "label_column": LABEL_COLUMN, + "inside_label": INSIDE_LABEL, + "outside_label": OUTSIDE_LABEL, + "input_points": int(len(points)), + "inside_points": int(np.count_nonzero(indicators > 0.0)), + "outside_points": int(np.count_nonzero(indicators < 0.0)), + "raw_extent_min": raw_minimum.tolist(), + "raw_extent_max": raw_maximum.tolist(), + "model_extent_factor": float(MODEL_EXTENT_FACTOR), + "model_bounds_min": model_min.tolist(), + "model_bounds_max": model_max.tolist(), + "model_padding_each_side": padding.tolist(), + "surface_resolution": float(suite.SURFACE_RESOLUTION), + "cases": {}, + } + + print( + "PROGRESS\tFold dataset sweep: " + f"{len(points)} midpoint samples " + f"({report['inside_points']} inside, {report['outside_points']} outside).", + flush=True, + ) + print( + f"PROGRESS\tTrend mesh: {len(trend_vertices)} vertices, " + f"{len(trend_faces)} triangles.", + flush=True, + ) + print( + f"PROGRESS\tRaw combined extent: min={raw_minimum.tolist()}, " + f"max={raw_maximum.tolist()}.", + flush=True, + ) + print( + f"PROGRESS\tExpanded model extent: factor={MODEL_EXTENT_FACTOR:g} per axis, " + f"min={model_min.tolist()}, max={model_max.tolist()}, " + f"padding each side={padding.tolist()}.", + flush=True, + ) + print( + f"PROGRESS\tRunning {len(cases)} full-depth LVA cases; " + f"results directory: {suite.OUTPUT_DIR}", + flush=True, + ) + + for index, case in enumerate(cases, start=1): + print( + f"PROGRESS\tCase {index}/{len(cases)}: {case.name}", + flush=True, + ) + report["cases"][case.name] = suite.build_case( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + (suite.OUTPUT_DIR / "metrics.json").write_text( + json.dumps(report, indent=2), + encoding="utf-8", + ) + + report["parameter_response"] = suite.response_metrics(report["cases"]) + (suite.OUTPUT_DIR / "metrics.json").write_text( + json.dumps(report, indent=2), + encoding="utf-8", + ) + suite.write_summary(report) + diagnostic._write_cross_case_comparison() + print( + f"PROGRESS\tCompleted fold sweep. Summary: " + f"{suite.OUTPUT_DIR / 'summary.md'}", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/run_exact_lva_full_depth_parameter_sweep.py b/benchmarks/leapfrog_gold/run_exact_lva_full_depth_parameter_sweep.py new file mode 100644 index 000000000..e17476d2d --- /dev/null +++ b/benchmarks/leapfrog_gold/run_exact_lva_full_depth_parameter_sweep.py @@ -0,0 +1,277 @@ +"""Run the confirmed full-depth-domain fix across a strength/range grid. + +Defaults: +- strengths: 2, 3, 4, 5 +- ranges: 50, 100, ..., 500 + +The sweep is intentionally allowed to include parameter combinations for which no +Leapfrog reference OBJ exists. Every requested case still generates a Polatory OBJ, +a three-projection image, basal diagnostics, domain CSVs and an LVA-field CSV. +When a matching Leapfrog OBJ is present, the normal comparison metrics and overlay +are retained; otherwise the report marks the case as generated-only. + +Optional environment overrides: +- POLATORY_SWEEP_STRENGTHS=2,3,4,5 +- POLATORY_SWEEP_RANGES=50,100,150 +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import numpy as np + + +def _parse_numbers(name: str, default: list[float]) -> list[float]: + text = os.environ.get(name, "").strip() + if not text: + return default + values: list[float] = [] + for item in text.split(","): + item = item.strip() + if not item: + continue + value = float(item) + if not value > 0.0: + raise ValueError(f"{name} values must be positive, got {value}") + values.append(value) + if not values: + raise ValueError(f"{name} did not contain any numeric values") + return values + + +def _case_number(value: float) -> str: + rounded = round(value) + if abs(value - rounded) < 1.0e-12: + return str(int(rounded)) + return f"{value:g}" + + +STRENGTHS = _parse_numbers( + "POLATORY_SWEEP_STRENGTHS", + [2.0, 3.0, 4.0, 5.0], +) +RANGES = _parse_numbers( + "POLATORY_SWEEP_RANGES", + [float(value) for value in range(50, 501, 50)], +) + +CASE_NAMES = [ + f"S{_case_number(strength)}_R{_case_number(range_)}" + for strength in STRENGTHS + for range_ in RANGES +] + +# Keep the requested case list visible to the imported basal diagnostic, then +# replace its reference-only case resolver below with a synthetic sweep resolver. +os.environ["POLATORY_BASAL_CASES"] = ",".join(CASE_NAMES) + +import run_selected_exact_leapfrog_lva_full_depth_domains as full_depth # noqa: E402 + +suite = full_depth.suite +diagnostic = full_depth.diagnostic + +suite.OUTPUT_DIR = ( + suite.ROOT / "benchmark-results" / "exact-leapfrog-lva-full-depth-sweep" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" +diagnostic.DIAGNOSTIC_DIR = suite.OUTPUT_DIR / "basal-diagnostics" +full_depth.baseline.CSV_DIR = suite.OUTPUT_DIR / "inspection-csv" + + +def _sweep_cases() -> list[Any]: + """Create every requested case, whether or not a reference OBJ exists.""" + return [ + suite.Case( + name=f"S{_case_number(strength)}_R{_case_number(range_)}", + strength=float(strength), + trend_range=float(range_), + reference=Path(suite.DATA_DIR) + / f"S{_case_number(strength)}_R{_case_number(range_)}.obj", + ) + for strength in STRENGTHS + for range_ in RANGES + ] + + +# The normal basal runner validates requested names against reference OBJ names. +# A parameter sweep must instead be able to generate arbitrary combinations. +suite.available_cases = _sweep_cases + +_ORIGINAL_COMPARE_MESHES = suite.compare_meshes +_ORIGINAL_RENDER_OVERLAY = suite.render_overlay + + +def _optional_compare_meshes(generated_path: Path, reference_path: Path) -> dict[str, Any]: + """Compare when an oracle exists; otherwise retain generated mesh metrics.""" + reference_path = Path(reference_path) + if reference_path.is_file(): + comparison = _ORIGINAL_COMPARE_MESHES(generated_path, reference_path) + comparison["reference_available"] = True + return comparison + + generated = suite.polydata(generated_path) + return { + "reference_available": False, + "generated": suite.mesh_metrics(generated), + "reference": None, + "generated_to_reference": None, + "reference_to_generated": None, + "symmetric_surface_distance": None, + "area_ratio_generated_over_reference": None, + } + + +def _generated_projection(case: Any, generated_path: Path) -> None: + """Render a useful generated-only view when no Leapfrog oracle is available.""" + if Path(case.reference).is_file(): + _ORIGINAL_RENDER_OVERLAY(case, generated_path) + return + + generated, _ = suite.read_obj(generated_path) + limits = [ + (suite.MODEL_MIN[0], suite.MODEL_MAX[0]), + (suite.MODEL_MIN[1], suite.MODEL_MAX[1]), + (suite.MODEL_MIN[2], suite.MODEL_MAX[2]), + ] + projections = ( + ("XY plan", 0, 1), + ("XZ section projection", 0, 2), + ("YZ section projection", 1, 2), + ) + labels = ("X", "Y", "Z") + figure, axes = suite.plt.subplots(1, 3, figsize=(18, 6)) + stride = max(1, len(generated) // 100_000) + for axis, (title, first, second) in zip(axes, projections): + axis.scatter( + generated[::stride, first], + generated[::stride, second], + s=0.4, + alpha=0.45, + label="Polatory", + ) + axis.set_title(title) + axis.set_xlabel(labels[first]) + axis.set_ylabel(labels[second]) + axis.set_xlim(limits[first]) + axis.set_ylim(limits[second]) + axis.set_aspect("equal", adjustable="box") + axes[0].legend(markerscale=10) + figure.suptitle( + f"{case.name}: Polatory generated surface, " + f"resolution {suite.SURFACE_RESOLUTION:g} m\n" + "No matching Leapfrog reference OBJ was available" + ) + figure.tight_layout() + suite.PLOT_DIR.mkdir(parents=True, exist_ok=True) + figure.savefig(suite.PLOT_DIR / f"{case.name}_overlay.png", dpi=180) + suite.plt.close(figure) + + +suite.compare_meshes = _optional_compare_meshes +suite.render_overlay = _generated_projection + + +def _generated_response_metrics( + results: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Summarize generated changes without requiring reference meshes.""" + output: list[dict[str, Any]] = [] + grouped: dict[float, list[dict[str, Any]]] = {} + for item in results.values(): + grouped.setdefault(float(item["strength"]), []).append(item) + + for strength, group in sorted(grouped.items()): + group.sort(key=lambda item: float(item["trend_range"])) + for lower, upper in zip(group, group[1:]): + lower_generated = lower["comparison"]["generated"] + upper_generated = upper["comparison"]["generated"] + lower_bounds = np.asarray( + lower_generated["bounds_min"] + lower_generated["bounds_max"], + dtype=float, + ) + upper_bounds = np.asarray( + upper_generated["bounds_min"] + upper_generated["bounds_max"], + dtype=float, + ) + output.append( + { + "strength": float(strength), + "from_range": float(lower["trend_range"]), + "to_range": float(upper["trend_range"]), + "generated_bounds_delta": (upper_bounds - lower_bounds).tolist(), + "generated_area_ratio": float( + upper_generated["area"] / lower_generated["area"] + ), + "generated_vertex_delta": int( + upper_generated["vertices"] - lower_generated["vertices"] + ), + } + ) + return output + + +def _write_sweep_summary(report: dict[str, Any]) -> None: + lines = [ + "# Full-depth LVA parameter sweep", + "", + f"- Surface resolution: {suite.SURFACE_RESOLUTION:g} m", + f"- Cases: {len(report['cases'])}", + "- Missing references are generated and visualized without oracle metrics.", + "", + "| Case | Domains | Reference | Z min | Z max | Vertices | Area | Seconds | Symmetric mean | Symmetric p95 |", + "|---|---:|:---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for name, item in report["cases"].items(): + comparison = item["comparison"] + generated = comparison["generated"] + distance = comparison.get("symmetric_surface_distance") + symmetric_mean = f"{distance['mean']:.3f}" if distance is not None else "—" + symmetric_p95 = f"{distance['p95']:.3f}" if distance is not None else "—" + lines.append( + f"| {name} | {item['domain_count']} | " + f"{'yes' if comparison.get('reference_available') else 'no'} | " + f"{generated['bounds_min'][2]:.2f} | {generated['bounds_max'][2]:.2f} | " + f"{generated['vertices']} | {generated['area']:.2f} | " + f"{item['elapsed_seconds']:.1f} | {symmetric_mean} | {symmetric_p95} |" + ) + (suite.OUTPUT_DIR / "summary.md").write_text( + "\n".join(lines) + "\n", + encoding="utf-8", + ) + + +suite.response_metrics = _generated_response_metrics +suite.write_summary = _write_sweep_summary + +_REFERENCE_COUNT = sum(Path(case.reference).is_file() for case in _sweep_cases()) +print( + "PROGRESS\tFull-depth parameter sweep enabled: " + f"{len(STRENGTHS)} strengths x {len(RANGES)} ranges = {len(CASE_NAMES)} cases.", + flush=True, +) +print( + "PROGRESS\tStrengths: " + ", ".join(_case_number(v) for v in STRENGTHS), + flush=True, +) +print( + "PROGRESS\tRanges: " + ", ".join(_case_number(v) for v in RANGES), + flush=True, +) +print( + f"PROGRESS\tLeapfrog references found for {_REFERENCE_COUNT}/{len(CASE_NAMES)} cases; " + "all remaining combinations will be generated without oracle comparison.", + flush=True, +) +print( + f"PROGRESS\tResults directory: {suite.OUTPUT_DIR}", + flush=True, +) + + +if __name__ == "__main__": + exit_code = suite.main() + diagnostic._write_cross_case_comparison() + raise SystemExit(exit_code) diff --git a/benchmarks/leapfrog_gold/run_exact_lva_full_model_fold_sweep.py b/benchmarks/leapfrog_gold/run_exact_lva_full_model_fold_sweep.py new file mode 100644 index 000000000..b13ce8471 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_exact_lva_full_model_fold_sweep.py @@ -0,0 +1,306 @@ +"""Run the fold LVA sweep with full-model domains and support-aware components. + +The 5x model extent can expose a second zero surface at the finite automatic-domain +envelope. This runner keeps the same data, LVA matrices, support memberships and +local RBF models, but extends every domain evaluation box to the complete model +bounds. After meshing, disconnected components with no nearby input support are +removed from the primary OBJ. + +By default only the cleaned, data-supported OBJ is retained. Set +``POLATORY_FOLD_KEEP_ALL_COMPONENTS=1`` only when the unfiltered diagnostic OBJ is +also required. + +Use the same environment variables as run_exact_lva_full_depth_fold_sweep.py. +""" +from __future__ import annotations + +import json +import math +import os +import shutil +from pathlib import Path +from typing import Any + +import numpy as np + +# Keep this run separate from the earlier full-depth-only fold output. +os.environ.setdefault( + "POLATORY_FOLD_OUTPUT_NAME", + "fold-exact-lva-full-model-sweep-5x-extent", +) + +import run_exact_lva_full_depth_fold_sweep as fold # noqa: E402 + +suite = fold.suite +diagnostic = fold.diagnostic + +_BASE_DOMAIN_BUILDER = suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder +_BASE_BUILD_CASE = suite.build_case +_BASE_GENERATE_ISOSURFACE = suite.SAFE_MESHER.generate_safe_isosurface +_CURRENT_POINTS: np.ndarray | None = None + +_KEEP_ALL_COMPONENTS = os.environ.get( + "POLATORY_FOLD_KEEP_ALL_COMPONENTS", "0" +).strip().casefold() in {"1", "true", "yes", "on"} + + +class _FullModelDomainBuilder: + """Extend all six faces of each automatic domain to the model bbox.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._wrapped = _BASE_DOMAIN_BUILDER(*args, **kwargs) + + def build_from_inputs(self, *args: Any, **kwargs: Any): + original_domains = list(self._wrapped.build_from_inputs(*args, **kwargs)) + bbox_min = np.asarray(suite.MODEL_MIN, dtype=np.float64) + bbox_max = np.asarray(suite.MODEL_MAX, dtype=np.float64) + extended_domains = [ + fold.sweep.full_depth.polatory.StructuralDomain3( + np.asarray(domain.anisotropy, dtype=np.float64), + bbox_min.copy(), + bbox_max.copy(), + np.asarray(domain.support_indices, dtype=np.int64).tolist(), + np.asarray(domain.model_parameters, dtype=np.float64).tolist(), + ) + for domain in original_domains + ] + + # Diagnostics and CSV exports must see the domains actually used to fit. + diagnostic._CAPTURE["domains"] = extended_domains + print( + "PROGRESS\tExtended every automatic domain to the complete model bbox: " + f"min={bbox_min.tolist()}, max={bbox_max.tolist()}. LVA matrices, support " + "indices and local RBF parameters are unchanged.", + flush=True, + ) + return extended_domains + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder = _FullModelDomainBuilder + + +def _support_distance(points: np.ndarray) -> tuple[float, float]: + """Return the component support threshold and median input spacing.""" + points = np.asarray(points, dtype=np.float64) + if len(points) < 2: + median_spacing = float(suite.SURFACE_RESOLUTION) + else: + nearest = np.asarray( + suite.cKDTree(points).query(points, k=2)[0][:, 1], + dtype=np.float64, + ) + nearest = nearest[np.isfinite(nearest) & (nearest > 0.0)] + median_spacing = ( + float(np.median(nearest)) + if len(nearest) + else float(suite.SURFACE_RESOLUTION) + ) + + explicit = os.environ.get( + "POLATORY_FOLD_COMPONENT_SUPPORT_DISTANCE", "" + ).strip() + if explicit: + threshold = float(explicit) + if not np.isfinite(threshold) or threshold <= 0.0: + raise ValueError( + "POLATORY_FOLD_COMPONENT_SUPPORT_DISTANCE must be positive and finite" + ) + else: + multiplier = float( + os.environ.get("POLATORY_FOLD_COMPONENT_SPACING_MULTIPLIER", "2") + ) + if not np.isfinite(multiplier) or multiplier <= 0.0: + raise ValueError( + "POLATORY_FOLD_COMPONENT_SPACING_MULTIPLIER must be positive and finite" + ) + threshold = max( + 3.0 * float(suite.SURFACE_RESOLUTION), + multiplier * median_spacing, + ) + return float(threshold), float(median_spacing) + + +def _component_from_region(connected: Any, region_id: int) -> Any: + region_values = np.asarray(connected.cell_data["RegionId"], dtype=np.int64) + selected = connected.extract_cells(region_values == int(region_id)) + return selected.extract_surface().triangulate().clean() + + +def _merge_components(components: list[Any]) -> Any: + merged = components[0].copy(deep=True) + for component in components[1:]: + merged = merged.merge(component, merge_points=False) + return merged.extract_surface().triangulate().clean() + + +def _filter_supported_components(output_obj: Path, points: np.ndarray) -> dict[str, Any]: + """Keep connected isosurfaces supported by nearby input data.""" + output_obj = Path(output_obj) + points = np.asarray(points, dtype=np.float64) + mesh = suite.pv.read(output_obj).extract_surface().triangulate().clean() + connected = mesh.connectivity() + if "RegionId" not in connected.cell_data: + return { + "raw_component_count": 1, + "kept_component_count": 1, + "support_filter_applied": False, + } + + region_ids = sorted( + int(value) + for value in np.unique( + np.asarray(connected.cell_data["RegionId"], dtype=np.int64) + ) + ) + threshold, median_spacing = _support_distance(points) + min_points = max( + int(os.environ.get("POLATORY_FOLD_COMPONENT_MIN_SUPPORT_POINTS", "3")), + int( + math.ceil( + float( + os.environ.get( + "POLATORY_FOLD_COMPONENT_MIN_SUPPORT_FRACTION", "0.01" + ) + ) + * len(points) + ) + ), + ) + + point_cloud = suite.pv.PolyData(points) + components: list[Any] = [] + records: list[dict[str, Any]] = [] + for region_id in region_ids: + component = _component_from_region(connected, region_id) + measured = point_cloud.compute_implicit_distance(component) + distances = np.abs( + np.asarray(measured["implicit_distance"], dtype=np.float64) + ) + support_count = int(np.count_nonzero(distances <= threshold)) + record = { + "region_id": int(region_id), + "vertices": int(component.n_points), + "triangles": int(component.n_cells), + "bounds": [float(value) for value in component.bounds], + "minimum_data_distance": float(np.min(distances)), + "median_data_distance": float(np.median(distances)), + "p90_data_distance": float(np.percentile(distances, 90.0)), + "support_distance": float(threshold), + "support_point_count": support_count, + "support_fraction": float(support_count / len(points)), + "kept": bool(support_count >= min_points), + } + records.append(record) + components.append(component) + + kept_indices = [index for index, record in enumerate(records) if record["kept"]] + if not kept_indices: + # Conservative fallback: retain the component that is globally closest to + # the input samples instead of producing an empty mesh. + best = int( + np.argmin([record["median_data_distance"] for record in records]) + ) + records[best]["kept"] = True + records[best]["fallback_closest_component"] = True + kept_indices = [best] + + raw_path = output_obj.with_name(output_obj.stem + "_all_components.obj") + if _KEEP_ALL_COMPONENTS: + shutil.copy2(output_obj, raw_path) + elif raw_path.exists(): + # Remove a stale diagnostic OBJ from an earlier run in the same folder. + raw_path.unlink() + + cleaned = _merge_components([components[index] for index in kept_indices]) + cleaned.save(output_obj) + + report = { + "raw_obj": str(raw_path) if _KEEP_ALL_COMPONENTS else None, + "cleaned_obj": str(output_obj), + "raw_component_count": int(len(records)), + "kept_component_count": int(len(kept_indices)), + "median_input_spacing": float(median_spacing), + "support_distance": float(threshold), + "minimum_support_points": int(min_points), + "kept_all_components_obj": bool(_KEEP_ALL_COMPONENTS), + "components": records, + } + report_path = output_obj.with_name(output_obj.stem + "_components.json") + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + + removed = len(records) - len(kept_indices) + extra = f" Raw OBJ: {raw_path.name}" if _KEEP_ALL_COMPONENTS else "" + print( + f"PROGRESS\tComponent support filter kept {len(kept_indices)}/{len(records)} " + f"components and removed {removed}; threshold={threshold:g}, " + f"median input spacing={median_spacing:g}.{extra}", + flush=True, + ) + return report + + +def _filtered_generate_isosurface(*args: Any, **kwargs: Any): + result = _BASE_GENERATE_ISOSURFACE(*args, **kwargs) + output_obj = kwargs.get("output_obj") + if output_obj is None: + raise RuntimeError("The fold mesher did not provide output_obj") + if _CURRENT_POINTS is None: + raise RuntimeError("Fold component filtering has no current input points") + _filter_supported_components(Path(output_obj), _CURRENT_POINTS) + return result + + +suite.SAFE_MESHER.generate_safe_isosurface = _filtered_generate_isosurface + + +def _support_aware_build_case( + case: Any, + points: np.ndarray, + indicators: np.ndarray, + trend_vertices: np.ndarray, + trend_faces: np.ndarray, +) -> dict[str, Any]: + global _CURRENT_POINTS + _CURRENT_POINTS = np.asarray(points, dtype=np.float64) + try: + result = _BASE_BUILD_CASE( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + finally: + _CURRENT_POINTS = None + + output_path = suite.ROOT / result["generated_obj"] + component_report_path = output_path.with_name( + output_path.stem + "_components.json" + ) + if component_report_path.is_file(): + result["component_filter"] = json.loads( + component_report_path.read_text(encoding="utf-8") + ) + return result + + +suite.build_case = _support_aware_build_case + +print( + "PROGRESS\tFull-model fold correction enabled: all automatic domain boxes span " + "the model bbox, and disconnected isosurfaces without nearby data support are " + "removed from the primary OBJ. " + + ( + "Raw all-component OBJs will also be preserved." + if _KEEP_ALL_COMPONENTS + else "Only cleaned data-supported OBJs will be retained." + ), + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(fold.main()) diff --git a/benchmarks/leapfrog_gold/run_full_extent_domain_diagnostic.py b/benchmarks/leapfrog_gold/run_full_extent_domain_diagnostic.py new file mode 100644 index 000000000..1a17d1e87 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_full_extent_domain_diagnostic.py @@ -0,0 +1,95 @@ +"""Run one or more real-gold cases with automatic domain boxes expanded globally. + +This is a diagnostic only. It preserves the recovered automatic-domain anisotropies, +support indices, model parameters, and fitted structural interpolant, but removes finite +axis-aligned domain cutoffs by making every recovered domain active across the complete +model extent plus one base-range halo. Comparing this output with the normal finite- +domain output isolates whether shelves, vertical walls, and forced bottoms originate at +the automatic domain bounding boxes rather than in the isosurface extractor. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Sequence + +import numpy as np +import polatory + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import run_real_gold_suite as suite # noqa: E402 + + +_BASE_BUILDER = suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder + + +class FullExtentDiagnosticBuilder: + """Delegate clustering, then remove only the finite axis-aligned box cutoffs.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._builder = _BASE_BUILDER(*args, **kwargs) + + def build_from_inputs( + self, + points: np.ndarray, + inputs: Sequence[object], + model_parameters: Sequence[float], + trend_type: object = polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) -> list[Any]: + domains = list( + self._builder.build_from_inputs( + points, + inputs, + model_parameters=model_parameters, + trend_type=trend_type, + ) + ) + + halo = float(suite.BASE_RANGE) + bbox_min = np.asarray(suite.MODEL_MIN, dtype=float) - halo + bbox_max = np.asarray(suite.MODEL_MAX, dtype=float) + halo + expanded: list[Any] = [] + for domain in domains: + expanded.append( + polatory.StructuralDomain3( + np.asarray(domain.anisotropy, dtype=float), + bbox_min, + bbox_max, + np.asarray(domain.support_indices, dtype=np.int64).tolist(), + np.asarray(domain.model_parameters, dtype=float) + .reshape(-1) + .tolist(), + ) + ) + + print( + "PROGRESS\tDiagnostic mode: expanded every recovered automatic domain " + "across the model extent plus one base-range halo.", + flush=True, + ) + return expanded + + @property + def diagnostics_(self) -> Any: + return self._builder.diagnostics_ + + @property + def labels_(self) -> Any: + return self._builder.labels_ + + def __getattr__(self, name: str) -> Any: + return getattr(self._builder, name) + + +suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder = FullExtentDiagnosticBuilder +suite.OUTPUT_DIR = suite.ROOT / "benchmark-results" / "domain-diagnostic-full-extent" +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_no_background_blending_diagnostic.py b/benchmarks/leapfrog_gold/run_no_background_blending_diagnostic.py new file mode 100644 index 000000000..571b6cfe4 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_no_background_blending_diagnostic.py @@ -0,0 +1,50 @@ +"""Run the real-gold benchmark with structural background blending disabled. + +This controlled diagnostic keeps the recovered automatic domains, support indices, +local anisotropies, RBF parameters, and aligned global-grid mesher unchanged. It +changes only the StructuralInterpolant3 background_blending flag from True to False. +The result isolates whether blending each finite domain toward outside_value creates +box-aligned zero-surface shelves, vertical walls, and forced terminations. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import polatory + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import run_real_gold_suite as suite # noqa: E402 + + +_ORIGINAL_STRUCTURAL_INTERPOLANT = polatory.StructuralInterpolant3 + + +def _no_background_blending(*args: Any, **kwargs: Any): + positional = list(args) + if len(positional) >= 5: + positional[4] = False + kwargs.pop("background_blending", None) + else: + kwargs["background_blending"] = False + return _ORIGINAL_STRUCTURAL_INTERPOLANT(*positional, **kwargs) + + +polatory.StructuralInterpolant3 = _no_background_blending +suite.OUTPUT_DIR = suite.ROOT / "benchmark-results" / "diagnostic-no-background-blending" +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: StructuralInterpolant3 background_blending=False; " + "all automatic domains and meshing settings are unchanged.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_real_gold_suite.py b/benchmarks/leapfrog_gold/run_real_gold_suite.py new file mode 100644 index 000000000..a9515c482 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_real_gold_suite.py @@ -0,0 +1,552 @@ +"""Run the real WolfPass/Leapfrog structural-LVA gold benchmark headlessly. + +The data are downloaded from the repository release asset rather than committed to +source control. Every available S_R.obj reference is regenerated +with the same production automatic SubDomainer, structural interpolant, and +chunk-safe isosurface path used by the standalone GUI. +""" + +from __future__ import annotations + +import json +import math +import os +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pyvista as pv +from scipy.spatial import cKDTree + +import polatory +from polatory import three as p3 + + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLES = ROOT / "examples" +if str(EXAMPLES) not in sys.path: + sys.path.insert(0, str(EXAMPLES)) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +# Importing the compatibility worker installs the exact production corrections: +# finite LVA-geodesic domains, smooth outside-field completion, and overlap-safe +# chunk extraction. +import polatory_lva_worker_process as production_worker # noqa: E402 + +LVA_WORKER = production_worker.worker +SAFE_MESHER = LVA_WORKER.v2.v8.v5.v3 + +DATA_DIR = Path( + os.environ.get( + "LEAPFROG_GOLD_DIR", + str(ROOT / "benchmark-data" / "leapfrog-benchmark-data-v1"), + ) +) +OUTPUT_DIR = ROOT / "benchmark-results" / "real-gold" +MESH_DIR = OUTPUT_DIR / "meshes" +PLOT_DIR = OUTPUT_DIR / "overlays" + +MODEL_MIN = np.array([444600.0, 492600.0, 2000.0], dtype=float) +MODEL_MAX = np.array([445900.0, 494600.0, 3600.0], dtype=float) + +BASE_RANGE = 400.0 +TOTAL_SILL = 100.0 +OUTSIDE_VALUE = -1.0 +SURFACE_RESOLUTION = float(os.environ.get("POLATORY_BENCHMARK_RESOLUTION", "10")) +MAX_CASES = int(os.environ.get("POLATORY_BENCHMARK_MAX_CASES", "0")) + +REFERENCE_PATTERN = re.compile( + r"^S(?P\d+(?:\.\d+)?)_R(?P\d+(?:\.\d+)?)\.obj$", + re.I, +) + + +@dataclass(frozen=True) +class Case: + name: str + strength: float + trend_range: float + reference: Path + + +def read_obj(path: Path) -> tuple[np.ndarray, np.ndarray]: + vertices: list[list[float]] = [] + faces: list[list[int]] = [] + with path.open("r", encoding="utf-8", errors="ignore") as stream: + for line in stream: + if line.startswith("v "): + fields = line.split() + vertices.append( + [float(fields[1]), float(fields[2]), float(fields[3])] + ) + elif line.startswith("f "): + polygon = [ + int(field.split("/", 1)[0]) - 1 + for field in line.split()[1:] + ] + for index in range(1, len(polygon) - 1): + faces.append( + [polygon[0], polygon[index], polygon[index + 1]] + ) + vertex_array = np.asarray(vertices, dtype=float) + face_array = np.asarray(faces, dtype=np.int64) + if ( + vertex_array.ndim != 2 + or vertex_array.shape[1] != 3 + or len(vertex_array) == 0 + ): + raise ValueError(f"No OBJ vertices found in {path}") + if face_array.ndim != 2 or face_array.shape[1] != 3 or len(face_array) == 0: + raise ValueError(f"No OBJ triangles found in {path}") + return vertex_array, face_array + + +def read_dataset() -> tuple[np.ndarray, np.ndarray]: + path = DATA_DIR / "Used Data(1).csv" + frame = pd.read_csv(path) + required = {"xe", "ye", "ze", "SDF"} + missing = required.difference(frame.columns) + if missing: + raise ValueError(f"{path} is missing columns {sorted(missing)}") + points = frame[["xe", "ye", "ze"]].to_numpy(dtype=float) + indicators = frame["SDF"].to_numpy(dtype=float) + if not np.all(np.isfinite(points)) or not np.all(np.isfinite(indicators)): + raise ValueError("Benchmark points and indicators must be finite.") + categories = set(np.unique(indicators).tolist()) + if not categories.issubset({-1.0, 1.0}): + raise ValueError( + f"Expected only -1/+1 SDF indicators, found {sorted(categories)}" + ) + return points, indicators + + +def available_cases() -> list[Case]: + cases: list[Case] = [] + for path in sorted(DATA_DIR.glob("S*_R*.obj")): + match = REFERENCE_PATTERN.match(path.name) + if match is None: + continue + cases.append( + Case( + name=path.stem, + strength=float(match.group("strength")), + trend_range=float(match.group("range")), + reference=path, + ) + ) + cases.sort(key=lambda item: (item.strength, item.trend_range)) + if MAX_CASES > 0: + cases = cases[:MAX_CASES] + if not cases: + raise FileNotFoundError( + f"No S_R.obj references found in {DATA_DIR}" + ) + return cases + + +def polydata(path: Path) -> pv.PolyData: + return pv.read(path).extract_surface().triangulate().clean() + + +def distribution(values: np.ndarray) -> dict[str, float]: + values = np.asarray(values, dtype=float) + return { + "mean": float(np.mean(values)), + "median": float(np.median(values)), + "p90": float(np.percentile(values, 90.0)), + "p95": float(np.percentile(values, 95.0)), + "p99": float(np.percentile(values, 99.0)), + "maximum": float(np.max(values)), + } + + +def point_to_surface_distance( + source: pv.PolyData, + target: pv.PolyData, +) -> np.ndarray: + measured = source.copy(deep=True) + measured.compute_implicit_distance(target, inplace=True) + return np.abs(np.asarray(measured["implicit_distance"], dtype=float)) + + +def suspicious_planes( + vertices: np.ndarray, +) -> dict[str, list[dict[str, float | int]]]: + result: dict[str, list[dict[str, float | int]]] = {} + threshold = max(20, int(math.ceil(0.01 * len(vertices)))) + for axis, name in enumerate(("x", "y", "z")): + values, counts = np.unique( + np.round(vertices[:, axis], 6), + return_counts=True, + ) + order = np.argsort(counts)[::-1] + result[name] = [ + {"coordinate": float(values[index]), "vertices": int(counts[index])} + for index in order[:10] + if int(counts[index]) >= threshold + ] + return result + + +def connected_components(mesh: pv.PolyData) -> int: + labelled = mesh.connectivity() + if "RegionId" not in labelled.cell_data or labelled.n_cells == 0: + return 0 + return int(np.max(np.asarray(labelled.cell_data["RegionId"]))) + 1 + + +def mesh_metrics(mesh: pv.PolyData) -> dict[str, Any]: + boundary = mesh.extract_feature_edges( + boundary_edges=True, + non_manifold_edges=False, + feature_edges=False, + manifold_edges=False, + ) + return { + "vertices": int(mesh.n_points), + "triangles": int(mesh.n_cells), + "area": float(mesh.area), + "bounds_min": [ + float(mesh.bounds[0]), + float(mesh.bounds[2]), + float(mesh.bounds[4]), + ], + "bounds_max": [ + float(mesh.bounds[1]), + float(mesh.bounds[3]), + float(mesh.bounds[5]), + ], + "components": connected_components(mesh), + "boundary_edge_cells": int(boundary.n_cells), + "suspicious_coordinate_planes": suspicious_planes( + np.asarray(mesh.points, dtype=float) + ), + } + + +def compare_meshes( + generated_path: Path, + reference_path: Path, +) -> dict[str, Any]: + generated = polydata(generated_path) + reference = polydata(reference_path) + generated_to_reference = point_to_surface_distance(generated, reference) + reference_to_generated = point_to_surface_distance(reference, generated) + symmetric = np.concatenate( + [generated_to_reference, reference_to_generated] + ) + return { + "generated": mesh_metrics(generated), + "reference": mesh_metrics(reference), + "generated_to_reference": distribution(generated_to_reference), + "reference_to_generated": distribution(reference_to_generated), + "symmetric_surface_distance": distribution(symmetric), + "area_ratio_generated_over_reference": float( + generated.area / reference.area + ), + } + + +def render_overlay(case: Case, generated_path: Path) -> None: + generated, _ = read_obj(generated_path) + reference, _ = read_obj(case.reference) + limits = [ + (MODEL_MIN[0], MODEL_MAX[0]), + (MODEL_MIN[1], MODEL_MAX[1]), + (MODEL_MIN[2], MODEL_MAX[2]), + ] + projections = ( + ("XY plan", 0, 1), + ("XZ section projection", 0, 2), + ("YZ section projection", 1, 2), + ) + labels = ("X", "Y", "Z") + figure, axes = plt.subplots(1, 3, figsize=(18, 6)) + for axis, (title, first, second) in zip(axes, projections): + stride_reference = max(1, len(reference) // 80_000) + stride_generated = max(1, len(generated) // 80_000) + axis.scatter( + reference[::stride_reference, first], + reference[::stride_reference, second], + s=0.35, + alpha=0.35, + label="Leapfrog", + ) + axis.scatter( + generated[::stride_generated, first], + generated[::stride_generated, second], + s=0.35, + alpha=0.35, + label="Polatory", + ) + axis.set_title(title) + axis.set_xlabel(labels[first]) + axis.set_ylabel(labels[second]) + axis.set_xlim(limits[first]) + axis.set_ylim(limits[second]) + axis.set_aspect("equal", adjustable="box") + axes[0].legend(markerscale=10) + figure.suptitle( + f"{case.name}: Polatory vs Leapfrog, " + f"resolution {SURFACE_RESOLUTION:g} m" + ) + figure.tight_layout() + PLOT_DIR.mkdir(parents=True, exist_ok=True) + figure.savefig(PLOT_DIR / f"{case.name}_overlay.png", dpi=180) + plt.close(figure) + + +def build_case( + case: Case, + points: np.ndarray, + indicators: np.ndarray, + trend_vertices: np.ndarray, + trend_faces: np.ndarray, +) -> dict[str, Any]: + started = time.perf_counter() + print( + f"Running {case.name}: strength={case.strength:g}, " + f"trend range={case.trend_range:g}", + flush=True, + ) + + value_info = polatory.leapfrog_indicator_values3( + points, + indicators, + fit_accuracy=0.0, + ) + values = np.asarray(value_info.values, dtype=float) + + trend_input = polatory.StructuralTrendInput3( + trend_vertices, + trend_faces, + float(case.strength), + float(case.trend_range), + ) + model = p3.Model( + p3.CovSpheroidal3([TOTAL_SILL, BASE_RANGE]), + 0, + ) + model.nugget = 0.0 + model_parameters = ( + np.asarray(model.parameters, dtype=float).reshape(-1).tolist() + ) + + builder = LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder( + centroid_count=6000, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.10, + consistency_threshold=0.60, + base_range=BASE_RANGE, + support_multiplier=5, + minimum_support_points=1, + ) + domains = builder.build_from_inputs( + points, + [trend_input], + model_parameters=model_parameters, + trend_type=polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) + diagnostics = builder.diagnostics_ + + structural = polatory.StructuralInterpolant3( + model, + OUTSIDE_VALUE, + 1.0, + 0.0, + True, + ) + structural.fit( + points, + values, + domains, + tolerance=float(value_info.fit_accuracy), + max_iter=100, + ) + predictions = np.asarray(structural.evaluate(points), dtype=float) + errors = predictions - values + + MESH_DIR.mkdir(parents=True, exist_ok=True) + output_path = MESH_DIR / f"{case.name}_polatory.obj" + + # Allow the complete documented model extent while retaining small native + # calls. The search cycle defaults to a 10 m surface; final candidates are + # re-run at 5 m through the workflow input. + SAFE_MESHER.MAX_TOTAL_BASE_CELLS = 50_000_000 + SAFE_MESHER.MAX_CHUNKS = 256 + SAFE_MESHER.generate_safe_isosurface( + structural=structural, + bbox_min=MODEL_MIN, + bbox_max=MODEL_MAX, + resolution=SURFACE_RESOLUTION, + refine=0, + output_obj=output_path, + progress=lambda message: print( + f"[{case.name}] {message}", + flush=True, + ), + ) + + comparison = compare_meshes(output_path, case.reference) + render_overlay(case, output_path) + + elapsed = time.perf_counter() - started + return { + "case": case.name, + "strength": case.strength, + "trend_range": case.trend_range, + "base_range": BASE_RANGE, + "total_sill": TOTAL_SILL, + "outside_value": OUTSIDE_VALUE, + "surface_resolution": SURFACE_RESOLUTION, + "input_points": int(len(points)), + "domain_count": int(len(domains)), + "centroid_grid_shape": ( + list(diagnostics.centroid_grid_shape) + if diagnostics is not None + else None + ), + "training_rmse": float(np.sqrt(np.mean(errors**2))), + "training_max_abs": float(np.max(np.abs(errors))), + "elapsed_seconds": float(elapsed), + "generated_obj": str(output_path.relative_to(ROOT)), + "reference_obj": case.reference.name, + "comparison": comparison, + } + + +def response_metrics( + results: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Summarize whether range changes move broad bounds in the same direction.""" + output: list[dict[str, Any]] = [] + grouped: dict[float, list[dict[str, Any]]] = {} + for item in results.values(): + grouped.setdefault(float(item["strength"]), []).append(item) + for strength, group in sorted(grouped.items()): + group.sort(key=lambda item: float(item["trend_range"])) + for lower, upper in zip(group, group[1:]): + lower_comp = lower["comparison"] + upper_comp = upper["comparison"] + generated_delta_min = ( + np.asarray(upper_comp["generated"]["bounds_min"]) + - np.asarray(lower_comp["generated"]["bounds_min"]) + ) + generated_delta_max = ( + np.asarray(upper_comp["generated"]["bounds_max"]) + - np.asarray(lower_comp["generated"]["bounds_max"]) + ) + reference_delta_min = ( + np.asarray(upper_comp["reference"]["bounds_min"]) + - np.asarray(lower_comp["reference"]["bounds_min"]) + ) + reference_delta_max = ( + np.asarray(upper_comp["reference"]["bounds_max"]) + - np.asarray(lower_comp["reference"]["bounds_max"]) + ) + generated_delta = np.concatenate( + [generated_delta_min, generated_delta_max] + ) + reference_delta = np.concatenate( + [reference_delta_min, reference_delta_max] + ) + denominator = float( + np.linalg.norm(generated_delta) + * np.linalg.norm(reference_delta) + ) + cosine = ( + float(np.dot(generated_delta, reference_delta) / denominator) + if denominator > 0.0 + else None + ) + output.append( + { + "strength": strength, + "from_range": float(lower["trend_range"]), + "to_range": float(upper["trend_range"]), + "bounds_change_cosine_similarity": cosine, + "generated_bounds_delta": generated_delta.tolist(), + "reference_bounds_delta": reference_delta.tolist(), + } + ) + return output + + +def write_summary(report: dict[str, Any]) -> None: + lines = [ + "# Real Leapfrog LVA benchmark", + "", + f"- Surface resolution: {SURFACE_RESOLUTION:g} m", + f"- Cases: {len(report['cases'])}", + "", + "| Case | Domains | Symmetric mean | Symmetric p95 | Area ratio | Seconds |", + "|---|---:|---:|---:|---:|---:|", + ] + for name, item in report["cases"].items(): + distance = item["comparison"]["symmetric_surface_distance"] + lines.append( + f"| {name} | {item['domain_count']} | {distance['mean']:.3f} | " + f"{distance['p95']:.3f} | " + f"{item['comparison']['area_ratio_generated_over_reference']:.3f} | " + f"{item['elapsed_seconds']:.1f} |" + ) + (OUTPUT_DIR / "summary.md").write_text( + "\n".join(lines) + "\n", + encoding="utf-8", + ) + + +def main() -> int: + if not DATA_DIR.exists(): + raise FileNotFoundError( + f"Benchmark data directory does not exist: {DATA_DIR}" + ) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + points, indicators = read_dataset() + trend_vertices, trend_faces = read_obj(DATA_DIR / "Reference Mesh(2).obj") + cases = available_cases() + + report: dict[str, Any] = { + "data_directory": str(DATA_DIR), + "model_bounds_min": MODEL_MIN.tolist(), + "model_bounds_max": MODEL_MAX.tolist(), + "surface_resolution": SURFACE_RESOLUTION, + "cases": {}, + } + + for case in cases: + report["cases"][case.name] = build_case( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + # Persist after every case so a timeout still leaves useful partial artifacts. + (OUTPUT_DIR / "metrics.json").write_text( + json.dumps(report, indent=2), + encoding="utf-8", + ) + + report["parameter_response"] = response_metrics(report["cases"]) + (OUTPUT_DIR / "metrics.json").write_text( + json.dumps(report, indent=2), + encoding="utf-8", + ) + write_summary(report) + print(json.dumps(report, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/run_real_gold_suite_ci.py b/benchmarks/leapfrog_gold/run_real_gold_suite_ci.py new file mode 100644 index 000000000..d8d4cb886 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_real_gold_suite_ci.py @@ -0,0 +1,29 @@ +"""CI entry point for the real Leapfrog gold benchmark. + +The production worker chain replaces ``polatory.StructuralInterpolant3`` while it is +imported. That Python wrapper exposes ``background_blending`` as a keyword-only +option. Import the full benchmark first, then adapt the final constructor object so +the benchmark's historical five-positional-argument call reaches the production +wrapper using its supported API. +""" + +from __future__ import annotations + +import run_real_gold_suite as suite + + +_original_structural_interpolant = suite.polatory.StructuralInterpolant3 + + +def _structural_interpolant_compat(*args, **kwargs): + if len(args) == 5 and "background_blending" not in kwargs: + args, background_blending = args[:4], args[4] + kwargs["background_blending"] = background_blending + return _original_structural_interpolant(*args, **kwargs) + + +suite.polatory.StructuralInterpolant3 = _structural_interpolant_compat + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva.py b/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva.py new file mode 100644 index 000000000..4269fd869 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva.py @@ -0,0 +1,397 @@ +"""Run the basal-range diagnostic with the recovered Leapfrog LVA sampler forced. + +Alongside each generated benchmark OBJ, export CSV files describing the actual +finite structural domains, the point/centroid partition, and the recovered LVA +field on a regular model grid. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +from scipy.spatial import cKDTree + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import polatory +import polatory.automatic_domain_builder as automatic_module # noqa: E402 + + +def _lva_components( + points: np.ndarray, + input_: Any, + *, + non_decaying: bool = False, +) -> dict[str, np.ndarray]: + """Sample the recovered single-mesh Leapfrog LVA field.""" + points = np.asarray(points, dtype=np.float64) + vertices = np.asarray(input_.vertices, dtype=np.float64) + faces = np.asarray(input_.faces, dtype=np.int64) + strength = float(input_.strength) + range_ = float(input_.range) + + triangles = vertices[faces] + face_normals = np.cross( + triangles[:, 1] - triangles[:, 0], + triangles[:, 2] - triangles[:, 0], + ) + lengths = np.linalg.norm(face_normals, axis=1) + valid = lengths > 0.0 + face_normals[valid] /= lengths[valid, None] + face_normals[~valid] = 0.0 + + vertex_normals = np.zeros_like(vertices) + for corner in range(3): + np.add.at(vertex_normals, faces[:, corner], face_normals) + lengths = np.linalg.norm(vertex_normals, axis=1) + valid = lengths > 0.0 + vertex_normals[valid] /= lengths[valid, None] + vertex_normals[~valid] = np.array([0.0, 0.0, 1.0]) + + tree = cKDTree(vertices) + try: + distance, nearest = tree.query(points, k=1, workers=-1) + except TypeError: + distance, nearest = tree.query(points, k=1) + distance = np.asarray(distance, dtype=np.float64) + nearest = np.asarray(nearest, dtype=np.int64) + + if non_decaying: + q = np.ones(len(points), dtype=np.float64) + inside_cutoff = np.ones(len(points), dtype=bool) + else: + q = np.exp(-distance / range_) + inside_cutoff = distance < 4.0 * range_ + q[~inside_cutoff] = 0.0 + + ratio = 1.0 + (strength - 1.0) * q + normals = vertex_normals[nearest] + projectors = normals[:, :, None] * normals[:, None, :] + identity = np.eye(3, dtype=np.float64)[None, :, :] + tangent = ratio ** (-1.0 / 3.0) + normal = ratio ** (2.0 / 3.0) + matrices = ( + tangent[:, None, None] * (identity - projectors) + + normal[:, None, None] * projectors + ) + eigenvalues = np.linalg.eigvalsh(matrices) + + return { + "nearest": nearest, + "distance": distance, + "inside_cutoff": inside_cutoff, + "q": q, + "ratio": ratio, + "normals": normals, + "matrices": matrices, + "eigenvalues": eigenvalues, + "glyph_major": 4.0 * q * ratio ** (1.0 / 3.0), + "glyph_minor": 4.0 * q / ratio ** (2.0 / 3.0), + } + + +def exact_leapfrog_single_input_anisotropies3( + points: np.ndarray, + input_: Any, + *, + non_decaying: bool = False, +) -> np.ndarray: + return _lva_components( + points, + input_, + non_decaying=non_decaying, + )["matrices"] + + +# Patch both paths: the automatic builder and finite-geodesic propagation worker. +automatic_module.sample_single_input_anisotropies3 = ( + exact_leapfrog_single_input_anisotropies3 +) +polatory.sample_single_input_anisotropies3 = ( + exact_leapfrog_single_input_anisotropies3 +) + +import run_basal_range_diagnostic as diagnostic # noqa: E402 + +suite = diagnostic.suite +suite.OUTPUT_DIR = suite.ROOT / "benchmark-results" / "exact-leapfrog-lva-forced" +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" +diagnostic.DIAGNOSTIC_DIR = suite.OUTPUT_DIR / "basal-diagnostics" +CSV_DIR = suite.OUTPUT_DIR / "inspection-csv" +LVA_GRID_DIMENSION = int(os.environ.get("POLATORY_LVA_EXPORT_GRID_DIMENSION", "25")) +if not 2 <= LVA_GRID_DIMENSION <= 100: + raise ValueError("POLATORY_LVA_EXPORT_GRID_DIMENSION must be between 2 and 100") + + +def _write(frame: pd.DataFrame, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(path, index=False, float_format="%.17g") + return path + + +def _indices_text(indices: np.ndarray) -> str: + return "|".join(str(int(value)) for value in np.asarray(indices).reshape(-1)) + + +def _domain_csvs(case: Any, points: np.ndarray) -> dict[str, Path]: + builder = diagnostic._CAPTURE.get("builder") + domains = diagnostic._CAPTURE.get("domains") + if builder is None or not domains: + raise RuntimeError("The benchmark did not capture automatic domains") + details = builder.diagnostics_ + if details is None: + raise RuntimeError("Automatic domain diagnostics are unavailable") + + points = np.asarray(points, dtype=np.float64) + labels = np.asarray(builder.labels_, dtype=np.int64) + centroid_points = np.asarray(details.centroid_points, dtype=np.float64) + centroid_labels = np.asarray(details.centroid_labels, dtype=np.int64) + postcluster = list(details.postcluster) + + summary_rows: list[dict[str, Any]] = [] + for domain_id, domain in enumerate(domains): + recovered = postcluster[domain_id] if domain_id < len(postcluster) else None + actual_min = np.asarray(domain.bbox_min, dtype=np.float64) + actual_max = np.asarray(domain.bbox_max, dtype=np.float64) + matrix = np.asarray(domain.anisotropy, dtype=np.float64) + eig = np.linalg.eigvalsh(matrix) + support = np.asarray(domain.support_indices, dtype=np.int64) + core = np.flatnonzero(labels == domain_id).astype(np.int64) + recovered_min = ( + np.asarray(recovered.bbox_min, dtype=np.float64) + if recovered is not None + else np.full(3, np.nan) + ) + recovered_max = ( + np.asarray(recovered.bbox_max, dtype=np.float64) + if recovered is not None + else np.full(3, np.nan) + ) + row: dict[str, Any] = { + "case": case.name, + "domain_id": domain_id, + "core_point_count": len(core), + "support_point_count": len(support), + "centroid_count": int(np.count_nonzero(centroid_labels == domain_id)), + "core_indices": _indices_text(core), + "support_indices": _indices_text(support), + "actual_bbox_min_x": actual_min[0], + "actual_bbox_min_y": actual_min[1], + "actual_bbox_min_z": actual_min[2], + "actual_bbox_max_x": actual_max[0], + "actual_bbox_max_y": actual_max[1], + "actual_bbox_max_z": actual_max[2], + "recovered_bbox_min_x": recovered_min[0], + "recovered_bbox_min_y": recovered_min[1], + "recovered_bbox_min_z": recovered_min[2], + "recovered_bbox_max_x": recovered_max[0], + "recovered_bbox_max_y": recovered_max[1], + "recovered_bbox_max_z": recovered_max[2], + "extension_below_x": recovered_min[0] - actual_min[0], + "extension_below_y": recovered_min[1] - actual_min[1], + "extension_below_z": recovered_min[2] - actual_min[2], + "extension_above_x": actual_max[0] - recovered_max[0], + "extension_above_y": actual_max[1] - recovered_max[1], + "extension_above_z": actual_max[2] - recovered_max[2], + "desired_internal_radius": ( + float(recovered.desired_internal_radius) + if recovered is not None + else np.nan + ), + "internal_radius": ( + float(recovered.internal_radius) if recovered is not None else np.nan + ), + "local_kernel_range": ( + float(recovered.local_kernel_range) + if recovered is not None + else np.nan + ), + "anisotropy_ratio": eig[-1] / eig[0], + "matrix_determinant": np.linalg.det(matrix), + "matrix_00": matrix[0, 0], + "matrix_01": matrix[0, 1], + "matrix_02": matrix[0, 2], + "matrix_10": matrix[1, 0], + "matrix_11": matrix[1, 1], + "matrix_12": matrix[1, 2], + "matrix_20": matrix[2, 0], + "matrix_21": matrix[2, 1], + "matrix_22": matrix[2, 2], + } + for index, value in enumerate(np.asarray(domain.model_parameters).reshape(-1)): + row[f"model_parameter_{index}"] = float(value) + summary_rows.append(row) + + own_support = np.zeros(len(points), dtype=bool) + inside_own_bbox = np.zeros(len(points), dtype=bool) + for domain_id, domain in enumerate(domains): + owned = labels == domain_id + support = np.asarray(domain.support_indices, dtype=np.int64) + support_mask = np.zeros(len(points), dtype=bool) + support_mask[support] = True + own_support[owned] = support_mask[owned] + minimum = np.asarray(domain.bbox_min, dtype=np.float64) + maximum = np.asarray(domain.bbox_max, dtype=np.float64) + inside = np.all(points >= minimum, axis=1) & np.all(points <= maximum, axis=1) + inside_own_bbox[owned] = inside[owned] + + point_frame = pd.DataFrame( + { + "case": case.name, + "point_index": np.arange(len(points), dtype=np.int64), + "x": points[:, 0], + "y": points[:, 1], + "z": points[:, 2], + "automatic_domain": labels, + "is_support_of_own_domain": own_support, + "inside_own_actual_bbox": inside_own_bbox, + } + ) + + shape = tuple(int(value) for value in details.centroid_grid_shape) + grid_indices = np.column_stack( + np.unravel_index(np.arange(len(centroid_points)), shape) + ) + centroid_frame = pd.DataFrame( + { + "case": case.name, + "centroid_index": np.arange(len(centroid_points), dtype=np.int64), + "grid_i": grid_indices[:, 0], + "grid_j": grid_indices[:, 1], + "grid_k": grid_indices[:, 2], + "x": centroid_points[:, 0], + "y": centroid_points[:, 1], + "z": centroid_points[:, 2], + "automatic_domain": centroid_labels, + } + ) + + return { + "domains": _write( + pd.DataFrame(summary_rows), CSV_DIR / f"{case.name}_domains.csv" + ), + "domain_points": _write( + point_frame, CSV_DIR / f"{case.name}_domain_points.csv" + ), + "domain_centroids": _write( + centroid_frame, CSV_DIR / f"{case.name}_domain_centroids.csv" + ), + } + + +def _lva_field_csv(case: Any, trend_vertices: np.ndarray, trend_faces: np.ndarray) -> Path: + axes = [ + np.linspace( + float(suite.MODEL_MIN[axis]), + float(suite.MODEL_MAX[axis]), + LVA_GRID_DIMENSION, + ) + for axis in range(3) + ] + xx, yy, zz = np.meshgrid(*axes, indexing="ij") + points = np.column_stack( + [xx.ravel(order="C"), yy.ravel(order="C"), zz.ravel(order="C")] + ) + indices = np.column_stack( + np.unravel_index( + np.arange(len(points)), + (LVA_GRID_DIMENSION, LVA_GRID_DIMENSION, LVA_GRID_DIMENSION), + ) + ) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + float(case.strength), + float(case.trend_range), + ) + lva = _lva_components(points, trend_input) + matrix = lva["matrices"] + normal = lva["normals"] + eig = lva["eigenvalues"] + frame = pd.DataFrame( + { + "case": case.name, + "grid_i": indices[:, 0], + "grid_j": indices[:, 1], + "grid_k": indices[:, 2], + "x": points[:, 0], + "y": points[:, 1], + "z": points[:, 2], + "nearest_trend_vertex": lva["nearest"], + "distance_to_trend_vertex": lva["distance"], + "inside_4r_cutoff": lva["inside_cutoff"], + "influence_q": lva["q"], + "anisotropy_ratio": lva["ratio"], + "normal_x": normal[:, 0], + "normal_y": normal[:, 1], + "normal_z": normal[:, 2], + "glyph_major": lva["glyph_major"], + "glyph_semi_major": lva["glyph_major"], + "glyph_minor": lva["glyph_minor"], + "eigenvalue_min": eig[:, 0], + "eigenvalue_mid": eig[:, 1], + "eigenvalue_max": eig[:, 2], + "matrix_determinant": np.linalg.det(matrix), + "matrix_00": matrix[:, 0, 0], + "matrix_01": matrix[:, 0, 1], + "matrix_02": matrix[:, 0, 2], + "matrix_10": matrix[:, 1, 0], + "matrix_11": matrix[:, 1, 1], + "matrix_12": matrix[:, 1, 2], + "matrix_20": matrix[:, 2, 0], + "matrix_21": matrix[:, 2, 1], + "matrix_22": matrix[:, 2, 2], + } + ) + return _write(frame, CSV_DIR / f"{case.name}_lva_field.csv") + + +_BASE_BUILD_CASE = suite.build_case + + +def _exporting_build_case( + case: Any, + points: np.ndarray, + indicators: np.ndarray, + trend_vertices: np.ndarray, + trend_faces: np.ndarray, +) -> dict[str, Any]: + result = _BASE_BUILD_CASE( + case, + points, + indicators, + trend_vertices, + trend_faces, + ) + paths = _domain_csvs(case, points) + paths["lva_field"] = _lva_field_csv(case, trend_vertices, trend_faces) + result["inspection_csv"] = { + name: str(path.relative_to(suite.ROOT)) for name, path in paths.items() + } + print( + f"[{case.name}] Wrote domain and LVA CSV files to {CSV_DIR}", + flush=True, + ) + return result + + +suite.build_case = _exporting_build_case + +print( + "PROGRESS\tExact Leapfrog LVA forced. Each generated case also exports domain " + "summary/point/centroid CSVs and a regular-grid LVA field CSV " + f"({LVA_GRID_DIMENSION} x {LVA_GRID_DIMENSION} x {LVA_GRID_DIMENSION}).", + flush=True, +) + +if __name__ == "__main__": + exit_code = suite.main() + diagnostic._write_cross_case_comparison() + raise SystemExit(exit_code) diff --git a/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva_full_depth_domains.py b/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva_full_depth_domains.py new file mode 100644 index 000000000..6f274fb98 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_exact_leapfrog_lva_full_depth_domains.py @@ -0,0 +1,95 @@ +"""Test the basal domain-boundary hypothesis with full-depth domain boxes. + +This diagnostic preserves the recovered Leapfrog LVA sampler, automatic domain +partition, support memberships, local RBF models, and all meshing settings. The +only change is that every automatic domain evaluation box is extended downward +to the benchmark model minimum Z before fitting. This prevents the local field +from disappearing at a finite domain floor and falling immediately to the +constant outside value. + +The baseline exact-LVA runner remains unchanged. Results are written to a +separate output directory so the two runs can be compared directly. +""" +from __future__ import annotations + +from typing import Any + +import numpy as np + +import polatory +import run_selected_exact_leapfrog_lva as baseline + +suite = baseline.suite +diagnostic = baseline.diagnostic + +suite.OUTPUT_DIR = ( + suite.ROOT / "benchmark-results" / "exact-leapfrog-lva-full-depth-domains" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" +diagnostic.DIAGNOSTIC_DIR = suite.OUTPUT_DIR / "basal-diagnostics" +baseline.CSV_DIR = suite.OUTPUT_DIR / "inspection-csv" + +_BASE_CAPTURE_BUILDER = suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder +_MODEL_MIN_Z = float(suite.MODEL_MIN[2]) + + +class _FullDepthDomainBuilder: + """Extend only the lower Z face of each returned automatic domain.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._wrapped = _BASE_CAPTURE_BUILDER(*args, **kwargs) + + def build_from_inputs(self, *args: Any, **kwargs: Any): + original_domains = list(self._wrapped.build_from_inputs(*args, **kwargs)) + extended_domains = [] + changed = 0 + + for domain in original_domains: + bbox_min = np.asarray(domain.bbox_min, dtype=np.float64).copy() + bbox_max = np.asarray(domain.bbox_max, dtype=np.float64).copy() + original_min_z = float(bbox_min[2]) + bbox_min[2] = min(original_min_z, _MODEL_MIN_Z) + if bbox_min[2] < original_min_z: + changed += 1 + + extended_domains.append( + polatory.StructuralDomain3( + np.asarray(domain.anisotropy, dtype=np.float64), + bbox_min, + bbox_max, + np.asarray(domain.support_indices, dtype=np.int64).tolist(), + np.asarray(domain.model_parameters, dtype=np.float64).tolist(), + ) + ) + + # The basal diagnostic and CSV exporter must inspect the domains that are + # actually fitted, rather than the pre-extension copies captured by the + # delegated diagnostic builder. + diagnostic._CAPTURE["domains"] = extended_domains + + print( + f"PROGRESS\tExtended the lower Z face of {changed}/{len(extended_domains)} " + f"automatic domains to model Z={_MODEL_MIN_Z:g}; X/Y and upper Z faces, " + "support indices, anisotropy matrices and model parameters are unchanged.", + flush=True, + ) + return extended_domains + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +suite.LVA_WORKER.FiniteLvaGeodesicAutomaticBuilder = _FullDepthDomainBuilder + +print( + "PROGRESS\tFull-depth-domain test enabled. This is a single-variable diagnostic: " + "only automatic-domain bbox_min_z is extended to MODEL_MIN[2].", + flush=True, +) + + +if __name__ == "__main__": + exit_code = suite.main() + diagnostic._write_cross_case_comparison() + raise SystemExit(exit_code) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending.py new file mode 100644 index 000000000..132774cda --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending.py @@ -0,0 +1,61 @@ +"""Run one selected Leapfrog gold case with background blending disabled. + +Select the exact Leapfrog reference case through ``POLATORY_BENCHMARK_CASE`` using +names such as ``S3_R300`` or ``S5_R100``. The modelling and aligned global-grid +meshing path is identical to ``run_no_background_blending_diagnostic.py``. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing this module installs background_blending=False and exposes its suite. +import run_no_background_blending_diagnostic as diagnostic # noqa: E402 + +suite = diagnostic.suite +_ORIGINAL_AVAILABLE_CASES = suite.available_cases + + +def _selected_cases(): + requested = os.environ.get("POLATORY_BENCHMARK_CASE", "").strip().upper() + if not requested: + raise ValueError( + "Set POLATORY_BENCHMARK_CASE to an existing Leapfrog case, for example " + "S3_R300 or S5_R100." + ) + + # Disable the old first-N limiter while collecting the complete reference list. + previous_max_cases = suite.MAX_CASES + suite.MAX_CASES = 0 + try: + cases = _ORIGINAL_AVAILABLE_CASES() + finally: + suite.MAX_CASES = previous_max_cases + + matches = [case for case in cases if case.name.upper() == requested] + if not matches: + available = ", ".join(case.name for case in cases) + raise ValueError( + f"Leapfrog benchmark case {requested!r} was not found. Available cases: " + f"{available}" + ) + return matches + + +suite.available_cases = _selected_cases +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-selected" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay.py new file mode 100644 index 000000000..6a3072486 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay.py @@ -0,0 +1,460 @@ +"""Run one selected case with fully data-driven structural-field support completion. + +This diagnostic contains no case names, range thresholds, absolute support distances, or +strength-specific tuning. It learns where the fitted field stops being supported from +that case's own input geometry and raw zero-surface behaviour: + +1. Local data scale is estimated at every input point from its automatically selected + neighbourhood size (cube root of the sample count). +2. Before final meshing, the raw background_blending=False field is sampled on a coarse + globally aligned probe grid covering the requested model extent. +3. Distances from raw zero-crossing cells to the data are normalized by the local data + scale, making the calculation independent of coordinate units and dataset density. +4. A deterministic two-population model separates the near-data supported crossings from + a far-field crossing population such as the attached basal pancake. +5. The two learned population centres define a smooth transition toward OUTSIDE_VALUE. + +The fitted field remains untouched throughout the learned supported population. Range, +strength, data spacing, model extent, and field behaviour affect the result only through +the model and measurements themselves; none are mapped to hand-picked decay distances. +""" +from __future__ import annotations + +import math +import os +import sys +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import polatory +from scipy.spatial import cKDTree + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Installs the selected-case filter and StructuralInterpolant3 background_blending=False. +import run_selected_no_background_blending as selected # noqa: E402 + +suite = selected.suite +_BASE_FACTORY = polatory.StructuralInterpolant3 +_OUTSIDE_VALUE = float(suite.OUTSIDE_VALUE) +_ORIGINAL_MESHER = suite.SAFE_MESHER.generate_safe_isosurface + +# These settings limit diagnostic work only; they do not encode geological distances. +_PROBE_TARGET_NODES = int(os.environ.get("POLATORY_AUTO_SUPPORT_PROBE_NODES", "180000")) +_PROBE_BATCH_SIZE = int(os.environ.get("POLATORY_AUTO_SUPPORT_PROBE_BATCH_SIZE", "250000")) +_EM_MAX_ITERATIONS = int(os.environ.get("POLATORY_AUTO_SUPPORT_EM_MAX_ITERATIONS", "200")) + + +def _log_normal_density(values: np.ndarray, mean: float, variance: float) -> np.ndarray: + return -0.5 * ( + np.log(2.0 * math.pi * variance) + + ((values - mean) * (values - mean)) / variance + ) + + +def _fit_shared_variance_mixture(values: np.ndarray) -> dict[str, float | bool]: + """Fit one- and two-population 1-D Gaussian models and select with BIC. + + Equal variance makes the supported-population probability monotonic with distance. + The two starting centres are selected by splitting the sorted samples into equal-count + halves, so initialization also contains no dataset-specific distance or quantile. + """ + + x = np.asarray(values, dtype=np.float64).reshape(-1) + x = x[np.isfinite(x)] + if len(x) < 16: + return { + "use_two_populations": False, + "supported_center": float(np.min(x)) if len(x) else 0.0, + "unsupported_center": float(np.max(x)) if len(x) else 0.0, + "bic_one": float("inf"), + "bic_two": float("inf"), + "shared_standard_deviation": 0.0, + "unsupported_fraction": 0.0, + } + + ordered = np.sort(x) + split = len(ordered) // 2 + mean_supported = float(np.mean(ordered[:split])) + mean_unsupported = float(np.mean(ordered[split:])) + total_variance = float(np.var(ordered)) + variance_floor = max(total_variance * np.finfo(np.float64).eps ** 0.5, 1.0e-12) + variance = max(total_variance, variance_floor) + supported_weight = 0.5 + + previous_log_likelihood = -np.inf + for _ in range(max(_EM_MAX_ITERATIONS, 1)): + log_supported = ( + math.log(max(supported_weight, np.finfo(float).tiny)) + + _log_normal_density(ordered, mean_supported, variance) + ) + log_unsupported = ( + math.log(max(1.0 - supported_weight, np.finfo(float).tiny)) + + _log_normal_density(ordered, mean_unsupported, variance) + ) + maximum = np.maximum(log_supported, log_unsupported) + denominator = maximum + np.log( + np.exp(log_supported - maximum) + np.exp(log_unsupported - maximum) + ) + responsibility_supported = np.exp(log_supported - denominator) + responsibility_unsupported = 1.0 - responsibility_supported + + count_supported = float(np.sum(responsibility_supported)) + count_unsupported = float(np.sum(responsibility_unsupported)) + if count_supported <= 1.0 or count_unsupported <= 1.0: + break + + new_supported_weight = count_supported / len(ordered) + new_mean_supported = float( + np.sum(responsibility_supported * ordered) / count_supported + ) + new_mean_unsupported = float( + np.sum(responsibility_unsupported * ordered) / count_unsupported + ) + new_variance = float( + ( + np.sum( + responsibility_supported + * (ordered - new_mean_supported) + * (ordered - new_mean_supported) + ) + + np.sum( + responsibility_unsupported + * (ordered - new_mean_unsupported) + * (ordered - new_mean_unsupported) + ) + ) + / len(ordered) + ) + new_variance = max(new_variance, variance_floor) + log_likelihood = float(np.sum(denominator)) + + supported_weight = new_supported_weight + mean_supported = new_mean_supported + mean_unsupported = new_mean_unsupported + variance = new_variance + if abs(log_likelihood - previous_log_likelihood) <= ( + np.finfo(float).eps ** 0.5 * max(1.0, abs(log_likelihood)) + ): + break + previous_log_likelihood = log_likelihood + + if mean_supported > mean_unsupported: + mean_supported, mean_unsupported = mean_unsupported, mean_supported + supported_weight = 1.0 - supported_weight + + one_mean = float(np.mean(ordered)) + one_variance = max(float(np.var(ordered)), variance_floor) + one_log_likelihood = float( + np.sum(_log_normal_density(ordered, one_mean, one_variance)) + ) + + log_supported = ( + math.log(max(supported_weight, np.finfo(float).tiny)) + + _log_normal_density(ordered, mean_supported, variance) + ) + log_unsupported = ( + math.log(max(1.0 - supported_weight, np.finfo(float).tiny)) + + _log_normal_density(ordered, mean_unsupported, variance) + ) + maximum = np.maximum(log_supported, log_unsupported) + two_log_likelihood = float( + np.sum( + maximum + + np.log( + np.exp(log_supported - maximum) + + np.exp(log_unsupported - maximum) + ) + ) + ) + + sample_count = float(len(ordered)) + bic_one = -2.0 * one_log_likelihood + 2.0 * math.log(sample_count) + bic_two = -2.0 * two_log_likelihood + 4.0 * math.log(sample_count) + use_two = bool( + np.isfinite(bic_two) + and bic_two < bic_one + and mean_unsupported > mean_supported + ) + return { + "use_two_populations": use_two, + "supported_center": mean_supported, + "unsupported_center": mean_unsupported, + "bic_one": bic_one, + "bic_two": bic_two, + "shared_standard_deviation": float(math.sqrt(variance)), + "unsupported_fraction": float(1.0 - supported_weight), + } + + +class _AutoSupportDecayInterpolant: + """Fit normally, then learn a support-confidence field before meshing.""" + + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + self._tree: cKDTree | None = None + self._local_scales: np.ndarray | None = None + self._supported_center: float | None = None + self._unsupported_center: float | None = None + self.calibration_: dict[str, Any] | None = None + + def fit(self, points: Any, *args: Any, **kwargs: Any): + fit_points = np.asarray(points, dtype=np.float64) + if fit_points.ndim != 2 or fit_points.shape[1] != 3: + raise ValueError("Structural fit points must have shape (n, 3).") + if len(fit_points) < 2: + raise ValueError("Automatic support calibration needs at least two points.") + + self._tree = cKDTree(fit_points) + # The neighbourhood size follows sample count rather than a geological distance. + neighbour_count = min( + len(fit_points), + max(2, int(math.ceil(len(fit_points) ** (1.0 / 3.0))) + 1), + ) + neighbour_distances = np.asarray( + self._tree.query(fit_points, k=neighbour_count)[0], + dtype=np.float64, + ) + if neighbour_distances.ndim == 1: + neighbour_distances = neighbour_distances[:, None] + local_scales = neighbour_distances[:, -1] + valid = np.isfinite(local_scales) & (local_scales > 0.0) + if not np.any(valid): + raise ValueError("Input points do not define a positive local spacing scale.") + replacement = float(np.median(local_scales[valid])) + local_scales = np.where(valid, local_scales, replacement) + self._local_scales = local_scales + self.calibration_ = { + "input_points": int(len(fit_points)), + "neighbour_count": int(neighbour_count - 1), + "local_scale_min": float(np.min(local_scales)), + "local_scale_median": float(np.median(local_scales)), + "local_scale_max": float(np.max(local_scales)), + } + return self._wrapped.fit(points, *args, **kwargs) + + def _raw_evaluate(self, points: np.ndarray, *args: Any, **kwargs: Any) -> np.ndarray: + return np.asarray( + self._wrapped.evaluate(points, *args, **kwargs), + dtype=np.float64, + ) + + def _normalized_distance(self, query: np.ndarray) -> np.ndarray: + if self._tree is None or self._local_scales is None: + raise RuntimeError("Structural interpolant must be fitted before calibration.") + distances, nearest = self._tree.query(query, k=1) + distances = np.asarray(distances, dtype=np.float64) + nearest = np.asarray(nearest, dtype=np.int64) + return distances / self._local_scales[nearest] + + def calibrate_support( + self, + bbox_min: Any, + bbox_max: Any, + resolution: float, + progress: Callable[[str], None], + ) -> None: + if self._supported_center is not None: + return + if self._tree is None or self._local_scales is None: + raise RuntimeError("Structural interpolant must be fitted before calibration.") + + minimum = np.asarray(bbox_min, dtype=np.float64) + maximum = np.asarray(bbox_max, dtype=np.float64) + span = maximum - minimum + if minimum.shape != (3,) or maximum.shape != (3,) or not np.all(span > 0.0): + raise ValueError("Automatic support calibration needs valid 3-D bounds.") + + target_nodes = max(_PROBE_TARGET_NODES, 8) + node_density = (target_nodes / float(np.prod(span))) ** (1.0 / 3.0) + node_counts = np.maximum(np.rint(span * node_density).astype(np.int64) + 1, 3) + # Correct rounding so the probe remains close to its requested computational budget. + while int(np.prod(node_counts)) > 2 * target_nodes: + axis = int(np.argmax(node_counts)) + node_counts[axis] = max(3, int(node_counts[axis]) - 1) + + coordinates = [ + np.linspace(minimum[axis], maximum[axis], int(node_counts[axis])) + for axis in range(3) + ] + grid = np.meshgrid(*coordinates, indexing="ij") + query = np.column_stack([axis.ravel(order="C") for axis in grid]) + progress( + "Auto-calibrating field support on a data-scaled probe grid " + f"{tuple(int(value) for value in node_counts)} ({len(query):,} nodes)…" + ) + + raw_flat = np.empty(len(query), dtype=np.float64) + for start in range(0, len(query), max(_PROBE_BATCH_SIZE, 1)): + stop = min(start + max(_PROBE_BATCH_SIZE, 1), len(query)) + raw_flat[start:stop] = self._raw_evaluate(query[start:stop]).reshape(-1) + if not np.all(np.isfinite(raw_flat)): + raise RuntimeError("Raw structural field returned non-finite probe values.") + + field_scale = max(1.0, float(np.max(np.abs(raw_flat)))) + zero_tolerance = np.finfo(np.float64).eps * field_scale + raw_flat[np.abs(raw_flat) <= zero_tolerance] = -zero_tolerance + volume = raw_flat.reshape(tuple(int(value) for value in node_counts), order="C") + + corners = ( + volume[:-1, :-1, :-1], + volume[1:, :-1, :-1], + volume[:-1, 1:, :-1], + volume[:-1, :-1, 1:], + volume[1:, 1:, :-1], + volume[1:, :-1, 1:], + volume[:-1, 1:, 1:], + volume[1:, 1:, 1:], + ) + cell_minimum = np.minimum.reduce(corners) + cell_maximum = np.maximum.reduce(corners) + crossing_indices = np.argwhere( + (cell_minimum <= 0.0) & (cell_maximum >= 0.0) + ) + if len(crossing_indices) < 16: + self._supported_center = float("inf") + self._unsupported_center = float("inf") + assert self.calibration_ is not None + self.calibration_.update( + { + "probe_node_counts": node_counts.tolist(), + "raw_zero_crossing_cells": int(len(crossing_indices)), + "support_decay_enabled": False, + "reason": "too few raw zero-crossing cells for population separation", + } + ) + progress("Auto support calibration found no separable far-field population.") + return + + centers = np.empty((len(crossing_indices), 3), dtype=np.float64) + for axis in range(3): + lower = coordinates[axis][crossing_indices[:, axis]] + upper = coordinates[axis][crossing_indices[:, axis] + 1] + centers[:, axis] = 0.5 * (lower + upper) + + normalized_distance = self._normalized_distance(centers) + log_distance = np.log1p(normalized_distance) + mixture = _fit_shared_variance_mixture(log_distance) + enabled = bool(mixture["use_two_populations"]) + if enabled: + self._supported_center = float(mixture["supported_center"]) + self._unsupported_center = float(mixture["unsupported_center"]) + else: + self._supported_center = float("inf") + self._unsupported_center = float("inf") + + assert self.calibration_ is not None + self.calibration_.update( + { + "probe_node_counts": node_counts.tolist(), + "raw_zero_crossing_cells": int(len(crossing_indices)), + "normalized_crossing_distance_min": float(np.min(normalized_distance)), + "normalized_crossing_distance_median": float( + np.median(normalized_distance) + ), + "normalized_crossing_distance_max": float(np.max(normalized_distance)), + "support_decay_enabled": enabled, + **mixture, + } + ) + if enabled: + progress( + "Auto support calibration separated supported and unsupported raw " + "zero-crossing populations: normalized log-distance centres " + f"{self._supported_center:.6g} and {self._unsupported_center:.6g}." + ) + else: + progress( + "Auto support calibration found one statistically preferred crossing " + "population; the fitted field will remain unchanged." + ) + + def evaluate(self, points: Any, *args: Any, **kwargs: Any): + query = np.asarray(points, dtype=np.float64) + values = self._raw_evaluate(query, *args, **kwargs) + original_shape = values.shape + flat_values = values.reshape(-1) + if query.ndim != 2 or query.shape[1] != 3 or len(query) != len(flat_values): + raise ValueError( + "Structural evaluation points must have shape (m, 3) and match the " + "number of returned values." + ) + + if ( + self._supported_center is not None + and self._unsupported_center is not None + and np.isfinite(self._supported_center) + and self._unsupported_center > self._supported_center + ): + normalized_distance = self._normalized_distance(query) + log_distance = np.log1p(normalized_distance) + t = np.clip( + (log_distance - self._supported_center) + / (self._unsupported_center - self._supported_center), + 0.0, + 1.0, + ) + weight = t * t * (3.0 - 2.0 * t) + flat_values = (1.0 - weight) * flat_values + weight * _OUTSIDE_VALUE + + exact_zero = flat_values == 0.0 + if np.any(exact_zero): + nonzero = np.abs(flat_values[~exact_zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + flat_values = flat_values.copy() + flat_values[exact_zero] = -np.finfo(np.float64).eps ** 0.5 * scale + return flat_values.reshape(original_shape) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _auto_support_factory(*args: Any, **kwargs: Any): + return _AutoSupportDecayInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +def _calibrating_mesher( + structural: Any, + bbox_min: Any, + bbox_max: Any, + resolution: float, + refine: int, + output_obj: Path, + progress: Callable[[str], None], +): + calibrate = getattr(structural, "calibrate_support", None) + if callable(calibrate): + calibrate(bbox_min, bbox_max, resolution, progress) + return _ORIGINAL_MESHER( + structural=structural, + bbox_min=bbox_min, + bbox_max=bbox_max, + resolution=resolution, + refine=refine, + output_obj=output_obj, + progress=progress, + ) + + +polatory.StructuralInterpolant3 = _auto_support_factory +suite.SAFE_MESHER.generate_safe_isosurface = _calibrating_mesher +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-auto-calibrated-support-decay" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: fully data-driven support calibration from local input " + "spacing and the raw fitted field; no case/range/strength-specific decay values.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay_v2.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay_v2.py new file mode 100644 index 000000000..83fddc2d6 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_auto_support_decay_v2.py @@ -0,0 +1,274 @@ +"""Run one selected case with topology-local, fully data-driven support completion. + +Version 1 learned two distance populations correctly, but then applied the learned decay +radially to every point at the same normalized data distance. That can erode valid remote +parts of the geological surface even when only one attached branch is unsupported. + +This version keeps the same data-driven population fit, then spatially localizes the +correction using the raw zero-surface itself: + +1. Probe the raw ``background_blending=False`` field on the automatic calibration grid. +2. Classify raw zero-crossing cells by their fitted supported/unsupported posterior. +3. Build spatial indices for both crossing populations. +4. Modify the field only where a query point is both statistically more likely to belong + to the unsupported population and spatially closer to an unsupported crossing branch. + +The 0.5 boundaries below are posterior decision boundaries, not geological distances or +case tuning. There are no case names, range thresholds, strength rules, metre values, or +dataset-specific support multipliers. If either population cannot be established, the +raw fitted field remains unchanged. +""" +from __future__ import annotations + +import math +import sys +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import polatory +from scipy.spatial import cKDTree + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import run_selected_no_background_blending_auto_support_decay as v1 # noqa: E402 + +suite = v1.suite +_BASE_FACTORY = v1._BASE_FACTORY +_OUTSIDE_VALUE = float(suite.OUTSIDE_VALUE) + + +def _posterior_unsupported( + values: np.ndarray, + supported_center: float, + unsupported_center: float, + standard_deviation: float, + unsupported_fraction: float, +) -> np.ndarray: + """Return the fitted two-population posterior without physical thresholds.""" + x = np.asarray(values, dtype=np.float64) + variance = max(float(standard_deviation) ** 2, np.finfo(np.float64).tiny) + unsupported_weight = float( + np.clip(unsupported_fraction, np.finfo(float).eps, 1.0 - np.finfo(float).eps) + ) + supported_weight = 1.0 - unsupported_weight + log_supported = ( + math.log(supported_weight) + + v1._log_normal_density(x, float(supported_center), variance) + ) + log_unsupported = ( + math.log(unsupported_weight) + + v1._log_normal_density(x, float(unsupported_center), variance) + ) + maximum = np.maximum(log_supported, log_unsupported) + supported_score = np.exp(log_supported - maximum) + unsupported_score = np.exp(log_unsupported - maximum) + return unsupported_score / (supported_score + unsupported_score) + + +def _decision_ramp(probability: np.ndarray) -> np.ndarray: + """Map the natural MAP boundary to zero and certainty to one, smoothly.""" + t = np.clip(2.0 * np.asarray(probability, dtype=np.float64) - 1.0, 0.0, 1.0) + return t * t * (3.0 - 2.0 * t) + + +class _TopologyLocalAutoSupportInterpolant(v1._AutoSupportDecayInterpolant): + """Learn unsupported crossings, but apply completion only near those branches.""" + + def __init__(self, wrapped: Any) -> None: + super().__init__(wrapped) + self._supported_crossing_tree: cKDTree | None = None + self._unsupported_crossing_tree: cKDTree | None = None + + def calibrate_support( + self, + bbox_min: Any, + bbox_max: Any, + resolution: float, + progress: Callable[[str], None], + ) -> None: + # Version 1 performs the unitless local-spacing calculation, raw-field probing, + # BIC model selection and two-population fit. + super().calibrate_support(bbox_min, bbox_max, resolution, progress) + calibration = self.calibration_ or {} + if not bool(calibration.get("support_decay_enabled", False)): + return + + supported_center = float(calibration["supported_center"]) + unsupported_center = float(calibration["unsupported_center"]) + standard_deviation = float(calibration["shared_standard_deviation"]) + unsupported_fraction = float(calibration["unsupported_fraction"]) + if not ( + np.isfinite(supported_center) + and np.isfinite(unsupported_center) + and unsupported_center > supported_center + and np.isfinite(standard_deviation) + and standard_deviation > 0.0 + ): + progress("Topology-local support calibration rejected an invalid mixture fit.") + return + + minimum = np.asarray(bbox_min, dtype=np.float64) + maximum = np.asarray(bbox_max, dtype=np.float64) + span = maximum - minimum + target_nodes = max(v1._PROBE_TARGET_NODES, 8) + node_density = (target_nodes / float(np.prod(span))) ** (1.0 / 3.0) + node_counts = np.maximum(np.rint(span * node_density).astype(np.int64) + 1, 3) + while int(np.prod(node_counts)) > 2 * target_nodes: + axis = int(np.argmax(node_counts)) + node_counts[axis] = max(3, int(node_counts[axis]) - 1) + + coordinates = [ + np.linspace(minimum[axis], maximum[axis], int(node_counts[axis])) + for axis in range(3) + ] + grid = np.meshgrid(*coordinates, indexing="ij") + query = np.column_stack([axis.ravel(order="C") for axis in grid]) + raw_flat = np.empty(len(query), dtype=np.float64) + batch_size = max(v1._PROBE_BATCH_SIZE, 1) + for start in range(0, len(query), batch_size): + stop = min(start + batch_size, len(query)) + raw_flat[start:stop] = self._raw_evaluate(query[start:stop]).reshape(-1) + + field_scale = max(1.0, float(np.max(np.abs(raw_flat)))) + zero_tolerance = np.finfo(np.float64).eps * field_scale + raw_flat[np.abs(raw_flat) <= zero_tolerance] = -zero_tolerance + volume = raw_flat.reshape(tuple(int(value) for value in node_counts), order="C") + corners = ( + volume[:-1, :-1, :-1], + volume[1:, :-1, :-1], + volume[:-1, 1:, :-1], + volume[:-1, :-1, 1:], + volume[1:, 1:, :-1], + volume[1:, :-1, 1:], + volume[:-1, 1:, 1:], + volume[1:, 1:, 1:], + ) + cell_minimum = np.minimum.reduce(corners) + cell_maximum = np.maximum.reduce(corners) + crossing_indices = np.argwhere( + (cell_minimum <= 0.0) & (cell_maximum >= 0.0) + ) + if len(crossing_indices) == 0: + progress("Topology-local calibration found no raw zero-crossing cells.") + return + + crossing_centers = np.empty((len(crossing_indices), 3), dtype=np.float64) + for axis in range(3): + lower = coordinates[axis][crossing_indices[:, axis]] + upper = coordinates[axis][crossing_indices[:, axis] + 1] + crossing_centers[:, axis] = 0.5 * (lower + upper) + + normalized_distance = self._normalized_distance(crossing_centers) + log_distance = np.log1p(normalized_distance) + posterior = _posterior_unsupported( + log_distance, + supported_center, + unsupported_center, + standard_deviation, + unsupported_fraction, + ) + unsupported_mask = posterior > 0.5 + supported_mask = ~unsupported_mask + if not np.any(supported_mask) or not np.any(unsupported_mask): + progress( + "Topology-local calibration could not retain both crossing populations; " + "the fitted field remains unchanged." + ) + return + + self._supported_crossing_tree = cKDTree(crossing_centers[supported_mask]) + self._unsupported_crossing_tree = cKDTree(crossing_centers[unsupported_mask]) + calibration.update( + { + "topology_localization_enabled": True, + "supported_crossing_cells": int(np.count_nonzero(supported_mask)), + "unsupported_crossing_cells": int(np.count_nonzero(unsupported_mask)), + } + ) + progress( + "Topology-local calibration retained " + f"{np.count_nonzero(supported_mask):,} supported and " + f"{np.count_nonzero(unsupported_mask):,} unsupported raw crossing cells; " + "decay will be confined to the unsupported branch neighbourhood." + ) + + def evaluate(self, points: Any, *args: Any, **kwargs: Any): + query = np.asarray(points, dtype=np.float64) + raw_values = self._raw_evaluate(query, *args, **kwargs) + original_shape = raw_values.shape + flat_values = raw_values.reshape(-1) + if query.ndim != 2 or query.shape[1] != 3 or len(query) != len(flat_values): + raise ValueError( + "Structural evaluation points must have shape (m, 3) and match the " + "number of returned values." + ) + + calibration = self.calibration_ or {} + if ( + self._supported_crossing_tree is not None + and self._unsupported_crossing_tree is not None + and bool(calibration.get("topology_localization_enabled", False)) + ): + normalized_distance = self._normalized_distance(query) + log_distance = np.log1p(normalized_distance) + posterior = _posterior_unsupported( + log_distance, + float(calibration["supported_center"]), + float(calibration["unsupported_center"]), + float(calibration["shared_standard_deviation"]), + float(calibration["unsupported_fraction"]), + ) + statistical_weight = _decision_ramp(posterior) + + distance_supported = np.asarray( + self._supported_crossing_tree.query(query, k=1)[0], dtype=np.float64 + ) + distance_unsupported = np.asarray( + self._unsupported_crossing_tree.query(query, k=1)[0], dtype=np.float64 + ) + denominator = distance_supported + distance_unsupported + spatial_probability = np.divide( + distance_supported, + denominator, + out=np.zeros_like(distance_supported), + where=denominator > 0.0, + ) + spatial_weight = _decision_ramp(spatial_probability) + weight = statistical_weight * spatial_weight + flat_values = (1.0 - weight) * flat_values + weight * _OUTSIDE_VALUE + + exact_zero = flat_values == 0.0 + if np.any(exact_zero): + nonzero = np.abs(flat_values[~exact_zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + flat_values = flat_values.copy() + flat_values[exact_zero] = -np.finfo(np.float64).eps ** 0.5 * scale + return flat_values.reshape(original_shape) + + +def _topology_local_factory(*args: Any, **kwargs: Any): + return _TopologyLocalAutoSupportInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +polatory.StructuralInterpolant3 = _topology_local_factory +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-topology-local-auto-support-decay" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: fully data-driven topology-local support calibration; " + "supported surface branches remain untouched and decay is confined to spatially " + "unsupported raw zero-surface branches.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_smooth_support_decay.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_smooth_support_decay.py new file mode 100644 index 000000000..d4f0d0f60 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_smooth_support_decay.py @@ -0,0 +1,133 @@ +"""Run one selected no-background-blending case with smooth data-support decay. + +The best current field uses StructuralInterpolant3 background_blending=False, which +preserves the Leapfrog-like shape near the data but can leave a long unsupported basal +lobe attached to the main component. This diagnostic keeps the fitted structural field +exactly unchanged inside a data-support radius, then smoothly blends it toward the +outside value according to Euclidean distance from the nearest input point. Unlike the +old per-domain background blending, the completion has no axis-aligned domain boxes and +therefore should close unsupported lobes without reintroducing flat shelves or walls. + +Defaults are expressed as fractions of the base RBF range: + start = 0.60 * BASE_RANGE + end = 1.00 * BASE_RANGE +They can be overridden with POLATORY_SUPPORT_DECAY_START_FRACTION and +POLATORY_SUPPORT_DECAY_END_FRACTION. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import polatory +from scipy.spatial import cKDTree + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Installs the selected-case runner and background_blending=False first. +import run_selected_no_background_blending as selected # noqa: E402 + +suite = selected.suite +_BASE_FACTORY = polatory.StructuralInterpolant3 +_START_FRACTION = float( + os.environ.get("POLATORY_SUPPORT_DECAY_START_FRACTION", "0.60") +) +_END_FRACTION = float( + os.environ.get("POLATORY_SUPPORT_DECAY_END_FRACTION", "1.00") +) + +if not 0.0 <= _START_FRACTION < _END_FRACTION: + raise ValueError( + "Support-decay fractions must satisfy 0 <= start < end; got " + f"{_START_FRACTION:g} and {_END_FRACTION:g}." + ) + +_DECAY_START = _START_FRACTION * float(suite.BASE_RANGE) +_DECAY_END = _END_FRACTION * float(suite.BASE_RANGE) +_OUTSIDE_VALUE = float(suite.OUTSIDE_VALUE) + + +class _SmoothSupportDecayInterpolant: + """Delegate fitting while smoothly completing unsupported field regions.""" + + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + self._tree: cKDTree | None = None + + def fit(self, points: Any, *args: Any, **kwargs: Any): + fit_points = np.asarray(points, dtype=np.float64) + if fit_points.ndim != 2 or fit_points.shape[1] != 3: + raise ValueError("Structural fit points must have shape (n, 3).") + self._tree = cKDTree(fit_points) + return self._wrapped.fit(points, *args, **kwargs) + + def evaluate(self, points: Any, *args: Any, **kwargs: Any): + query = np.asarray(points, dtype=np.float64) + values = np.asarray( + self._wrapped.evaluate(points, *args, **kwargs), + dtype=np.float64, + ) + original_shape = values.shape + flat_values = values.reshape(-1) + + if self._tree is None: + return values + if query.ndim != 2 or query.shape[1] != 3 or len(query) != len(flat_values): + raise ValueError( + "Structural evaluation points must have shape (m, 3) and match the " + "number of returned values." + ) + + distances = np.asarray(self._tree.query(query, k=1)[0], dtype=np.float64) + t = np.clip( + (distances - _DECAY_START) / (_DECAY_END - _DECAY_START), + 0.0, + 1.0, + ) + # Cubic smoothstep: zero slope at both ends avoids a visible support shell. + weight = t * t * (3.0 - 2.0 * t) + adjusted = (1.0 - weight) * flat_values + weight * _OUTSIDE_VALUE + + # Exact zero is ambiguous for marching cubes. Bias only exact zeros by a tiny + # scale-relative amount; every nonzero value remains governed by the smooth blend. + exact_zero = adjusted == 0.0 + if np.any(exact_zero): + nonzero = np.abs(adjusted[~exact_zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + adjusted = adjusted.copy() + adjusted[exact_zero] = -1.0e-6 * scale + + return adjusted.reshape(original_shape) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _smooth_support_factory(*args: Any, **kwargs: Any): + return _SmoothSupportDecayInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +polatory.StructuralInterpolant3 = _smooth_support_factory +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-smooth-support-decay" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: background_blending=False with smooth nearest-data " + f"support decay from {_DECAY_START:g} m to {_DECAY_END:g} m; the fitted field is " + "unchanged inside the start radius.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau.py new file mode 100644 index 000000000..e8f654af4 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau.py @@ -0,0 +1,82 @@ +"""Run one selected no-background-blending case with exact-zero plateau suppression. + +Disabling structural background blending gave the closest Leapfrog shape so far, but the +uncovered structural field can return exact zeros over finite patches. Because zero is +also the requested isovalue, marching cubes may turn such a plateau into a thin flat +sheet. This diagnostic changes only those exact-zero evaluation samples to a tiny +negative value. Every nonzero field value, automatic domain, RBF fit, LVA parameter, +and global-grid sample location remains unchanged. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import polatory + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# This installs background_blending=False and exact-case selection first. +import run_selected_no_background_blending as selected # noqa: E402 + +suite = selected.suite +_BASE_FACTORY = polatory.StructuralInterpolant3 +_RELATIVE_BIAS = 1.0e-6 + + +class _ZeroPlateauSuppressedInterpolant: + """Delegate the fitted model while biasing only exact zero evaluations negative.""" + + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + self._biased_total = 0 + + def evaluate(self, *args: Any, **kwargs: Any): + values = np.asarray(self._wrapped.evaluate(*args, **kwargs), dtype=float) + zero = values == 0.0 + count = int(np.count_nonzero(zero)) + if count: + nonzero = np.abs(values[~zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + epsilon = _RELATIVE_BIAS * scale + values = values.copy() + values[zero] = -epsilon + self._biased_total += count + print( + "PROGRESS\tZero-plateau suppression biased " + f"{count:,} exact-zero field sample(s) to {-epsilon:.6g} " + f"({self._biased_total:,} cumulative).", + flush=True, + ) + return values + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _zero_plateau_suppressed_factory(*args: Any, **kwargs: Any): + return _ZeroPlateauSuppressedInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +polatory.StructuralInterpolant3 = _zero_plateau_suppressed_factory +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-zero-plateau-fix" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: background_blending=False plus exact-zero plateau " + "suppression; all nonzero structural-field values remain unchanged.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_fix.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_fix.py new file mode 100644 index 000000000..e8f654af4 --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_fix.py @@ -0,0 +1,82 @@ +"""Run one selected no-background-blending case with exact-zero plateau suppression. + +Disabling structural background blending gave the closest Leapfrog shape so far, but the +uncovered structural field can return exact zeros over finite patches. Because zero is +also the requested isovalue, marching cubes may turn such a plateau into a thin flat +sheet. This diagnostic changes only those exact-zero evaluation samples to a tiny +negative value. Every nonzero field value, automatic domain, RBF fit, LVA parameter, +and global-grid sample location remains unchanged. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import polatory + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# This installs background_blending=False and exact-case selection first. +import run_selected_no_background_blending as selected # noqa: E402 + +suite = selected.suite +_BASE_FACTORY = polatory.StructuralInterpolant3 +_RELATIVE_BIAS = 1.0e-6 + + +class _ZeroPlateauSuppressedInterpolant: + """Delegate the fitted model while biasing only exact zero evaluations negative.""" + + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + self._biased_total = 0 + + def evaluate(self, *args: Any, **kwargs: Any): + values = np.asarray(self._wrapped.evaluate(*args, **kwargs), dtype=float) + zero = values == 0.0 + count = int(np.count_nonzero(zero)) + if count: + nonzero = np.abs(values[~zero]) + scale = max(1.0, float(np.max(nonzero)) if nonzero.size else 1.0) + epsilon = _RELATIVE_BIAS * scale + values = values.copy() + values[zero] = -epsilon + self._biased_total += count + print( + "PROGRESS\tZero-plateau suppression biased " + f"{count:,} exact-zero field sample(s) to {-epsilon:.6g} " + f"({self._biased_total:,} cumulative).", + flush=True, + ) + return values + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _zero_plateau_suppressed_factory(*args: Any, **kwargs: Any): + return _ZeroPlateauSuppressedInterpolant(_BASE_FACTORY(*args, **kwargs)) + + +polatory.StructuralInterpolant3 = _zero_plateau_suppressed_factory +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-zero-plateau-fix" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: background_blending=False plus exact-zero plateau " + "suppression; all nonzero structural-field values remain unchanged.", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_padded_bottom.py b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_padded_bottom.py new file mode 100644 index 000000000..8e541e2ce --- /dev/null +++ b/benchmarks/leapfrog_gold/run_selected_no_background_blending_zero_plateau_padded_bottom.py @@ -0,0 +1,52 @@ +"""Run one selected no-background-blending case with zero-plateau suppression and extra bottom meshing padding. + +This diagnostic keeps the fitted structural model, automatic domains, supports, LVA parameters, +and all nonzero field values unchanged. It changes only the meshing extent by lowering the Z +minimum by one base range so a zero surface that reaches the benchmark floor can continue and +close naturally instead of being clipped into a flat open termination. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import run_selected_no_background_blending_zero_plateau as diagnostic # noqa: E402 + +suite = diagnostic.suite + +padding = float( + os.environ.get("POLATORY_BENCHMARK_BOTTOM_PADDING", str(suite.BASE_RANGE)) +) +if not np.isfinite(padding) or padding <= 0.0: + raise ValueError("POLATORY_BENCHMARK_BOTTOM_PADDING must be a positive finite number.") + +original_min = np.asarray(suite.MODEL_MIN, dtype=float).copy() +padded_min = original_min.copy() +padded_min[2] -= padding +suite.MODEL_MIN = padded_min + +suite.OUTPUT_DIR = ( + suite.ROOT + / "benchmark-results" + / "diagnostic-no-background-blending-zero-plateau-padded-bottom" +) +suite.MESH_DIR = suite.OUTPUT_DIR / "meshes" +suite.PLOT_DIR = suite.OUTPUT_DIR / "overlays" + +print( + "PROGRESS\tDiagnostic mode: preserved the fitted field and lowered only the meshing " + f"bottom from Z={original_min[2]:g} to Z={padded_min[2]:g} " + f"(padding {padding:g}).", + flush=True, +) + + +if __name__ == "__main__": + raise SystemExit(suite.main()) diff --git a/benchmarks/leapfrog_gold/sweep_domainer_consistency.py b/benchmarks/leapfrog_gold/sweep_domainer_consistency.py new file mode 100644 index 000000000..f8aede6fc --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_domainer_consistency.py @@ -0,0 +1,346 @@ +"""Fast screening sweep for candidate Leapfrog SubDomainer consistency transforms. + +This script deliberately avoids fitting RBF domains and generating meshes. It reads +inspection CSVs produced by ``run_selected_exact_leapfrog_lva.py``, reconstructs the +structured centroid graph, samples the exported LVA matrix field at the centroids, and +runs the recovered greedy six-neighbour region-growing algorithm for several candidate +transforms of the proposed merged-matrix determinant. + +The exported LVA field is a regular diagnostic grid (25^3 by default), so this is a +screening tool rather than a final parity measurement. The strongest candidates should +be validated afterward with the full mesh benchmark. +""" +from __future__ import annotations + +import argparse +import csv +import json +import math +import time +from dataclasses import asdict, dataclass +from heapq import heappop, heappush +from pathlib import Path +from typing import Callable + +import numpy as np +from scipy.spatial import cKDTree + + +Matrix = np.ndarray +Consistency = Callable[[float], float] + + +@dataclass(frozen=True) +class SweepResult: + formula: str + threshold: float + centroid_count: int + grid_shape: tuple[int, int, int] + merge_count: int + surviving_grid_domains: int + populated_domains: int + minimum_domain_size: int + median_domain_size: float + maximum_domain_size: int + elapsed_seconds: float + + +def _read_csv(path: Path) -> list[dict[str, str]]: + if not path.is_file(): + raise FileNotFoundError(path) + with path.open("r", encoding="utf-8-sig", newline="") as stream: + return list(csv.DictReader(stream)) + + +def _matrix_from_row(row: dict[str, str]) -> Matrix: + return np.asarray( + [ + [float(row["matrix_00"]), float(row["matrix_01"]), float(row["matrix_02"])], + [float(row["matrix_10"]), float(row["matrix_11"]), float(row["matrix_12"])], + [float(row["matrix_20"]), float(row["matrix_21"]), float(row["matrix_22"])], + ], + dtype=np.float64, + ) + + +def _symmetric_determinant(matrix: Matrix) -> float: + matrix = 0.5 * (matrix + matrix.T) + a, b, c = float(matrix[0, 0]), float(matrix[0, 1]), float(matrix[0, 2]) + e, f, i = float(matrix[1, 1]), float(matrix[1, 2]), float(matrix[2, 2]) + return a * e * i + 2.0 * b * c * f - a * f * f - e * c * c - i * b * b + + +def _normalise_determinant(matrix: Matrix) -> Matrix: + symmetric = 0.5 * (matrix + matrix.T) + determinant = _symmetric_determinant(symmetric) + if not np.isfinite(determinant) or determinant <= 0.0: + raise ValueError("LVA matrices must be finite and positive definite") + return symmetric / determinant ** (1.0 / 3.0) + + +def _grid_edges(shape: tuple[int, int, int]) -> np.ndarray: + grid = np.arange(np.prod(shape), dtype=np.int64).reshape(shape) + edges: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1: + continue + left = [slice(None), slice(None), slice(None)] + right = [slice(None), slice(None), slice(None)] + left[axis] = slice(0, size - 1) + right[axis] = slice(1, size) + edges.append(np.column_stack([grid[tuple(left)].ravel(), grid[tuple(right)].ravel()])) + return np.vstack(edges) if edges else np.empty((0, 2), dtype=np.int64) + + +def _formulae() -> dict[str, Consistency]: + tiny = np.finfo(np.float64).tiny + return { + "reciprocal_det": lambda det: 1.0 / max(det, tiny), + "inverse_sqrt_det": lambda det: 1.0 / math.sqrt(max(det, tiny)), + "exp_abs_log_det": lambda det: math.exp(-abs(math.log(max(det, tiny)))), + "symmetric_reciprocal": lambda det: min(det, 1.0 / max(det, tiny)), + } + + +def _load_inputs(directory: Path, case: str) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int], np.ndarray]: + centroid_rows = _read_csv(directory / f"{case}_domain_centroids.csv") + point_rows = _read_csv(directory / f"{case}_domain_points.csv") + field_rows = _read_csv(directory / f"{case}_lva_field.csv") + + centroid_rows.sort(key=lambda row: int(row["centroid_index"])) + centroids = np.asarray( + [[float(row["x"]), float(row["y"]), float(row["z"])] for row in centroid_rows], + dtype=np.float64, + ) + grid_indices = np.asarray( + [[int(row["grid_i"]), int(row["grid_j"]), int(row["grid_k"])] for row in centroid_rows], + dtype=np.int64, + ) + shape = tuple(int(value) for value in (grid_indices.max(axis=0) + 1)) + if int(np.prod(shape)) != len(centroids): + raise ValueError(f"Centroid CSV is not a complete structured grid: shape={shape}") + + points = np.asarray( + [[float(row["x"]), float(row["y"]), float(row["z"])] for row in point_rows], + dtype=np.float64, + ) + field_points = np.asarray( + [[float(row["x"]), float(row["y"]), float(row["z"])] for row in field_rows], + dtype=np.float64, + ) + field_matrices = np.asarray([_matrix_from_row(row) for row in field_rows], dtype=np.float64) + nearest = np.asarray(cKDTree(field_points).query(centroids, k=1)[1], dtype=np.int64) + centroid_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in field_matrices[nearest]], + dtype=np.float64, + ) + return points, centroids, shape, centroid_matrices + + +def _point_cells(points: np.ndarray, centroids: np.ndarray, shape: tuple[int, int, int]) -> np.ndarray: + # The benchmark centroids are cell centres. Reconstruct cell bounds from their + # regular spacing, then use the same C-order flattening as the production builder. + minimum = np.empty(3, dtype=np.float64) + maximum = np.empty(3, dtype=np.float64) + volume = centroids.reshape(shape + (3,)) + for axis, size in enumerate(shape): + line = np.take(volume, indices=range(size), axis=axis) + coordinates = np.unique(line[..., axis]) + coordinates.sort() + if size == 1: + minimum[axis] = coordinates[0] + maximum[axis] = coordinates[0] + else: + step = float(np.median(np.diff(coordinates))) + minimum[axis] = coordinates[0] - 0.5 * step + maximum[axis] = coordinates[-1] + 0.5 * step + indices = np.zeros((len(points), 3), dtype=np.int64) + for axis, size in enumerate(shape): + span = maximum[axis] - minimum[axis] + if size <= 1 or span <= 0.0: + continue + normalized = (points[:, axis] - minimum[axis]) / span + indices[:, axis] = np.clip(np.floor(normalized * size).astype(np.int64), 0, size - 1) + return (indices[:, 0] * shape[1] + indices[:, 1]) * shape[2] + indices[:, 2] + + +def _run_one( + points: np.ndarray, + centroids: np.ndarray, + shape: tuple[int, int, int], + initial_matrices: np.ndarray, + formula_name: str, + transform: Consistency, + threshold: float, + maximum_fraction: float, +) -> SweepResult: + started = time.perf_counter() + total = len(centroids) + maximum_size = max(1, int(math.floor(maximum_fraction * total))) + active = np.ones(2 * total + 1, dtype=bool) + version = np.zeros(2 * total + 1, dtype=np.int64) + sizes = np.zeros(2 * total + 1, dtype=np.int64) + sizes[:total] = 1 + matrices = np.zeros((2 * total + 1, 3, 3), dtype=np.float64) + matrices[:total] = initial_matrices + leaves: list[list[int]] = [[index] for index in range(total)] + [[] for _ in range(total + 1)] + neighbours: list[set[int]] = [set() for _ in range(2 * total + 1)] + edges = _grid_edges(shape) + for first, second in edges: + neighbours[int(first)].add(int(second)) + neighbours[int(second)].add(int(first)) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if first == second or not active[first] or not active[second]: + return + if sizes[first] + sizes[second] > maximum_size: + return + merged_size = int(sizes[first] + sizes[second]) + merged = (sizes[first] * matrices[first] + sizes[second] * matrices[second]) / merged_size + determinant = _symmetric_determinant(merged) + if not np.isfinite(determinant) or determinant <= 0.0: + return + score = float(transform(determinant)) + low, high = sorted((first, second)) + heappush(heap, (-score, low, high, int(version[low]), int(version[high]))) + + for first, second in edges: + push(int(first), int(second)) + + next_id = total + merge_count = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + continue + if second not in neighbours[first] or first not in neighbours[second]: + continue + score = -negative + if score < threshold: + break + if sizes[first] + sizes[second] > maximum_size: + continue + + merged_size = int(sizes[first] + sizes[second]) + merged_matrix = (sizes[first] * matrices[first] + sizes[second] * matrices[second]) / merged_size + merged_neighbours = (neighbours[first] | neighbours[second]) - {first, second} + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + active[next_id] = True + sizes[next_id] = merged_size + matrices[next_id] = merged_matrix + leaves[next_id] = leaves[first] + leaves[second] + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + version[neighbour] += 1 + neighbours[next_id].add(neighbour) + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + next_id += 1 + merge_count += 1 + + owner = np.empty(total, dtype=np.int64) + survivors: list[int] = [] + for domain_id in range(next_id): + if active[domain_id]: + survivors.append(domain_id) + owner[np.asarray(leaves[domain_id], dtype=np.int64)] = domain_id + point_domains = owner[_point_cells(points, centroids, shape)] + populated, populations = np.unique(point_domains, return_counts=True) + populations = np.asarray(populations, dtype=np.int64) + return SweepResult( + formula=formula_name, + threshold=threshold, + centroid_count=total, + grid_shape=shape, + merge_count=merge_count, + surviving_grid_domains=len(survivors), + populated_domains=len(populated), + minimum_domain_size=int(populations.min()) if len(populations) else 0, + median_domain_size=float(np.median(populations)) if len(populations) else 0.0, + maximum_domain_size=int(populations.max()) if len(populations) else 0, + elapsed_seconds=time.perf_counter() - started, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--inspection-dir", + type=Path, + default=Path("benchmark-results/exact-leapfrog-lva-forced/inspection-csv"), + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/consistency-sweep.json"), + ) + args = parser.parse_args() + if not 0.0 < args.threshold <= 1.0: + parser.error("--threshold must be in (0, 1]") + if not 0.0 < args.maximum_fraction <= 1.0: + parser.error("--maximum-fraction must be in (0, 1]") + + points, centroids, shape, matrices = _load_inputs(args.inspection_dir, args.case.upper()) + results = [ + _run_one( + points, + centroids, + shape, + matrices, + name, + transform, + args.threshold, + args.maximum_fraction, + ) + for name, transform in _formulae().items() + ] + results.sort(key=lambda item: (abs(item.populated_domains - 15), item.populated_domains, item.formula)) + + print( + f"case={args.case.upper()} centroids={len(centroids)} grid={shape} " + f"points={len(points)} threshold={args.threshold:g}" + ) + header = ( + "formula", "populated", "grid_domains", "merges", "min_points", + "median_points", "max_points", "seconds", + ) + print(" ".join(f"{item:>20}" for item in header)) + for result in results: + print( + f"{result.formula:>20} {result.populated_domains:>20d} " + f"{result.surviving_grid_domains:>20d} {result.merge_count:>20d} " + f"{result.minimum_domain_size:>20d} {result.median_domain_size:>20.1f} " + f"{result.maximum_domain_size:>20d} {result.elapsed_seconds:>20.3f}" + ) + + args.output.parent.mkdir(parents=True, exist_ok=True) + payload = { + "case": args.case.upper(), + "inspection_directory": str(args.inspection_dir), + "screening_note": ( + "Centroid matrices are nearest-neighbour samples of the exported regular LVA " + "diagnostic grid; validate finalists with the full mesh benchmark." + ), + "results": [asdict(result) for result in results], + } + args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_exact_domainer_consistency.py b/benchmarks/leapfrog_gold/sweep_exact_domainer_consistency.py new file mode 100644 index 000000000..6ec275168 --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_exact_domainer_consistency.py @@ -0,0 +1,387 @@ +"""Exact in-process sweep of candidate Leapfrog SubDomainer consistency transforms. + +Unlike ``sweep_domainer_consistency.py``, this runner does not transfer matrices from +the exported 25^3 diagnostic field. It loads the benchmark's real structural trend +mesh, creates the production automatic builder's centroid grid, samples the recovered +single-input LVA field directly at every centroid, and then changes only the +merged-matrix consistency transform. + +The reciprocal-determinant control must reproduce the production clustering result +before any alternative is considered meaningful. For S3_R100 the current verified +control is 32 populated domains on a 16 x 25 x 15 centroid grid. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import math +import os +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +import numpy as np + + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + + +ConsistencyTransform = Callable[[float], float] + + +@dataclass(frozen=True) +class SweepResult: + formula: str + threshold: float + centroid_count: int + grid_shape: tuple[int, int, int] + merge_count: int + surviving_grid_domains: int + populated_domains: int + minimum_domain_size: int + median_domain_size: float + maximum_domain_size: int + elapsed_seconds: float + + +def _formulae() -> dict[str, ConsistencyTransform | None]: + """Return determinant-only candidates plus the unmodified production control.""" + tiny = np.finfo(np.float64).tiny + return { + # None means call the currently installed production implementation unchanged. + "reciprocal_det_control": None, + "inverse_sqrt_det": lambda determinant: 1.0 + / math.sqrt(max(determinant, tiny)), + "inverse_cuberoot_det": lambda determinant: max(determinant, tiny) + ** (-1.0 / 3.0), + "inverse_det_squared": lambda determinant: 1.0 + / (max(determinant, tiny) ** 2.0), + "inverse_one_plus_log_det": lambda determinant: 1.0 + / (1.0 + abs(math.log(max(determinant, tiny)))), + "exp_neg_sqrt_abs_log_det": lambda determinant: math.exp( + -math.sqrt(abs(math.log(max(determinant, tiny)))) + ), + } + + +def _candidate_merge_function( + determinant_function: Callable[[np.ndarray], float], + transform: ConsistencyTransform, +): + """Build a drop-in replacement changing only determinant-to-consistency mapping.""" + + def merged_matrix_and_consistency( + first_matrix: np.ndarray, + first_size: int, + second_matrix: np.ndarray, + second_size: int, + ) -> tuple[np.ndarray, float]: + total = int(first_size) + int(second_size) + if total <= 0: + raise ValueError("Merged domain population must be positive.") + weight = int(first_size) / float(total) + merged = weight * first_matrix + (1.0 - weight) * second_matrix + merged = 0.5 * (merged + merged.T) + determinant = float(determinant_function(merged)) + consistency = ( + float("-inf") + if not np.isfinite(determinant) or determinant <= 0.0 + else float(transform(determinant)) + ) + return merged, consistency + + return merged_matrix_and_consistency + + +def _run_formula( + *, + builder_class: type, + builder_module: object, + original_merge_function: Callable[..., tuple[np.ndarray, float]], + determinant_function: Callable[[np.ndarray], float], + formula_name: str, + transform: ConsistencyTransform | None, + points: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + threshold: float, + centroid_count: int, + minimum_fraction: float, + maximum_fraction: float, +) -> SweepResult: + started = time.perf_counter() + replacement = ( + original_merge_function + if transform is None + else _candidate_merge_function(determinant_function, transform) + ) + setattr(builder_module, "_merged_matrix_and_consistency", replacement) + + builder = builder_class( + centroid_count=centroid_count, + minimum_cluster_fraction=minimum_fraction, + maximum_cluster_fraction=maximum_fraction, + consistency_threshold=threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + labels, centroid_labels, _, _, merge_count = builder._automatic_labels( + points, + centroid_anisotropies, + minimum, + maximum, + shape, + ) + + labels = np.asarray(labels, dtype=np.int64) + centroid_labels = np.asarray(centroid_labels, dtype=np.int64) + _, populations = np.unique(labels, return_counts=True) + populated = int(len(populations)) + surviving_grid = int(len(np.unique(centroid_labels[centroid_labels >= 0]))) + + return SweepResult( + formula=formula_name, + threshold=float(threshold), + centroid_count=int(np.prod(shape)), + grid_shape=shape, + merge_count=int(merge_count), + surviving_grid_domains=surviving_grid, + populated_domains=populated, + minimum_domain_size=int(populations.min()) if len(populations) else 0, + median_domain_size=float(np.median(populations)) if len(populations) else 0.0, + maximum_domain_size=int(populations.max()) if len(populations) else 0, + elapsed_seconds=float(time.perf_counter() - started), + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--expected-control-domains", + type=int, + default=None, + help=( + "Required populated-domain count for the unmodified reciprocal-determinant " + "control. Defaults to 32 for S3_R100 and is otherwise not enforced." + ), + ) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/exact-consistency-sweep.json"), + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + if not case_name: + parser.error("--case must not be empty") + if not 0.0 < args.threshold <= 1.0: + parser.error("--threshold must be in (0, 1]") + if args.centroid_count <= 0: + parser.error("--centroid-count must be positive") + if not 0.0 < args.minimum_fraction <= args.maximum_fraction <= 1.0: + parser.error("cluster fractions must satisfy 0 < minimum <= maximum <= 1") + + # Set selection before importing the benchmark stack. Importing the exact runner + # installs the recovered single-input sampler but does not execute the mesh suite. + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory # noqa: E402 + import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + suite = exact.suite + points, _ = suite.read_dataset() + trend_vertices, trend_faces = suite.read_obj( + suite.DATA_DIR / "Reference Mesh(2).obj" + ) + cases = suite.available_cases() + matching = [case for case in cases if case.name.upper() == case_name] + if len(matching) != 1: + raise RuntimeError( + f"Expected one benchmark case named {case_name}, found {len(matching)}." + ) + case = matching[0] + + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + float(case.strength), + float(case.trend_range), + ) + + builder_class = polatory.AutomaticStructuralDomainBuilder3 + required_methods = ("_prepare_grid", "_automatic_labels") + missing = [name for name in required_methods if not hasattr(builder_class, name)] + if missing: + raise RuntimeError( + "The installed top-level AutomaticStructuralDomainBuilder3 is not the " + f"reconstructed Leapfrog builder; missing {missing}." + ) + + builder_module = importlib.import_module(builder_class.__module__) + original_merge_function = getattr( + builder_module, "_merged_matrix_and_consistency", None + ) + determinant_function = getattr(builder_module, "_symmetric_determinant", None) + if original_merge_function is None or determinant_function is None: + raise RuntimeError( + f"{builder_class.__module__} does not expose the recovered merge helpers." + ) + + preparation_builder = builder_class( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, active_axes, shape, centroids = ( + preparation_builder._prepare_grid(np.asarray(points, dtype=np.float64)) + ) + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, + trend_input, + non_decaying=False, + ) + + print( + f"case={case.name} points={len(points)} centroids={len(centroids)} " + f"grid={shape} threshold={args.threshold:g}", + flush=True, + ) + print( + "Sampling source: real Reference Mesh(2).obj evaluated directly at every " + "production centroid.", + flush=True, + ) + + results: list[SweepResult] = [] + try: + for formula_name, transform in _formulae().items(): + result = _run_formula( + builder_class=builder_class, + builder_module=builder_module, + original_merge_function=original_merge_function, + determinant_function=determinant_function, + formula_name=formula_name, + transform=transform, + points=np.asarray(points, dtype=np.float64), + centroid_anisotropies=np.asarray( + centroid_anisotropies, dtype=np.float64 + ), + minimum=np.asarray(minimum, dtype=np.float64), + maximum=np.asarray(maximum, dtype=np.float64), + shape=tuple(int(value) for value in shape), + threshold=args.threshold, + centroid_count=args.centroid_count, + minimum_fraction=args.minimum_fraction, + maximum_fraction=args.maximum_fraction, + ) + results.append(result) + finally: + setattr( + builder_module, + "_merged_matrix_and_consistency", + original_merge_function, + ) + + expected_control = args.expected_control_domains + if expected_control is None and case_name == "S3_R100": + expected_control = 32 + control = next( + item for item in results if item.formula == "reciprocal_det_control" + ) + if ( + expected_control is not None + and control.populated_domains != expected_control + ): + raise RuntimeError( + "Exact sweep validation failed: the unmodified production control " + f"returned {control.populated_domains} populated domains, expected " + f"{expected_control}. Do not interpret the alternative formulas." + ) + + headers = ( + "formula", + "populated", + "grid_domains", + "merges", + "min_points", + "median_points", + "max_points", + "seconds", + ) + print( + " ".join( + ( + f"{headers[0]:>29}", + f"{headers[1]:>10}", + f"{headers[2]:>14}", + f"{headers[3]:>10}", + f"{headers[4]:>11}", + f"{headers[5]:>13}", + f"{headers[6]:>10}", + f"{headers[7]:>9}", + ) + ) + ) + for item in results: + print( + f"{item.formula:>29} " + f"{item.populated_domains:>10d} " + f"{item.surviving_grid_domains:>14d} " + f"{item.merge_count:>10d} " + f"{item.minimum_domain_size:>11d} " + f"{item.median_domain_size:>13.1f} " + f"{item.maximum_domain_size:>10d} " + f"{item.elapsed_seconds:>9.3f}" + ) + + payload = { + "case": case.name, + "strength": float(case.strength), + "trend_range": float(case.trend_range), + "input_points": int(len(points)), + "centroid_count_requested": int(args.centroid_count), + "centroid_count_actual": int(len(centroids)), + "centroid_grid_shape": [int(value) for value in shape], + "active_axes": np.asarray(active_axes, dtype=bool).tolist(), + "threshold": float(args.threshold), + "minimum_fraction": float(args.minimum_fraction), + "maximum_fraction": float(args.maximum_fraction), + "sampling": "exact structural mesh at production centroids", + "builder_module": builder_class.__module__, + "control_expected_populated_domains": expected_control, + "control_validated": ( + expected_control is None + or control.populated_domains == expected_control + ), + "results": [asdict(item) for item in results], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(payload, indent=2) + "\n", + encoding="utf-8", + ) + print(f"wrote {args.output}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_oracle_grid_geometry.py b/benchmarks/leapfrog_gold/sweep_oracle_grid_geometry.py new file mode 100644 index 000000000..66af583cb --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_oracle_grid_geometry.py @@ -0,0 +1,376 @@ +"""Compare candidate Leapfrog grid geometries directly against decoded oracle labels. + +The consistency-formula and threshold sweeps showed that the S3_R100 partition is +controlled by grid topology/size constraints rather than the 0.6 threshold. This +runner therefore keeps the recovered merge implementation unchanged and varies only: + +* data bounding box versus the benchmark/model bounding box; +* the current automatically factored shape versus the captured 15 x 23 x 19 shape; +* cell-centre locations with floor assignment versus endpoint nodes with nearest-node + assignment. + +No RBF fitting or surface meshing is performed. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import os +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +import numpy as np +from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import compare_automatic_domains_to_oracle_robust as robust # noqa: E402 + + +@dataclass(frozen=True) +class GeometryResult: + geometry: str + bbox_min: tuple[float, float, float] + bbox_max: tuple[float, float, float] + grid_shape: tuple[int, int, int] + centroid_count: int + oracle_domains: int + predicted_domains: int + adjusted_rand_index: float + adjusted_mutual_information: float + optimal_label_accuracy: float + matched_points: int + merge_count: int + elapsed_seconds: float + + +def _endpoint_nodes( + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> np.ndarray: + axes = [] + for axis, size in enumerate(shape): + if size <= 1 or not maximum[axis] > minimum[axis]: + axes.append(np.asarray([(minimum[axis] + maximum[axis]) * 0.5])) + else: + axes.append(np.linspace(minimum[axis], maximum[axis], size, dtype=float)) + xx, yy, zz = np.meshgrid(*axes, indexing="ij") + return np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + + +def _nearest_node_indices( + points: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> np.ndarray: + indices = np.zeros((len(points), 3), dtype=np.int64) + for axis, size in enumerate(shape): + span = float(maximum[axis] - minimum[axis]) + if size <= 1 or not span > 0.0: + continue + scaled = (points[:, axis] - minimum[axis]) / span * float(size - 1) + indices[:, axis] = np.clip(np.rint(scaled).astype(np.int64), 0, size - 1) + return (indices[:, 0] * shape[1] + indices[:, 1]) * shape[2] + indices[:, 2] + + +def _run_geometry( + *, + name: str, + points: np.ndarray, + oracle_labels: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + centroids: np.ndarray, + point_indexer: Callable[[np.ndarray, np.ndarray, np.ndarray, tuple[int, int, int]], np.ndarray], + builder_class: type, + builder_module: object, + trend_input: object, + exact_module: object, + threshold: float, + centroid_count: int, + minimum_fraction: float, + maximum_fraction: float, +) -> GeometryResult: + started = time.perf_counter() + centroid_anisotropies = exact_module.exact_leapfrog_single_input_anisotropies3( + centroids, + trend_input, + non_decaying=False, + ) + original_indexer = getattr(builder_module, "_point_cell_indices") + setattr(builder_module, "_point_cell_indices", point_indexer) + try: + builder = builder_class( + centroid_count=centroid_count, + minimum_cluster_fraction=minimum_fraction, + maximum_cluster_fraction=maximum_fraction, + consistency_threshold=threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + predicted, _, _, _, merge_count = builder._automatic_labels( # noqa: SLF001 + points, + np.asarray(centroid_anisotropies, dtype=np.float64), + minimum, + maximum, + shape, + ) + finally: + setattr(builder_module, "_point_cell_indices", original_indexer) + + predicted = np.asarray(predicted, dtype=np.int64) + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, predicted) # noqa: SLF001 + return GeometryResult( + geometry=name, + bbox_min=tuple(float(value) for value in minimum), + bbox_max=tuple(float(value) for value in maximum), + grid_shape=tuple(int(value) for value in shape), + centroid_count=int(len(centroids)), + oracle_domains=int(len(np.unique(oracle_labels))), + predicted_domains=int(len(np.unique(predicted))), + adjusted_rand_index=float(adjusted_rand_score(oracle_labels, predicted)), + adjusted_mutual_information=float( + adjusted_mutual_info_score(oracle_labels, predicted) + ), + optimal_label_accuracy=float(accuracy), + matched_points=int(matched), + merge_count=int(merge_count), + elapsed_seconds=float(time.perf_counter() - started), + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", + type=Path, + default=Path("Leapfrog_LVA_decoded_benchmark"), + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument( + "--captured-shape", + type=int, + nargs=3, + metavar=("NX", "NY", "NZ"), + default=(15, 23, 19), + ) + parser.add_argument( + "--model-min", + type=float, + nargs=3, + metavar=("XMIN", "YMIN", "ZMIN"), + default=(444600.0, 492600.0, 2000.0), + ) + parser.add_argument( + "--model-max", + type=float, + nargs=3, + metavar=("XMAX", "YMAX", "ZMAX"), + default=(445900.0, 494600.0, 3600.0), + ) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/oracle-grid-geometry-sweep.json"), + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory # noqa: E402 + import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + points, oracle_labels, mesh_path = robust._load_oracle_inputs( # noqa: SLF001 + args.decoded_root, + case_name, + ) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) # noqa: SLF001 + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder_class = polatory.AutomaticStructuralDomainBuilder3 + builder_module = importlib.import_module(builder_class.__module__) + grid_shape_function = getattr(builder_module, "_leapfrog_grid_shape") + centre_function = getattr(builder_module, "_grid_centroids") + floor_indexer = getattr(builder_module, "_point_cell_indices") + + preparation = builder_class( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + data_min, data_max, data_active, data_auto_shape, data_auto_centres = ( + preparation._prepare_grid(points) # noqa: SLF001 + ) + model_min = np.asarray(args.model_min, dtype=np.float64) + model_max = np.asarray(args.model_max, dtype=np.float64) + if np.any(model_max <= model_min): + parser.error("--model-max must be greater than --model-min on every axis") + model_spans = model_max - model_min + model_active = model_spans > max(float(model_spans.max()), 1.0) * 1.0e-12 + model_auto_shape = grid_shape_function( + args.centroid_count, + model_spans, + model_active, + ) + captured_shape = tuple(int(value) for value in args.captured_shape) + if any(value <= 0 for value in captured_shape): + parser.error("--captured-shape values must be positive") + + variants = [ + ( + "data_bbox_auto_centres", + data_min, + data_max, + tuple(int(value) for value in data_auto_shape), + np.asarray(data_auto_centres, dtype=np.float64), + floor_indexer, + ), + ( + "data_bbox_captured_centres", + data_min, + data_max, + captured_shape, + centre_function(data_min, data_max, captured_shape), + floor_indexer, + ), + ( + "data_bbox_captured_nodes", + data_min, + data_max, + captured_shape, + _endpoint_nodes(data_min, data_max, captured_shape), + _nearest_node_indices, + ), + ( + "model_bbox_auto_centres", + model_min, + model_max, + tuple(int(value) for value in model_auto_shape), + centre_function(model_min, model_max, model_auto_shape), + floor_indexer, + ), + ( + "model_bbox_captured_centres", + model_min, + model_max, + captured_shape, + centre_function(model_min, model_max, captured_shape), + floor_indexer, + ), + ( + "model_bbox_captured_nodes", + model_min, + model_max, + captured_shape, + _endpoint_nodes(model_min, model_max, captured_shape), + _nearest_node_indices, + ), + ] + + print( + f"case={case_name} points={len(points)} oracle_domains={len(np.unique(oracle_labels))}", + flush=True, + ) + print( + f"data_bbox={tuple(data_min)} -> {tuple(data_max)} auto_shape={tuple(data_auto_shape)}", + flush=True, + ) + print( + f"model_bbox={tuple(model_min)} -> {tuple(model_max)} auto_shape={tuple(model_auto_shape)} " + f"captured_shape={captured_shape}", + flush=True, + ) + + results = [] + for name, minimum, maximum, shape, centroids, point_indexer in variants: + result = _run_geometry( + name=name, + points=np.asarray(points, dtype=np.float64), + oracle_labels=np.asarray(oracle_labels, dtype=np.int64), + minimum=np.asarray(minimum, dtype=np.float64), + maximum=np.asarray(maximum, dtype=np.float64), + shape=tuple(int(value) for value in shape), + centroids=np.asarray(centroids, dtype=np.float64), + point_indexer=point_indexer, + builder_class=builder_class, + builder_module=builder_module, + trend_input=trend_input, + exact_module=exact, + threshold=args.threshold, + centroid_count=args.centroid_count, + minimum_fraction=args.minimum_fraction, + maximum_fraction=args.maximum_fraction, + ) + results.append(result) + print( + f"{result.geometry:>31} grid={result.grid_shape!s:>13} " + f"pred={result.predicted_domains:>3d} ARI={result.adjusted_rand_index:>8.5f} " + f"AMI={result.adjusted_mutual_information:>8.5f} " + f"match={result.optimal_label_accuracy:>8.5f} merges={result.merge_count:>5d}", + flush=True, + ) + + results.sort( + key=lambda item: ( + -item.adjusted_rand_index, + -item.optimal_label_accuracy, + abs(item.predicted_domains - item.oracle_domains), + item.geometry, + ) + ) + print("\nBest geometries:", flush=True) + for item in results: + print( + f"{item.geometry:>31} pred={item.predicted_domains:>3d} " + f"ARI={item.adjusted_rand_index:>8.5f} match={item.optimal_label_accuracy:>8.5f} " + f"grid={item.grid_shape}", + flush=True, + ) + + payload = { + "case": case_name, + "input_points": int(len(points)), + "oracle_domains": int(len(np.unique(oracle_labels))), + "threshold": float(args.threshold), + "minimum_fraction": float(args.minimum_fraction), + "maximum_fraction": float(args.maximum_fraction), + "captured_shape": list(captured_shape), + "model_bbox_min": model_min.tolist(), + "model_bbox_max": model_max.tolist(), + "results": [asdict(item) for item in results], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_oracle_threshold_and_size.py b/benchmarks/leapfrog_gold/sweep_oracle_threshold_and_size.py new file mode 100644 index 000000000..834d41616 --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_oracle_threshold_and_size.py @@ -0,0 +1,263 @@ +"""Sweep consistency threshold and maximum domain fraction against Leapfrog labels. + +This diagnostic keeps the recovered structural field, centroid grid, adjacency, merge +matrix, heap implementation, and point assignment fixed. It changes only the +consistency threshold and maximum centroid-domain fraction for the unmodified +production reciprocal-determinant control. + +The purpose is diagnostic, not parameter fitting. If some parameter pair approaches +the decoded Leapfrog labels, the remaining mismatch is probably score scaling or size +semantics. If no pair improves materially, the likely error is earlier in mini-cluster +construction, adjacency, stale-heap/tie handling, or point-to-grid assignment. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import os +import sys +from dataclasses import asdict +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing this wrapper patches the oracle CSV loader used by comparison. +import compare_automatic_domains_to_oracle_robust as robust # noqa: E402,F401 + +comparison = robust.comparison + + +def _parse_float_list(text: str, *, name: str) -> list[float]: + values: list[float] = [] + for token in text.split(","): + token = token.strip() + if not token: + continue + try: + value = float(token) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"{name} contains a non-numeric value: {token!r}" + ) from exc + values.append(value) + if not values: + raise argparse.ArgumentTypeError(f"{name} must contain at least one value") + return values + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", + type=Path, + default=Path("Leapfrog_LVA_decoded_benchmark"), + ) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument( + "--thresholds", + default="0.15,0.30,0.45,0.60,0.75,0.90", + help="Comma-separated consistency thresholds.", + ) + parser.add_argument( + "--maximum-fractions", + default="0.10,0.15,0.20,0.30", + help="Comma-separated maximum centroid-domain fractions.", + ) + parser.add_argument("--top", type=int, default=24) + parser.add_argument( + "--output", + type=Path, + default=Path("benchmark-results/oracle-threshold-size-sweep.json"), + ) + args = parser.parse_args() + + case_name = args.case.strip().upper() + if not case_name: + parser.error("--case must not be empty") + if not args.decoded_root.is_dir(): + parser.error(f"Decoded benchmark root was not found: {args.decoded_root}") + if args.centroid_count <= 0: + parser.error("--centroid-count must be positive") + if not 0.0 < args.minimum_fraction <= 1.0: + parser.error("--minimum-fraction must be in (0, 1]") + if args.top <= 0: + parser.error("--top must be positive") + + try: + thresholds = _parse_float_list(args.thresholds, name="--thresholds") + maximum_fractions = _parse_float_list( + args.maximum_fractions, + name="--maximum-fractions", + ) + except argparse.ArgumentTypeError as exc: + parser.error(str(exc)) + + if any(not 0.0 < value <= 1.0 for value in thresholds): + parser.error("every threshold must be in (0, 1]") + if any( + not args.minimum_fraction <= value <= 1.0 + for value in maximum_fractions + ): + parser.error( + "every maximum fraction must satisfy minimum_fraction <= value <= 1" + ) + + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory # noqa: E402 + import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( # noqa: SLF001 + args.decoded_root, + case_name, + ) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) # noqa: SLF001 + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder_class = polatory.AutomaticStructuralDomainBuilder3 + builder_module = importlib.import_module(builder_class.__module__) + original_merge_function = getattr( + builder_module, + "_merged_matrix_and_consistency", + None, + ) + determinant_function = getattr(builder_module, "_symmetric_determinant", None) + if original_merge_function is None or determinant_function is None: + raise RuntimeError( + f"{builder_class.__module__} does not expose the recovered merge helpers." + ) + + preparation_builder = builder_class( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=max(maximum_fractions), + consistency_threshold=min(thresholds), + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = preparation_builder._prepare_grid( # noqa: SLF001 + points + ) + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, + trend_input, + non_decaying=False, + ) + + print( + f"case={case_name} points={len(points)} oracle_domains=" + f"{len(np.unique(oracle_labels))} centroids={len(centroids)} grid={shape}", + flush=True, + ) + print( + f"sweeping {len(thresholds)} thresholds x " + f"{len(maximum_fractions)} maximum fractions = " + f"{len(thresholds) * len(maximum_fractions)} runs", + flush=True, + ) + + results = [] + try: + for maximum_fraction in maximum_fractions: + for threshold in thresholds: + result = comparison._compare_formula( # noqa: SLF001 + case_name=case_name, + formula_name="reciprocal_det_control", + transform=None, + builder_class=builder_class, + builder_module=builder_module, + original_merge_function=original_merge_function, + determinant_function=determinant_function, + points=np.asarray(points, dtype=np.float64), + oracle_labels=np.asarray(oracle_labels, dtype=np.int64), + centroid_anisotropies=np.asarray( + centroid_anisotropies, + dtype=np.float64, + ), + minimum=np.asarray(minimum, dtype=np.float64), + maximum=np.asarray(maximum, dtype=np.float64), + shape=tuple(int(value) for value in shape), + threshold=float(threshold), + centroid_count=args.centroid_count, + minimum_fraction=args.minimum_fraction, + maximum_fraction=float(maximum_fraction), + ) + row = asdict(result) + row["maximum_fraction"] = float(maximum_fraction) + results.append(row) + print( + f"threshold={threshold:>5.2f} max_fraction={maximum_fraction:>5.2f} " + f"pred={result.predicted_domains:>3d} " + f"ARI={result.adjusted_rand_index:>8.5f} " + f"match={result.optimal_label_accuracy:>8.5f}", + flush=True, + ) + finally: + setattr( + builder_module, + "_merged_matrix_and_consistency", + original_merge_function, + ) + + results.sort( + key=lambda item: ( + -item["adjusted_rand_index"], + -item["optimal_label_accuracy"], + abs(item["predicted_domains"] - item["oracle_domains"]), + item["maximum_fraction"], + item["threshold"], + ) + ) + + print("\nBest parameter pairs:", flush=True) + print( + f"{'threshold':>9} {'max_frac':>9} {'oracle':>7} {'pred':>7} " + f"{'ARI':>9} {'AMI':>9} {'match_acc':>10} {'merges':>8}", + flush=True, + ) + for item in results[: min(args.top, len(results))]: + print( + f"{item['threshold']:>9.2f} {item['maximum_fraction']:>9.2f} " + f"{item['oracle_domains']:>7d} {item['predicted_domains']:>7d} " + f"{item['adjusted_rand_index']:>9.5f} " + f"{item['adjusted_mutual_information']:>9.5f} " + f"{item['optimal_label_accuracy']:>10.5f} " + f"{item['merge_count']:>8d}", + flush=True, + ) + + payload = { + "case": case_name, + "point_count": int(len(points)), + "oracle_domains": int(len(np.unique(oracle_labels))), + "centroid_count_requested": int(args.centroid_count), + "centroid_count_actual": int(len(centroids)), + "centroid_grid_shape": [int(value) for value in shape], + "minimum_fraction": float(args.minimum_fraction), + "thresholds": thresholds, + "maximum_fractions": maximum_fractions, + "results": results, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_real_subdomainer_adjacency.py b/benchmarks/leapfrog_gold/sweep_real_subdomainer_adjacency.py new file mode 100644 index 000000000..4d6388f1e --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_real_subdomainer_adjacency.py @@ -0,0 +1,393 @@ +"""Sweep plausible real-location SubDomainer adjacency graphs against Leapfrog labels. + +The recovered runtime shows that Leapfrog first assigns real locations to coarse +GridSeededDomainer domains, then constructs one ``SubDomainer`` per populated +coarse domain with ``bbox=None``, the same consistency threshold, and point-count +limits computed from that coarse-domain population. What remains unknown is how +those irregular real locations are connected. This script tests several +reasonable graph constructions without changing the production builder. +""" +from __future__ import annotations + +import argparse +import os +import sys +from heapq import heappop, heappush +from itertools import combinations +from math import floor +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing this module patches the strict coordinate/label loader in comparison. +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _unique_edges(edges: list[tuple[int, int]] | np.ndarray) -> np.ndarray: + if len(edges) == 0: + return np.empty((0, 2), dtype=np.int64) + array = np.asarray(edges, dtype=np.int64).reshape(-1, 2) + array.sort(axis=1) + array = array[array[:, 0] != array[:, 1]] + if len(array) == 0: + return np.empty((0, 2), dtype=np.int64) + return np.unique(array, axis=0) + + +def _delaunay_edges(points: np.ndarray) -> np.ndarray: + count = len(points) + if count <= 1: + return np.empty((0, 2), dtype=np.int64) + if count <= 4: + return _unique_edges(list(combinations(range(count), 2))) + from scipy.spatial import Delaunay, QhullError + + try: + simplices = Delaunay(points, qhull_options="QJ").simplices + except QhullError: + return _knn_edges(points, 6, mutual=False) + edges: list[tuple[int, int]] = [] + for simplex in simplices: + edges.extend(combinations((int(value) for value in simplex), 2)) + return _unique_edges(edges) + + +def _knn_edges(points: np.ndarray, k: int, *, mutual: bool) -> np.ndarray: + count = len(points) + if count <= 1: + return np.empty((0, 2), dtype=np.int64) + from scipy.spatial import cKDTree + + actual_k = min(max(int(k), 1), count - 1) + neighbours = np.asarray( + cKDTree(points).query(points, k=actual_k + 1)[1], dtype=np.int64 + )[:, 1:] + neighbour_sets = [set(int(value) for value in row) for row in neighbours] + edges: list[tuple[int, int]] = [] + for first, row in enumerate(neighbours): + for second_value in row: + second = int(second_value) + if mutual and first not in neighbour_sets[second]: + continue + edges.append((first, second)) + return _unique_edges(edges) + + +def _radius_edges(points: np.ndarray, factor: float) -> np.ndarray: + count = len(points) + if count <= 1: + return np.empty((0, 2), dtype=np.int64) + from scipy.spatial import cKDTree + + tree = cKDTree(points) + distances, nearest = tree.query(points, k=2) + positive = distances[:, 1][distances[:, 1] > 0.0] + if len(positive) == 0: + return _knn_edges(points, 1, mutual=False) + radius = float(np.median(positive) * factor) + pairs = tree.query_pairs(radius, output_type="ndarray") + edges = [tuple(int(value) for value in pair) for pair in np.asarray(pairs)] + + # Keep every point connected to at least its nearest distinct location. This + # avoids graph artefacts caused only by a locally sparse sampling pattern. + degree = np.zeros(count, dtype=np.int64) + for first, second in edges: + degree[first] += 1 + degree[second] += 1 + for index in np.flatnonzero(degree == 0): + edges.append((int(index), int(nearest[index, 1]))) + return _unique_edges(edges) + + +def _graph_edges(points: np.ndarray, name: str, value: float | int | None) -> np.ndarray: + if name == "delaunay": + return _delaunay_edges(points) + if name == "knn": + return _knn_edges(points, int(value), mutual=False) + if name == "mutual-knn": + return _knn_edges(points, int(value), mutual=True) + if name == "radius": + return _radius_edges(points, float(value)) + raise ValueError(f"unknown graph: {name}") + + +def _region_grow( + anisotropies: np.ndarray, + edges: np.ndarray, + *, + threshold: float, + maximum_points: int, +) -> tuple[np.ndarray, int]: + """Return component labels using the corrected Leapfrog-style greedy grower.""" + import polatory + + count = len(anisotropies) + if count <= 1: + return np.zeros(count, dtype=np.int64), 0 + + normalise = polatory.leapfrog_automatic_domain_builder_fixed._normalise_determinant + merged_score = ( + polatory.leapfrog_automatic_domain_builder_fixed._merged_matrix_and_consistency + ) + + capacity = max(2 * count + 1, 3) + active = np.zeros(capacity, dtype=bool) + active[:count] = True + version = np.zeros(capacity, dtype=np.int64) + sizes = np.zeros(capacity, dtype=np.int64) + sizes[:count] = 1 + matrices = np.zeros((capacity, 3, 3), dtype=float) + matrices[:count] = np.asarray([normalise(matrix) for matrix in anisotropies]) + leaves: list[list[int]] = [[] for _ in range(capacity)] + for index in range(count): + leaves[index] = [index] + neighbours: list[set[int]] = [set() for _ in range(capacity)] + for first_value, second_value in edges: + first, second = int(first_value), int(second_value) + neighbours[first].add(second) + neighbours[second].add(first) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if not active[first] or not active[second] or first == second: + return + if int(sizes[first] + sizes[second]) > maximum_points: + return + _, consistency = merged_score( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + if not np.isfinite(consistency): + return + low, high = sorted((first, second)) + heappush( + heap, + (-float(consistency), low, high, int(version[low]), int(version[high])), + ) + + for first, second in edges: + push(int(first), int(second)) + + next_id = count + merges = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + continue + if second not in neighbours[first] or first not in neighbours[second]: + continue + consistency = -negative + if consistency < threshold: + break + if int(sizes[first] + sizes[second]) > maximum_points: + continue + + merged_matrix, _ = merged_score( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + merged_neighbours = (neighbours[first] | neighbours[second]) - {first, second} + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + + active[next_id] = True + sizes[next_id] = sizes[first] + sizes[second] + matrices[next_id] = merged_matrix + leaves[next_id] = leaves[first] + leaves[second] + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + neighbours[next_id].add(neighbour) + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + next_id += 1 + merges += 1 + + labels = np.empty(count, dtype=np.int64) + label = 0 + for domain_id in range(next_id): + if not active[domain_id]: + continue + labels[np.asarray(leaves[domain_id], dtype=np.int64)] = label + label += 1 + return labels, merges + + +def _second_stage_labels( + points: np.ndarray, + anisotropies: np.ndarray, + coarse_labels: np.ndarray, + *, + graph_name: str, + graph_value: float | int | None, + threshold: float, + maximum_fraction: float, +) -> tuple[np.ndarray, int, int]: + labels = np.empty(len(points), dtype=np.int64) + next_label = 0 + total_edges = 0 + total_merges = 0 + + for coarse_label in np.unique(coarse_labels): + indices = np.flatnonzero(coarse_labels == coarse_label) + local_points = points[indices] + local_anisotropies = anisotropies[indices] + maximum_points = max(1, int(floor(maximum_fraction * len(indices)))) + edges = _graph_edges(local_points, graph_name, graph_value) + local_labels, merges = _region_grow( + local_anisotropies, + edges, + threshold=threshold, + maximum_points=maximum_points, + ) + labels[indices] = local_labels + next_label + next_label += int(local_labels.max()) + 1 if len(local_labels) else 0 + total_edges += len(edges) + total_merges += merges + + return labels, total_edges, total_merges + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + coarse_labels, _, coarse_minimum, coarse_maximum, coarse_merges = ( + builder._automatic_labels( + points, + np.asarray(centroid_anisotropies, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + ) + + variants: list[tuple[str, float | int | None]] = [("delaunay", None)] + variants.extend(("knn", k) for k in (2, 4, 6, 8, 12, 16)) + variants.extend(("mutual-knn", k) for k in (4, 6, 8, 12)) + variants.extend(("radius", factor) for factor in (1.5, 2.0, 3.0)) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + coarse_ari, coarse_ami, coarse_match, coarse_matched = metrics(coarse_labels) + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={len(np.unique(oracle_labels))}" + ) + print( + f"coarse: domains={len(np.unique(coarse_labels))} min={coarse_minimum} " + f"max={coarse_maximum} merges={coarse_merges} ARI={coarse_ari:.5f} " + f"AMI={coarse_ami:.5f} match={coarse_match:.5f} " + f"({coarse_matched}/{len(points)})" + ) + print() + + rows: list[tuple[float, float, float, str, int, int, int]] = [] + for graph_name, graph_value in variants: + labels, edge_count, merge_count = _second_stage_labels( + points, + np.asarray(point_anisotropies, dtype=np.float64), + np.asarray(coarse_labels, dtype=np.int64), + graph_name=graph_name, + graph_value=graph_value, + threshold=args.threshold, + maximum_fraction=args.maximum_fraction, + ) + ari, ami, match, matched = metrics(labels) + display = graph_name if graph_value is None else f"{graph_name}:{graph_value}" + rows.append( + ( + ari, + ami, + match, + display, + len(np.unique(labels)), + edge_count, + merge_count, + ) + ) + print( + f"{display:<16} domains={len(np.unique(labels)):>4d} " + f"edges={edge_count:>7d} merges={merge_count:>4d} " + f"ARI={ari:.5f} AMI={ami:.5f} match={match:.5f} " + f"({matched}/{len(points)})" + ) + + print("\nRanked by ARI:") + for ari, ami, match, display, domains, edge_count, merge_count in sorted( + rows, reverse=True + ): + print( + f"{display:<16} domains={domains:>4d} edges={edge_count:>7d} " + f"merges={merge_count:>4d} ARI={ari:.5f} AMI={ami:.5f} " + f"match={match:.5f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_real_subdomainer_matrix_reassignment.py b/benchmarks/leapfrog_gold/sweep_real_subdomainer_matrix_reassignment.py new file mode 100644 index 000000000..b1235be2e --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_real_subdomainer_matrix_reassignment.py @@ -0,0 +1,293 @@ +"""Test whether Leapfrog redraws coarse-domain boundaries by matrix reassignment. + +The decoded final partitions cross-cut the transferred GridSeededDomainer labels. A +possible explanation is that Leapfrog uses the coarse domains only as initial matrix +seeds, then reassigns real locations to the most compatible nearby domain matrix. +This diagnostic keeps the production builder unchanged and sweeps iterative +matrix-consistency reassignment with several spatial neighbourhoods and penalties. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +# Importing this module patches the strict coordinate/label loader in comparison. +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 + + +def _relabel(labels: np.ndarray) -> np.ndarray: + values = np.unique(labels) + mapping = {int(value): index for index, value in enumerate(values)} + return np.asarray([mapping[int(value)] for value in labels], dtype=np.int64) + + +def _domain_statistics( + points: np.ndarray, + point_matrices: np.ndarray, + labels: np.ndarray, + normalise, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + values = np.unique(labels) + matrices = np.empty((len(values), 3, 3), dtype=float) + centroids = np.empty((len(values), 3), dtype=float) + sizes = np.empty(len(values), dtype=np.int64) + for output_index, value in enumerate(values): + indices = np.flatnonzero(labels == value) + sizes[output_index] = len(indices) + centroids[output_index] = points[indices].mean(axis=0) + matrices[output_index] = normalise(point_matrices[indices].mean(axis=0)) + return values, matrices, centroids, sizes + + +def _run_reassignment( + points: np.ndarray, + point_matrices: np.ndarray, + initial_labels: np.ndarray, + *, + nearest_domains: int, + spatial_penalty: float, + iterations: int, + threshold: float, + normalise, + merged_score, +) -> tuple[np.ndarray, int, list[int]]: + labels = _relabel(np.asarray(initial_labels, dtype=np.int64)) + total_changes = 0 + domain_history = [int(len(np.unique(labels)))] + + for _ in range(max(1, int(iterations))): + values, matrices, centroids, _ = _domain_statistics( + points, point_matrices, labels, normalise + ) + label_to_position = {int(value): index for index, value in enumerate(values)} + distances = np.linalg.norm(points[:, None, :] - centroids[None, :, :], axis=2) + own_positions = np.asarray( + [label_to_position[int(value)] for value in labels], dtype=np.int64 + ) + own_distances = distances[np.arange(len(points)), own_positions] + positive = own_distances[own_distances > 0.0] + if len(positive): + spatial_scale = float(np.median(positive)) + else: + spans = np.ptp(points, axis=0) + spatial_scale = float(np.linalg.norm(spans)) / max(len(values), 1) + spatial_scale = max(spatial_scale, np.finfo(float).eps) + + new_labels = labels.copy() + for point_index in range(len(points)): + if nearest_domains <= 0 or nearest_domains >= len(values): + candidates = np.arange(len(values), dtype=np.int64) + else: + candidates = np.argpartition( + distances[point_index], nearest_domains - 1 + )[:nearest_domains] + own = own_positions[point_index] + if own not in candidates: + candidates = np.append(candidates, own) + + best_position = own_positions[point_index] + _, current_consistency = merged_score( + matrices[best_position], 1, point_matrices[point_index], 1 + ) + best_score = float(current_consistency) - spatial_penalty * ( + distances[point_index, best_position] / spatial_scale + ) ** 2 + + for candidate in candidates: + candidate = int(candidate) + _, consistency = merged_score( + matrices[candidate], 1, point_matrices[point_index], 1 + ) + consistency = float(consistency) + if not np.isfinite(consistency) or consistency < threshold: + continue + score = consistency - spatial_penalty * ( + distances[point_index, candidate] / spatial_scale + ) ** 2 + if score > best_score + 1.0e-14 or ( + abs(score - best_score) <= 1.0e-14 + and int(values[candidate]) < int(values[best_position]) + ): + best_score = score + best_position = candidate + + new_labels[point_index] = int(values[best_position]) + + new_labels = _relabel(new_labels) + changes = int(np.count_nonzero(new_labels != labels)) + total_changes += changes + labels = new_labels + domain_history.append(int(len(np.unique(labels)))) + if changes == 0: + break + + return labels, total_changes, domain_history + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + parser.add_argument("--top", type=int, default=25) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from polatory.leapfrog_automatic_domain_builder import ( + _merged_matrix_and_consistency, + _normalise_determinant, + ) + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + oracle_domain_count = int(len(np.unique(oracle_labels))) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_matrices = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_matrices = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + point_matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in point_matrices], dtype=float + ) + coarse_labels, _, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_matrices, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + coarse_ari, coarse_ami, coarse_match, coarse_matched = metrics(coarse_labels) + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={oracle_domain_count} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_ari:.5f} AMI={coarse_ami:.5f} " + f"match={coarse_match:.5f} ({coarse_matched}/{len(points)})" + ) + + neighbourhoods = (0, 2, 3, 4, 6, 8) + penalties = (0.0, 0.01, 0.03, 0.10, 0.30, 1.0, 3.0) + iteration_counts = (1, 2, 4, 8) + rows: list[dict[str, object]] = [] + + for nearest_domains in neighbourhoods: + for spatial_penalty in penalties: + for iterations in iteration_counts: + labels, changes, history = _run_reassignment( + points, + point_matrices, + coarse_labels, + nearest_domains=nearest_domains, + spatial_penalty=spatial_penalty, + iterations=iterations, + threshold=args.threshold, + normalise=_normalise_determinant, + merged_score=_merged_matrix_and_consistency, + ) + ari, ami, match, matched = metrics(labels) + rows.append( + { + "nearest": nearest_domains, + "penalty": spatial_penalty, + "iterations": iterations, + "domains": int(len(np.unique(labels))), + "changes": changes, + "history": history, + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + def format_row(row: dict[str, object]) -> str: + neighbourhood = "all" if int(row["nearest"]) == 0 else str(int(row["nearest"])) + return ( + f"near={neighbourhood:>3s} penalty={float(row['penalty']):>4.2f} " + f"iter={int(row['iterations']):>2d} domains={int(row['domains']):>3d} " + f"changes={int(row['changes']):>5d} history={row['history']} " + f"ARI={float(row['ari']):.5f} AMI={float(row['ami']):.5f} " + f"match={float(row['match']):.5f} ({int(row['matched'])}/{len(points)})" + ) + + exact_count = [row for row in rows if int(row["domains"]) == oracle_domain_count] + print(f"\nCandidates with exactly {oracle_domain_count} domains, ranked by ARI:") + if exact_count: + for row in sorted(exact_count, key=lambda item: float(item["ari"]), reverse=True)[ + : max(args.top, 1) + ]: + print(format_row(row)) + else: + print("none") + + print(f"\nTop {max(args.top, 1)} candidates by ARI:") + for row in sorted(rows, key=lambda item: float(item["ari"]), reverse=True)[ + : max(args.top, 1) + ]: + print(format_row(row)) + + print(f"\nTop {max(args.top, 1)} candidates by optimal label match:") + for row in sorted(rows, key=lambda item: float(item["match"]), reverse=True)[ + : max(args.top, 1) + ]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination.py b/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination.py new file mode 100644 index 000000000..142d6120c --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination.py @@ -0,0 +1,388 @@ +"""Test whether Leapfrog recombines local real-location subdomains globally. + +The recovered runtime shows a coarse GridSeededDomainer followed by one +SubDomainer per populated coarse domain. The previous adjacency sweep proved +that treating those local SubDomainer outputs as final domains over-fragments +the data. This diagnostic therefore creates local Delaunay subdomains using +the recovered 10%% local cap, then tests several plausible global recombination +graphs and point-count limits. +""" +from __future__ import annotations + +import argparse +import os +import sys +from heapq import heappop, heappush +from itertools import combinations +from math import floor +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_adjacency as adjacency # noqa: E402 + + +def _parent_adjacency(centroid_labels: np.ndarray, shape: tuple[int, int, int]) -> set[tuple[int, int]]: + import polatory + + edges = polatory.leapfrog_automatic_domain_builder_fixed._grid_edges(shape) + pairs: set[tuple[int, int]] = set() + for first, second in edges: + left = int(centroid_labels[int(first)]) + right = int(centroid_labels[int(second)]) + if left < 0 or right < 0 or left == right: + continue + pairs.add(tuple(sorted((left, right)))) + return pairs + + +def _build_local_components( + points: np.ndarray, + anisotropies: np.ndarray, + coarse_labels: np.ndarray, + *, + threshold: float, + maximum_fraction: float, +) -> tuple[list[np.ndarray], np.ndarray, np.ndarray, np.ndarray, int, int]: + import polatory + + normalise = polatory.leapfrog_automatic_domain_builder_fixed._normalise_determinant + members: list[np.ndarray] = [] + matrices: list[np.ndarray] = [] + sizes: list[int] = [] + centroids: list[np.ndarray] = [] + parents: list[int] = [] + total_edges = 0 + total_merges = 0 + + for parent in np.unique(coarse_labels): + indices = np.flatnonzero(coarse_labels == parent) + local_points = points[indices] + local_anisotropies = anisotropies[indices] + maximum_points = max(1, int(floor(maximum_fraction * len(indices)))) + edges = adjacency._delaunay_edges(local_points) + local_labels, merges = adjacency._region_grow( + local_anisotropies, + edges, + threshold=threshold, + maximum_points=maximum_points, + ) + total_edges += len(edges) + total_merges += merges + for label in np.unique(local_labels): + local_members = np.flatnonzero(local_labels == label) + global_members = indices[local_members] + normalised = np.asarray( + [normalise(matrix) for matrix in anisotropies[global_members]], dtype=float + ) + members.append(global_members) + matrices.append(normalised.mean(axis=0)) + sizes.append(len(global_members)) + centroids.append(points[global_members].mean(axis=0)) + parents.append(int(parent)) + + return ( + members, + np.asarray(matrices, dtype=float), + np.asarray(sizes, dtype=np.int64), + np.asarray(centroids, dtype=float), + np.asarray(parents, dtype=np.int64), + total_edges, + total_merges, + ) + + +def _global_edges( + centroids: np.ndarray, + parents: np.ndarray, + parent_pairs: set[tuple[int, int]], + mode: str, + value: int | None, +) -> np.ndarray: + count = len(centroids) + if count <= 1: + return np.empty((0, 2), dtype=np.int64) + if mode == "complete": + return adjacency._unique_edges(list(combinations(range(count), 2))) + if mode == "knn": + return adjacency._knn_edges(centroids, int(value), mutual=False) + if mode == "mutual-knn": + return adjacency._knn_edges(centroids, int(value), mutual=True) + if mode == "parent-adjacent": + edges: list[tuple[int, int]] = [] + for first, second in combinations(range(count), 2): + left = int(parents[first]) + right = int(parents[second]) + if left == right or tuple(sorted((left, right))) in parent_pairs: + edges.append((first, second)) + return adjacency._unique_edges(edges) + raise ValueError(mode) + + +def _merge_components( + point_count: int, + members: list[np.ndarray], + matrices: np.ndarray, + sizes: np.ndarray, + edges: np.ndarray, + *, + threshold: float, + maximum_points: int, +) -> tuple[np.ndarray, int]: + import polatory + + merged_score = ( + polatory.leapfrog_automatic_domain_builder_fixed._merged_matrix_and_consistency + ) + count = len(members) + if count == 0: + raise ValueError("no local components") + + capacity = max(2 * count + 1, 3) + active = np.zeros(capacity, dtype=bool) + active[:count] = True + version = np.zeros(capacity, dtype=np.int64) + domain_sizes = np.zeros(capacity, dtype=np.int64) + domain_sizes[:count] = sizes + domain_matrices = np.zeros((capacity, 3, 3), dtype=float) + domain_matrices[:count] = matrices + domain_members: list[list[int]] = [[] for _ in range(capacity)] + for index, values in enumerate(members): + domain_members[index] = [int(value) for value in values] + neighbours: list[set[int]] = [set() for _ in range(capacity)] + for first_value, second_value in edges: + first, second = int(first_value), int(second_value) + neighbours[first].add(second) + neighbours[second].add(first) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if not active[first] or not active[second] or first == second: + return + if int(domain_sizes[first] + domain_sizes[second]) > maximum_points: + return + _, consistency = merged_score( + domain_matrices[first], + int(domain_sizes[first]), + domain_matrices[second], + int(domain_sizes[second]), + ) + if not np.isfinite(consistency): + return + low, high = sorted((first, second)) + heappush( + heap, + (-float(consistency), low, high, int(version[low]), int(version[high])), + ) + + for first, second in edges: + push(int(first), int(second)) + + next_id = count + merges = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + continue + if second not in neighbours[first] or first not in neighbours[second]: + continue + consistency = -negative + if consistency < threshold: + break + if int(domain_sizes[first] + domain_sizes[second]) > maximum_points: + continue + + merged_matrix, _ = merged_score( + domain_matrices[first], + int(domain_sizes[first]), + domain_matrices[second], + int(domain_sizes[second]), + ) + merged_neighbours = (neighbours[first] | neighbours[second]) - {first, second} + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + + active[next_id] = True + domain_sizes[next_id] = domain_sizes[first] + domain_sizes[second] + domain_matrices[next_id] = merged_matrix + domain_members[next_id] = domain_members[first] + domain_members[second] + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + neighbours[next_id].add(neighbour) + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + next_id += 1 + merges += 1 + + labels = np.empty(point_count, dtype=np.int64) + label = 0 + for domain_id in range(next_id): + if not active[domain_id]: + continue + labels[np.asarray(domain_members[domain_id], dtype=np.int64)] = label + label += 1 + return labels, merges + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--maximum-fraction", type=float, default=0.10) + args = parser.parse_args() + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + coarse_labels, centroid_labels, _, _, _ = builder._automatic_labels( + points, + np.asarray(centroid_anisotropies, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + + ( + members, + component_matrices, + component_sizes, + component_centroids, + component_parents, + local_edges, + local_merges, + ) = _build_local_components( + points, + np.asarray(point_anisotropies, dtype=np.float64), + np.asarray(coarse_labels, dtype=np.int64), + threshold=args.threshold, + maximum_fraction=args.maximum_fraction, + ) + parent_pairs = _parent_adjacency( + np.asarray(centroid_labels, dtype=np.int64), + tuple(int(value) for value in shape), + ) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + local_labels = np.empty(len(points), dtype=np.int64) + for label, values in enumerate(members): + local_labels[values] = label + local_ari, local_ami, local_match, local_matched = metrics(local_labels) + print( + f"case={case_name} points={len(points)} coarse_domains={len(np.unique(coarse_labels))} " + f"local_components={len(members)} local_edges={local_edges} local_merges={local_merges}" + ) + print( + f"local-only: ARI={local_ari:.5f} AMI={local_ami:.5f} " + f"match={local_match:.5f} ({local_matched}/{len(points)})" + ) + print() + + graph_variants: list[tuple[str, int | None]] = [("complete", None), ("parent-adjacent", None)] + graph_variants.extend(("knn", k) for k in (2, 4, 6, 8, 12, 16)) + graph_variants.extend(("mutual-knn", k) for k in (4, 8, 12)) + caps = [ + ("none", len(points)), + ("10pct", max(1, int(floor(0.10 * len(points))))), + ("20pct", max(1, int(floor(0.20 * len(points))))), + ] + + rows: list[tuple[float, str]] = [] + for graph_name, graph_value in graph_variants: + edges = _global_edges( + component_centroids, + component_parents, + parent_pairs, + graph_name, + graph_value, + ) + graph_label = graph_name if graph_value is None else f"{graph_name}:{graph_value}" + for cap_name, maximum_points in caps: + labels, merges = _merge_components( + len(points), + members, + component_matrices, + component_sizes, + edges, + threshold=args.threshold, + maximum_points=maximum_points, + ) + ari, ami, match, matched = metrics(labels) + text = ( + f"{graph_label:18s} cap={cap_name:5s} domains={len(np.unique(labels)):4d} " + f"edges={len(edges):6d} merges={merges:4d} ARI={ari:.5f} " + f"AMI={ami:.5f} match={match:.5f} ({matched}/{len(points)})" + ) + print(text) + rows.append((ari, text)) + + print("\nRanked by ARI:") + for _, text in sorted(rows, key=lambda item: item[0], reverse=True): + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination_fine.py b/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination_fine.py new file mode 100644 index 000000000..34bad77ad --- /dev/null +++ b/benchmarks/leapfrog_gold/sweep_real_subdomainer_recombination_fine.py @@ -0,0 +1,217 @@ +"""Fine sweep of the most promising real-SubDomainer recombination model. + +The broad sweep showed that local Delaunay subdivision followed by global k-nearest- +component recombination is plausible, but the tested 10% and 20% caps bracket the +nine decoded Leapfrog domains. This script searches every k from 6 through 20 and +global point caps from 10% through 20% in 1% increments. It ranks all candidates, +reports candidates that produce the decoded domain count, and leaves production code +unchanged. +""" +from __future__ import annotations + +import argparse +import os +import sys +from math import floor +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import compare_automatic_domains_to_oracle_robust # noqa: F401,E402 +import compare_automatic_domains_to_oracle as comparison # noqa: E402 +import run_selected_exact_leapfrog_lva as exact # noqa: E402 +import sweep_real_subdomainer_recombination as recombination # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="S3_R100") + parser.add_argument( + "--decoded-root", type=Path, default=Path("Leapfrog_LVA_decoded_benchmark") + ) + parser.add_argument("--threshold", type=float, default=0.60) + parser.add_argument("--centroid-count", type=int, default=6000) + parser.add_argument("--minimum-fraction", type=float, default=0.001) + parser.add_argument("--local-maximum-fraction", type=float, default=0.10) + parser.add_argument("--k-min", type=int, default=6) + parser.add_argument("--k-max", type=int, default=20) + parser.add_argument("--cap-min", type=float, default=0.10) + parser.add_argument("--cap-max", type=float, default=0.20) + parser.add_argument("--cap-step", type=float, default=0.01) + parser.add_argument("--top", type=int, default=30) + args = parser.parse_args() + + if args.k_min < 1 or args.k_max < args.k_min: + raise SystemExit("k range must satisfy 1 <= k-min <= k-max") + if not 0.0 < args.cap_min <= args.cap_max <= 1.0: + raise SystemExit("cap range must satisfy 0 < cap-min <= cap-max <= 1") + if args.cap_step <= 0.0: + raise SystemExit("cap-step must be positive") + + case_name = args.case.strip().upper() + os.environ["POLATORY_BENCHMARK_CASE"] = case_name + os.environ["POLATORY_BASAL_CASES"] = case_name + + import polatory + from sklearn.metrics import adjusted_mutual_info_score, adjusted_rand_score + + points, oracle_labels, mesh_path = comparison._load_oracle_inputs( + args.decoded_root, case_name + ) + points = np.asarray(points, dtype=np.float64) + oracle_labels = np.asarray(oracle_labels, dtype=np.int64) + oracle_domain_count = len(np.unique(oracle_labels)) + + trend_vertices, trend_faces = exact.suite.read_obj(mesh_path) + strength, trend_range = comparison._parse_case_parameters(case_name) + trend_input = polatory.StructuralTrendInput3( + np.asarray(trend_vertices, dtype=np.float64), + np.asarray(trend_faces, dtype=np.int64), + strength, + trend_range, + ) + + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=args.centroid_count, + minimum_cluster_fraction=args.minimum_fraction, + maximum_cluster_fraction=args.local_maximum_fraction, + consistency_threshold=args.threshold, + base_range=0.0, + support_multiplier=5, + minimum_support_points=1, + ) + minimum, maximum, _, shape, centroids = builder._prepare_grid(points) + centroid_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + centroids, trend_input, non_decaying=False + ) + point_anisotropies = exact.exact_leapfrog_single_input_anisotropies3( + points, trend_input, non_decaying=False + ) + coarse_labels, centroid_labels, _, _, coarse_merges = builder._automatic_labels( + points, + np.asarray(centroid_anisotropies, dtype=np.float64), + np.asarray(minimum, dtype=np.float64), + np.asarray(maximum, dtype=np.float64), + tuple(int(value) for value in shape), + ) + + ( + members, + component_matrices, + component_sizes, + component_centroids, + component_parents, + local_edges, + local_merges, + ) = recombination._build_local_components( + points, + np.asarray(point_anisotropies, dtype=np.float64), + np.asarray(coarse_labels, dtype=np.int64), + threshold=args.threshold, + maximum_fraction=args.local_maximum_fraction, + ) + parent_pairs = recombination._parent_adjacency( + np.asarray(centroid_labels, dtype=np.int64), + tuple(int(value) for value in shape), + ) + + def metrics(labels: np.ndarray) -> tuple[float, float, float, int]: + accuracy, matched = comparison._optimal_label_accuracy(oracle_labels, labels) + return ( + float(adjusted_rand_score(oracle_labels, labels)), + float(adjusted_mutual_info_score(oracle_labels, labels)), + float(accuracy), + int(matched), + ) + + coarse_ari, coarse_ami, coarse_match, coarse_matched = metrics(coarse_labels) + print( + f"case={case_name} points={len(points)} grid={shape} " + f"oracle_domains={oracle_domain_count} coarse_domains={len(np.unique(coarse_labels))}" + ) + print( + f"coarse: merges={coarse_merges} ARI={coarse_ari:.5f} AMI={coarse_ami:.5f} " + f"match={coarse_match:.5f} ({coarse_matched}/{len(points)})" + ) + print( + f"local: components={len(members)} edges={local_edges} merges={local_merges} " + f"cap={args.local_maximum_fraction:.3f}" + ) + + cap_values: list[float] = [] + value = args.cap_min + while value <= args.cap_max + args.cap_step * 1.0e-6: + cap_values.append(round(value, 10)) + value += args.cap_step + + rows: list[dict[str, object]] = [] + edge_cache: dict[int, np.ndarray] = {} + for k in range(args.k_min, args.k_max + 1): + edges = recombination._global_edges( + component_centroids, + component_parents, + parent_pairs, + "knn", + k, + ) + edge_cache[k] = edges + for cap_fraction in cap_values: + maximum_points = max(1, int(floor(cap_fraction * len(points)))) + labels, merges = recombination._merge_components( + len(points), + members, + component_matrices, + component_sizes, + edges, + threshold=args.threshold, + maximum_points=maximum_points, + ) + ari, ami, match, matched = metrics(labels) + rows.append( + { + "k": k, + "cap": cap_fraction, + "maximum_points": maximum_points, + "domains": int(len(np.unique(labels))), + "edges": int(len(edges)), + "merges": int(merges), + "ari": ari, + "ami": ami, + "match": match, + "matched": matched, + } + ) + + def format_row(row: dict[str, object]) -> str: + return ( + f"k={int(row['k']):2d} cap={float(row['cap']):.2f} " + f"max={int(row['maximum_points']):3d} domains={int(row['domains']):3d} " + f"edges={int(row['edges']):4d} merges={int(row['merges']):3d} " + f"ARI={float(row['ari']):.5f} AMI={float(row['ami']):.5f} " + f"match={float(row['match']):.5f} ({int(row['matched'])}/{len(points)})" + ) + + exact_count = [row for row in rows if int(row["domains"]) == oracle_domain_count] + print(f"\nCandidates with exactly {oracle_domain_count} domains, ranked by ARI:") + if exact_count: + for row in sorted(exact_count, key=lambda item: float(item["ari"]), reverse=True): + print(format_row(row)) + else: + print("none") + + print(f"\nTop {max(args.top, 1)} candidates by ARI:") + for row in sorted(rows, key=lambda item: float(item["ari"]), reverse=True)[: max(args.top, 1)]: + print(format_row(row)) + + print(f"\nTop {max(args.top, 1)} candidates by optimal label match:") + for row in sorted(rows, key=lambda item: float(item["match"]), reverse=True)[: max(args.top, 1)]: + print(format_row(row)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/leapfrog_gold/test_heap_edge_preservation.py b/benchmarks/leapfrog_gold/test_heap_edge_preservation.py new file mode 100644 index 000000000..e056f3cd9 --- /dev/null +++ b/benchmarks/leapfrog_gold/test_heap_edge_preservation.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import numpy as np + +import polatory + + +def test_unaffected_heap_edges_survive_neighbour_merge() -> None: + """Independent valid pairs must not disappear after a neighbouring merge. + + Four identical cells form a one-dimensional chain. With a hard maximum + domain size of two, the correct result is two merged pairs. The old + neighbour-version increment invalidated the untouched (2, 3) heap entry + after merging (0, 1), leaving three domains instead. + """ + + points = np.array( + [ + [0.5, 0.0, 0.0], + [1.5, 0.0, 0.0], + [2.5, 0.0, 0.0], + [3.5, 0.0, 0.0], + ], + dtype=float, + ) + centroid_anisotropies = np.repeat(np.eye(3)[None, :, :], 4, axis=0) + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=4, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.5, + consistency_threshold=0.6, + ) + + labels, centroid_labels, minimum_points, maximum_points, merge_count = ( + builder._automatic_labels( + points, + centroid_anisotropies, + np.array([0.0, 0.0, 0.0]), + np.array([4.0, 0.0, 0.0]), + (4, 1, 1), + ) + ) + + assert minimum_points == 1 + assert maximum_points == 2 + assert merge_count == 2 + assert np.unique(labels).size == 2 + assert np.unique(centroid_labels[centroid_labels >= 0]).size == 2 + assert labels[0] == labels[1] + assert labels[2] == labels[3] + assert labels[0] != labels[2] diff --git a/benchmarks/leapfrog_gold/test_leapfrog_grid_seeded_domainer.py b/benchmarks/leapfrog_gold/test_leapfrog_grid_seeded_domainer.py new file mode 100644 index 000000000..00318fb17 --- /dev/null +++ b/benchmarks/leapfrog_gold/test_leapfrog_grid_seeded_domainer.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import numpy as np + +from leapfrog_grid_seeded_domainer import ( + GridSeededDomainer, + reciprocal_determinant_consistency, + six_connected_neighbours, + symmetric_determinant, + weighted_mean_matrix, +) + + +def test_six_connected_neighbours_use_c_order_strides() -> None: + assert six_connected_neighbours(13, (3, 3, 3)) == (4, 22, 10, 16, 12, 14) + + +def test_symmetric_determinant_matches_numpy() -> None: + matrix = np.array( + [ + [2.0, 0.25, -0.1], + [0.25, 1.5, 0.3], + [-0.1, 0.3, 0.8], + ] + ) + assert np.isclose(symmetric_determinant(matrix), np.linalg.det(matrix)) + + +def test_weighted_matrix_mean_uses_domain_sizes() -> None: + first = np.diag([4.0, 1.0, 1.0]) + second = np.diag([1.0, 4.0, 1.0]) + result = weighted_mean_matrix(first, 4, second, 1) + assert np.allclose(result, 0.8 * first + 0.2 * second) + + +def test_identical_volume_normalised_matrices_have_unit_consistency() -> None: + matrix = np.diag([0.5, 1.0, 2.0]) + assert np.isclose(np.linalg.det(matrix), 1.0) + assert np.isclose(reciprocal_determinant_consistency(matrix), 1.0) + + +def test_identical_grid_merges_to_one_domain() -> None: + matrices = np.repeat(np.eye(3)[None, :, :], 8, axis=0) + result = GridSeededDomainer(consistency_thresh=0.6, max_points=8).fit( + matrices, + (2, 2, 2), + ) + assert result.merge_count == 7 + assert len(result.domains) == 1 + assert np.unique(result.grid_domain_ids).size == 1 + + +def test_max_points_is_a_hard_merged_domain_limit() -> None: + matrices = np.repeat(np.eye(3)[None, :, :], 8, axis=0) + result = GridSeededDomainer(consistency_thresh=0.6, max_points=3).fit( + matrices, + (2, 2, 2), + ) + assert max(domain.size for domain in result.domains.values()) <= 3 diff --git a/docs/structural_lva.md b/docs/structural_lva.md index 23d9cca25..d711ff95f 100644 --- a/docs/structural_lva.md +++ b/docs/structural_lva.md @@ -1,11 +1,13 @@ # Structural LVA prototype The `feature/structural-lva` branch introduced a native Polatory structural-LVA -path. The `feature/leapfrog-parity` branch adds exact value preprocessing and +path. The `feature/leapfrog-parity` branch added exact value preprocessing and post-clustering construction recovered from the supplied WolfPass Leapfrog -benchmark. +benchmark. The `feature/automatic-subdomainer` branch adds a standalone, +deterministic geometric-centroid SubDomainer that does not require decoded +Leapfrog point labels. -## Python API +## Standalone automatic Python API ```python import numpy as np @@ -14,6 +16,7 @@ from polatory import three as p3 rbf = p3.CovSpheroidal3([100.0, 400.0]) model = p3.Model(rbf, 0) +model.nugget = 0.0 trend_input = polatory.StructuralTrendInput3( vertices=mesh_vertices, @@ -22,17 +25,19 @@ trend_input = polatory.StructuralTrendInput3( range=100.0, ) -# Leapfrog does not fit +/-1 indicators directly. value_info = polatory.leapfrog_indicator_values3(points, sdf_indicators) -# labels contains one recovered or externally generated final cluster ID per -# interpolation point. -builder = polatory.LabeledStructuralDomainBuilder3(base_range=400.0) +builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=6000, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.10, + consistency_threshold=0.60, + base_range=400.0, +) domains = builder.build_from_inputs( points, - labels, [trend_input], - model_parameters=[100.0, 400.0], + model_parameters=np.asarray(model.parameters, dtype=float).tolist(), ) structural = polatory.StructuralInterpolant3( @@ -49,6 +54,35 @@ structural.fit( predictions = structural.evaluate(query_points) ``` +The automatic builder contains no benchmark coordinates, case names, domain +counts or precomputed point labels. Its pipeline is: + +1. factor the requested centroid count into a data-aspect-ratio-aware grid; +2. sample the structural LVA matrix at every centroid; +3. connect face-neighbouring grid cells; +4. greedily merge adjacent cells in decreasing SPD-matrix consistency order; +5. enforce the requested minimum and maximum core populations; +6. assign a canonical automatic label to every interpolation point; +7. pass those labels to `LabeledStructuralDomainBuilder3` for the recovered + support, local-range, representative-matrix and bounding-box rules. + +The complete runnable example is +`examples/standalone_automatic_lva.ipynb`. + +## Oracle-label Python API + +The oracle path remains useful for isolating errors after automatic clustering: + +```python +builder = polatory.LabeledStructuralDomainBuilder3(base_range=400.0) +domains = builder.build_from_inputs( + points, + labels, + [trend_input], + model_parameters=np.asarray(model.parameters, dtype=float).tolist(), +) +``` + ## Recovered binary indicator values Leapfrog does not pass the imported `+1/-1` SDF column directly to FastRBF. @@ -113,7 +147,7 @@ conversion independently of the final meshes. ## Exactly recovered post-cluster construction -Once the final point labels are known, the local domain construction is now +Once the final point labels are known, the local domain construction is recovered for all 82 domains across the nine WolfPass strength/range cases. For one cluster with core point index set `C`: @@ -145,38 +179,36 @@ approximately: - blending/culling box: `3.6e-8`; - transformed local centres mapped back to input points: `7.0e-10`. -`LabeledStructuralDomainBuilder3` implements these rules. It is deliberately -separate from automatic clustering so interpolation and blending can be tested -against Leapfrog without conflating them with the SubDomainer partition. +`LabeledStructuralDomainBuilder3` implements these rules. The automatic builder +uses it internally after generating labels, so there is only one implementation +of the recovered post-cluster rules. + +## Parity status and validation boundary -## What remains before full automatic parity +The supplied serialized objects preserve final point assignments but not the +temporary geometric leaf/neighbour queues. The automatic implementation is +therefore a deterministic reconstruction of the observed 6000-centroid, +adjacency-constrained architecture, not a copy of hidden Leapfrog source code. -The remaining unknowns are now limited to: +The following items still require empirical regression across independent +Leapfrog exports before universal exact-parity can be claimed: -1. Leapfrog's initial geometric mini-cluster grid and deterministic adjacent - merge ordering; -2. the exact local-function blending weight in overlap regions; -3. the exact relationship between Leapfrog's requested fit accuracy and the - stopping behaviour of its approximate FastRBF solver; +1. exact tie-breaking in geometric centroid partition and adjacent merge order; +2. the exact local-function blend weight in overlap regions; +3. the relationship between requested fit accuracy and FastRBF stopping; 4. multiple-input `BLENDING` orientation and strength equations; 5. global mean trend and compatibility-version interactions. -The former fixed `0.8 * base_range` local range and axis-aligned-box support -heuristics are not Leapfrog rules and should not be used for parity claims. -The current `StructuralInterpolant3` box smoothstep weighting also remains an -experimental approximation until the overlap function is identified. - -## Validation strategy - -Use two separate benchmarks: +Use two separate validation layers: -- **Oracle-label benchmark:** feed the decoded final labels to - `LabeledStructuralDomainBuilder3`. Any remaining mesh difference is caused by - the local solver, blending or isosurface extraction—not clustering or value - preprocessing. -- **Automatic benchmark:** generate labels from the replacement SubDomainer and - compare label partitions first, then run the same exact post-cluster builder. +- **Oracle-label benchmark:** feed decoded final labels to + `LabeledStructuralDomainBuilder3`. Remaining mesh difference is caused by the + local solver, blending or isosurface extraction. +- **Automatic benchmark:** generate labels using + `AutomaticStructuralDomainBuilder3`, compare partitions first, then compare + the final scalar field and surface. -A parity claim requires one unchanged implementation to pass every supplied -strength/range case, including the 50 m cutoff cases, without case-specific -parameter tuning. +A strict parity claim requires one unchanged automatic implementation to pass +every supplied strength/range case, including the 50 m cutoff cases, without +case-specific parameter tuning, followed by held-out datasets not used during +reconstruction. diff --git a/examples/polatory_lva_chunk_overlap.py b/examples/polatory_lva_chunk_overlap.py new file mode 100644 index 000000000..80734b124 --- /dev/null +++ b/examples/polatory_lva_chunk_overlap.py @@ -0,0 +1,192 @@ +"""Overlap-and-own meshing for the process-isolated LVA application. + +The native isosurface extractor is evaluated on padded chunks. Complete triangles +are then assigned to exactly one unpadded core by their cell centres. Unlike a +geometric clip, this does not cut triangles on the core planes and therefore cannot +manufacture a new flat wall or pinched termination at a temporary chunk boundary. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import pyvista as pv + +import polatory +from polatory import three as p3 + + +def _owned_cell_indices( + mesh: pv.DataSet, + core_lower: np.ndarray, + core_upper: np.ndarray, + model_upper: np.ndarray, + tolerance: float, +) -> np.ndarray: + centres = np.asarray(mesh.cell_centers().points, dtype=float) + lower = np.asarray(core_lower, dtype=float) + upper = np.asarray(core_upper, dtype=float) + model_upper = np.asarray(model_upper, dtype=float) + + owned = np.all(centres >= lower[None, :] - tolerance, axis=1) + for axis in range(3): + is_last = abs(float(upper[axis] - model_upper[axis])) <= tolerance + if is_last: + owned &= centres[:, axis] <= upper[axis] + tolerance + else: + # Half-open ownership makes every overlapping triangle belong to one + # core only, while retaining the triangle itself without cutting it. + owned &= centres[:, axis] < upper[axis] - tolerance + return np.flatnonzero(owned).astype(np.int64) + + +def install_chunk_overlap(safe_module: Any) -> None: + """Replace ``generate_safe_isosurface`` on the supplied v3-safe module.""" + + if getattr(safe_module, "_overlap_chunk_meshing_installed", False): + return + + original = safe_module.generate_safe_isosurface + + def generate_overlap_isosurface( + structural: Any, + bbox_min: np.ndarray, + bbox_max: np.ndarray, + resolution: float, + refine: int, + output_obj: Path, + progress: Callable[[str], None], + ) -> dict[str, Any]: + bbox_min_array = np.asarray(bbox_min, dtype=float) + bbox_max_array = np.asarray(bbox_max, dtype=float) + resolution_value = float(resolution) + plan = safe_module.grid_plan( + bbox_min_array, + bbox_max_array, + resolution_value, + ) + + if plan["chunk_total"] == 1: + return original( + structural, + bbox_min_array, + bbox_max_array, + resolution_value, + refine, + output_obj, + progress, + ) + + if plan["total"] > safe_module.MAX_TOTAL_BASE_CELLS: + suggested = safe_module.recommended_resolution( + bbox_min_array, + bbox_max_array, + resolution_value, + safe_module.MAX_TOTAL_BASE_CELLS, + ) + raise MemoryError( + f"Resolution {resolution_value:g} creates {plan['total']:,} base " + f"cells. The safety limit is " + f"{safe_module.MAX_TOTAL_BASE_CELLS:,}. Use approximately " + f"{suggested:.6g} or coarser, or reduce the bounding box." + ) + if plan["chunk_total"] > safe_module.MAX_CHUNKS: + raise MemoryError( + f"The mesh needs {plan['chunk_total']:,} chunks, above the safety " + f"limit of {safe_module.MAX_CHUNKS}. Use a coarser resolution or " + "smaller extent." + ) + + cores = list( + safe_module.chunk_bounds( + bbox_min_array, + bbox_max_array, + resolution_value, + plan["cells"], + plan["chunks"], + ) + ) + field = polatory.StructuralRbfFieldFunction(structural) + + # Keep two complete base cells on each side of a core. The padded chunks + # therefore extract the same crossing triangles before ownership is decided. + padding = 2.0 * resolution_value + tolerance = max(1.0e-8 * resolution_value, 1.0e-9) + progress( + f"Fine grid: {plan['total']:,} base cells. Processing safely in " + f"{plan['chunk_total']:,} overlapping chunks " + f"{tuple(int(value) for value in plan['chunks'])}; complete triangles " + "will be assigned by cell centre without clipping…" + ) + + combined: pv.DataSet | None = None + with tempfile.TemporaryDirectory(prefix="polatory_lva_overlap_chunks_") as directory: + folder = Path(directory) + for index, (core_lower, core_upper) in enumerate(cores, start=1): + progress(f"Generating padded mesh chunk {index:,}/{len(cores):,}…") + padded_lower = np.maximum( + bbox_min_array, + np.asarray(core_lower, dtype=float) - padding, + ) + padded_upper = np.minimum( + bbox_max_array, + np.asarray(core_upper, dtype=float) + padding, + ) + + padded_bbox = p3.Bbox( + padded_lower.reshape(1, 3), + padded_upper.reshape(1, 3), + ) + result = polatory.Isosurface( + padded_bbox, + resolution_value, + np.eye(3), + ).generate(field, isovalue=0.0, refine=int(refine)) + + part_path = folder / f"part_{index:04d}.obj" + result.export_obj(str(part_path)) + if not safe_module.obj_has_vertices(part_path): + continue + + part = pv.read(part_path).extract_surface().triangulate().clean() + if part.n_points == 0 or part.n_cells == 0: + continue + + owned_indices = _owned_cell_indices( + part, + np.asarray(core_lower, dtype=float), + np.asarray(core_upper, dtype=float), + bbox_max_array, + tolerance, + ) + if len(owned_indices) == 0: + continue + + retained = part.extract_cells(owned_indices) + retained = retained.extract_surface().triangulate().clean() + if retained.n_points == 0 or retained.n_cells == 0: + continue + + if combined is None: + combined = retained + else: + combined = combined.merge(retained, merge_points=True) + combined = combined.extract_surface().triangulate().clean() + + if combined is None or combined.n_points == 0 or combined.n_cells == 0: + raise RuntimeError( + "No zero isosurface was found inside the selected model extent." + ) + + progress( + "Joining overlap-owned triangles and writing the final result without " + "temporary chunk-plane cuts…" + ) + safe_module.write_obj(combined, output_obj) + return plan + + safe_module.generate_safe_isosurface = generate_overlap_isosurface + safe_module._overlap_chunk_meshing_installed = True diff --git a/examples/polatory_lva_global_grid.py b/examples/polatory_lva_global_grid.py new file mode 100644 index 000000000..dbed66cac --- /dev/null +++ b/examples/polatory_lva_global_grid.py @@ -0,0 +1,341 @@ +"""Globally aligned, slab-streamed isosurface extraction for structural LVA models. + +The previous memory-safe path called Polatory's adaptive isosurface extractor once per +3-D chunk. Even with overlap, each call refined its own temporary lattice, so adjacent +chunks could disagree and leave axis-aligned stairs, shelves, open seams, or artificial +flat terminations. + +This module samples ``StructuralInterpolant3.evaluate`` on one globally aligned regular +grid. The scalar field is evaluated in memory-safe slabs, and every neighbouring slab +shares the exact same boundary-node values. Marching cubes is then run on those slabs +and the resulting vertices are merged on the common grid planes. The field itself is +unchanged; only the surface extraction path is replaced. +""" + +from __future__ import annotations + +import math +import os +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import pyvista as pv + +try: + from skimage.measure import marching_cubes +except ImportError as error: # pragma: no cover - exercised by deployment validation + raise ImportError( + "The globally aligned LVA mesher requires scikit-image. Install it with: " + "python -m pip install scikit-image" + ) from error + + +EVALUATION_BATCH_SIZE = int( + os.environ.get("POLATORY_GLOBAL_GRID_EVALUATION_BATCH_SIZE", "250000") +) +MAX_POINTS_PER_SLAB = int( + os.environ.get("POLATORY_GLOBAL_GRID_MAX_POINTS_PER_SLAB", "2000000") +) + + +def _grid_geometry( + minimum: np.ndarray, + maximum: np.ndarray, + requested_resolution: float, +) -> tuple[np.ndarray, np.ndarray, list[np.ndarray]]: + minimum = np.asarray(minimum, dtype=float) + maximum = np.asarray(maximum, dtype=float) + requested_resolution = float(requested_resolution) + if minimum.shape != (3,) or maximum.shape != (3,): + raise ValueError("Meshing bounds must be three-dimensional.") + if not np.all(np.isfinite(minimum)) or not np.all(np.isfinite(maximum)): + raise ValueError("Meshing bounds must be finite.") + span = maximum - minimum + if not np.all(span > 0.0): + raise ValueError("Every meshing-box span must be positive.") + if not requested_resolution > 0.0: + raise ValueError("Isosurface resolution must be positive.") + + # Use an integer number of globally uniform cells on every axis. The resulting + # spacing is never coarser than the requested resolution, and the final node lands + # exactly on the user-supplied maximum rather than creating a short last cell. + cells = np.maximum(np.ceil(span / requested_resolution).astype(np.int64), 1) + spacing = span / cells.astype(float) + coordinates = [ + minimum[axis] + + np.arange(int(cells[axis]) + 1, dtype=np.float64) * spacing[axis] + for axis in range(3) + ] + for axis in range(3): + coordinates[axis][-1] = maximum[axis] + return cells, spacing, coordinates + + +def _evaluate_layers( + structural: Any, + slab_axis: int, + plane_axes: tuple[int, int], + plane_first: np.ndarray, + plane_second: np.ndarray, + slab_coordinates: np.ndarray, +) -> np.ndarray: + """Evaluate complete globally aligned node layers in bounded point batches.""" + + first_flat = np.asarray(plane_first, dtype=np.float64).ravel(order="C") + second_flat = np.asarray(plane_second, dtype=np.float64).ravel(order="C") + plane_count = len(first_flat) + layer_count = len(slab_coordinates) + total = plane_count * layer_count + values = np.empty(total, dtype=np.float32) + + for start in range(0, total, EVALUATION_BATCH_SIZE): + stop = min(start + EVALUATION_BATCH_SIZE, total) + linear = np.arange(start, stop, dtype=np.int64) + layer_indices = linear // plane_count + plane_indices = linear - layer_indices * plane_count + + query = np.empty((stop - start, 3), dtype=np.float64) + query[:, slab_axis] = slab_coordinates[layer_indices] + query[:, plane_axes[0]] = first_flat[plane_indices] + query[:, plane_axes[1]] = second_flat[plane_indices] + + evaluated = np.asarray(structural.evaluate(query), dtype=np.float64).reshape(-1) + if len(evaluated) != len(query): + raise RuntimeError( + "Structural field evaluation did not return one value per grid node." + ) + if not np.all(np.isfinite(evaluated)): + raise RuntimeError("Structural field evaluation returned non-finite values.") + values[start:stop] = evaluated.astype(np.float32, copy=False) + + return values.reshape( + (layer_count, plane_first.shape[0], plane_first.shape[1]), + order="C", + ) + + +def _polydata(vertices: np.ndarray, faces: np.ndarray) -> pv.PolyData: + face_stream = np.empty((len(faces), 4), dtype=np.int64) + face_stream[:, 0] = 3 + face_stream[:, 1:] = np.asarray(faces, dtype=np.int64) + return pv.PolyData( + np.asarray(vertices, dtype=np.float64), + face_stream.ravel(order="C"), + ) + + +def install_global_grid_meshing(safe_module: Any) -> None: + """Replace ``generate_safe_isosurface`` on the supplied v3-safe module.""" + + if getattr(safe_module, "_global_grid_meshing_installed", False): + return + + def generate_global_grid_isosurface( + structural: Any, + bbox_min: np.ndarray, + bbox_max: np.ndarray, + resolution: float, + refine: int, + output_obj: Path, + progress: Callable[[str], None], + ) -> dict[str, Any]: + minimum = np.asarray(bbox_min, dtype=float) + maximum = np.asarray(bbox_max, dtype=float) + cells, spacing, coordinates = _grid_geometry( + minimum, + maximum, + float(resolution), + ) + total_cells = int(math.prod(int(value) for value in cells)) + if total_cells > int(safe_module.MAX_TOTAL_BASE_CELLS): + suggested = safe_module.recommended_resolution( + minimum, + maximum, + float(resolution), + int(safe_module.MAX_TOTAL_BASE_CELLS), + ) + raise MemoryError( + f"Resolution {float(resolution):g} creates {total_cells:,} base cells. " + f"The safety limit is {int(safe_module.MAX_TOTAL_BASE_CELLS):,}. Use " + f"approximately {suggested:.6g} or coarser, or reduce the bounding box." + ) + + node_counts = cells + 1 + # Stream along the longest axis so each scalar plane has the fewest nodes. + slab_axis = int(np.argmax(node_counts)) + plane_axes_list = [axis for axis in range(3) if axis != slab_axis] + plane_axes = (plane_axes_list[0], plane_axes_list[1]) + plane_first, plane_second = np.meshgrid( + coordinates[plane_axes[0]], + coordinates[plane_axes[1]], + indexing="ij", + ) + plane_points = int(plane_first.size) + if plane_points <= 0: + raise RuntimeError("The global scalar grid contains no plane nodes.") + + # A slab with N cells needs N+1 scalar layers. Adjacent slabs reuse the exact + # same boundary layer, so no independently refined chunk surface can appear. + cells_per_slab = max( + 1, + int(MAX_POINTS_PER_SLAB // max(plane_points, 1)) - 1, + ) + cells_per_slab = min(cells_per_slab, int(cells[slab_axis])) + slab_count = int(math.ceil(int(cells[slab_axis]) / cells_per_slab)) + if slab_count > int(safe_module.MAX_CHUNKS): + raise MemoryError( + f"The globally aligned grid needs {slab_count:,} scalar slabs, above " + f"the safety limit of {int(safe_module.MAX_CHUNKS):,}. Increase " + "POLATORY_GLOBAL_GRID_MAX_POINTS_PER_SLAB, use a coarser resolution, " + "or reduce the bounding box." + ) + + axis_names = ("X", "Y", "Z") + progress( + f"Global scalar grid: {tuple(int(value) for value in node_counts)} nodes; " + f"streaming {slab_count:,} aligned slab(s) along " + f"{axis_names[slab_axis]} with shared boundary values…" + ) + + all_vertices: list[np.ndarray] = [] + all_faces: list[np.ndarray] = [] + vertex_offset = 0 + previous_top: np.ndarray | None = None + slab_cell_start = 0 + + for slab_index in range(slab_count): + slab_cell_stop = min( + slab_cell_start + cells_per_slab, + int(cells[slab_axis]), + ) + node_start = slab_cell_start + node_stop = slab_cell_stop + 1 + slab_coordinates = coordinates[slab_axis][node_start:node_stop] + progress( + f"Evaluating aligned scalar slab {slab_index + 1:,}/{slab_count:,} " + f"({node_stop - node_start:,} node layers)…" + ) + + if previous_top is None: + volume = _evaluate_layers( + structural, + slab_axis, + plane_axes, + plane_first, + plane_second, + slab_coordinates, + ) + else: + volume = np.empty( + ( + len(slab_coordinates), + plane_first.shape[0], + plane_first.shape[1], + ), + dtype=np.float32, + ) + volume[0] = previous_top + if len(slab_coordinates) > 1: + volume[1:] = _evaluate_layers( + structural, + slab_axis, + plane_axes, + plane_first, + plane_second, + slab_coordinates[1:], + ) + previous_top = np.asarray(volume[-1], dtype=np.float32).copy() + + minimum_value = float(np.min(volume)) + maximum_value = float(np.max(volume)) + if minimum_value <= 0.0 <= maximum_value: + local_vertices, local_faces, _, _ = marching_cubes( + volume, + level=0.0, + spacing=( + float(spacing[slab_axis]), + float(spacing[plane_axes[0]]), + float(spacing[plane_axes[1]]), + ), + allow_degenerate=False, + method="lewiner", + ) + + vertices = np.empty_like(local_vertices, dtype=np.float64) + origins = ( + float(coordinates[slab_axis][node_start]), + float(coordinates[plane_axes[0]][0]), + float(coordinates[plane_axes[1]][0]), + ) + vertices[:, slab_axis] = local_vertices[:, 0] + origins[0] + vertices[:, plane_axes[0]] = local_vertices[:, 1] + origins[1] + vertices[:, plane_axes[1]] = local_vertices[:, 2] + origins[2] + + all_vertices.append(vertices) + all_faces.append(np.asarray(local_faces, dtype=np.int64) + vertex_offset) + vertex_offset += len(vertices) + + slab_cell_start = slab_cell_stop + + if not all_vertices or not all_faces: + raise RuntimeError( + "No zero isosurface was found inside the selected model extent." + ) + + progress( + "Merging shared global-grid vertices and removing duplicate slab-boundary " + "nodes…" + ) + vertices = np.concatenate(all_vertices, axis=0) + faces = np.concatenate(all_faces, axis=0) + mesh = _polydata(vertices, faces) + tolerance = max( + 1.0e-8 * float(np.linalg.norm(maximum - minimum)), + 1.0e-9, + ) + mesh = mesh.clean(tolerance=tolerance, absolute=True) + mesh = mesh.triangulate() + + # ``refine`` belonged to Polatory's independently adaptive native lattice. + # Global marching cubes already interpolates every crossing on shared grid + # edges. Keeping topology globally consistent is more important than applying + # a second per-slab refinement that could reintroduce cracks. + if int(refine) > 0: + progress( + "Global-grid topology is active; native per-chunk refine passes are " + "intentionally skipped to preserve shared slab boundaries." + ) + + boundary = mesh.extract_feature_edges( + boundary_edges=True, + non_manifold_edges=False, + feature_edges=False, + manifold_edges=False, + ) + progress( + f"Global-grid surface assembled: {mesh.n_points:,} vertices, " + f"{mesh.n_cells:,} triangles, {boundary.n_cells:,} boundary-edge cells." + ) + safe_module.write_obj(mesh, Path(output_obj)) + + peak_cells = int( + cells_per_slab + * int(cells[plane_axes[0]]) + * int(cells[plane_axes[1]]) + ) + return { + "cells": cells, + "total": total_cells, + "chunks": np.asarray( + [slab_count if axis == slab_axis else 1 for axis in range(3)], + dtype=np.int64, + ), + "chunk_total": slab_count, + "peak": peak_cells, + "spacing": spacing, + "slab_axis": slab_axis, + } + + safe_module.generate_safe_isosurface = generate_global_grid_isosurface + safe_module._global_grid_meshing_installed = True diff --git a/examples/polatory_lva_pyqt_app_v10_process_isolated.py b/examples/polatory_lva_pyqt_app_v10_process_isolated.py new file mode 100644 index 000000000..f7e1b845e --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v10_process_isolated.py @@ -0,0 +1,321 @@ +"""Process-isolated repeat-run launcher for the Polatory LVA application. + +This version keeps the Qt/PyVista GUI in the main process but executes every +native Polatory modelling run in a fresh Python subprocess. The fitted +StructuralInterpolant3 and all native solver state are therefore destroyed by +process exit after each run. A native abort can no longer terminate the GUI. + +Only the generated "Automatic LVA surface" is visible by default; every other +layer remains available but hidden. + +Run: + python polatory_lva_pyqt_app_v10_process_isolated.py +""" + +from __future__ import annotations + +import gc +import os +import pickle +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + +import numpy as np +import polatory + +import polatory_lva_pyqt_app_v5_streamed_lva as v5 +import polatory_lva_pyqt_app_v9_rerun_safe as v9 + +app = v9.app + +_original_window_init = app.MainWindow.__init__ +_original_model_finished = app.MainWindow.model_finished +_original_close_event = app.MainWindow.closeEvent + + +def _cleanup_process_files(self: Any, *, keep_obj: bool = False) -> None: + work_dir = getattr(self, "_model_process_work_dir", None) + if work_dir: + try: + shutil.rmtree(work_dir, ignore_errors=True) + except Exception: + pass + self._model_process_work_dir = None + + output_obj = getattr(self, "_model_process_output_obj", None) + if output_obj and not keep_obj: + path = Path(output_obj) + if path.exists(): + try: + path.unlink() + except OSError: + pass + self._model_process_output_obj = None + self._model_process_input = None + self._model_process_result = None + + +def _restore_lva_plane_metadata(result: dict[str, Any]) -> None: + """Rebuild v5's ndarray subclass after crossing the pickle boundary.""" + points = result.get("lva_points") + if points is None or isinstance(points, v5.PlanePointArray): + return + + dimension = result.pop("lva_plane_dimension", None) + if dimension is None: + dimensions = result.get("lva_dimensions") + if dimensions is not None and len(dimensions) == 3: + candidate = int(dimensions[0]) + if tuple(int(value) for value in dimensions) == ( + candidate, + candidate, + candidate, + ) and len(points) == 3 * candidate * candidate: + dimension = candidate + + if dimension is not None: + result["lva_points"] = v5.PlanePointArray( + np.asarray(points, dtype=np.float32), + int(dimension), + ) + + +def process_window_init(self: Any) -> None: + _original_window_init(self) + self._model_process: app.QtCore.QProcess | None = None + self._model_process_work_dir: str | None = None + self._model_process_input: str | None = None + self._model_process_result: str | None = None + self._model_process_output_obj: str | None = None + self._model_process_output_buffer = "" + self.run_button.setToolTip( + "Each modelling run starts in a fresh child process. Native fitting state " + "cannot survive into the next parameter test or terminate the GUI." + ) + + +def _process_is_running(self: Any) -> bool: + process = getattr(self, "_model_process", None) + if process is None: + return False + return process.state() != app.QtCore.QProcess.ProcessState.NotRunning + + +def _read_process_output(self: Any) -> None: + process = self._model_process + if process is None: + return + chunk = bytes(process.readAllStandardOutput()).decode("utf-8", errors="replace") + if not chunk: + return + self._model_process_output_buffer += chunk + while "\n" in self._model_process_output_buffer: + line, self._model_process_output_buffer = self._model_process_output_buffer.split( + "\n", 1 + ) + line = line.rstrip("\r") + if not line: + continue + if line.startswith("PROGRESS\t"): + self._log(line.split("\t", 1)[1]) + elif line != "RESULT_READY": + self._log(f"Worker: {line}") + + +def _flush_process_output(self: Any) -> None: + _read_process_output(self) + remaining = self._model_process_output_buffer.strip() + self._model_process_output_buffer = "" + if remaining: + for line in remaining.splitlines(): + if line.startswith("PROGRESS\t"): + self._log(line.split("\t", 1)[1]) + elif line != "RESULT_READY": + self._log(f"Worker: {line}") + + +def _process_error(self: Any, error: Any) -> None: + self._log(f"Native worker process error: {error}") + + +def _process_finished(self: Any, exit_code: int, exit_status: Any) -> None: + _flush_process_output(self) + self._model_process = None + self.run_button.setEnabled(True) + self.progress_bar.setRange(0, 1) + self.progress_bar.setValue(0) + + normal = exit_status == app.QtCore.QProcess.ExitStatus.NormalExit + result_path = Path(self._model_process_result) if self._model_process_result else None + + if exit_code == 0 and normal and result_path is not None and result_path.exists(): + try: + with result_path.open("rb") as stream: + result = pickle.load(stream) + _restore_lva_plane_metadata(result) + _cleanup_process_files(self, keep_obj=True) + _original_model_finished(self, result) + v9.show_only_generated_surface(self) + self._log( + "Isolated modelling process exited cleanly. The next parameter run " + "will start in a new interpreter." + ) + gc.collect() + return + except Exception as error: + _cleanup_process_files(self, keep_obj=False) + self._show_error("Could not load the isolated model result", error) + return + + status_name = "crashed" if not normal else "failed" + details = ( + f"The isolated Polatory worker {status_name} during native modelling " + f"(exit code {exit_code}). The GUI was protected and remains open." + ) + _cleanup_process_files(self, keep_obj=False) + self._log(details) + app.QtWidgets.QMessageBox.critical(self, "Polatory worker failed", details) + + +def process_run_model(self: Any) -> None: + if _process_is_running(self): + app.QtWidgets.QMessageBox.information( + self, + app.APP_TITLE, + "A modelling run is already in progress.", + ) + return + + try: + if not hasattr(polatory, "AutomaticStructuralDomainBuilder3"): + raise RuntimeError( + "AutomaticStructuralDomainBuilder3 is unavailable. Reinstall the " + "feature/automatic-subdomainer branch and restart the app." + ) + if self.reference_vertices is None or self.reference_faces is None: + raise ValueError("Load the structural reference OBJ first.") + + points, indicators, roles, source_rows = self._mapped_arrays() + contact_count = int(np.count_nonzero(indicators == 0.0)) + if contact_count and self.nugget_spin.value() != 0.0: + self.nugget_spin.setValue(0.0) + self._log("Nugget was forced to 0 because Contact categories are active.") + + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + bbox_min, bbox_max = self.current_bbox() + parameters = self.model_parameters() + payload = { + "points": points.copy(), + "indicators": indicators.copy(), + "trend_vertices": self.reference_vertices.copy(), + "trend_faces": self.reference_faces.copy(), + "bbox_min": bbox_min.copy(), + "bbox_max": bbox_max.copy(), + "parameters": parameters, + } + + v9.retire_previous_result(self) + _cleanup_process_files(self, keep_obj=False) + + work_dir = Path(tempfile.mkdtemp(prefix="polatory_lva_process_")) + input_path = work_dir / "payload.pkl" + result_path = work_dir / "result.pkl" + descriptor, output_obj_name = tempfile.mkstemp( + prefix="polatory_lva_result_", suffix=".obj" + ) + os.close(descriptor) + output_obj = Path(output_obj_name) + try: + output_obj.unlink() + except OSError: + pass + + with input_path.open("wb") as stream: + pickle.dump(payload, stream, protocol=pickle.HIGHEST_PROTOCOL) + + helper = Path(__file__).with_name("polatory_lva_worker_process.py") + if not helper.exists(): + raise FileNotFoundError(f"Missing isolated worker script: {helper}") + + process = app.QtCore.QProcess(self) + process.setProcessChannelMode(app.QtCore.QProcess.ProcessChannelMode.MergedChannels) + environment = app.QtCore.QProcessEnvironment.systemEnvironment() + environment.insert("PYTHONUNBUFFERED", "1") + environment.insert("PYTHONIOENCODING", "utf-8") + process.setProcessEnvironment(environment) + process.setProgram(sys.executable) + process.setArguments( + [ + str(helper), + "--input", + str(input_path), + "--result", + str(result_path), + "--obj", + str(output_obj), + ] + ) + process.readyReadStandardOutput.connect( + lambda: _read_process_output(self) + ) + process.errorOccurred.connect(lambda error: _process_error(self, error)) + process.finished.connect( + lambda code, status: _process_finished(self, int(code), status) + ) + + self._model_process = process + self._model_process_work_dir = str(work_dir) + self._model_process_input = str(input_path) + self._model_process_result = str(result_path) + self._model_process_output_obj = str(output_obj) + self._model_process_output_buffer = "" + + self.run_button.setEnabled(False) + self.progress_bar.setRange(0, 0) + self.tabs.setCurrentIndex(self.log_tab_index) + self._log( + "Starting Polatory in an isolated process. A native fitting crash can " + "no longer close this application." + ) + process.start() + except Exception as error: + _cleanup_process_files(self, keep_obj=False) + self.run_button.setEnabled(True) + self._show_error("Could not start isolated modelling", error) + + +def process_close_event(self: Any, event: Any) -> None: + process = getattr(self, "_model_process", None) + if process is not None and process.state() != app.QtCore.QProcess.ProcessState.NotRunning: + answer = app.QtWidgets.QMessageBox.question( + self, + "A model is still running", + "Stop the isolated modelling process and close the application?", + ) + if answer != app.QtWidgets.QMessageBox.StandardButton.Yes: + event.ignore() + return + process.terminate() + if not process.waitForFinished(3000): + process.kill() + process.waitForFinished(1000) + self._model_process = None + _cleanup_process_files(self, keep_obj=False) + + _original_close_event(self, event) + + +app.MainWindow.__init__ = process_window_init +app.MainWindow.run_model = process_run_model +app.MainWindow.closeEvent = process_close_event + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v11_leapfrog_defaults.py b/examples/polatory_lva_pyqt_app_v11_leapfrog_defaults.py new file mode 100644 index 000000000..70c6bf306 --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v11_leapfrog_defaults.py @@ -0,0 +1,68 @@ +"""Process-isolated LVA GUI with Leapfrog-style automatic defaults. + +The advanced controls remain visible for diagnostics, but the normal workflow is +to set the structural input, Strength and Trend range. The recovered defaults are +applied at startup. Finite local-domain weights now blend smoothly into the outside +field, so the neutral blend exponent is 1 rather than the old compensating value 7. + +Run: + python polatory_lva_pyqt_app_v11_leapfrog_defaults.py +""" + +from __future__ import annotations + +from typing import Any + +import polatory_lva_pyqt_app_v10_process_isolated as v10 + +app = v10.app +_original_window_init = app.MainWindow.__init__ + + +def leapfrog_default_window_init(self: Any) -> None: + _original_window_init(self) + + # Strength and trend range remain the two normal user-facing structural + # controls. Blend power 1 is the neutral exponent for the recovered smooth + # local-domain/outside partition of unity. + defaults = ( + ("alignment_spin", 0.0), + ("blend_power_spin", 1.0), + ("centroid_count_spin", 6000), + ("minimum_fraction_spin", 0.001), + ("maximum_fraction_spin", 0.10), + ("consistency_spin", 0.60), + ("support_multiplier_spin", 5), + ("minimum_support_spin", 1), + ) + for attribute, value in defaults: + widget = getattr(self, attribute, None) + if widget is not None: + widget.setValue(value) + + trend_type = getattr(self, "trend_type_combo", None) + if trend_type is not None: + index = trend_type.findText("Strongest along inputs") + if index >= 0: + trend_type.setCurrentIndex(index) + + blend = getattr(self, "blend_power_spin", None) + if blend is not None: + blend.setToolTip( + "Leapfrog-style automatic default: 1. Finite local-domain influence " + "is blended smoothly into the Outside field. Normally leave this " + "unchanged and adjust only Strength and Trend range." + ) + + self._log( + "Leapfrog-style defaults loaded: automatic SubDomainer, finite " + "support-radius-bounded LVA coverage, smooth Outside-field blending, " + "and blend power 1." + ) + + +app.MainWindow.__init__ = leapfrog_default_window_init + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v3_safe.py b/examples/polatory_lva_pyqt_app_v3_safe.py new file mode 100644 index 000000000..a0da8021b --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v3_safe.py @@ -0,0 +1,468 @@ +"""Memory-safe launcher for ``polatory_lva_pyqt_app_v2.py``. + +Place this file in the same directory as ``polatory_lva_pyqt_app_v2.py`` and run:: + + python polatory_lva_pyqt_app_v3_safe.py + +The original application performs the complete isosurface extraction in one native +Polatory call. A smaller numeric resolution means a finer 3-D grid, so reducing +25 to 10 can increase the number of base cells by roughly 15.6 times. Large native +allocations may terminate the whole process before Python can raise an exception. + +This launcher replaces only the model worker. It splits fine isosurface grids into +aligned boxes, generates them sequentially, joins the surfaces, and preserves the +original user interface and output layers. +""" + +from __future__ import annotations + +import math +import os +import tempfile +import traceback +from pathlib import Path +from typing import Any, Callable, Iterator + +import numpy as np +import pyvista as pv + +import polatory +from polatory import three as p3 + +try: + import polatory_lva_pyqt_app_v2 as app +except ImportError as error: + raise SystemExit( + "Place polatory_lva_pyqt_app_v3_safe.py beside " + "polatory_lva_pyqt_app_v2.py, then run the v3 file." + ) from error + + +# Keep each native meshing call close to the grid size that already worked at +# resolution 25. Fine jobs are divided automatically instead of being rejected. +MAX_CELLS_PER_CHUNK = 250_000 +MAX_TOTAL_BASE_CELLS = 30_000_000 +MAX_CHUNKS = 128 + + +def grid_plan( + minimum: np.ndarray, + maximum: np.ndarray, + resolution: float, +) -> dict[str, Any]: + minimum = np.asarray(minimum, dtype=float) + maximum = np.asarray(maximum, dtype=float) + resolution = float(resolution) + if minimum.shape != (3,) or maximum.shape != (3,): + raise ValueError("Meshing bounds must be three-dimensional.") + if not np.all(np.isfinite(minimum)) or not np.all(np.isfinite(maximum)): + raise ValueError("Meshing bounds must be finite.") + spans = maximum - minimum + if not np.all(spans > 0.0): + raise ValueError("Every meshing-box span must be positive.") + if not resolution > 0.0: + raise ValueError("Isosurface resolution must be positive.") + + cells = np.maximum(np.ceil(spans / resolution).astype(np.int64), 1) + total = int(math.prod(int(value) for value in cells)) + + chunks = np.ones(3, dtype=np.int64) + while True: + peak_axis = np.ceil(cells / chunks).astype(np.int64) + peak = int(math.prod(int(value) for value in peak_axis)) + if peak <= MAX_CELLS_PER_CHUNK: + break + candidates = np.flatnonzero(chunks < cells) + if len(candidates) == 0: + break + loads = cells[candidates] / chunks[candidates] + axis = int(candidates[int(np.argmax(loads))]) + chunks[axis] += 1 + + peak_axis = np.ceil(cells / chunks).astype(np.int64) + peak = int(math.prod(int(value) for value in peak_axis)) + chunk_total = int(math.prod(int(value) for value in chunks)) + return { + "cells": cells, + "total": total, + "chunks": chunks, + "chunk_total": chunk_total, + "peak": peak, + } + + +def recommended_resolution( + minimum: np.ndarray, + maximum: np.ndarray, + current: float, + limit: int, +) -> float: + spans = np.asarray(maximum, dtype=float) - np.asarray(minimum, dtype=float) + + def count(cell_size: float) -> int: + cells = np.maximum(np.ceil(spans / cell_size).astype(np.int64), 1) + return int(math.prod(int(value) for value in cells)) + + low = max(float(current), np.finfo(float).eps) + if count(low) <= limit: + return low + high = max(float(np.max(spans)), low) + while count(high) > limit: + high *= 2.0 + for _ in range(64): + middle = 0.5 * (low + high) + if count(middle) <= limit: + high = middle + else: + low = middle + return high + + +def chunk_bounds( + minimum: np.ndarray, + maximum: np.ndarray, + resolution: float, + cells: np.ndarray, + chunks: np.ndarray, +) -> Iterator[tuple[np.ndarray, np.ndarray]]: + minimum = np.asarray(minimum, dtype=float) + maximum = np.asarray(maximum, dtype=float) + cells = np.asarray(cells, dtype=np.int64) + chunks = np.asarray(chunks, dtype=np.int64) + + edges: list[np.ndarray] = [] + for axis in range(3): + cell_count = int(cells[axis]) + chunk_count = int(chunks[axis]) + axis_edges = np.asarray( + [(index * cell_count) // chunk_count for index in range(chunk_count + 1)], + dtype=np.int64, + ) + axis_edges[-1] = cell_count + edges.append(axis_edges) + + for ix in range(int(chunks[0])): + for iy in range(int(chunks[1])): + for iz in range(int(chunks[2])): + lower_index = np.array( + [edges[0][ix], edges[1][iy], edges[2][iz]], dtype=np.int64 + ) + upper_index = np.array( + [edges[0][ix + 1], edges[1][iy + 1], edges[2][iz + 1]], + dtype=np.int64, + ) + lower = minimum + lower_index * float(resolution) + upper = minimum + upper_index * float(resolution) + upper = np.minimum(upper, maximum) + for axis in range(3): + if upper_index[axis] == cells[axis]: + upper[axis] = maximum[axis] + if np.all(upper > lower): + yield lower, upper + + +def obj_has_vertices(path: Path) -> bool: + if not path.exists() or path.stat().st_size == 0: + return False + with path.open("r", encoding="utf-8", errors="ignore") as handle: + return any(line.startswith("v ") for line in handle) + + +def write_obj(mesh: pv.DataSet, path: Path) -> None: + surface = mesh.extract_surface().triangulate().clean() + points = np.asarray(surface.points, dtype=float) + raw_faces = np.asarray(surface.faces, dtype=np.int64) + if len(points) == 0 or len(raw_faces) == 0: + raise RuntimeError("The generated zero isosurface is empty.") + faces = raw_faces.reshape(-1, 4) + if not np.all(faces[:, 0] == 3): + raise RuntimeError("The merged isosurface could not be triangulated.") + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write("# Polatory chunked isosurface\n") + for x, y, z in points: + handle.write(f"v {x:.17g} {y:.17g} {z:.17g}\n") + for first, second, third in faces[:, 1:4]: + handle.write( + f"f {int(first) + 1} {int(second) + 1} {int(third) + 1}\n" + ) + + +def generate_safe_isosurface( + structural: Any, + bbox_min: np.ndarray, + bbox_max: np.ndarray, + resolution: float, + refine: int, + output_obj: Path, + progress: Callable[[str], None], +) -> dict[str, Any]: + plan = grid_plan(bbox_min, bbox_max, resolution) + if plan["total"] > MAX_TOTAL_BASE_CELLS: + suggested = recommended_resolution( + bbox_min, + bbox_max, + resolution, + MAX_TOTAL_BASE_CELLS, + ) + raise MemoryError( + f"Resolution {resolution:g} creates {plan['total']:,} base cells. " + f"The safety limit is {MAX_TOTAL_BASE_CELLS:,}. Use approximately " + f"{suggested:.6g} or coarser, or reduce the bounding box." + ) + if plan["chunk_total"] > MAX_CHUNKS: + raise MemoryError( + f"The mesh needs {plan['chunk_total']:,} chunks, above the safety " + f"limit of {MAX_CHUNKS}. Use a coarser resolution or smaller extent." + ) + + field = polatory.StructuralRbfFieldFunction(structural) + if plan["chunk_total"] == 1: + progress( + f"Generating one mesh grid with {plan['total']:,} base cells…" + ) + bbox = p3.Bbox(bbox_min.reshape(1, 3), bbox_max.reshape(1, 3)) + result = polatory.Isosurface( + bbox, + float(resolution), + np.eye(3), + ).generate(field, isovalue=0.0, refine=int(refine)) + result.export_obj(str(output_obj)) + return plan + + progress( + f"Fine grid: {plan['total']:,} base cells. Processing safely in " + f"{plan['chunk_total']:,} aligned chunks " + f"{tuple(int(v) for v in plan['chunks'])}…" + ) + + combined: pv.DataSet | None = None + all_chunks = list( + chunk_bounds( + bbox_min, + bbox_max, + resolution, + plan["cells"], + plan["chunks"], + ) + ) + with tempfile.TemporaryDirectory(prefix="polatory_lva_chunks_") as directory: + folder = Path(directory) + for index, (lower, upper) in enumerate(all_chunks, start=1): + progress(f"Generating mesh chunk {index:,}/{len(all_chunks):,}…") + bbox = p3.Bbox(lower.reshape(1, 3), upper.reshape(1, 3)) + result = polatory.Isosurface( + bbox, + float(resolution), + np.eye(3), + ).generate(field, isovalue=0.0, refine=int(refine)) + part_path = folder / f"part_{index:04d}.obj" + result.export_obj(str(part_path)) + if not obj_has_vertices(part_path): + continue + part = pv.read(part_path).extract_surface().triangulate().clean() + if part.n_points == 0 or part.n_cells == 0: + continue + if combined is None: + combined = part + else: + combined = combined.merge(part, merge_points=True) + combined = combined.extract_surface().triangulate().clean() + + if combined is None or combined.n_points == 0 or combined.n_cells == 0: + raise RuntimeError( + "No zero isosurface was found inside the selected model extent." + ) + progress("Joining chunk boundaries and writing the final result…") + write_obj(combined, output_obj) + return plan + + +def safe_worker_run(self: Any) -> None: + temp_obj: str | None = None + try: + if not hasattr(polatory, "AutomaticStructuralDomainBuilder3"): + raise RuntimeError( + "AutomaticStructuralDomainBuilder3 is unavailable. Reinstall " + "Polatory from feature/automatic-subdomainer and restart the app." + ) + + points = self.payload["points"] + indicators = self.payload["indicators"] + trend_vertices = self.payload["trend_vertices"] + trend_faces = self.payload["trend_faces"] + parameters = self.payload["parameters"] + bbox_min = self.payload["bbox_min"] + bbox_max = self.payload["bbox_max"] + + self.progress.emit("Calculating Leapfrog-compatible indicator distances…") + value_info = polatory.leapfrog_indicator_values3( + points, + indicators, + fit_accuracy=float(parameters["fit_tolerance"]), + ) + values = np.asarray(value_info.values, dtype=float) + + self.progress.emit("Creating structural LVA input…") + trend_input = polatory.StructuralTrendInput3( + trend_vertices, + trend_faces, + float(parameters["strength"]), + float(parameters["trend_range"]), + ) + + rbf = p3.CovSpheroidal3( + [float(parameters["sill"]), float(parameters["base_range"])] + ) + model = p3.Model(rbf, int(parameters["poly_degree"])) + model.nugget = float(parameters["nugget"]) + model_parameters = np.asarray( + model.parameters, dtype=float + ).reshape(-1).tolist() + + trend_type = { + "Strongest along inputs": ( + polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS + ), + "Blending": polatory.StructuralTrendType.BLENDING, + "Non-decaying": polatory.StructuralTrendType.NON_DECAYING, + }[parameters["trend_type"]] + + self.progress.emit("Building automatic structural domains…") + builder = polatory.AutomaticStructuralDomainBuilder3( + centroid_count=int(parameters["centroid_count"]), + minimum_cluster_fraction=float( + parameters["minimum_cluster_fraction"] + ), + maximum_cluster_fraction=float( + parameters["maximum_cluster_fraction"] + ), + consistency_threshold=float(parameters["consistency_threshold"]), + base_range=float(parameters["base_range"]), + support_multiplier=int(parameters["support_multiplier"]), + minimum_support_points=int(parameters["minimum_support_points"]), + ) + domains = builder.build_from_inputs( + points, + [trend_input], + model_parameters=model_parameters, + trend_type=trend_type, + ) + diagnostics = builder.diagnostics_ + labels = np.asarray(builder.labels_, dtype=np.int64) + if diagnostics is None: + raise RuntimeError("The automatic builder returned no diagnostics.") + + self.progress.emit( + f"Fitting {diagnostics.final_domain_count} structural domains…" + ) + structural = polatory.StructuralInterpolant3( + model, + outside_value=float(parameters["outside_value"]), + blend_power=float(parameters["blend_power"]), + alignment_strength=float(parameters["alignment_strength"]), + ) + structural.fit( + points, + values, + domains, + tolerance=float(value_info.fit_accuracy), + max_iter=int(parameters["max_iterations"]), + ) + + predictions = np.asarray(structural.evaluate(points), dtype=float) + errors = predictions - values + training_rmse = float(np.sqrt(np.mean(errors**2))) + training_max_abs = float(np.max(np.abs(errors))) + + file_descriptor, temp_obj = tempfile.mkstemp( + prefix="polatory_lva_", suffix=".obj" + ) + os.close(file_descriptor) + plan = generate_safe_isosurface( + structural=structural, + bbox_min=np.asarray(bbox_min, dtype=float), + bbox_max=np.asarray(bbox_max, dtype=float), + resolution=float(parameters["isosurface_resolution"]), + refine=int(parameters["isosurface_refine"]), + output_obj=Path(temp_obj), + progress=self.progress.emit, + ) + + self.progress.emit("Sampling the LVA field for 3-D display…") + lva_dimension = int(parameters["lva_grid_dimension"]) + lva_dimensions = (lva_dimension, lva_dimension, lva_dimension) + lva_points = app.structured_points(bbox_min, bbox_max, lva_dimensions) + non_decaying = trend_type == polatory.StructuralTrendType.NON_DECAYING + lva_matrices = polatory.sample_single_input_anisotropies3( + lva_points, + trend_input, + non_decaying=non_decaying, + ) + lva_eigenvalues, _ = np.linalg.eigh(lva_matrices) + lva_ratio = lva_eigenvalues[:, -1] / lva_eigenvalues[:, 0] + + glyph_dimension = int(parameters["lva_glyph_dimension"]) + glyph_dimensions = (glyph_dimension, glyph_dimension, glyph_dimension) + glyph_points = app.structured_points( + bbox_min, bbox_max, glyph_dimensions + ) + glyph_matrices = polatory.sample_single_input_anisotropies3( + glyph_points, + trend_input, + non_decaying=non_decaying, + ) + glyph_eigenvalues, glyph_eigenvectors = np.linalg.eigh(glyph_matrices) + glyph_ratio = glyph_eigenvalues[:, -1] / glyph_eigenvalues[:, 0] + principal_axes = glyph_eigenvectors[:, :, -1] + + result = { + "temp_obj": temp_obj, + "values": values, + "predictions": predictions, + "labels": labels, + "centroid_points": np.asarray( + diagnostics.centroid_points, dtype=float + ), + "centroid_labels": np.asarray( + diagnostics.centroid_labels, dtype=np.int64 + ), + "centroid_grid_shape": tuple(diagnostics.centroid_grid_shape), + "merge_count": int(diagnostics.merge_count), + "domain_count": int(diagnostics.final_domain_count), + "minimum_points": int(diagnostics.minimum_points), + "maximum_points": int(diagnostics.maximum_points), + "training_rmse": training_rmse, + "training_max_abs": training_max_abs, + "fit_tolerance": float(value_info.fit_accuracy), + "clipping_distance": float(value_info.clipping_distance), + "data_diagonal": float(value_info.data_diagonal), + "lva_points": lva_points, + "lva_dimensions": lva_dimensions, + "lva_ratio": lva_ratio, + "glyph_points": glyph_points, + "glyph_axes": principal_axes, + "glyph_ratio": glyph_ratio, + "bbox_min": bbox_min, + "bbox_max": bbox_max, + } + self.progress.emit( + f"Meshing complete: {plan['total']:,} base cells in " + f"{plan['chunk_total']:,} chunk(s); largest chunk " + f"{plan['peak']:,} cells." + ) + self.finished.emit(result) + except Exception: + if temp_obj and Path(temp_obj).exists(): + try: + Path(temp_obj).unlink() + except OSError: + pass + self.failed.emit(traceback.format_exc()) + + +# Replace the original QThread worker while keeping the complete v2 interface. +app.ModelWorker.run = safe_worker_run + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v4_lva_safe.py b/examples/polatory_lva_pyqt_app_v4_lva_safe.py new file mode 100644 index 000000000..06dbeea29 --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v4_lva_safe.py @@ -0,0 +1,94 @@ +"""Stable LVA-field launcher for the Polatory PyQt application. + +Place this file beside ``polatory_lva_pyqt_app_v3_safe.py`` and +``polatory_lva_pyqt_app_v2.py``, then run:: + + python polatory_lva_pyqt_app_v4_lva_safe.py + +The v2 display path used VTK's interpolating ``slice_orthogonal`` filter on a +three-dimensional StructuredGrid. Some low grid dimensions can terminate the +VTK/Qt process inside that native filter. This launcher keeps the same model and +LVA samples, but displays the three centre planes by extracting exact structured +grid index planes. No interpolating slice filter is used. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pyvista as pv + +import polatory_lva_pyqt_app_v3_safe as v3 + +app = v3.app + + +def _nearest_grid_index( + coordinate: float | None, + minimum: float, + maximum: float, + size: int, +) -> int: + if size < 1: + raise ValueError("Structured-grid dimensions must be positive.") + if size == 1 or not maximum > minimum: + return 0 + if coordinate is None or not np.isfinite(coordinate): + coordinate = 0.5 * (minimum + maximum) + fraction = (float(coordinate) - minimum) / (maximum - minimum) + return int(np.clip(np.rint(fraction * (size - 1)), 0, size - 1)) + + +def safe_slice_orthogonal( + self: pv.StructuredGrid, + x: float | None = None, + y: float | None = None, + z: float | None = None, + *args: Any, + **kwargs: Any, +) -> pv.PolyData: + """Return three exact centre planes without invoking VTK's slice filter.""" + + dimensions = tuple(int(value) for value in self.dimensions) + if len(dimensions) != 3 or any(value < 2 for value in dimensions): + raise ValueError( + "LVA field display requires at least two samples along every axis." + ) + + bounds = self.bounds + ix = _nearest_grid_index(x, bounds.x_min, bounds.x_max, dimensions[0]) + iy = _nearest_grid_index(y, bounds.y_min, bounds.y_max, dimensions[1]) + iz = _nearest_grid_index(z, bounds.z_min, bounds.z_max, dimensions[2]) + + # VTK VOI order: xmin, xmax, ymin, ymax, zmin, zmax. + vois = ( + (ix, ix, 0, dimensions[1] - 1, 0, dimensions[2] - 1), + (0, dimensions[0] - 1, iy, iy, 0, dimensions[2] - 1), + (0, dimensions[0] - 1, 0, dimensions[1] - 1, iz, iz), + ) + + planes: list[pv.PolyData] = [] + for voi in vois: + plane = self.extract_subset(voi) + surface = plane.extract_surface().triangulate().clean() + if surface.n_points and surface.n_cells: + planes.append(surface) + + if not planes: + raise RuntimeError("The LVA field centre planes are empty.") + + combined: pv.DataSet = planes[0] + for plane in planes[1:]: + combined = combined.merge(plane, merge_points=False) + + return combined.extract_surface().triangulate().clean() + + +# Patch only the problematic display operation. Meshing, automatic domains, +# exported OBJ geometry and LVA values remain unchanged. +pv.StructuredGrid.slice_orthogonal = safe_slice_orthogonal + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v5_streamed_lva.py b/examples/polatory_lva_pyqt_app_v5_streamed_lva.py new file mode 100644 index 000000000..b3e3dff3f --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v5_streamed_lva.py @@ -0,0 +1,313 @@ +"""Scalable LVA-field launcher for the Polatory PyQt application. + +Place this file beside ``polatory_lva_pyqt_app_v2.py``, +``polatory_lva_pyqt_app_v3_safe.py`` and +``polatory_lva_pyqt_app_v4_lva_safe.py``, then run:: + + python polatory_lva_pyqt_app_v5_streamed_lva.py + +The old display path sampled an N x N x N volume even though the UI displayed +only three centre planes. This launcher samples only those planes, reducing the +LVA display workload from O(N^3) to O(N^2). It also computes the recovered +single-input ratio directly instead of allocating one 3 x 3 matrix and running +an eigensolver for every display point. +""" + +from __future__ import annotations + +import math +import threading +from typing import Any + +import numpy as np +import pyvista as pv + +import polatory + +try: + from scipy.spatial import cKDTree +except ImportError as error: + raise SystemExit( + "This launcher requires SciPy. Install it with: python -m pip install scipy" + ) from error + +import polatory_lva_pyqt_app_v4_lva_safe as v4 + +v3 = v4.v3 +app = v4.app + +# This protects the VTK/GPU display path. The spin box accepts larger values so +# the app can report a normal validation error rather than terminating natively. +MAX_LVA_DIMENSION = 1500 +QUERY_CHUNK_SIZE = 250_000 + +_original_structured_points = app.structured_points +_original_sample_anisotropies = polatory.sample_single_input_anisotropies3 +_original_eigh = np.linalg.eigh +_original_window_init = app.MainWindow.__init__ +_original_model_parameters = app.MainWindow.model_parameters +_original_model_finished = app.MainWindow.model_finished +_worker_state = threading.local() + + +class PlanePointArray(np.ndarray): + """Three concatenated centre planes carrying their common dimension.""" + + dimension: int + + def __new__(cls, points: np.ndarray, dimension: int): + instance = np.asarray(points, dtype=np.float32).view(cls) + instance.dimension = int(dimension) + return instance + + def __array_finalize__(self, source: Any) -> None: + if source is not None: + self.dimension = int(getattr(source, "dimension", 0)) + + +class LvaRatioProxy: + """Avoid allocating anisotropy matrices solely to recover eigenvalue ratios.""" + + def __init__(self, ratios: np.ndarray) -> None: + self.ratios = np.asarray(ratios, dtype=np.float32) + + +def _plane_points( + minimum: np.ndarray, + maximum: np.ndarray, + dimension: int, +) -> PlanePointArray: + minimum = np.asarray(minimum, dtype=np.float64) + maximum = np.asarray(maximum, dtype=np.float64) + centre = 0.5 * (minimum + maximum) + axes = [ + np.linspace(minimum[i], maximum[i], dimension, dtype=np.float32) + for i in range(3) + ] + + planes: list[np.ndarray] = [] + specifications = ( + (0, axes[1], axes[2]), + (1, axes[0], axes[2]), + (2, axes[0], axes[1]), + ) + for fixed_axis, first_axis, second_axis in specifications: + first, second = np.meshgrid(first_axis, second_axis, indexing="ij") + points = np.empty((dimension * dimension, 3), dtype=np.float32) + varying_axes = [axis for axis in range(3) if axis != fixed_axis] + points[:, fixed_axis] = np.float32(centre[fixed_axis]) + points[:, varying_axes[0]] = first.ravel(order="F") + points[:, varying_axes[1]] = second.ravel(order="F") + planes.append(points) + + return PlanePointArray(np.concatenate(planes, axis=0), dimension) + + +def scalable_structured_points( + minimum: np.ndarray, + maximum: np.ndarray, + dimensions: tuple[int, int, int], +) -> np.ndarray: + """Replace only the first worker grid request with three centre planes.""" + if getattr(_worker_state, "active", False): + call_index = int(getattr(_worker_state, "structured_call", 0)) + _worker_state.structured_call = call_index + 1 + if call_index == 0: + if not (dimensions[0] == dimensions[1] == dimensions[2]): + raise ValueError("The LVA display grid must use equal dimensions.") + dimension = int(dimensions[0]) + return _plane_points(minimum, maximum, dimension) + return _original_structured_points(minimum, maximum, dimensions) + + +def _vertex_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray: + triangles = vertices[faces] + face_normals = np.cross( + triangles[:, 1] - triangles[:, 0], + triangles[:, 2] - triangles[:, 0], + ) + lengths = np.linalg.norm(face_normals, axis=1) + valid = lengths > 0.0 + face_normals[valid] /= lengths[valid, None] + face_normals[~valid] = 0.0 + + normals = np.zeros_like(vertices) + for corner in range(3): + np.add.at(normals, faces[:, corner], face_normals) + lengths = np.linalg.norm(normals, axis=1) + valid = lengths > 0.0 + normals[valid] /= lengths[valid, None] + normals[~valid] = np.array([0.0, 0.0, 1.0]) + return normals + + +def scalable_sample_anisotropies( + points: np.ndarray, + input_: object, + *, + non_decaying: bool = False, +) -> Any: + """Directly sample LVA ratios for the plane-only display request.""" + if not isinstance(points, PlanePointArray): + return _original_sample_anisotropies( + points, + input_, + non_decaying=non_decaying, + ) + + vertices = np.asarray(input_.vertices, dtype=np.float64) + faces = np.asarray(input_.faces, dtype=np.int64) + strength = float(input_.strength) + range_ = float(input_.range) + normals = _vertex_normals(vertices, faces) + tree = cKDTree(vertices) + + ratios = np.empty(len(points), dtype=np.float32) + query_points = np.asarray(points, dtype=np.float32) + for start in range(0, len(query_points), QUERY_CHUNK_SIZE): + stop = min(start + QUERY_CHUNK_SIZE, len(query_points)) + try: + distances, _ = tree.query( + query_points[start:stop], + k=1, + workers=-1, + ) + except TypeError: + distances, _ = tree.query(query_points[start:stop], k=1) + distances = np.asarray(distances, dtype=np.float64) + if non_decaying: + influence = np.ones(len(distances), dtype=np.float64) + else: + influence = np.exp(-distances / range_) + influence[distances >= 4.0 * range_] = 0.0 + ratios[start:stop] = 1.0 + (strength - 1.0) * influence + + # Keep a reference to normals so this function validates and prepares the + # same reference orientation field as the original sampler. The principal + # axes used by glyphs still follow the original full-matrix path below. + del normals + return LvaRatioProxy(ratios) + + +def scalable_eigh(value: Any, *args: Any, **kwargs: Any): + if isinstance(value, LvaRatioProxy): + ratios = np.asarray(value.ratios, dtype=np.float64) + tangent = ratios ** (-1.0 / 3.0) + normal = ratios ** (2.0 / 3.0) + eigenvalues = np.column_stack([tangent, tangent, normal]) + return eigenvalues, None + return _original_eigh(value, *args, **kwargs) + + +def scalable_worker_run(self: Any) -> None: + _worker_state.active = True + _worker_state.structured_call = 0 + try: + v3.safe_worker_run(self) + finally: + _worker_state.active = False + _worker_state.structured_call = 0 + + +def _lva_multiblock( + points: PlanePointArray, + ratios: np.ndarray, +) -> pv.MultiBlock: + dimension = int(points.dimension) + count = dimension * dimension + dimensions = ( + (1, dimension, dimension), + (dimension, 1, dimension), + (dimension, dimension, 1), + ) + names = ("X centre plane", "Y centre plane", "Z centre plane") + + blocks = pv.MultiBlock() + for index, (name, grid_dimensions) in enumerate(zip(names, dimensions)): + start = index * count + stop = start + count + grid = pv.StructuredGrid() + grid.points = np.asarray(points[start:stop], dtype=np.float32) + grid.dimensions = grid_dimensions + grid["LVA ratio"] = np.asarray(ratios[start:stop], dtype=np.float32) + blocks[name] = grid + return blocks + + +def scalable_window_init(self: Any) -> None: + _original_window_init(self) + self.lva_grid_spin.setRange(2, 100_000) + self.lva_grid_spin.setToolTip( + "Samples per axis on each of the three LVA centre planes. v5 samples " + "O(N^2) plane points instead of an O(N^3) volume. Requests above the " + f"safe interactive limit ({MAX_LVA_DIMENSION:,}) are rejected normally " + "instead of crashing VTK or the GPU driver." + ) + + +def scalable_model_parameters(self: Any) -> dict[str, Any]: + parameters = _original_model_parameters(self) + dimension = int(parameters["lva_grid_dimension"]) + if dimension > MAX_LVA_DIMENSION: + raise ValueError( + f"LVA grid dimension {dimension:,} exceeds the safe interactive " + f"limit of {MAX_LVA_DIMENSION:,}. This would create " + f"{3 * dimension * dimension:,} displayed plane points. The request " + "was stopped before modelling so the application remains open." + ) + return parameters + + +def scalable_model_finished(self: Any, result: dict[str, Any]) -> None: + plane_points = result.get("lva_points") + plane_ratios = result.get("lva_ratio") + if not isinstance(plane_points, PlanePointArray): + _original_model_finished(self, result) + return + + # Let the original completion path create every existing model layer using a + # tiny placeholder LVA grid, then replace only that placeholder layer. + display_result = dict(result) + dummy_dimensions = (2, 2, 2) + dummy_points = _original_structured_points( + np.asarray(result["bbox_min"], dtype=float), + np.asarray(result["bbox_max"], dtype=float), + dummy_dimensions, + ) + display_result["lva_points"] = dummy_points + display_result["lva_dimensions"] = dummy_dimensions + display_result["lva_ratio"] = np.ones(len(dummy_points), dtype=np.float32) + _original_model_finished(self, display_result) + + try: + self._remove_layer("LVA field slices") + blocks = _lva_multiblock(plane_points, np.asarray(plane_ratios)) + self._add_layer( + "LVA field slices", + blocks, + kind="mesh", + scalars="LVA ratio", + cmap="viridis", + opacity=0.82, + show_edges=False, + ) + dimension = int(plane_points.dimension) + self._log( + f"LVA field display complete: {dimension:,} x {dimension:,} samples " + "on each of three centre planes." + ) + except Exception as error: + self._show_error("The LVA field could not be displayed", error) + + +app.structured_points = scalable_structured_points +polatory.sample_single_input_anisotropies3 = scalable_sample_anisotropies +np.linalg.eigh = scalable_eigh +app.ModelWorker.run = scalable_worker_run +app.MainWindow.__init__ = scalable_window_init +app.MainWindow.model_parameters = scalable_model_parameters +app.MainWindow.model_finished = scalable_model_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v6_numeric_sdf.py b/examples/polatory_lva_pyqt_app_v6_numeric_sdf.py new file mode 100644 index 000000000..5ce955666 --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v6_numeric_sdf.py @@ -0,0 +1,290 @@ +"""Numerical-SDF input launcher for the Polatory structural-LVA application. + +This launcher extends v5 with two input modes: + +1. Binary categories: the existing Leapfrog-compatible indicator conversion. +2. Numerical SDF/scalar values: use a selected CSV value column directly while + retaining the category-role table only as the Include/Ignore mask. + +Run: + python polatory_lva_pyqt_app_v6_numeric_sdf.py +""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pandas as pd + +import polatory + +import polatory_lva_pyqt_app_v5_streamed_lva as v5 + +app = v5.app + +BINARY_MODE = "Binary categories" +NUMERICAL_MODE = "Numerical SDF / scalar values" + +_original_indicator_values = polatory.leapfrog_indicator_values3 +_original_window_init = app.MainWindow.__init__ +_original_load_csv = app.MainWindow.load_csv +_original_model_parameters = app.MainWindow.model_parameters +_numeric_state = threading.local() + + +def _input_aware_indicator_values( + points: np.ndarray, + indicators: np.ndarray, + fit_accuracy: float = 0.0, +) -> Any: + """Return direct numerical values when the current worker requests them.""" + values = getattr(_numeric_state, "values", None) + if values is None: + return _original_indicator_values( + points, + indicators, + fit_accuracy=fit_accuracy, + ) + + points = np.asarray(points, dtype=float) + values = np.asarray(values, dtype=float) + if values.ndim != 1 or len(values) != len(points): + raise ValueError("The numerical value column does not match the model points.") + if not np.all(np.isfinite(values)): + raise ValueError("The numerical value/SDF column contains non-finite rows.") + + span = points.max(axis=0) - points.min(axis=0) + diagonal = float(np.linalg.norm(span)) + resolved_accuracy = float(fit_accuracy) + if not resolved_accuracy > 0.0: + resolved_accuracy = max(1.0e-5 * diagonal, np.finfo(float).eps) + + return SimpleNamespace( + values=values.copy(), + fit_accuracy=resolved_accuracy, + clipping_distance=float("nan"), + data_diagonal=diagonal, + ) + + +def numerical_worker_run(self: Any) -> None: + parameters = self.payload.get("parameters", {}) + numerical = parameters.get("input_mode") == NUMERICAL_MODE + if numerical: + _numeric_state.values = np.asarray( + self.payload["numeric_values"], + dtype=float, + ) + else: + _numeric_state.values = None + try: + v5.scalable_worker_run(self) + finally: + _numeric_state.values = None + + +def _set_value_mode_enabled(self: Any) -> None: + numerical = self.input_mode_combo.currentText() == NUMERICAL_MODE + self.value_column_combo.setEnabled(numerical) + self.numeric_mode_help.setText( + ( + "Numerical mode uses the selected value column directly and extracts " + "the zero isosurface. Category roles still control which rows are " + "ignored; Inside/Outside labels are not converted to +/-1." + ) + if numerical + else ( + "Binary mode converts the category roles to Leapfrog-compatible " + "inside/outside indicator distances." + ) + ) + + +def numerical_window_init(self: Any) -> None: + _original_window_init(self) + + group = app.QtWidgets.QGroupBox("Input field mode") + form = app.QtWidgets.QFormLayout(group) + + self.input_mode_combo = app.QtWidgets.QComboBox() + self.input_mode_combo.addItems([BINARY_MODE, NUMERICAL_MODE]) + + self.value_column_combo = app.QtWidgets.QComboBox() + self.numeric_mode_help = app.QtWidgets.QLabel() + self.numeric_mode_help.setWordWrap(True) + + form.addRow("Mode", self.input_mode_combo) + form.addRow("Value / SDF column", self.value_column_combo) + form.addRow(self.numeric_mode_help) + + data_scroll = self.tabs.widget(0) + data_page = data_scroll.widget() if hasattr(data_scroll, "widget") else None + if data_page is None or data_page.layout() is None: + raise RuntimeError("Could not locate the Data-tab layout.") + data_page.layout().insertWidget(2, group) + + self.input_mode_combo.currentTextChanged.connect( + lambda _text: _set_value_mode_enabled(self) + ) + _set_value_mode_enabled(self) + + +def numerical_load_csv(self: Any) -> None: + _original_load_csv(self) + if self.frame is None: + return + + columns = [str(column) for column in self.frame.columns] + previous = self.value_column_combo.currentText() + self.value_column_combo.clear() + self.value_column_combo.addItems(columns) + + preferred = None + for candidate in ("Values", "Value", "SDF", "Signed distance", "Distance"): + for column in columns: + if column.strip().lower() == candidate.lower(): + preferred = column + break + if preferred is not None: + break + + if preferred is None: + numeric_columns = [ + column + for column in columns + if pd.api.types.is_numeric_dtype(self.frame[column]) + and column + not in { + self.x_combo.currentText(), + self.y_combo.currentText(), + self.z_combo.currentText(), + } + ] + if numeric_columns: + preferred = numeric_columns[0] + + if previous in columns: + self.value_column_combo.setCurrentText(previous) + elif preferred is not None: + self.value_column_combo.setCurrentText(preferred) + + +def numerical_model_parameters(self: Any) -> dict[str, Any]: + parameters = _original_model_parameters(self) + parameters["input_mode"] = self.input_mode_combo.currentText() + parameters["value_column"] = self.value_column_combo.currentText() + return parameters + + +def numerical_run_model(self: Any) -> None: + if self._thread is not None: + app.QtWidgets.QMessageBox.information( + self, + app.APP_TITLE, + "A modelling run is already in progress.", + ) + return + + try: + if not hasattr(polatory, "AutomaticStructuralDomainBuilder3"): + raise RuntimeError( + "This installed Polatory package does not expose " + "AutomaticStructuralDomainBuilder3. Reinstall the " + "feature/automatic-subdomainer branch, then restart this app." + ) + if self.reference_vertices is None or self.reference_faces is None: + raise ValueError("Load the structural reference OBJ first.") + + points, indicators, roles, source_rows = self._mapped_arrays() + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + bbox_min, bbox_max = self.current_bbox() + parameters = self.model_parameters() + + payload = { + "points": points.copy(), + "indicators": indicators.copy(), + "trend_vertices": self.reference_vertices.copy(), + "trend_faces": self.reference_faces.copy(), + "bbox_min": bbox_min.copy(), + "bbox_max": bbox_max.copy(), + "parameters": parameters, + } + + if parameters["input_mode"] == NUMERICAL_MODE: + if self.frame is None: + raise ValueError("Load a CSV first.") + column = parameters["value_column"] + if not column or column not in self.frame.columns: + raise ValueError("Select a valid numerical value/SDF column.") + + all_values = pd.to_numeric( + self.frame[column], + errors="coerce", + ).to_numpy(dtype=float) + numerical_values = all_values[source_rows] + if not np.all(np.isfinite(numerical_values)): + bad = int(np.count_nonzero(~np.isfinite(numerical_values))) + raise ValueError( + f"The selected value column has {bad:,} non-finite model rows." + ) + if not ( + np.any(numerical_values < 0.0) + and np.any(numerical_values > 0.0) + ): + raise ValueError( + "The numerical field must contain values on both sides of " + "zero to generate a zero isosurface." + ) + payload["numeric_values"] = numerical_values.copy() + + self.run_button.setEnabled(False) + self.progress_bar.setRange(0, 0) + self.tabs.setCurrentIndex(self.log_tab_index) + + if parameters["input_mode"] == NUMERICAL_MODE: + values = payload["numeric_values"] + self._log( + f"Starting numerical-SDF LVA model from " + f"'{parameters['value_column']}': range " + f"{float(np.min(values)):.6g} to " + f"{float(np.max(values)):.6g}." + ) + else: + self._log("Starting binary-category automatic LVA model…") + + thread = app.QtCore.QThread(self) + worker = app.ModelWorker(payload) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.progress.connect(self._log) + worker.finished.connect(self.model_finished) + worker.failed.connect(self.model_failed) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._thread_finished) + self._thread = thread + self._worker = worker + thread.start() + except Exception as error: + self._show_error("Could not start modelling", error) + + +polatory.leapfrog_indicator_values3 = _input_aware_indicator_values +app.ModelWorker.run = numerical_worker_run +app.MainWindow.__init__ = numerical_window_init +app.MainWindow.load_csv = numerical_load_csv +app.MainWindow.model_parameters = numerical_model_parameters +app.MainWindow.run_model = numerical_run_model + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v7_contact_roles.py b/examples/polatory_lva_pyqt_app_v7_contact_roles.py new file mode 100644 index 000000000..4cb29d5ef --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v7_contact_roles.py @@ -0,0 +1,550 @@ +"""Four-role contact-constrained launcher for the Polatory LVA application. + +This launcher extends v5 with the modelling roles requested by the user: + +- Inside: positive fitted distance (UI indicator -1) +- Outside: negative fitted distance (UI indicator +1) +- Contact: exact zero constraint +- Ignore: excluded from domain construction and RBF fitting + +A selected numeric column can optionally promote rows whose value is near zero to +Contact before the role table is populated. This is useful for CSV files where +contact rows are stored as Outside or Ignored in the categorical column while a +separate Values/SDF column contains the exact zeros. + +Run: + python polatory_lva_pyqt_app_v7_contact_roles.py +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pandas as pd + +import polatory + +try: + from scipy.spatial import cKDTree +except ImportError as error: + raise SystemExit( + "This launcher requires SciPy. Install it with: python -m pip install scipy" + ) from error + +import polatory_lva_pyqt_app_v5_streamed_lva as v5 + +app = v5.app + +ROLE_CONTACT = "Contact" +CONTACT_COLUMN_NONE = "" +CONTACT_COLUMN_CANDIDATES = ( + "Values", + "Value", + "SDF", + "Signed distance", + "Signed Distance", + "Distance", +) + +app.ROLE_CONTACT = ROLE_CONTACT +app.ROLE_OPTIONS = ( + app.ROLE_INSIDE, + app.ROLE_OUTSIDE, + ROLE_CONTACT, + app.ROLE_IGNORE, +) + +_original_indicator_values = polatory.leapfrog_indicator_values3 +_original_window_init = app.MainWindow.__init__ +_original_load_csv = app.MainWindow.load_csv +_original_run_model = app.MainWindow.run_model +_original_model_finished = app.MainWindow.model_finished + + +def contact_default_role(value: str) -> str: + text = str(value).strip().lower() + try: + number = float(text) + except ValueError: + number = np.nan + + if np.isfinite(number): + if number > 0.0: + return app.ROLE_OUTSIDE + if number < 0.0: + return app.ROLE_INSIDE + return ROLE_CONTACT + + contact_tokens = ("contact", "boundary", "interface", "surface", "zero") + ignored_tokens = ( + "ignore", + "ignored", + "exclude", + "excluded", + "skip", + "unused", + ) + inside_tokens = ( + "inside", + "interior", + "ore", + "mineral", + "deposit", + "target", + "true", + "yes", + ) + outside_tokens = ( + "outside", + "exterior", + "waste", + "background", + "false", + "no", + ) + + if any(token in text for token in contact_tokens): + return ROLE_CONTACT + if any(token in text for token in ignored_tokens): + return app.ROLE_IGNORE + if any(token in text for token in inside_tokens): + return app.ROLE_INSIDE + if any(token in text for token in outside_tokens): + return app.ROLE_OUTSIDE + return app.ROLE_IGNORE + + +app.default_role_for_category = contact_default_role + + +def _selected_contact_column(self: Any) -> str | None: + if not hasattr(self, "contact_column_combo"): + return None + column = self.contact_column_combo.currentText().strip() + if not column or column == CONTACT_COLUMN_NONE: + return None + if self.frame is None or column not in self.frame.columns: + return None + return column + + +def _contact_override_mask(self: Any) -> np.ndarray: + if self.frame is None: + return np.zeros(0, dtype=bool) + column = _selected_contact_column(self) + if column is None: + return np.zeros(len(self.frame), dtype=bool) + + values = pd.to_numeric(self.frame[column], errors="coerce").to_numpy(dtype=float) + tolerance = float(self.contact_zero_tolerance_spin.value()) + return np.isfinite(values) & (np.abs(values) <= tolerance) + + +def _effective_category_keys(self: Any, column: str) -> pd.Series: + if self.frame is None: + return pd.Series(dtype="string") + keys = app.category_keys(self.frame[column]).copy() + contact_mask = _contact_override_mask(self) + if len(contact_mask) == len(keys) and np.any(contact_mask): + keys.loc[contact_mask] = ROLE_CONTACT + return keys + + +def contact_populate_category_roles(self: Any, column: str) -> None: + if self.frame is None or not column or column not in self.frame.columns: + self.role_table.setRowCount(0) + return + + keys = _effective_category_keys(self, column) + counts = keys.value_counts(dropna=False, sort=False) + if len(counts) > 500: + answer = app.QtWidgets.QMessageBox.question( + self, + "Many unique categories", + f"The selected field contains {len(counts):,} effective categories. " + "It may be continuous rather than categorical. Populate the table anyway?", + ) + if answer != app.QtWidgets.QMessageBox.StandardButton.Yes: + self.role_table.setRowCount(0) + return + + previous = self.category_role_mapping() + self.role_table.setRowCount(len(counts)) + for row, (value, count) in enumerate(counts.items()): + value_text = str(value) + value_item = app.QtWidgets.QTableWidgetItem(value_text) + value_item.setFlags( + value_item.flags() & ~app.QtCore.Qt.ItemFlag.ItemIsEditable + ) + count_item = app.QtWidgets.QTableWidgetItem(f"{int(count):,}") + count_item.setTextAlignment(app.QtCore.Qt.AlignmentFlag.AlignRight) + count_item.setFlags( + count_item.flags() & ~app.QtCore.Qt.ItemFlag.ItemIsEditable + ) + role_combo = app.QtWidgets.QComboBox() + role_combo.addItems(app.ROLE_OPTIONS) + role_combo.setCurrentText( + previous.get(value_text, contact_default_role(value_text)) + ) + self.role_table.setItem(row, 0, value_item) + self.role_table.setItem(row, 1, count_item) + self.role_table.setCellWidget(row, 2, role_combo) + + +def contact_mapped_arrays( + self: Any, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + if self.frame is None: + raise ValueError("Load a CSV first.") + + columns = [ + self.x_combo.currentText(), + self.y_combo.currentText(), + self.z_combo.currentText(), + self.category_combo.currentText(), + ] + if any(not column for column in columns): + raise ValueError("Map X, Y, Z, and category columns.") + if len(set(columns[:3])) != 3: + raise ValueError("X, Y, and Z must use three different columns.") + + coordinates = self.frame[columns[:3]].apply(pd.to_numeric, errors="coerce") + coordinate_array = coordinates.to_numpy(dtype=float) + finite_coordinates = np.all(np.isfinite(coordinate_array), axis=1) + + keys = _effective_category_keys(self, columns[3]) + role_mapping = self.category_role_mapping() + roles = keys.map(role_mapping).fillna(app.ROLE_IGNORE).to_numpy(dtype=object) + model_mask = finite_coordinates & (roles != app.ROLE_IGNORE) + + points = coordinate_array[model_mask] + model_roles = roles[model_mask] + indicators = np.zeros(len(points), dtype=float) + indicators[model_roles == app.ROLE_INSIDE] = -1.0 + indicators[model_roles == app.ROLE_OUTSIDE] = 1.0 + indicators[model_roles == ROLE_CONTACT] = 0.0 + source_rows = np.flatnonzero(model_mask) + + if len(points) < 3: + raise ValueError( + "At least three non-ignored points with valid coordinates are required." + ) + if not np.any(indicators < 0.0): + raise ValueError("Assign at least one category to Inside.") + if not np.any(indicators > 0.0): + raise ValueError("Assign at least one category to Outside.") + + return points, indicators, model_roles, source_rows + + +def contact_apply_data_mapping(self: Any) -> None: + try: + points, indicators, roles, source_rows = self._mapped_arrays() + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + for name in ( + "Inside points", + "Outside points", + "Contact points", + "Ignored points", + ): + self._remove_layer(name) + + inside = points[indicators < 0.0] + outside = points[indicators > 0.0] + contacts = points[indicators == 0.0] + + self._add_layer( + "Inside points", + app.pv.PolyData(inside), + kind="points", + color="#40c463", + opacity=1.0, + point_size=9, + ) + self._add_layer( + "Outside points", + app.pv.PolyData(outside), + kind="points", + color="#f05b61", + opacity=1.0, + point_size=9, + ) + if len(contacts): + self._add_layer( + "Contact points", + app.pv.PolyData(contacts), + kind="points", + color="#ffb300", + opacity=1.0, + point_size=12, + ) + + mapped_columns = [ + self.x_combo.currentText(), + self.y_combo.currentText(), + self.z_combo.currentText(), + ] + all_coordinates = self.frame[mapped_columns].apply( + pd.to_numeric, + errors="coerce", + ) + all_points = all_coordinates.to_numpy(dtype=float) + finite = np.all(np.isfinite(all_points), axis=1) + ignored_mask = finite.copy() + ignored_mask[source_rows] = False + ignored = all_points[ignored_mask] + if len(ignored): + self._add_layer( + "Ignored points", + app.pv.PolyData(ignored), + kind="points", + color="#9aa0a6", + opacity=0.35, + point_size=5, + visible=False, + ) + + self.fit_bbox_to_data() + self._log( + f"Mapped {len(points):,} modelling points: " + f"{len(inside):,} inside, {len(outside):,} outside, " + f"{len(contacts):,} exact contacts; {len(ignored):,} ignored." + ) + except Exception as error: + self._show_error("Invalid data mapping", error) + + +def contact_indicator_values3( + points: np.ndarray, + indicators: np.ndarray, + *, + fit_accuracy: float = 0.0, + clipping_distance: float = 0.0, +) -> Any: + points = np.asarray(points, dtype=float) + indicators = np.asarray(indicators, dtype=float) + + contact = indicators == 0.0 + if not np.any(contact): + return _original_indicator_values( + points, + indicators, + fit_accuracy=fit_accuracy, + clipping_distance=clipping_distance, + ) + + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError("points must have shape (n, 3)") + if len(points) == 0: + raise ValueError("points must not be empty") + if indicators.ndim != 1 or len(indicators) != len(points): + raise ValueError("indicators must have shape (n,)") + if not np.all(np.isfinite(points)) or not np.all(np.isfinite(indicators)): + raise ValueError("points and indicators must be finite") + + non_contact = ~contact + signs = np.sign(indicators[non_contact]) + if not (np.any(signs < 0.0) and np.any(signs > 0.0)): + raise ValueError( + "Contact-constrained modelling still requires Inside and Outside points." + ) + + data_diagonal = float( + np.linalg.norm(points.max(axis=0) - points.min(axis=0)) + ) + if not data_diagonal > 0.0: + raise ValueError("point bounding box must have a positive diagonal") + + if fit_accuracy == 0.0: + fit_accuracy = 1.0e-7 * data_diagonal + elif not fit_accuracy > 0.0: + raise ValueError("fit_accuracy must be positive or zero for automatic") + + if clipping_distance == 0.0: + clipping_distance = 1.0e-2 * data_diagonal + elif not clipping_distance > 0.0: + raise ValueError( + "clipping_distance must be positive or zero for automatic" + ) + + tree = cKDTree(points[contact]) + try: + distances, _ = tree.query(points[non_contact], k=1, workers=-1) + except TypeError: + distances, _ = tree.query(points[non_contact], k=1) + distances = np.asarray(distances, dtype=float) + + raw_signed_distances = np.zeros(len(points), dtype=float) + raw_signed_distances[non_contact] = -np.sign(indicators[non_contact]) * distances + values = np.clip( + raw_signed_distances, + -float(clipping_distance), + float(clipping_distance), + ) + values[contact] = 0.0 + + return SimpleNamespace( + values=values, + raw_signed_distances=raw_signed_distances, + data_diagonal=data_diagonal, + fit_accuracy=float(fit_accuracy), + clipping_distance=float(clipping_distance), + ) + + +def _refresh_effective_roles(self: Any) -> None: + if self.frame is not None: + self.populate_category_roles(self.category_combo.currentText()) + + +def contact_window_init(self: Any) -> None: + _original_window_init(self) + + group = app.QtWidgets.QGroupBox("Contact constraints") + form = app.QtWidgets.QFormLayout(group) + + self.contact_column_combo = app.QtWidgets.QComboBox() + self.contact_column_combo.addItem(CONTACT_COLUMN_NONE) + self.contact_zero_tolerance_spin = app.QtWidgets.QDoubleSpinBox() + self.contact_zero_tolerance_spin.setRange(0.0, 1.0e12) + self.contact_zero_tolerance_spin.setDecimals(10) + self.contact_zero_tolerance_spin.setValue(1.0e-8) + self.contact_zero_tolerance_spin.setKeyboardTracking(False) + + help_label = app.QtWidgets.QLabel( + "Rows assigned Contact are fitted as exact value 0. Optionally select a " + "Values/SDF column so near-zero rows become a separate Contact category " + "even when their original category says Outside or Ignored. Ignore rows " + "are excluded from the RBF unless promoted to Contact by this override." + ) + help_label.setWordWrap(True) + + form.addRow("Promote zero values from", self.contact_column_combo) + form.addRow("Zero tolerance", self.contact_zero_tolerance_spin) + form.addRow(help_label) + + data_scroll = self.tabs.widget(0) + data_page = data_scroll.widget() if hasattr(data_scroll, "widget") else None + if data_page is None or data_page.layout() is None: + raise RuntimeError("Could not locate the Data-tab layout.") + data_page.layout().insertWidget(3, group) + + self.contact_column_combo.currentTextChanged.connect( + lambda _text: _refresh_effective_roles(self) + ) + self.contact_zero_tolerance_spin.valueChanged.connect( + lambda _value: _refresh_effective_roles(self) + ) + + for label in self.findChildren(app.QtWidgets.QLabel): + if "Assign any number of categories" in label.text(): + label.setText( + "Assign every effective category to Inside, Outside, Contact, " + "or Ignore. Inside = -1, Outside = +1, Contact = exact 0, and " + "Ignore is excluded from the RBF." + ) + break + + self.nugget_spin.setToolTip( + "Contact-constrained runs force Nugget to 0 so contact points remain " + "interpolation constraints." + ) + + +def contact_load_csv(self: Any) -> None: + _original_load_csv(self) + if self.frame is None: + return + + columns = [str(column) for column in self.frame.columns] + previous = self.contact_column_combo.currentText() + self.contact_column_combo.blockSignals(True) + self.contact_column_combo.clear() + self.contact_column_combo.addItem(CONTACT_COLUMN_NONE) + self.contact_column_combo.addItems(columns) + + selected = None + if previous in columns: + selected = previous + else: + for candidate in CONTACT_COLUMN_CANDIDATES: + for column in columns: + if column.strip().lower() == candidate.lower(): + selected = column + break + if selected is not None: + break + + self.contact_column_combo.setCurrentText( + selected if selected is not None else CONTACT_COLUMN_NONE + ) + self.contact_column_combo.blockSignals(False) + self.populate_category_roles(self.category_combo.currentText()) + + +def contact_run_model(self: Any) -> None: + try: + _, indicators, _, _ = self._mapped_arrays() + contact_count = int(np.count_nonzero(indicators == 0.0)) + if contact_count: + if self.nugget_spin.value() != 0.0: + self.nugget_spin.setValue(0.0) + self._log( + "Nugget was forced to 0 because exact Contact constraints are active." + ) + self._log( + f"Contact mode active: {contact_count:,} points are exact zero " + "constraints; automatic fit tolerance uses 1e-7 x data diagonal." + ) + except Exception as error: + self._show_error("Could not validate contact constraints", error) + return + _original_run_model(self) + + +def contact_model_finished(self: Any, result: dict[str, Any]) -> None: + _original_model_finished(self, result) + indicators = self.current_indicators + if indicators is None: + return + contact = np.asarray(indicators, dtype=float) == 0.0 + if not np.any(contact): + return + predictions = np.asarray(result.get("predictions", []), dtype=float) + if len(predictions) != len(contact): + return + + errors = np.abs(predictions[contact]) + maximum = float(np.max(errors)) + mean = float(np.mean(errors)) + tolerance = float(result.get("fit_tolerance", np.nan)) + self._log( + f"Contact snap check: {int(np.count_nonzero(contact)):,} contacts; " + f"mean |field| {mean:.6g}; maximum |field| {maximum:.6g}; " + f"fit tolerance {tolerance:.6g}." + ) + if np.isfinite(tolerance) and maximum > 10.0 * tolerance: + self._log( + "Warning: some contact residuals exceed 10 x the requested fitting " + "tolerance. Increase Maximum iterations before interpreting the surface." + ) + + +polatory.leapfrog_indicator_values3 = contact_indicator_values3 +app.MainWindow.__init__ = contact_window_init +app.MainWindow.load_csv = contact_load_csv +app.MainWindow.populate_category_roles = contact_populate_category_roles +app.MainWindow._mapped_arrays = contact_mapped_arrays +app.MainWindow.apply_data_mapping = contact_apply_data_mapping +app.MainWindow.run_model = contact_run_model +app.MainWindow.model_finished = contact_model_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v8_category_contacts.py b/examples/polatory_lva_pyqt_app_v8_category_contacts.py new file mode 100644 index 000000000..3dc7602d0 --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v8_category_contacts.py @@ -0,0 +1,435 @@ +"""Category-only four-role launcher for the Polatory structural-LVA app. + +Every selected category value is treated as categorical, including numeric-looking +values. The user assigns each category manually to one role: + +- Inside: inside constraint +- Outside: outside constraint +- Contact: exact zero constraint +- Ignore: excluded from domain construction and RBF fitting + +There is no numerical/SDF mode and no automatic promotion from another column. + +Run: + python polatory_lva_pyqt_app_v8_category_contacts.py +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pandas as pd + +import polatory + +try: + from scipy.spatial import cKDTree +except ImportError as error: + raise SystemExit( + "This launcher requires SciPy. Install it with: python -m pip install scipy" + ) from error + +import polatory_lva_pyqt_app_v5_streamed_lva as v5 + +app = v5.app + +ROLE_CONTACT = "Contact" +app.ROLE_CONTACT = ROLE_CONTACT +app.ROLE_OPTIONS = ( + app.ROLE_INSIDE, + app.ROLE_OUTSIDE, + ROLE_CONTACT, + app.ROLE_IGNORE, +) + +_original_indicator_values = polatory.leapfrog_indicator_values3 +_original_window_init = app.MainWindow.__init__ +_original_run_model = app.MainWindow.run_model +_original_model_finished = app.MainWindow.model_finished + + +def category_default_role(value: str) -> str: + """Use only obvious text labels; all other categories stay manual/ignored.""" + text = str(value).strip().lower() + + contact_tokens = ("contact", "boundary", "interface", "surface", "zero") + ignored_tokens = ( + "ignore", + "ignored", + "exclude", + "excluded", + "skip", + "unused", + ) + inside_tokens = ( + "inside", + "interior", + "ore", + "mineral", + "deposit", + "target", + "true", + "yes", + ) + outside_tokens = ( + "outside", + "exterior", + "waste", + "background", + "false", + "no", + ) + + if any(token in text for token in contact_tokens): + return ROLE_CONTACT + if any(token in text for token in ignored_tokens): + return app.ROLE_IGNORE + if any(token in text for token in inside_tokens): + return app.ROLE_INSIDE + if any(token in text for token in outside_tokens): + return app.ROLE_OUTSIDE + return app.ROLE_IGNORE + + +app.default_role_for_category = category_default_role + + +def category_populate_roles(self: Any, column: str) -> None: + if self.frame is None or not column or column not in self.frame.columns: + self.role_table.setRowCount(0) + return + + keys = app.category_keys(self.frame[column]) + counts = keys.value_counts(dropna=False, sort=False) + if len(counts) > 500: + answer = app.QtWidgets.QMessageBox.question( + self, + "Many unique categories", + f"The selected field contains {len(counts):,} unique values. Every " + "value will be treated as a category. Populate the table anyway?", + ) + if answer != app.QtWidgets.QMessageBox.StandardButton.Yes: + self.role_table.setRowCount(0) + return + + previous = self.category_role_mapping() + self.role_table.setRowCount(len(counts)) + + for row, (value, count) in enumerate(counts.items()): + value_text = str(value) + value_item = app.QtWidgets.QTableWidgetItem(value_text) + value_item.setFlags( + value_item.flags() & ~app.QtCore.Qt.ItemFlag.ItemIsEditable + ) + + count_item = app.QtWidgets.QTableWidgetItem(f"{int(count):,}") + count_item.setTextAlignment(app.QtCore.Qt.AlignmentFlag.AlignRight) + count_item.setFlags( + count_item.flags() & ~app.QtCore.Qt.ItemFlag.ItemIsEditable + ) + + role_combo = app.QtWidgets.QComboBox() + role_combo.addItems(app.ROLE_OPTIONS) + role_combo.setCurrentText( + previous.get(value_text, category_default_role(value_text)) + ) + + self.role_table.setItem(row, 0, value_item) + self.role_table.setItem(row, 1, count_item) + self.role_table.setCellWidget(row, 2, role_combo) + + +def category_mapped_arrays( + self: Any, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + if self.frame is None: + raise ValueError("Load a CSV first.") + + columns = [ + self.x_combo.currentText(), + self.y_combo.currentText(), + self.z_combo.currentText(), + self.category_combo.currentText(), + ] + if any(not column for column in columns): + raise ValueError("Map X, Y, Z, and category columns.") + if len(set(columns[:3])) != 3: + raise ValueError("X, Y, and Z must use three different columns.") + + coordinate_frame = self.frame[columns[:3]].apply( + pd.to_numeric, + errors="coerce", + ) + coordinate_array = coordinate_frame.to_numpy(dtype=float) + finite_coordinates = np.all(np.isfinite(coordinate_array), axis=1) + + category_keys = app.category_keys(self.frame[columns[3]]) + role_mapping = self.category_role_mapping() + roles = ( + category_keys.map(role_mapping) + .fillna(app.ROLE_IGNORE) + .to_numpy(dtype=object) + ) + + modelling_mask = finite_coordinates & (roles != app.ROLE_IGNORE) + points = coordinate_array[modelling_mask] + model_roles = roles[modelling_mask] + source_rows = np.flatnonzero(modelling_mask) + + indicators = np.zeros(len(points), dtype=float) + indicators[model_roles == app.ROLE_INSIDE] = -1.0 + indicators[model_roles == app.ROLE_OUTSIDE] = 1.0 + indicators[model_roles == ROLE_CONTACT] = 0.0 + + if len(points) < 3: + raise ValueError( + "At least three non-ignored points with valid coordinates are required." + ) + if not np.any(indicators < 0.0): + raise ValueError("Assign at least one category to Inside.") + if not np.any(indicators > 0.0): + raise ValueError("Assign at least one category to Outside.") + + return points, indicators, model_roles, source_rows + + +def category_apply_mapping(self: Any) -> None: + try: + points, indicators, roles, source_rows = self._mapped_arrays() + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + for name in ( + "Inside points", + "Outside points", + "Contact points", + "Ignored points", + ): + self._remove_layer(name) + + inside = points[indicators < 0.0] + outside = points[indicators > 0.0] + contacts = points[indicators == 0.0] + + self._add_layer( + "Inside points", + app.pv.PolyData(inside), + kind="points", + color="#40c463", + opacity=1.0, + point_size=9, + ) + self._add_layer( + "Outside points", + app.pv.PolyData(outside), + kind="points", + color="#f05b61", + opacity=1.0, + point_size=9, + ) + if len(contacts): + self._add_layer( + "Contact points", + app.pv.PolyData(contacts), + kind="points", + color="#ffb300", + opacity=1.0, + point_size=12, + ) + + all_coordinates = self.frame[columns := [ + self.x_combo.currentText(), + self.y_combo.currentText(), + self.z_combo.currentText(), + ]].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=float) + finite = np.all(np.isfinite(all_coordinates), axis=1) + ignored_mask = finite.copy() + ignored_mask[source_rows] = False + ignored = all_coordinates[ignored_mask] + + if len(ignored): + self._add_layer( + "Ignored points", + app.pv.PolyData(ignored), + kind="points", + color="#9aa0a6", + opacity=0.35, + point_size=5, + visible=False, + ) + + self.fit_bbox_to_data() + self._log( + f"Mapped {len(points):,} modelling points from categorical roles: " + f"{len(inside):,} inside, {len(outside):,} outside, " + f"{len(contacts):,} contacts, and {len(ignored):,} ignored." + ) + except Exception as error: + self._show_error("Invalid data mapping", error) + + +def category_contact_values( + points: np.ndarray, + indicators: np.ndarray, + *, + fit_accuracy: float = 0.0, + clipping_distance: float = 0.0, +) -> Any: + """Build signed distances from categorical Inside/Outside/Contact roles.""" + points = np.asarray(points, dtype=float) + indicators = np.asarray(indicators, dtype=float) + contact_mask = indicators == 0.0 + + if not np.any(contact_mask): + return _original_indicator_values( + points, + indicators, + fit_accuracy=fit_accuracy, + clipping_distance=clipping_distance, + ) + + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError("points must have shape (n, 3)") + if indicators.ndim != 1 or len(indicators) != len(points): + raise ValueError("indicators must have shape (n,)") + if not np.all(np.isfinite(points)) or not np.all(np.isfinite(indicators)): + raise ValueError("points and indicators must be finite") + + non_contact = ~contact_mask + if not np.any(indicators[non_contact] < 0.0): + raise ValueError("Contact modelling requires at least one Inside point.") + if not np.any(indicators[non_contact] > 0.0): + raise ValueError("Contact modelling requires at least one Outside point.") + + diagonal = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0))) + if not diagonal > 0.0: + raise ValueError("point bounding box must have a positive diagonal") + + if fit_accuracy == 0.0: + fit_accuracy = 1.0e-7 * diagonal + elif not fit_accuracy > 0.0: + raise ValueError("fit_accuracy must be positive or zero for automatic") + + if clipping_distance == 0.0: + clipping_distance = 1.0e-2 * diagonal + elif not clipping_distance > 0.0: + raise ValueError( + "clipping_distance must be positive or zero for automatic" + ) + + contact_tree = cKDTree(points[contact_mask]) + try: + distances, _ = contact_tree.query( + points[non_contact], + k=1, + workers=-1, + ) + except TypeError: + distances, _ = contact_tree.query(points[non_contact], k=1) + + raw_signed_distances = np.zeros(len(points), dtype=float) + raw_signed_distances[non_contact] = ( + -np.sign(indicators[non_contact]) * np.asarray(distances, dtype=float) + ) + + values = np.clip( + raw_signed_distances, + -float(clipping_distance), + float(clipping_distance), + ) + values[contact_mask] = 0.0 + + return SimpleNamespace( + values=values, + raw_signed_distances=raw_signed_distances, + data_diagonal=diagonal, + fit_accuracy=float(fit_accuracy), + clipping_distance=float(clipping_distance), + ) + + +def category_window_init(self: Any) -> None: + _original_window_init(self) + + for label in self.findChildren(app.QtWidgets.QLabel): + if "Assign any number of categories" in label.text(): + label.setText( + "Every unique value in the selected category column is treated " + "as a category. Assign it manually to Inside, Outside, Contact, " + "or Ignore. Contact is an exact zero constraint; Ignore is not " + "used by the RBF." + ) + break + + self.nugget_spin.setToolTip( + "Runs containing Contact categories force Nugget to 0 so contacts remain " + "zero-value interpolation constraints." + ) + + +def category_run_model(self: Any) -> None: + try: + _, indicators, _, _ = self._mapped_arrays() + contact_count = int(np.count_nonzero(indicators == 0.0)) + if contact_count: + if self.nugget_spin.value() != 0.0: + self.nugget_spin.setValue(0.0) + self._log( + "Nugget was forced to 0 because Contact categories are active." + ) + self._log( + f"Category contact mode: {contact_count:,} rows are exact zero " + "constraints." + ) + except Exception as error: + self._show_error("Could not validate category roles", error) + return + + _original_run_model(self) + + +def category_model_finished(self: Any, result: dict[str, Any]) -> None: + _original_model_finished(self, result) + + if self.current_indicators is None: + return + contact_mask = np.asarray(self.current_indicators, dtype=float) == 0.0 + if not np.any(contact_mask): + return + + predictions = np.asarray(result.get("predictions", []), dtype=float) + if len(predictions) != len(contact_mask): + return + + absolute_errors = np.abs(predictions[contact_mask]) + maximum = float(np.max(absolute_errors)) + mean = float(np.mean(absolute_errors)) + tolerance = float(result.get("fit_tolerance", np.nan)) + + self._log( + f"Contact snap check: {int(np.count_nonzero(contact_mask)):,} contacts; " + f"mean |field| {mean:.6g}; maximum |field| {maximum:.6g}; " + f"fit tolerance {tolerance:.6g}." + ) + if np.isfinite(tolerance) and maximum > 10.0 * tolerance: + self._log( + "Warning: contact residuals exceed 10 x the requested tolerance. " + "Increase Maximum iterations before trusting the final surface." + ) + + +polatory.leapfrog_indicator_values3 = category_contact_values +app.MainWindow.__init__ = category_window_init +app.MainWindow.populate_category_roles = category_populate_roles +app.MainWindow._mapped_arrays = category_mapped_arrays +app.MainWindow.apply_data_mapping = category_apply_mapping +app.MainWindow.run_model = category_run_model +app.MainWindow.model_finished = category_model_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_pyqt_app_v9_rerun_safe.py b/examples/polatory_lva_pyqt_app_v9_rerun_safe.py new file mode 100644 index 000000000..a247f0c43 --- /dev/null +++ b/examples/polatory_lva_pyqt_app_v9_rerun_safe.py @@ -0,0 +1,253 @@ +"""Repeat-run-safe category-contact launcher for the Polatory LVA application. + +Extends v8 and fixes two GUI lifecycle problems: + +1. A completed ModelWorker was scheduled for deletion from QThread.finished, after + the worker thread event loop had already stopped. Large payload arrays and Qt + wrappers could therefore survive between runs. v9 schedules worker deletion + from the worker's own finished/failed signal before quitting its thread. +2. Previous result actors, scalar bars, datasets and temporary OBJ files are + retired on the GUI thread before a replacement run starts. + +Display policy: +- "Automatic LVA surface" is visible by default. +- every other layer is created hidden by default and remains available in the + layer list for manual display. + +Run: + python polatory_lva_pyqt_app_v9_rerun_safe.py +""" + +from __future__ import annotations + +import gc +from pathlib import Path +from typing import Any + +import numpy as np + +import polatory + +import polatory_lva_pyqt_app_v8_category_contacts as v8 + +app = v8.app + +GENERATED_SURFACE = "Automatic LVA surface" +RESULT_LAYER_NAMES = ( + GENERATED_SURFACE, + "Automatic domain points", + "Automatic centroid domains", + "LVA field slices", + "LVA principal axes", +) +RESULT_SCALAR_BAR_TITLES = ( + "Automatic domain", + "LVA ratio", +) + +_original_window_init = app.MainWindow.__init__ +_original_add_layer = app.MainWindow._add_layer +_original_model_finished = app.MainWindow.model_finished + + +def result_only_add_layer( + self: Any, + name: str, + dataset: Any, + **kwargs: Any, +) -> Any: + """Create every non-result layer hidden and select only the generated mesh.""" + is_generated = name == GENERATED_SURFACE + kwargs["visible"] = bool(is_generated) + kwargs["select"] = bool(is_generated) + return _original_add_layer(self, name, dataset, **kwargs) + + +def _remove_result_scalar_bars(self: Any) -> None: + remove_scalar_bar = getattr(self.plotter, "remove_scalar_bar", None) + if not callable(remove_scalar_bar): + return + for title in RESULT_SCALAR_BAR_TITLES: + try: + remove_scalar_bar(title, render=False) + except TypeError: + try: + remove_scalar_bar(title) + except Exception: + pass + except Exception: + pass + + +def retire_previous_result(self: Any) -> None: + """Release the complete previous result before allocating the next one.""" + for name in RESULT_LAYER_NAMES: + self._remove_layer(name) + + _remove_result_scalar_bars(self) + + old_path = self.result_temp_obj + self.result_temp_obj = None + self.result_surface = None + + try: + self.plotter.render() + except Exception: + pass + + if old_path is not None: + path = Path(old_path) + if path.exists(): + try: + path.unlink() + except OSError: + pass + + gc.collect() + + +def show_only_generated_surface(self: Any) -> None: + """Apply the requested result visibility policy after every successful run.""" + for name, record in tuple(self.layers.items()): + visible = name == GENERATED_SURFACE + try: + record.actor.SetVisibility(visible) + except Exception: + continue + + matches = self.layer_list.findItems( + GENERATED_SURFACE, + app.QtCore.Qt.MatchFlag.MatchExactly, + ) + if matches: + self.layer_list.setCurrentItem(matches[0]) + + try: + self.plotter.render() + except Exception: + pass + + +def rerun_safe_window_init(self: Any) -> None: + _original_window_init(self) + + self._rerun_enable_timer = app.QtCore.QTimer(self) + self._rerun_enable_timer.setSingleShot(True) + self._rerun_enable_timer.timeout.connect( + lambda: self.run_button.setEnabled(self._thread is None) + ) + + self.run_button.setToolTip( + "Runs may be repeated after changing parameters. v9 releases the previous " + "worker, VTK result layers and temporary OBJ before starting the next run." + ) + + +def rerun_safe_thread_finished(self: Any) -> None: + """Clear references only after the worker thread has genuinely stopped.""" + self._worker = None + self._thread = None + self.progress_bar.setRange(0, 1) + self.progress_bar.setValue(0) + + self.run_button.setEnabled(False) + self._rerun_enable_timer.start(150) + + +def rerun_safe_model_finished(self: Any, result: dict[str, Any]) -> None: + _original_model_finished(self, result) + show_only_generated_surface(self) + + +def rerun_safe_run_model(self: Any) -> None: + if self._thread is not None: + app.QtWidgets.QMessageBox.information( + self, + app.APP_TITLE, + "A modelling run is already in progress.", + ) + return + + try: + if not hasattr(polatory, "AutomaticStructuralDomainBuilder3"): + raise RuntimeError( + "This installed Polatory package does not expose " + "AutomaticStructuralDomainBuilder3. Reinstall the " + "feature/automatic-subdomainer branch, then restart this app." + ) + if self.reference_vertices is None or self.reference_faces is None: + raise ValueError("Load the structural reference OBJ first.") + + points, indicators, roles, source_rows = self._mapped_arrays() + contact_count = int(np.count_nonzero(indicators == 0.0)) + if contact_count and self.nugget_spin.value() != 0.0: + self.nugget_spin.setValue(0.0) + self._log( + "Nugget was forced to 0 because Contact categories are active." + ) + + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + bbox_min, bbox_max = self.current_bbox() + parameters = self.model_parameters() + payload = { + "points": points.copy(), + "indicators": indicators.copy(), + "trend_vertices": self.reference_vertices.copy(), + "trend_faces": self.reference_faces.copy(), + "bbox_min": bbox_min.copy(), + "bbox_max": bbox_max.copy(), + "parameters": parameters, + } + + retire_previous_result(self) + + self._rerun_enable_timer.stop() + self.run_button.setEnabled(False) + self.progress_bar.setRange(0, 0) + self.tabs.setCurrentIndex(self.log_tab_index) + + if contact_count: + self._log( + f"Starting category-contact LVA model with {contact_count:,} exact " + "zero constraints…" + ) + else: + self._log("Starting category-based automatic LVA model…") + + thread = app.QtCore.QThread(self) + worker = app.ModelWorker(payload) + worker.moveToThread(thread) + + thread.started.connect(worker.run) + worker.progress.connect(self._log) + worker.finished.connect(self.model_finished) + worker.failed.connect(self.model_failed) + + worker.finished.connect(worker.deleteLater) + worker.failed.connect(worker.deleteLater) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + + thread.finished.connect(self._rerun_safe_thread_finished) + thread.finished.connect(thread.deleteLater) + + self._thread = thread + self._worker = worker + thread.start() + except Exception as error: + self._show_error("Could not start modelling", error) + + +app.MainWindow.__init__ = rerun_safe_window_init +app.MainWindow._add_layer = result_only_add_layer +app.MainWindow.model_finished = rerun_safe_model_finished +app.MainWindow.run_model = rerun_safe_run_model +app.MainWindow._rerun_safe_thread_finished = rerun_safe_thread_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_workbench.py b/examples/polatory_lva_workbench.py new file mode 100644 index 000000000..18c91caef --- /dev/null +++ b/examples/polatory_lva_workbench.py @@ -0,0 +1,469 @@ +"""Interactive Polatory structural-LVA workbench launcher.""" +from __future__ import annotations + +import os +import pickle +import sys +import tempfile +from pathlib import Path +from typing import Any + +import numpy as np +import polatory + +from polatory_lva_workbench_results import * + + +def _make_field_group(self: Any) -> QtWidgets.QGroupBox: + group = QtWidgets.QGroupBox("Point and orientation field") + layout = QtWidgets.QVBoxLayout(group) + + top = QtWidgets.QHBoxLayout() + load_button = QtWidgets.QPushButton("Load field CSV…") + load_button.clicked.connect(lambda: _load_field_csv(self)) + self.field_path_label = QtWidgets.QLabel("No field CSV loaded") + self.field_path_label.setWordWrap(True) + top.addWidget(load_button) + top.addWidget(self.field_path_label, 1) + layout.addLayout(top) + + mapping = QtWidgets.QGridLayout() + self.field_x_combo = QtWidgets.QComboBox() + self.field_y_combo = QtWidgets.QComboBox() + self.field_z_combo = QtWidgets.QComboBox() + self.field_dip_combo = QtWidgets.QComboBox() + self.field_azimuth_combo = QtWidgets.QComboBox() + self.field_vector_x_combo = QtWidgets.QComboBox() + self.field_vector_y_combo = QtWidgets.QComboBox() + self.field_vector_z_combo = QtWidgets.QComboBox() + widgets = ( + ("X", self.field_x_combo, 0, 0), + ("Y", self.field_y_combo, 0, 2), + ("Z", self.field_z_combo, 0, 4), + ("Dip", self.field_dip_combo, 1, 0), + ("Azimuth", self.field_azimuth_combo, 1, 2), + ("Vector X", self.field_vector_x_combo, 2, 0), + ("Vector Y", self.field_vector_y_combo, 2, 2), + ("Vector Z", self.field_vector_z_combo, 2, 4), + ) + for label, widget, row, column in widgets: + mapping.addWidget(QtWidgets.QLabel(label), row, column) + mapping.addWidget(widget, row, column + 1) + layout.addLayout(mapping) + + display = QtWidgets.QGridLayout() + self.field_mode_combo = QtWidgets.QComboBox() + self.field_mode_combo.addItems( + ["Points", "Scaled spheres", "Dip/Azimuth arrows", "Vector arrows"] + ) + self.field_scale_combo = QtWidgets.QComboBox() + self.field_scale_combo.addItem(CONSTANT) + self.field_color_combo = QtWidgets.QComboBox() + self.field_color_combo.addItem(UNIFORM) + self.field_cmap_combo = QtWidgets.QComboBox() + self.field_cmap_combo.addItems( + ["viridis", "plasma", "turbo", "coolwarm", "terrain"] + ) + self.field_glyph_factor_spin = QtWidgets.QDoubleSpinBox() + self.field_glyph_factor_spin.setRange(1.0e-9, 1.0e12) + self.field_glyph_factor_spin.setDecimals(6) + self.field_glyph_factor_spin.setValue(10.0) + self.field_point_size_spin = QtWidgets.QDoubleSpinBox() + self.field_point_size_spin.setRange(1.0, 100.0) + self.field_point_size_spin.setValue(8.0) + self.field_opacity_spin = QtWidgets.QDoubleSpinBox() + self.field_opacity_spin.setRange(0.0, 1.0) + self.field_opacity_spin.setSingleStep(0.05) + self.field_opacity_spin.setValue(1.0) + self.field_stride_spin = QtWidgets.QSpinBox() + self.field_stride_spin.setRange(1, 1_000_000) + self.field_stride_spin.setValue(1) + self.field_normalise_scale_check = QtWidgets.QCheckBox( + "Normalize scale to 0.2–1" + ) + self.field_normalise_scale_check.setChecked(True) + self.field_round_points_check = QtWidgets.QCheckBox("Round point sprites") + self.field_round_points_check.setChecked(True) + self.field_color_button = QtWidgets.QPushButton() + _set_button_colour(self.field_color_button, "#36a2eb") + self.field_color_button.clicked.connect( + lambda: _choose_colour(self, self.field_color_button) + ) + + display_rows = ( + ("Display mode", self.field_mode_combo, 0, 0), + ("Scale variable", self.field_scale_combo, 0, 2), + ("Colour variable", self.field_color_combo, 0, 4), + ("Glyph factor", self.field_glyph_factor_spin, 1, 0), + ("Point size", self.field_point_size_spin, 1, 2), + ("Opacity", self.field_opacity_spin, 1, 4), + ("Display stride", self.field_stride_spin, 2, 0), + ("Colormap", self.field_cmap_combo, 2, 2), + ("Uniform colour", self.field_color_button, 2, 4), + ) + for label, widget, row, column in display_rows: + display.addWidget(QtWidgets.QLabel(label), row, column) + display.addWidget(widget, row, column + 1) + display.addWidget(self.field_normalise_scale_check, 3, 0, 1, 3) + display.addWidget(self.field_round_points_check, 3, 3, 1, 3) + layout.addLayout(display) + + create_button = QtWidgets.QPushButton("Create field layer") + create_button.clicked.connect(lambda: _build_field_layer(self)) + layout.addWidget(create_button) + return group + + +def _make_domain_extent_group(self: Any) -> QtWidgets.QGroupBox: + group = QtWidgets.QGroupBox("Automatic domain extent") + layout = QtWidgets.QVBoxLayout(group) + + form = QtWidgets.QFormLayout() + self.domain_extent_mode_combo = QtWidgets.QComboBox() + self.domain_extent_mode_combo.addItem( + "Exact full-depth — extend lower Z to model minimum", + "full_depth", + ) + self.domain_extent_mode_combo.addItem( + "Finite automatic domains — no model-boundary extension", + "finite", + ) + self.domain_extent_mode_combo.setCurrentIndex(0) + self.domain_extent_mode_combo.setToolTip( + "Full-depth preserves the previous fold benchmark behaviour. Finite keeps each " + "automatic domain within its recovered local LVA support and is usually more " + "appropriate for closed intrusions and bounded geological bodies." + ) + form.addRow("Domain extent mode", self.domain_extent_mode_combo) + layout.addLayout(form) + + explanation = QtWidgets.QLabel( + "Exact full-depth extends only the lower Z face of every automatic domain to the " + "model minimum. Finite automatic domains keep the recovered local bounds. All " + "other LVA, RBF, support-completion and meshing settings remain unchanged, so the " + "two results can be compared directly." + ) + explanation.setWordWrap(True) + layout.addWidget(explanation) + return group + + +def _make_mesh_group(self: Any) -> QtWidgets.QGroupBox: + group = QtWidgets.QGroupBox("Comparison meshes and distance") + layout = QtWidgets.QVBoxLayout(group) + load_button = QtWidgets.QPushButton("Load comparison mesh…") + load_button.clicked.connect(lambda: _load_comparison_mesh(self)) + layout.addWidget(load_button) + + form = QtWidgets.QFormLayout() + self.compare_source_combo = QtWidgets.QComboBox() + self.compare_target_combo = QtWidgets.QComboBox() + form.addRow("Source mesh", self.compare_source_combo) + form.addRow("Target mesh", self.compare_target_combo) + layout.addLayout(form) + compare_button = QtWidgets.QPushButton("Calculate surface distances") + compare_button.clicked.connect(lambda: _compare_meshes(self)) + layout.addWidget(compare_button) + self.comparison_stats = QtWidgets.QPlainTextEdit() + self.comparison_stats.setReadOnly(True) + self.comparison_stats.setMaximumHeight(130) + layout.addWidget(self.comparison_stats) + + self.inside_only_filter_check = QtWidgets.QCheckBox( + "After modelling, remove disconnected components unsupported by input data" + ) + self.inside_only_filter_check.setChecked(False) + self.inside_only_filter_check.setToolTip( + "Off preserves the raw exact-leapfrog-lva-full-depth-sweep surface for direct " + "parity comparison. Enable only when a dataset creates a separate unsupported " + "outside shell that you intentionally want removed." + ) + layout.addWidget(self.inside_only_filter_check) + return group + + +def _make_layer_group(self: Any) -> QtWidgets.QGroupBox: + group = QtWidgets.QGroupBox("Selected layer appearance") + layout = QtWidgets.QVBoxLayout(group) + self.selected_layer_label = QtWidgets.QLabel("No layer selected") + self.selected_layer_label.setWordWrap(True) + layout.addWidget(self.selected_layer_label) + + form = QtWidgets.QFormLayout() + self.layer_visible_check = QtWidgets.QCheckBox("Visible") + self.layer_visible_check.setChecked(True) + self.layer_opacity_spin = QtWidgets.QDoubleSpinBox() + self.layer_opacity_spin.setRange(0.0, 1.0) + self.layer_opacity_spin.setSingleStep(0.05) + self.layer_representation_combo = QtWidgets.QComboBox() + self.layer_representation_combo.addItems( + ["Surface", "Surface + wireframe", "Wireframe", "Points"] + ) + self.layer_scalar_colors_check = QtWidgets.QCheckBox( + "Use active scalar colours (disable to apply the uniform colour)" + ) + self.layer_color_button = QtWidgets.QPushButton() + _set_button_colour(self.layer_color_button, "#ffffff") + self.layer_color_button.clicked.connect( + lambda: _choose_colour(self, self.layer_color_button) + ) + self.edge_color_button = QtWidgets.QPushButton() + _set_button_colour(self.edge_color_button, "#202020") + self.edge_color_button.clicked.connect( + lambda: _choose_colour(self, self.edge_color_button) + ) + self.layer_line_width_spin = QtWidgets.QDoubleSpinBox() + self.layer_line_width_spin.setRange(1.0, 20.0) + self.layer_line_width_spin.setValue(1.0) + self.layer_point_size_spin = QtWidgets.QDoubleSpinBox() + self.layer_point_size_spin.setRange(1.0, 100.0) + self.layer_point_size_spin.setValue(5.0) + + form.addRow(self.layer_visible_check) + form.addRow("Opacity", self.layer_opacity_spin) + form.addRow("Representation", self.layer_representation_combo) + form.addRow(self.layer_scalar_colors_check) + form.addRow("Solid / point colour", self.layer_color_button) + form.addRow("Wireframe colour", self.edge_color_button) + form.addRow("Wireframe width", self.layer_line_width_spin) + form.addRow("Point size", self.layer_point_size_spin) + layout.addLayout(form) + + buttons = QtWidgets.QHBoxLayout() + apply_button = QtWidgets.QPushButton("Apply appearance") + apply_button.clicked.connect(lambda: _apply_selected_layer_style(self)) + export_button = QtWidgets.QPushButton("Export selected…") + export_button.clicked.connect(lambda: _export_selected_layer(self)) + remove_button = QtWidgets.QPushButton("Remove imported layer") + remove_button.clicked.connect(lambda: _remove_selected_workbench_layer(self)) + buttons.addWidget(apply_button) + buttons.addWidget(export_button) + buttons.addWidget(remove_button) + layout.addLayout(buttons) + return group + + +def _apply_exact_defaults(self: Any) -> None: + defaults = ( + ("sill_spin", 100.0), + ("total_sill_spin", 100.0), + ("base_range_spin", 400.0), + ("nugget_spin", 0.0), + ("outside_value_spin", -1.0), + ("blend_power_spin", 1.0), + ("alignment_spin", 0.0), + ("centroid_count_spin", 6000), + ("minimum_fraction_spin", 0.001), + ("maximum_fraction_spin", 0.10), + ("consistency_spin", 0.60), + ("support_multiplier_spin", 5), + ("minimum_support_spin", 1), + ("max_iterations_spin", 100), + ("maximum_iterations_spin", 100), + ("poly_degree_spin", 0), + ("degree_spin", 0), + ) + for name, value in defaults: + widget = getattr(self, name, None) + setter = getattr(widget, "setValue", None) + if callable(setter): + try: + setter(value) + except (TypeError, OverflowError): + pass + + trend_type = getattr(self, "trend_type_combo", None) + if trend_type is not None: + index = trend_type.findText("Strongest along inputs") + if index >= 0: + trend_type.setCurrentIndex(index) + + +def _workbench_run_model(self: Any) -> None: + """Launch the selectable domain-extent worker rather than the older generic worker.""" + if v10._process_is_running(self): + QtWidgets.QMessageBox.information( + self, + app.APP_TITLE, + "A modelling run is already in progress.", + ) + return + + try: + if not hasattr(polatory, "AutomaticStructuralDomainBuilder3"): + raise RuntimeError( + "AutomaticStructuralDomainBuilder3 is unavailable. Reinstall the " + "feature/automatic-subdomainer branch and restart the app." + ) + if self.reference_vertices is None or self.reference_faces is None: + raise ValueError("Load the structural reference OBJ first.") + + points, indicators, roles, source_rows = self._mapped_arrays() + contact_count = int(np.count_nonzero(indicators == 0.0)) + if contact_count and self.nugget_spin.value() != 0.0: + self.nugget_spin.setValue(0.0) + self._log("Nugget was forced to 0 because Contact categories are active.") + + self.current_points = points + self.current_indicators = indicators + self.current_roles = roles + self.current_source_rows = source_rows + + bbox_min, bbox_max = self.current_bbox() + parameters = self.model_parameters() + payload = { + "points": points.copy(), + "indicators": indicators.copy(), + "trend_vertices": self.reference_vertices.copy(), + "trend_faces": self.reference_faces.copy(), + "bbox_min": bbox_min.copy(), + "bbox_max": bbox_max.copy(), + "parameters": parameters, + } + + v10.v9.retire_previous_result(self) + v10._cleanup_process_files(self, keep_obj=False) + + work_dir = Path(tempfile.mkdtemp(prefix="polatory_lva_exact_process_")) + input_path = work_dir / "payload.pkl" + result_path = work_dir / "result.pkl" + descriptor, output_obj_name = tempfile.mkstemp( + prefix="polatory_lva_exact_result_", + suffix=".obj", + ) + os.close(descriptor) + output_obj = Path(output_obj_name) + try: + output_obj.unlink() + except OSError: + pass + + with input_path.open("wb") as stream: + pickle.dump(payload, stream, protocol=pickle.HIGHEST_PROTOCOL) + + helper = Path(__file__).with_name("polatory_lva_workbench_exact_worker.py") + if not helper.exists(): + raise FileNotFoundError(f"Missing selectable domain worker script: {helper}") + + process = QtCore.QProcess(self) + process.setProcessChannelMode(QtCore.QProcess.ProcessChannelMode.MergedChannels) + environment = QtCore.QProcessEnvironment.systemEnvironment() + environment.insert("PYTHONUNBUFFERED", "1") + environment.insert("PYTHONIOENCODING", "utf-8") + domain_mode = "full_depth" + combo = getattr(self, "domain_extent_mode_combo", None) + if combo is not None: + selected = combo.currentData() + if selected in {"full_depth", "finite"}: + domain_mode = str(selected) + environment.insert("POLATORY_DOMAIN_EXTENT_MODE", domain_mode) + process.setProcessEnvironment(environment) + process.setProgram(sys.executable) + process.setArguments( + [ + str(helper), + "--input", + str(input_path), + "--result", + str(result_path), + "--obj", + str(output_obj), + ] + ) + process.readyReadStandardOutput.connect( + lambda: v10._read_process_output(self) + ) + process.errorOccurred.connect( + lambda error: v10._process_error(self, error) + ) + process.finished.connect( + lambda code, status: v10._process_finished(self, int(code), status) + ) + + self._model_process = process + self._model_process_work_dir = str(work_dir) + self._model_process_input = str(input_path) + self._model_process_result = str(result_path) + self._model_process_output_obj = str(output_obj) + self._model_process_output_buffer = "" + + self.run_button.setEnabled(False) + self.progress_bar.setRange(0, 0) + self.tabs.setCurrentIndex(self.log_tab_index) + if domain_mode == "finite": + extent_description = ( + "finite automatic domains with no model-boundary extension" + ) + else: + extent_description = ( + "exact full-depth domains with lower-Z extension to the model minimum" + ) + self._log( + "Starting modelling in an isolated process using " + f"{extent_description}: exact 4R LVA sampler, finite-geodesic domain " + "construction, background blending disabled, topology-local support " + "completion and global aligned-grid meshing." + ) + process.start() + except Exception as error: + v10._cleanup_process_files(self, keep_obj=False) + self.run_button.setEnabled(True) + self._show_error("Could not start structural LVA modelling", error) + + +def workbench_window_init(self: Any) -> None: + _original_window_init(self) + self.setWindowTitle("Polatory Structural LVA Workbench") + self._field_state = FieldState() + self._last_workbench_result: dict[str, Any] | None = None + _apply_exact_defaults(self) + + page = QtWidgets.QWidget() + page_layout = QtWidgets.QVBoxLayout(page) + intro = QtWidgets.QLabel( + "Choose Exact full-depth to preserve the previous " + "exact-leapfrog-lva-full-depth-sweep behaviour, or Finite automatic domains to " + "keep the recovered local domain bounds. For a direct benchmark match, use the " + "same input rows, role/sign mapping, structural OBJ, model extent, Strength, " + "Trend range, surface resolution and base RBF settings. Imported field and " + "comparison layers do not alter the model." + ) + intro.setWordWrap(True) + page_layout.addWidget(intro) + page_layout.addWidget(_make_domain_extent_group(self)) + page_layout.addWidget(_make_mesh_group(self)) + page_layout.addWidget(_make_field_group(self)) + page_layout.addWidget(_make_layer_group(self)) + page_layout.addStretch(1) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(page) + self.workbench_tab_index = self.tabs.addTab(scroll, "Workbench") + + self.layer_list.currentItemChanged.connect( + lambda _current, _previous: _sync_selected_layer_controls(self) + ) + self.layer_list.model().rowsInserted.connect( + lambda *_args: _refresh_layer_combos(self) + ) + self.layer_list.model().rowsRemoved.connect( + lambda *_args: _refresh_layer_combos(self) + ) + + _refresh_layer_combos(self) + self._log( + "Workbench loaded with selectable domain extent. Exact full-depth is the default " + "to preserve the stable benchmark; switch to Finite automatic domains for a " + "direct comparison. Raw result filtering remains off by default." + ) + + +app.MainWindow.__init__ = workbench_window_init +app.MainWindow._add_layer = workbench_add_layer +app.MainWindow.run_model = _workbench_run_model +v10._original_model_finished = _enhanced_process_model_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_workbench_common.py b/examples/polatory_lva_workbench_common.py new file mode 100644 index 000000000..3631a6aac --- /dev/null +++ b/examples/polatory_lva_workbench_common.py @@ -0,0 +1,452 @@ +"""Interactive Polatory structural-LVA modelling and visualization workbench. + +This launcher extends ``polatory_lva_pyqt_app_v11_leapfrog_defaults.py`` without +changing its process-isolated modelling path. It adds: + +* arbitrary comparison-mesh loading; +* point/orientation-field CSV loading; +* Dip/Azimuth or XYZ-vector arrows; +* variable-scaled point/sphere/arrow glyphs; +* mesh-to-mesh distance comparison; +* per-layer colour, opacity, representation, edge, line and point controls. + +The existing Data and Model tabs still provide arbitrary CSV coordinate/category +mapping, Inside/Outside/Contact/Ignore roles, structural reference OBJ loading, +Strength, Trend range, automatic domains, LVA field slices and cluster diagnostics. + +Run from the repository virtual environment:: + + python examples/polatory_lva_workbench.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pyvista as pv + +try: + from PyQt6 import QtCore, QtGui, QtWidgets +except ImportError as error: # pragma: no cover - deployment validation + raise SystemExit("Install PyQt6: python -m pip install PyQt6") from error + +try: + from scipy.spatial import cKDTree +except ImportError as error: # pragma: no cover - deployment validation + raise SystemExit("Install SciPy: python -m pip install scipy") from error + +import polatory_lva_pyqt_app_v11_leapfrog_defaults as v11 + +app = v11.app +v10 = v11.v10 + +UNIFORM = "" +CONSTANT = "" +NONE = "" + +WORKBENCH_PREFIXES = ("Comparison: ", "Field: ", "Distance: ") + +_base_window_init = app.MainWindow.__init__ +_original_add_layer = app.MainWindow._add_layer +_process_model_finished = v10._original_model_finished + + +@dataclass +class FieldState: + path: Path | None = None + frame: pd.DataFrame | None = None + + +def _current_plot_background_is_dark(window: Any) -> bool: + try: + red, green, blue = window.plotter.renderer.GetBackground() + luminance = 0.2126 * float(red) + 0.7152 * float(green) + 0.0722 * float(blue) + return luminance < 0.5 + except Exception: + return False + + +def _update_background_button(window: Any) -> None: + button = getattr(window, "background_toggle_button", None) + if button is None: + return + dark = bool(getattr(window, "_dark_plot_background", False)) + if dark: + button.setText("Switch to white background") + button.setStyleSheet( + "QPushButton { background: #f2f2f2; color: #111111; " + "border: 1px solid #777777; border-radius: 4px; padding: 5px 9px; }" + ) + else: + button.setText("Switch to black background") + button.setStyleSheet( + "QPushButton { background: #202020; color: #ffffff; " + "border: 1px solid #777777; border-radius: 4px; padding: 5px 9px; }" + ) + + +def _apply_plot_background(window: Any, dark: bool) -> None: + window._dark_plot_background = bool(dark) + window.plotter.set_background("black" if dark else "white") + _update_background_button(window) + window.plotter.render() + + +def _toggle_plot_background(window: Any) -> None: + _apply_plot_background( + window, + not bool(getattr(window, "_dark_plot_background", False)), + ) + + +def _install_background_toggle(window: Any) -> None: + """Place a compact black/white switch over the PyVista viewport.""" + parent = getattr(window.plotter, "interactor", None) + if parent is None or not isinstance(parent, QtWidgets.QWidget): + parent = window + + button = QtWidgets.QPushButton(parent) + button.setObjectName("pyvistaBackgroundToggle") + button.setFixedSize(190, 32) + button.move(12, 12) + button.setToolTip("Switch the PyVista viewport between black and white backgrounds.") + button.clicked.connect(lambda _checked=False: _toggle_plot_background(window)) + window.background_toggle_button = button + window._dark_plot_background = _current_plot_background_is_dark(window) + _update_background_button(window) + button.show() + button.raise_() + + +def _window_init_with_background_toggle(window: Any) -> None: + _base_window_init(window) + _install_background_toggle(window) + + +# ``polatory_lva_workbench.py`` calls this captured initializer before adding its +# own Workbench tab, so the viewport switch is available in every workbench run. +_original_window_init = _window_init_with_background_toggle + + +def _unique_name(window: Any, base: str) -> str: + if base not in window.layers: + return base + index = 2 + while f"{base} ({index})" in window.layers: + index += 1 + return f"{base} ({index})" + + +def _record_dataset(record: Any) -> pv.DataSet | pv.MultiBlock: + if isinstance(record, dict): + for key in ("dataset", "data", "mesh"): + if record.get(key) is not None: + return record[key] + for attribute in ("dataset", "data", "mesh"): + value = getattr(record, attribute, None) + if value is not None: + return value + raise ValueError("The selected layer does not expose its PyVista dataset.") + + +def _record_actor(record: Any) -> Any: + actor = record.get("actor") if isinstance(record, dict) else getattr(record, "actor", None) + if actor is None: + raise ValueError("The selected layer does not expose a VTK actor.") + return actor + + +def _actor_property(actor: Any) -> Any: + getter = getattr(actor, "GetProperty", None) + if callable(getter): + return getter() + prop = getattr(actor, "prop", None) + if prop is not None: + return prop + raise ValueError("The selected actor does not expose editable properties.") + + +def _hex_to_rgb(color: str) -> tuple[float, float, float]: + qcolor = QtGui.QColor(color) + if not qcolor.isValid(): + raise ValueError(f"Invalid colour: {color}") + return qcolor.redF(), qcolor.greenF(), qcolor.blueF() + + +def _set_button_colour(button: QtWidgets.QPushButton, color: str) -> None: + qcolor = QtGui.QColor(color) + if not qcolor.isValid(): + return + button.setProperty("selectedColor", qcolor.name()) + foreground = "#000000" if qcolor.lightnessF() > 0.55 else "#ffffff" + button.setStyleSheet( + f"QPushButton {{ background: {qcolor.name()}; color: {foreground}; }}" + ) + button.setText(qcolor.name()) + + +def _button_colour(button: QtWidgets.QPushButton, fallback: str) -> str: + value = button.property("selectedColor") + return str(value) if value else fallback + + +def _choose_colour(window: Any, button: QtWidgets.QPushButton) -> None: + initial = QtGui.QColor(_button_colour(button, "#ffffff")) + chosen = QtWidgets.QColorDialog.getColor(initial, window, "Choose layer colour") + if chosen.isValid(): + _set_button_colour(button, chosen.name()) + + +def _mesh_surface(dataset: Any) -> pv.PolyData: + if isinstance(dataset, pv.MultiBlock): + combined = dataset.combine() + else: + combined = dataset + surface = combined.extract_surface().triangulate().clean() + if surface.n_points == 0 or surface.n_cells == 0: + raise ValueError("The selected layer has no triangulated surface cells.") + return surface + + +def _numeric_columns(frame: pd.DataFrame) -> list[str]: + result: list[str] = [] + for column in frame.columns: + values = pd.to_numeric(frame[column], errors="coerce") + if int(values.notna().sum()) > 0: + result.append(str(column)) + return result + + +def _preferred(columns: list[str], candidates: tuple[str, ...]) -> str | None: + lower = {column.strip().casefold(): column for column in columns} + for candidate in candidates: + match = lower.get(candidate.casefold()) + if match is not None: + return match + return None + + +def _set_combo(combo: QtWidgets.QComboBox, value: str | None) -> None: + if value is None: + return + index = combo.findText(value) + if index >= 0: + combo.setCurrentIndex(index) + + +def dip_azimuth_vectors(dip: np.ndarray, azimuth: np.ndarray) -> np.ndarray: + """Convert geology dip/azimuth to unit down-dip vectors. + + Azimuth is clockwise from north (+Y). Dip is positive downward from horizontal, + therefore Z is negative for a positive dip in an elevation coordinate system. + """ + dip_radians = np.deg2rad(np.asarray(dip, dtype=float)) + azimuth_radians = np.deg2rad(np.asarray(azimuth, dtype=float)) + horizontal = np.cos(dip_radians) + vectors = np.column_stack( + [ + horizontal * np.sin(azimuth_radians), + horizontal * np.cos(azimuth_radians), + -np.sin(dip_radians), + ] + ) + lengths = np.linalg.norm(vectors, axis=1) + valid = lengths > 0.0 + vectors[valid] /= lengths[valid, None] + return vectors + + +def _normalised_scale(values: np.ndarray) -> np.ndarray: + values = np.abs(np.asarray(values, dtype=float)) + finite = np.isfinite(values) + if not np.any(finite): + return np.ones(len(values), dtype=np.float32) + low = float(np.nanpercentile(values[finite], 2.0)) + high = float(np.nanpercentile(values[finite], 98.0)) + if not high > low: + return np.ones(len(values), dtype=np.float32) + clipped = np.clip(values, low, high) + result = 0.2 + 0.8 * (clipped - low) / (high - low) + result[~finite] = 0.2 + return result.astype(np.float32) + + +def workbench_add_layer( + self: Any, + name: str, + dataset: Any, + **kwargs: Any, +) -> Any: + requested_visible = bool(kwargs.get("visible", True)) + requested_select = bool(kwargs.get("select", True)) + result = _original_add_layer(self, name, dataset, **kwargs) + + # v9 intentionally hides every non-generated modelling result. Workbench + # imports should honour the visibility requested by the user. + if name.startswith(WORKBENCH_PREFIXES): + record = self.layers.get(name) + if record is not None: + try: + _record_actor(record).SetVisibility(requested_visible) + except Exception: + pass + matches = self.layer_list.findItems( + name, + QtCore.Qt.MatchFlag.MatchExactly, + ) + if matches: + item = matches[0] + if item.flags() & QtCore.Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState( + QtCore.Qt.CheckState.Checked + if requested_visible + else QtCore.Qt.CheckState.Unchecked + ) + if requested_select: + self.layer_list.setCurrentItem(item) + try: + self.plotter.render() + except Exception: + pass + return result + + +def _selected_layer_name(self: Any) -> str | None: + item = self.layer_list.currentItem() + if item is None: + return None + text = item.text().strip() + return text if text in self.layers else None + + +def _refresh_layer_combos(self: Any) -> None: + previous_source = self.compare_source_combo.currentText() + previous_target = self.compare_target_combo.currentText() + surface_names: list[str] = [] + for name, record in self.layers.items(): + try: + surface = _mesh_surface(_record_dataset(record)) + except Exception: + continue + if surface.n_cells: + surface_names.append(name) + + for combo, previous in ( + (self.compare_source_combo, previous_source), + (self.compare_target_combo, previous_target), + ): + combo.blockSignals(True) + combo.clear() + combo.addItems(surface_names) + if previous in surface_names: + combo.setCurrentText(previous) + combo.blockSignals(False) + + generated = "Automatic LVA surface" + comparison_names = [name for name in surface_names if name.startswith("Comparison: ")] + if generated in surface_names: + self.compare_source_combo.setCurrentText(generated) + if comparison_names: + self.compare_target_combo.setCurrentText(comparison_names[-1]) + + +def _sync_selected_layer_controls(self: Any) -> None: + name = _selected_layer_name(self) + self.selected_layer_label.setText(name or "No layer selected") + if name is None: + return + try: + record = self.layers[name] + actor = _record_actor(record) + prop = _actor_property(actor) + self.layer_visible_check.setChecked(bool(actor.GetVisibility())) + self.layer_opacity_spin.setValue(float(prop.GetOpacity())) + self.layer_line_width_spin.setValue(float(prop.GetLineWidth())) + self.layer_point_size_spin.setValue(float(prop.GetPointSize())) + color = prop.GetColor() + color_hex = QtGui.QColor.fromRgbF(*[float(value) for value in color]).name() + _set_button_colour(self.layer_color_button, color_hex) + edge = prop.GetEdgeColor() + edge_hex = QtGui.QColor.fromRgbF(*[float(value) for value in edge]).name() + _set_button_colour(self.edge_color_button, edge_hex) + mapper = actor.GetMapper() if hasattr(actor, "GetMapper") else None + self.layer_scalar_colors_check.setChecked( + bool(mapper.GetScalarVisibility()) if mapper is not None else False + ) + representation = int(prop.GetRepresentation()) + edge_visible = bool(prop.GetEdgeVisibility()) + if representation == 1: + text = "Wireframe" + elif representation == 0: + text = "Points" + elif edge_visible: + text = "Surface + wireframe" + else: + text = "Surface" + self.layer_representation_combo.setCurrentText(text) + except Exception as error: + self._log(f"Could not read layer appearance for '{name}': {error}") + + +def _apply_selected_layer_style(self: Any) -> None: + name = _selected_layer_name(self) + if name is None: + return + try: + record = self.layers[name] + actor = _record_actor(record) + prop = _actor_property(actor) + actor.SetVisibility(self.layer_visible_check.isChecked()) + prop.SetOpacity(float(self.layer_opacity_spin.value())) + prop.SetColor(*_hex_to_rgb(_button_colour(self.layer_color_button, "#ffffff"))) + prop.SetEdgeColor(*_hex_to_rgb(_button_colour(self.edge_color_button, "#202020"))) + prop.SetLineWidth(float(self.layer_line_width_spin.value())) + prop.SetPointSize(float(self.layer_point_size_spin.value())) + mapper = actor.GetMapper() if hasattr(actor, "GetMapper") else None + if mapper is not None: + mapper.SetScalarVisibility(self.layer_scalar_colors_check.isChecked()) + + representation = self.layer_representation_combo.currentText() + if representation == "Wireframe": + prop.SetRepresentationToWireframe() + prop.SetEdgeVisibility(False) + elif representation == "Points": + prop.SetRepresentationToPoints() + prop.SetEdgeVisibility(False) + else: + prop.SetRepresentationToSurface() + prop.SetEdgeVisibility(representation == "Surface + wireframe") + + matches = self.layer_list.findItems(name, QtCore.Qt.MatchFlag.MatchExactly) + if matches and matches[0].flags() & QtCore.Qt.ItemFlag.ItemIsUserCheckable: + matches[0].setCheckState( + QtCore.Qt.CheckState.Checked + if self.layer_visible_check.isChecked() + else QtCore.Qt.CheckState.Unchecked + ) + self.plotter.render() + except Exception as error: + self._show_error("Could not update the selected layer", error) + + +def _remove_selected_workbench_layer(self: Any) -> None: + name = _selected_layer_name(self) + if name is None: + return + if not name.startswith(WORKBENCH_PREFIXES): + QtWidgets.QMessageBox.information( + self, + "Protected model layer", + "Only imported Workbench layers can be removed here. Model result layers " + "are replaced automatically by the next modelling run.", + ) + return + self._remove_layer(name) + _refresh_layer_combos(self) + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/examples/polatory_lva_workbench_exact_worker.py b/examples/polatory_lva_workbench_exact_worker.py new file mode 100644 index 000000000..cec83e1a5 --- /dev/null +++ b/examples/polatory_lva_workbench_exact_worker.py @@ -0,0 +1,131 @@ +"""Process worker for exact-support LVA with selectable domain extent. + +This worker deliberately reuses the same recovered production corrections as the +confirmed benchmark: + +* exact nearest-vertex, equal-face-normal LVA sampling with the hard 4R cutoff; +* finite LVA-geodesic automatic domains; +* ``background_blending=False``; +* topology-local, data-driven unsupported-branch completion; +* optional lower-Z-only full-depth domain extension to the user model minimum; +* globally aligned slab-streamed marching cubes. + +The model extent, dataset, reference mesh, strength, range and domain-extent mode come +from the GUI, so no WolfPass coordinates or case names are embedded here. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import polatory +import polatory.automatic_domain_builder as automatic_module +from polatory.labeled_domain_builder import sample_single_input_anisotropies3 + +ROOT = Path(__file__).resolve().parents[1] +BENCHMARK_MODULES = ROOT / "benchmarks" / "leapfrog_gold" +if str(BENCHMARK_MODULES) not in sys.path: + sys.path.insert(0, str(BENCHMARK_MODULES)) + +# Importing this benchmark module installs exactly the no-background-blending, +# topology-local support-completion wrapper and its calibration-before-meshing hook. +# It does not execute a benchmark because its main block is not entered. +import run_selected_no_background_blending_auto_support_decay_v2 as parity_support # noqa: E402,F401 +import polatory_lva_worker_process_v3 as worker # noqa: E402 + +# Make the exact recovered sampler explicit on both Python paths used by the +# automatic SubDomainer and the finite-geodesic propagation grid. +automatic_module.sample_single_input_anisotropies3 = sample_single_input_anisotropies3 +polatory.sample_single_input_anisotropies3 = sample_single_input_anisotropies3 + +_BASE_BUILDER = worker.FiniteLvaGeodesicAutomaticBuilder + + +class ExactFullDepthAutomaticBuilder: + """Apply only the confirmed lower-Z domain extension after normal clustering.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._wrapped = _BASE_BUILDER(*args, **kwargs) + + def build_from_inputs(self, *args: Any, **kwargs: Any): + original_domains = list(self._wrapped.build_from_inputs(*args, **kwargs)) + model_min_z = float(worker.v2._USER_BBOX_MIN[2]) + domains: list[Any] = [] + changed = 0 + + for domain in original_domains: + bbox_min = np.asarray(domain.bbox_min, dtype=np.float64).copy() + bbox_max = np.asarray(domain.bbox_max, dtype=np.float64).copy() + original_min_z = float(bbox_min[2]) + bbox_min[2] = min(original_min_z, model_min_z) + changed += int(bbox_min[2] < original_min_z) + domains.append( + polatory.StructuralDomain3( + np.asarray(domain.anisotropy, dtype=np.float64), + bbox_min, + bbox_max, + np.asarray(domain.support_indices, dtype=np.int64).tolist(), + np.asarray(domain.model_parameters, dtype=np.float64) + .reshape(-1) + .tolist(), + ) + ) + + print( + "PROGRESS\tExact full-depth mode: extended only the lower Z face of " + f"{changed}/{len(domains)} automatic domains to model Z={model_min_z:g}; " + "X/Y, upper Z, support memberships, anisotropy and local RBF parameters " + "are unchanged.", + flush=True, + ) + return domains + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _domain_extent_mode() -> str: + value = os.environ.get("POLATORY_DOMAIN_EXTENT_MODE", "full_depth") + normalized = str(value).strip().casefold().replace("-", "_").replace(" ", "_") + aliases = { + "full": "full_depth", + "full_depth": "full_depth", + "exact": "full_depth", + "exact_full_depth": "full_depth", + "finite": "finite", + "finite_domains": "finite", + "finite_automatic_domains": "finite", + } + try: + return aliases[normalized] + except KeyError as error: + raise ValueError( + "POLATORY_DOMAIN_EXTENT_MODE must be 'full_depth' or 'finite', " + f"not {value!r}." + ) from error + + +def main() -> int: + mode = _domain_extent_mode() + if mode == "finite": + worker.FiniteLvaGeodesicAutomaticBuilder = _BASE_BUILDER + print( + "PROGRESS\tDomain extent mode: Finite automatic domains. Recovered local " + "LVA-geodesic bounds are retained; no face is extended to the model boundary.", + flush=True, + ) + else: + worker.FiniteLvaGeodesicAutomaticBuilder = ExactFullDepthAutomaticBuilder + print( + "PROGRESS\tDomain extent mode: Exact full-depth. Lower Z faces may be " + "extended to the model minimum after finite-domain construction.", + flush=True, + ) + return worker.main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/polatory_lva_workbench_fields.py b/examples/polatory_lva_workbench_fields.py new file mode 100644 index 000000000..77bae4dac --- /dev/null +++ b/examples/polatory_lva_workbench_fields.py @@ -0,0 +1,333 @@ +"""Comparison-mesh and arbitrary field visualization actions.""" +from polatory_lva_workbench_common import * + +def _load_comparison_mesh(self: Any) -> None: + path_text, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Load comparison mesh", + "", + "Surface meshes (*.obj *.stl *.ply *.vtk *.vtp);;All files (*)", + ) + if not path_text: + return + try: + path = Path(path_text) + mesh = pv.read(path).extract_surface().triangulate().clean() + if mesh.n_points == 0 or mesh.n_cells == 0: + raise ValueError("The selected file contains no usable surface cells.") + name = _unique_name(self, f"Comparison: {path.stem}") + self._add_layer( + name, + mesh, + kind="mesh", + color="#ff8c42", + opacity=0.55, + show_edges=True, + visible=True, + select=True, + ) + self._log( + f"Loaded comparison mesh '{path.name}': {mesh.n_points:,} vertices, " + f"{mesh.n_cells:,} triangles." + ) + _refresh_layer_combos(self) + self.plotter.reset_camera() + except Exception as error: + self._show_error("Could not load comparison mesh", error) + + +def _compare_meshes(self: Any) -> None: + source_name = self.compare_source_combo.currentText() + target_name = self.compare_target_combo.currentText() + if not source_name or not target_name or source_name == target_name: + QtWidgets.QMessageBox.information( + self, + "Select two meshes", + "Choose two different surface layers to compare.", + ) + return + try: + source = _mesh_surface(_record_dataset(self.layers[source_name])) + target = _mesh_surface(_record_dataset(self.layers[target_name])) + + source_distance = source.copy(deep=True) + source_distance.compute_implicit_distance(target, inplace=True) + forward = np.abs(np.asarray(source_distance["implicit_distance"], dtype=float)) + source_distance["Distance"] = forward + + target_distance = target.copy(deep=True) + target_distance.compute_implicit_distance(source, inplace=True) + reverse = np.abs(np.asarray(target_distance["implicit_distance"], dtype=float)) + symmetric = np.concatenate([forward, reverse]) + + name = _unique_name(self, f"Distance: {source_name} to {target_name}") + self._add_layer( + name, + source_distance, + kind="mesh", + scalars="Distance", + cmap="viridis", + opacity=1.0, + show_edges=False, + visible=True, + select=True, + ) + stats = { + "forward mean": float(np.mean(forward)), + "forward median": float(np.median(forward)), + "forward p95": float(np.percentile(forward, 95.0)), + "reverse mean": float(np.mean(reverse)), + "symmetric mean": float(np.mean(symmetric)), + "symmetric p95": float(np.percentile(symmetric, 95.0)), + "symmetric maximum": float(np.max(symmetric)), + } + self.comparison_stats.setPlainText( + "\n".join(f"{key}: {value:.6g}" for key, value in stats.items()) + ) + self._log( + f"Compared '{source_name}' against '{target_name}': symmetric mean " + f"{stats['symmetric mean']:.6g}, p95 {stats['symmetric p95']:.6g}." + ) + _refresh_layer_combos(self) + except Exception as error: + self._show_error("Could not compare the selected meshes", error) + + +def _load_field_csv(self: Any) -> None: + path_text, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Load point or orientation field", + "", + "CSV files (*.csv);;All files (*)", + ) + if not path_text: + return + try: + path = Path(path_text) + frame = pd.read_csv(path) + if len(frame) == 0: + raise ValueError("The selected CSV contains no rows.") + self._field_state = FieldState(path=path, frame=frame) + columns = [str(column) for column in frame.columns] + numeric = _numeric_columns(frame) + + for combo in ( + self.field_x_combo, + self.field_y_combo, + self.field_z_combo, + self.field_dip_combo, + self.field_azimuth_combo, + self.field_vector_x_combo, + self.field_vector_y_combo, + self.field_vector_z_combo, + ): + combo.clear() + combo.addItem(NONE) + combo.addItems(columns) + + self.field_scale_combo.clear() + self.field_scale_combo.addItem(CONSTANT) + self.field_scale_combo.addItems(numeric) + self.field_color_combo.clear() + self.field_color_combo.addItem(UNIFORM) + self.field_color_combo.addItems(numeric) + + _set_combo(self.field_x_combo, _preferred(columns, ("X", "xm", "easting", "east"))) + _set_combo(self.field_y_combo, _preferred(columns, ("Y", "ym", "northing", "north"))) + _set_combo(self.field_z_combo, _preferred(columns, ("Z", "zm", "elevation", "rl"))) + _set_combo(self.field_dip_combo, _preferred(columns, ("Dip", "dip_deg", "dip degrees"))) + _set_combo( + self.field_azimuth_combo, + _preferred(columns, ("Azimuth", "azimuth_deg", "bearing", "dip direction")), + ) + _set_combo(self.field_vector_x_combo, _preferred(columns, ("vx", "nx", "vector_x"))) + _set_combo(self.field_vector_y_combo, _preferred(columns, ("vy", "ny", "vector_y"))) + _set_combo(self.field_vector_z_combo, _preferred(columns, ("vz", "nz", "vector_z"))) + + self.field_path_label.setText(str(path)) + self._log( + f"Loaded field CSV '{path.name}': {len(frame):,} rows, " + f"{len(columns):,} columns." + ) + except Exception as error: + self._show_error("Could not load point/orientation field", error) + + +def _selected_column(combo: QtWidgets.QComboBox) -> str | None: + value = combo.currentText().strip() + return None if not value or value in {NONE, CONSTANT, UNIFORM} else value + + +def _field_arrays(self: Any) -> tuple[pd.DataFrame, np.ndarray, np.ndarray]: + frame = self._field_state.frame + if frame is None: + raise ValueError("Load a field CSV first.") + coordinate_columns = [ + _selected_column(self.field_x_combo), + _selected_column(self.field_y_combo), + _selected_column(self.field_z_combo), + ] + if any(column is None for column in coordinate_columns): + raise ValueError("Map valid X, Y and Z columns.") + coordinates = frame[list(coordinate_columns)].apply(pd.to_numeric, errors="coerce") + points = coordinates.to_numpy(dtype=float) + finite = np.all(np.isfinite(points), axis=1) + stride = int(self.field_stride_spin.value()) + selected = np.flatnonzero(finite)[::stride] + if len(selected) == 0: + raise ValueError("No finite mapped coordinates remain after filtering.") + return frame, points[selected], selected + + +def _build_field_layer(self: Any) -> None: + try: + frame, points, rows = _field_arrays(self) + mode = self.field_mode_combo.currentText() + cloud = pv.PolyData(points) + + numeric_columns = _numeric_columns(frame) + for column in numeric_columns: + values = pd.to_numeric(frame[column], errors="coerce").to_numpy(dtype=float)[rows] + if np.any(np.isfinite(values)): + cloud[column] = values.astype(np.float32) + + scale_column = _selected_column(self.field_scale_combo) + if scale_column is None: + glyph_scale = np.ones(len(points), dtype=np.float32) + else: + raw_scale = pd.to_numeric(frame[scale_column], errors="coerce").to_numpy(dtype=float)[rows] + glyph_scale = ( + _normalised_scale(raw_scale) + if self.field_normalise_scale_check.isChecked() + else np.nan_to_num(np.abs(raw_scale), nan=0.0).astype(np.float32) + ) + cloud["Glyph scale"] = glyph_scale + + color_column = _selected_column(self.field_color_combo) + layer_dataset: pv.DataSet = cloud + kind = "points" + kwargs: dict[str, Any] = { + "color": _button_colour(self.field_color_button, "#36a2eb"), + "opacity": float(self.field_opacity_spin.value()), + "visible": True, + "select": True, + } + if color_column is not None and color_column in cloud.array_names: + kwargs.pop("color", None) + kwargs["scalars"] = color_column + kwargs["cmap"] = self.field_cmap_combo.currentText() + + if mode == "Points": + kwargs["point_size"] = float(self.field_point_size_spin.value()) + kwargs["render_points_as_spheres"] = self.field_round_points_check.isChecked() + elif mode == "Scaled spheres": + if len(points) > 150_000: + raise ValueError( + "Scaled-sphere glyphs are limited to 150,000 displayed rows. " + "Increase the display stride." + ) + sphere = pv.Sphere(theta_resolution=10, phi_resolution=10, radius=1.0) + layer_dataset = cloud.glyph( + scale="Glyph scale", + geom=sphere, + factor=float(self.field_glyph_factor_spin.value()), + ) + kind = "mesh" + else: + if mode == "Dip/Azimuth arrows": + dip_column = _selected_column(self.field_dip_combo) + azimuth_column = _selected_column(self.field_azimuth_combo) + if dip_column is None or azimuth_column is None: + raise ValueError("Map both Dip and Azimuth columns.") + dip = pd.to_numeric(frame[dip_column], errors="coerce").to_numpy(dtype=float)[rows] + azimuth = pd.to_numeric(frame[azimuth_column], errors="coerce").to_numpy(dtype=float)[rows] + valid = np.isfinite(dip) & np.isfinite(azimuth) + if not np.all(valid): + cloud = cloud.extract_points(valid, adjacent_cells=False) + dip = dip[valid] + azimuth = azimuth[valid] + vectors = dip_azimuth_vectors(dip, azimuth) + cloud["Dip"] = dip.astype(np.float32) + cloud["Azimuth"] = azimuth.astype(np.float32) + else: + vector_columns = [ + _selected_column(self.field_vector_x_combo), + _selected_column(self.field_vector_y_combo), + _selected_column(self.field_vector_z_combo), + ] + if any(column is None for column in vector_columns): + raise ValueError("Map all three vector-component columns.") + vectors = frame[list(vector_columns)].apply( + pd.to_numeric, errors="coerce" + ).to_numpy(dtype=float)[rows] + valid = np.all(np.isfinite(vectors), axis=1) + if not np.all(valid): + cloud = cloud.extract_points(valid, adjacent_cells=False) + vectors = vectors[valid] + lengths = np.linalg.norm(vectors, axis=1) + valid_length = lengths > 0.0 + vectors[valid_length] /= lengths[valid_length, None] + cloud["Direction"] = vectors.astype(np.float32) + arrow = pv.Arrow( + tip_length=0.25, + tip_radius=0.10, + shaft_radius=0.025, + ) + layer_dataset = cloud.glyph( + orient="Direction", + scale="Glyph scale", + factor=float(self.field_glyph_factor_spin.value()), + geom=arrow, + ) + kind = "mesh" + + source_name = self._field_state.path.stem if self._field_state.path else "field" + name = _unique_name(self, f"Field: {source_name} - {mode}") + self._add_layer(name, layer_dataset, kind=kind, **kwargs) + self._log( + f"Created '{name}' from {len(points):,} displayed rows; " + f"mode={mode}, scale={scale_column or 'constant'}, " + f"colour={color_column or 'uniform'}." + ) + _refresh_layer_combos(self) + except Exception as error: + self._show_error("Could not create field visualization", error) + + +def _export_selected_layer(self: Any) -> None: + name = _selected_layer_name(self) + if name is None: + return + record = self.layers[name] + dataset = _record_dataset(record) + path_text, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export selected layer", + name.replace(":", "_").replace(" ", "_") + ".vtp", + "VTK PolyData (*.vtp);;Wavefront OBJ (*.obj);;CSV points (*.csv)", + ) + if not path_text: + return + try: + path = Path(path_text) + if path.suffix.casefold() == ".csv": + surface = dataset.combine() if isinstance(dataset, pv.MultiBlock) else dataset + data: dict[str, Any] = { + "x": np.asarray(surface.points)[:, 0], + "y": np.asarray(surface.points)[:, 1], + "z": np.asarray(surface.points)[:, 2], + } + for array_name in surface.point_data.keys(): + array = np.asarray(surface.point_data[array_name]) + if array.ndim == 1 and len(array) == surface.n_points: + data[str(array_name)] = array + pd.DataFrame(data).to_csv(path, index=False) + else: + surface = _mesh_surface(dataset) + surface.save(path) + self._log(f"Exported layer '{name}' to {path}.") + except Exception as error: + self._show_error("Could not export selected layer", error) + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/examples/polatory_lva_workbench_partitions.py b/examples/polatory_lva_workbench_partitions.py new file mode 100644 index 000000000..a58225f65 --- /dev/null +++ b/examples/polatory_lva_workbench_partitions.py @@ -0,0 +1,512 @@ +"""Polatory structural-LVA Workbench with an interactive partition explorer. + +Run from the repository virtual environment:: + + python examples/polatory_lva_workbench_partitions.py + +This launcher keeps every feature of ``polatory_lva_workbench.py`` and adds a +Partitions tab after each successful modelling run. Final automatic partitions +can be displayed together or isolated one at a time. Each partition contains +three independently toggleable diagnostics: + +* modelling input points assigned to that partition; +* centroid-grid cells assigned to that partition; +* an axis-aligned diagnostic envelope around the displayed partition points. + +The generated implicit surface is a blend of the structural domains, so it is +not split into mutually exclusive per-partition surface pieces. The explorer +shows the actual point and centroid assignments used by the automatic +SubDomainer and is intended for diagnosing partition edges and support coverage. +""" +from __future__ import annotations + +from typing import Any + +import numpy as np +import pyvista as pv + +# Importing the normal Workbench first installs its exact/finite domain selector, +# isolated worker, component filter, comparison tools and PyVista background switch. +import polatory_lva_workbench as workbench +from polatory_lva_workbench_common import QtCore, QtGui, QtWidgets, app, v10 + + +_USER_ROLE = QtCore.Qt.ItemDataRole.UserRole +_CHECKED = QtCore.Qt.CheckState.Checked +_PARTIAL = QtCore.Qt.CheckState.PartiallyChecked +_UNCHECKED = QtCore.Qt.CheckState.Unchecked + +_previous_window_init = app.MainWindow.__init__ +_previous_process_model_finished = v10._original_model_finished +_previous_run_model = app.MainWindow.run_model + + +def _partition_colour(index: int, count: int) -> str: + """Return a stable, well-separated Qt colour for one partition.""" + count = max(int(count), 1) + hue = int(round((359.0 * int(index)) / count)) % 360 + colour = QtGui.QColor.fromHsv(hue, 185, 215) + return colour.name() + + +def _checkable(item: QtWidgets.QTreeWidgetItem) -> None: + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, _UNCHECKED) + + +def _actor_visibility(actor: Any, visible: bool) -> None: + try: + actor.SetVisibility(bool(visible)) + return + except Exception: + pass + try: + actor.visibility = bool(visible) + except Exception: + pass + + +def _remove_partition_actors(self: Any) -> None: + groups = getattr(self, "_partition_actor_groups", {}) + for components in groups.values(): + for actors in components.values(): + for actor in actors: + try: + self.plotter.remove_actor(actor, render=False) + except TypeError: + try: + self.plotter.remove_actor(actor) + except Exception: + pass + except Exception: + pass + self._partition_actor_groups = {} + try: + self.plotter.render() + except Exception: + pass + + +def _add_point_actor( + self: Any, + points: np.ndarray, + *, + colour: str, + point_size: float, + opacity: float, + name: str, +) -> Any | None: + points = np.asarray(points, dtype=float) + if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0: + return None + data = pv.PolyData(points) + actor = self.plotter.add_mesh( + data, + style="points", + color=colour, + point_size=float(point_size), + opacity=float(opacity), + render_points_as_spheres=True, + show_scalar_bar=False, + name=name, + pickable=False, + render=False, + ) + _actor_visibility(actor, False) + return actor + + +def _add_envelope_actor( + self: Any, + points: np.ndarray, + *, + colour: str, + name: str, +) -> Any | None: + points = np.asarray(points, dtype=float) + if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0: + return None + + minimum = np.min(points, axis=0) + maximum = np.max(points, axis=0) + diagonal = float(np.linalg.norm(maximum - minimum)) + padding = max(1.0e-6 * max(diagonal, 1.0), 1.0e-6) + flat = maximum <= minimum + minimum[flat] -= padding + maximum[flat] += padding + + box = pv.Box( + bounds=( + float(minimum[0]), + float(maximum[0]), + float(minimum[1]), + float(maximum[1]), + float(minimum[2]), + float(maximum[2]), + ) + ) + actor = self.plotter.add_mesh( + box, + style="wireframe", + color=colour, + line_width=2.0, + opacity=0.7, + show_scalar_bar=False, + name=name, + pickable=False, + render=False, + ) + _actor_visibility(actor, False) + return actor + + +def _partition_metadata(item: QtWidgets.QTreeWidgetItem) -> tuple[Any, ...]: + value = item.data(0, _USER_ROLE) + return tuple(value) if isinstance(value, (tuple, list)) else tuple() + + +def _set_component_visible(self: Any, label: int, component: str, visible: bool) -> None: + groups = getattr(self, "_partition_actor_groups", {}) + for actor in groups.get(int(label), {}).get(str(component), []): + _actor_visibility(actor, visible) + + +def _apply_partition_tree_state( + self: Any, + item: QtWidgets.QTreeWidgetItem, + state: QtCore.Qt.CheckState, +) -> None: + """Apply a check state recursively and update the mapped PyVista actors.""" + item.setCheckState(0, state) + metadata = _partition_metadata(item) + kind = metadata[0] if metadata else "" + + if kind == "component": + _set_component_visible(self, int(metadata[1]), str(metadata[2]), state == _CHECKED) + return + + for index in range(item.childCount()): + _apply_partition_tree_state(self, item.child(index), state) + + +def _aggregate_child_state(item: QtWidgets.QTreeWidgetItem) -> QtCore.Qt.CheckState: + if item.childCount() == 0: + return item.checkState(0) + states = [item.child(index).checkState(0) for index in range(item.childCount())] + if all(state == _CHECKED for state in states): + return _CHECKED + if all(state == _UNCHECKED for state in states): + return _UNCHECKED + return _PARTIAL + + +def _sync_partition_parent_states(self: Any, item: QtWidgets.QTreeWidgetItem | None) -> None: + while item is not None: + item.setCheckState(0, _aggregate_child_state(item)) + item = item.parent() + + +def _partition_item_changed( + self: Any, + item: QtWidgets.QTreeWidgetItem, + column: int, +) -> None: + if column != 0 or bool(getattr(self, "_partition_tree_guard", False)): + return + state = item.checkState(0) + if state == _PARTIAL: + return + + self._partition_tree_guard = True + try: + _apply_partition_tree_state(self, item, state) + _sync_partition_parent_states(self, item.parent()) + finally: + self._partition_tree_guard = False + try: + self.plotter.render() + except Exception: + pass + + +def _partition_label_from_item(item: QtWidgets.QTreeWidgetItem | None) -> int | None: + while item is not None: + metadata = _partition_metadata(item) + if metadata and metadata[0] in {"partition", "component"}: + return int(metadata[1]) + item = item.parent() + return None + + +def _show_all_partitions(self: Any) -> None: + root = getattr(self, "partition_root_item", None) + if root is None: + return + self._partition_tree_guard = True + try: + _apply_partition_tree_state(self, root, _CHECKED) + finally: + self._partition_tree_guard = False + self.plotter.render() + + +def _hide_all_partitions(self: Any) -> None: + root = getattr(self, "partition_root_item", None) + if root is None: + return + self._partition_tree_guard = True + try: + _apply_partition_tree_state(self, root, _UNCHECKED) + finally: + self._partition_tree_guard = False + self.plotter.render() + + +def _isolate_selected_partition(self: Any) -> None: + item = self.partition_tree.currentItem() + label = _partition_label_from_item(item) + if label is None: + QtWidgets.QMessageBox.information( + self, + "Select a partition", + "Select a Partition item or one of its diagnostic children first.", + ) + return + + _hide_all_partitions(self) + partition_item = self._partition_items.get(int(label)) + if partition_item is None: + return + self._partition_tree_guard = True + try: + _apply_partition_tree_state(self, partition_item, _CHECKED) + _sync_partition_parent_states(self, partition_item.parent()) + finally: + self._partition_tree_guard = False + self.partition_tree.setCurrentItem(partition_item) + partition_item.setExpanded(True) + self.plotter.render() + + +def _build_partition_explorer(self: Any, result: dict[str, Any]) -> None: + _remove_partition_actors(self) + self._partition_tree_guard = True + try: + self.partition_tree.clear() + self._partition_items = {} + + data_points = np.asarray( + getattr(self, "current_points", np.empty((0, 3))), + dtype=float, + ) + data_labels = np.asarray(result.get("labels", []), dtype=np.int64) + centroid_points = np.asarray(result.get("centroid_points", []), dtype=float) + centroid_labels = np.asarray(result.get("centroid_labels", []), dtype=np.int64) + + if data_points.ndim != 2 or data_points.shape[1] != 3: + data_points = np.empty((0, 3), dtype=float) + if centroid_points.ndim != 2 or centroid_points.shape[1] != 3: + centroid_points = np.empty((0, 3), dtype=float) + if data_labels.shape != (len(data_points),): + data_labels = np.empty(0, dtype=np.int64) + if centroid_labels.shape != (len(centroid_points),): + centroid_labels = np.empty(0, dtype=np.int64) + + labels: set[int] = set() + labels.update(int(value) for value in np.unique(data_labels) if int(value) >= 0) + labels.update(int(value) for value in np.unique(centroid_labels) if int(value) >= 0) + ordered_labels = sorted(labels) + + root = QtWidgets.QTreeWidgetItem( + [f"Partitions ({len(ordered_labels)})", "", ""] + ) + root.setData(0, _USER_ROLE, ("root",)) + _checkable(root) + self.partition_tree.addTopLevelItem(root) + self.partition_root_item = root + + for colour_index, label in enumerate(ordered_labels): + colour = _partition_colour(colour_index, len(ordered_labels)) + owned_data = ( + data_points[data_labels == label] + if len(data_labels) + else np.empty((0, 3), dtype=float) + ) + owned_centroids = ( + centroid_points[centroid_labels == label] + if len(centroid_labels) + else np.empty((0, 3), dtype=float) + ) + envelope_points = np.vstack( + [part for part in (owned_data, owned_centroids) if len(part)] + ) if len(owned_data) or len(owned_centroids) else np.empty((0, 3), dtype=float) + + partition_item = QtWidgets.QTreeWidgetItem( + [ + f"Partition {label + 1}", + f"{len(owned_data):,}", + f"{len(owned_centroids):,}", + ] + ) + partition_item.setData(0, _USER_ROLE, ("partition", int(label))) + partition_item.setToolTip( + 0, + "Final automatic SubDomainer partition used by the structural RBF fit.", + ) + partition_item.setForeground(0, QtGui.QBrush(QtGui.QColor(colour))) + _checkable(partition_item) + root.addChild(partition_item) + self._partition_items[int(label)] = partition_item + + components = ( + ("data", "Input points", len(owned_data)), + ("centroids", "Centroid partition cells", len(owned_centroids)), + ("envelope", "Diagnostic envelope", len(envelope_points)), + ) + for component, title, count in components: + child = QtWidgets.QTreeWidgetItem([title, f"{count:,}", ""]) + child.setData( + 0, + _USER_ROLE, + ("component", int(label), str(component)), + ) + _checkable(child) + partition_item.addChild(child) + + data_actor = _add_point_actor( + self, + owned_data, + colour=colour, + point_size=11.0, + opacity=0.95, + name=f"partition_{label}_data", + ) + centroid_actor = _add_point_actor( + self, + owned_centroids, + colour=colour, + point_size=5.0, + opacity=0.55, + name=f"partition_{label}_centroids", + ) + envelope_actor = _add_envelope_actor( + self, + envelope_points, + colour=colour, + name=f"partition_{label}_envelope", + ) + self._partition_actor_groups[int(label)] = { + "data": [actor for actor in (data_actor,) if actor is not None], + "centroids": [actor for actor in (centroid_actor,) if actor is not None], + "envelope": [actor for actor in (envelope_actor,) if actor is not None], + } + + root.setExpanded(True) + self.partition_tree.resizeColumnToContents(0) + self.partition_tree.resizeColumnToContents(1) + self.partition_tree.resizeColumnToContents(2) + self.partition_summary_label.setText( + f"{len(ordered_labels):,} final partitions; " + f"{len(data_points):,} assigned input points; " + f"{len(centroid_points):,} assigned centroid cells." + ) + finally: + self._partition_tree_guard = False + + self._log( + "Partition explorer updated. Expand Partitions, then check one partition or the " + "root item to display all final automatic partition diagnostics." + ) + try: + self.plotter.render() + except Exception: + pass + + +def _make_partition_tab(self: Any) -> None: + page = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(page) + + explanation = QtWidgets.QLabel( + "These are the final automatic SubDomainer partitions used by the generated " + "structural RBF. Expand a partition to toggle its assigned input points, centroid " + "cells and diagnostic envelope. Check the root Partitions item to plot all of " + "them together. The final implicit mesh is blended across domains and therefore " + "is not divided into exclusive per-partition surface pieces." + ) + explanation.setWordWrap(True) + layout.addWidget(explanation) + + self.partition_summary_label = QtWidgets.QLabel("Run a model to populate partitions.") + self.partition_summary_label.setWordWrap(True) + layout.addWidget(self.partition_summary_label) + + buttons = QtWidgets.QHBoxLayout() + show_all = QtWidgets.QPushButton("Show all") + hide_all = QtWidgets.QPushButton("Hide all") + isolate = QtWidgets.QPushButton("Isolate selected") + show_all.clicked.connect(lambda: _show_all_partitions(self)) + hide_all.clicked.connect(lambda: _hide_all_partitions(self)) + isolate.clicked.connect(lambda: _isolate_selected_partition(self)) + buttons.addWidget(show_all) + buttons.addWidget(hide_all) + buttons.addWidget(isolate) + buttons.addStretch(1) + layout.addLayout(buttons) + + self.partition_tree = QtWidgets.QTreeWidget() + self.partition_tree.setHeaderLabels(["Partition / diagnostic", "Input", "Centroids"]) + self.partition_tree.setAlternatingRowColors(True) + self.partition_tree.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.SingleSelection + ) + self.partition_tree.itemChanged.connect( + lambda item, column: _partition_item_changed(self, item, int(column)) + ) + self.partition_tree.itemDoubleClicked.connect( + lambda _item, _column: _isolate_selected_partition(self) + ) + layout.addWidget(self.partition_tree, 1) + + self.partition_tab_index = self.tabs.addTab(page, "Partitions") + + +def partition_window_init(self: Any) -> None: + _previous_window_init(self) + self._partition_actor_groups: dict[int, dict[str, list[Any]]] = {} + self._partition_items: dict[int, QtWidgets.QTreeWidgetItem] = {} + self._partition_tree_guard = False + self.partition_root_item: QtWidgets.QTreeWidgetItem | None = None + _make_partition_tab(self) + self._log( + "Partition-explorer launcher loaded. A successful modelling run will populate " + "a nested Partitions tree for one-by-one or all-partition plotting." + ) + + +def partition_run_model(self: Any) -> None: + _remove_partition_actors(self) + tree = getattr(self, "partition_tree", None) + if tree is not None: + tree.clear() + summary = getattr(self, "partition_summary_label", None) + if summary is not None: + summary.setText("Modelling is running; partitions will appear after completion.") + _previous_run_model(self) + + +def partition_process_model_finished(self: Any, result: dict[str, Any]) -> None: + _previous_process_model_finished(self, result) + try: + _build_partition_explorer(self, result) + except Exception as error: + self._log(f"Partition explorer could not be populated: {error}") + + +app.MainWindow.__init__ = partition_window_init +app.MainWindow.run_model = partition_run_model +v10._original_model_finished = partition_process_model_finished + + +if __name__ == "__main__": + raise SystemExit(app.main()) diff --git a/examples/polatory_lva_workbench_results.py b/examples/polatory_lva_workbench_results.py new file mode 100644 index 000000000..b10fb13c0 --- /dev/null +++ b/examples/polatory_lva_workbench_results.py @@ -0,0 +1,141 @@ +"""Generated-surface component filtering and model-completion hooks.""" +from polatory_lva_workbench_fields import * + + +def _inside_supported_surface( + self: Any, + result: dict[str, Any], +) -> tuple[pv.PolyData, dict[str, Any]]: + """Keep disconnected result components that are supported by mapped data points.""" + name = "Automatic LVA surface" + if name not in self.layers: + raise ValueError("The generated surface layer is unavailable.") + surface = _mesh_surface(_record_dataset(self.layers[name])) + connected = surface.connectivity() + if "RegionId" not in connected.cell_data: + return surface, {"raw_components": 1, "kept_components": 1} + + points = np.asarray( + getattr(self, "current_points", np.empty((0, 3))), + dtype=float, + ) + if len(points) == 0: + return surface, {"raw_components": 1, "kept_components": 1} + if len(points) > 1: + nearest = np.asarray( + cKDTree(points).query(points, k=2)[0][:, 1], + dtype=float, + ) + nearest = nearest[np.isfinite(nearest) & (nearest > 0.0)] + median_spacing = float(np.median(nearest)) if len(nearest) else 1.0 + else: + median_spacing = 1.0 + + resolution = float(result.get("surface_resolution", 0.0) or 0.0) + if not resolution > 0.0: + widget = getattr(self, "surface_resolution_spin", None) + resolution = float(widget.value()) if widget is not None else median_spacing + threshold = max(3.0 * resolution, 2.0 * median_spacing) + minimum_support = max(3, int(np.ceil(0.01 * len(points)))) + + region_values = np.asarray(connected.cell_data["RegionId"], dtype=np.int64) + components: list[pv.PolyData] = [] + records: list[dict[str, Any]] = [] + for region_id in np.unique(region_values): + component = connected.extract_cells(region_values == int(region_id)) + component = component.extract_surface().triangulate().clean() + measured = pv.PolyData(points).compute_implicit_distance(component) + distances = np.abs(np.asarray(measured["implicit_distance"], dtype=float)) + support_count = int(np.count_nonzero(distances <= threshold)) + components.append(component) + records.append( + { + "region_id": int(region_id), + "vertices": int(component.n_points), + "triangles": int(component.n_cells), + "support_count": support_count, + "minimum_distance": float(np.min(distances)), + "median_distance": float(np.median(distances)), + "kept": support_count >= minimum_support, + } + ) + + kept = [index for index, record in enumerate(records) if record["kept"]] + if not kept: + closest = int( + np.argmin([record["median_distance"] for record in records]) + ) + records[closest]["kept"] = True + records[closest]["fallback_closest_component"] = True + kept = [closest] + + cleaned = components[kept[0]].copy(deep=True) + for index in kept[1:]: + cleaned = cleaned.merge(components[index], merge_points=False) + cleaned = cleaned.extract_surface().triangulate().clean() + return cleaned, { + "raw_components": len(records), + "kept_components": len(kept), + "support_distance": threshold, + "minimum_support_points": minimum_support, + "components": records, + } + + +def _replace_generated_with_supported_surface( + self: Any, + result: dict[str, Any], +) -> None: + try: + cleaned, report = _inside_supported_surface(self, result) + if report["raw_components"] == 1: + self._last_component_filter = report + return + self._remove_layer("Automatic LVA surface") + self._add_layer( + "Automatic LVA surface", + cleaned, + kind="mesh", + color="#d9d9d9", + opacity=1.0, + show_edges=False, + visible=True, + select=True, + ) + self.result_surface = cleaned + result_path = getattr(self, "result_temp_obj", None) + if result_path: + try: + cleaned.save(Path(result_path)) + except Exception as error: + self._log(f"Could not overwrite the temporary result OBJ: {error}") + self._last_component_filter = report + self._log( + f"Inside-only result filter kept {report['kept_components']}/" + f"{report['raw_components']} connected components; unsupported enclosing " + "shells were removed." + ) + except Exception as error: + self._log(f"Inside-only result filtering was skipped: {error}") + + +def _enhanced_process_model_finished(self: Any, result: dict[str, Any]) -> None: + _process_model_finished(self, result) + component_filter = getattr(self, "inside_only_filter_check", None) + if component_filter is not None and component_filter.isChecked(): + _replace_generated_with_supported_surface(self, result) + else: + self._log( + "Raw exact full-depth surface retained. The optional disconnected-component " + "filter is off so the generated OBJ remains directly comparable with the " + "exact-leapfrog-lva-full-depth-sweep output." + ) + self._last_workbench_result = result + _refresh_layer_combos(self) + self._log( + "Workbench result layers are available in the layer list: generated surface, " + "automatic domain points, centroid clusters, LVA field slices and principal axes." + ) + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/examples/polatory_lva_worker_process.py b/examples/polatory_lva_worker_process.py new file mode 100644 index 000000000..4be0cb4e3 --- /dev/null +++ b/examples/polatory_lva_worker_process.py @@ -0,0 +1,51 @@ +"""Compatibility entry point for the process-isolated LVA worker. + +The structural-domain implementation lives in ``polatory_lva_worker_process_v3``. +This entry point enables two corrections used by the Leapfrog-style launcher: + +* globally aligned scalar-grid surface extraction streamed through shared slabs; +* smooth completion of finite local-domain weights into the outside field. +""" + +from polatory_lva_global_grid import install_global_grid_meshing +import polatory_lva_worker_process_v3 as worker + + +install_global_grid_meshing(worker.v2.v8.v5.v3) + +_native_structural_interpolant = worker.v2.v8.v5.v3.polatory.StructuralInterpolant3 + + +def _leapfrog_structural_interpolant( + base_model, + outside_value=-1.0, + blend_power=1.0, + alignment_strength=0.0, + background_blending=True, +): + """Create the native interpolant while preserving the full constructor API. + + Leapfrog-style isolated runs default to background blending, but callers may + still pass the fifth argument either positionally or by keyword. Keeping the + native-compatible signature is important for headless benchmarks and any future + non-GUI use of the worker module. + """ + return _native_structural_interpolant( + base_model, + float(outside_value), + float(blend_power), + float(alignment_strength), + bool(background_blending), + ) + + +# The worker is a separate process, so this factory affects only isolated LVA runs +# and does not alter the normal Polatory API in the GUI process. +worker.v2.v8.v5.v3.polatory.StructuralInterpolant3 = ( + _leapfrog_structural_interpolant +) +main = worker.main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/polatory_lva_worker_process_v2.py b/examples/polatory_lva_worker_process_v2.py new file mode 100644 index 000000000..48d71210f --- /dev/null +++ b/examples/polatory_lva_worker_process_v2.py @@ -0,0 +1,406 @@ +"""Fast isolated worker for the v10 Polatory LVA GUI. + +The automatic SubDomainer is fitted only across the populated data envelope, where +its clustering controls are meaningful and fast. Its resulting domain labels are +then propagated across a separate centroid grid covering the exact user-defined +extent. Local structural-domain blend boxes are rebuilt from that propagated grid +with a one-cell external halo, so the RBF and LVA field cover the requested extent +without forcing thousands of empty centroid cells through agglomeration. +""" + +from __future__ import annotations + +import argparse +import os +import pickle +import shutil +import sys +import traceback +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable, Sequence + +import numpy as np +import polatory +import polatory.automatic_domain_builder as automatic_builder_module + +import polatory_lva_pyqt_app_v8_category_contacts as v8 + + +class CallbackSignal: + def __init__(self, callback: Callable[[Any], None]) -> None: + self._callback = callback + + def emit(self, value: Any = None) -> None: + self._callback(value) + + +_ORIGINAL_AUTOMATIC_BUILDER = polatory.AutomaticStructuralDomainBuilder3 +_USER_BBOX_MIN = np.zeros(3, dtype=float) +_USER_BBOX_MAX = np.ones(3, dtype=float) +_CONTACT_POINTS = np.empty((0, 3), dtype=float) +_CONTACT_INDICES = np.empty(0, dtype=np.int64) + + +def _nearest_indices(reference: np.ndarray, query: np.ndarray) -> np.ndarray: + reference = np.asarray(reference, dtype=float) + query = np.asarray(query, dtype=float) + try: + from scipy.spatial import cKDTree + except ImportError: + nearest = np.empty(len(query), dtype=np.int64) + best = np.full(len(query), np.inf) + for start in range(0, len(reference), 512): + stop = min(start + 512, len(reference)) + difference = query[:, None, :] - reference[None, start:stop, :] + squared = np.einsum("qpi,qpi->qp", difference, difference, optimize=True) + local = np.argmin(squared, axis=1) + local_squared = squared[np.arange(len(query)), local] + replace = local_squared < best + best[replace] = local_squared[replace] + nearest[replace] = start + local[replace] + return nearest + return np.asarray(cKDTree(reference).query(query, k=1)[1], dtype=np.int64) + + +def _strictly_inside( + points: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, +) -> np.ndarray: + return np.all(points > minimum[None, :], axis=1) & np.all( + points < maximum[None, :], axis=1 + ) + + +def _domain_specs(domains: Sequence[Any]) -> list[dict[str, Any]]: + specs: list[dict[str, Any]] = [] + for domain in domains: + specs.append( + { + "anisotropy": np.asarray(domain.anisotropy, dtype=float), + "bbox_min": np.asarray(domain.bbox_min, dtype=float).copy(), + "bbox_max": np.asarray(domain.bbox_max, dtype=float).copy(), + "support_indices": np.asarray( + domain.support_indices, dtype=np.int64 + ).copy(), + "model_parameters": np.asarray( + domain.model_parameters, dtype=float + ).reshape(-1).tolist(), + } + ) + return specs + + +def _ensure_every_domain_has_cells( + centroid_points: np.ndarray, + centroid_labels: np.ndarray, + points: np.ndarray, + point_labels: np.ndarray, + domain_count: int, +) -> np.ndarray: + labels = np.asarray(centroid_labels, dtype=np.int64).copy() + used_cells: set[int] = set() + for label in range(domain_count): + existing = np.flatnonzero(labels == label) + if len(existing): + used_cells.update(int(index) for index in existing[:1]) + continue + owned = points[point_labels == label] + if len(owned) == 0: + raise RuntimeError(f"Automatic structural domain {label} owns no data points.") + centre = owned.mean(axis=0) + order = np.argsort(np.sum((centroid_points - centre[None, :]) ** 2, axis=1)) + chosen = next((int(index) for index in order if int(index) not in used_cells), int(order[0])) + labels[chosen] = label + used_cells.add(chosen) + return labels + + +def _rebuild_full_extent_domains( + domains: Sequence[Any], + points: np.ndarray, + point_labels: np.ndarray, + centroid_points: np.ndarray, + centroid_labels: np.ndarray, + shape: tuple[int, int, int], +) -> tuple[list[Any], int, int, int]: + specs = _domain_specs(domains) + domain_count = len(specs) + centroid_labels = _ensure_every_domain_has_cells( + centroid_points, + centroid_labels, + points, + point_labels, + domain_count, + ) + + span = _USER_BBOX_MAX - _USER_BBOX_MIN + cell_width = span / np.maximum(np.asarray(shape, dtype=float), 1.0) + cell_width = np.maximum(cell_width, np.finfo(float).eps) + half_cell = 0.5 * cell_width + overlap = cell_width + coverage_min = _USER_BBOX_MIN - cell_width + coverage_max = _USER_BBOX_MAX + cell_width + tolerance = max(1.0e-9 * float(np.linalg.norm(span)), 1.0e-9) + changed_faces = 0 + + for label, spec in enumerate(specs): + region = centroid_points[centroid_labels == label] + core_min = np.maximum(region.min(axis=0) - half_cell, _USER_BBOX_MIN) + core_max = np.minimum(region.max(axis=0) + half_cell, _USER_BBOX_MAX) + region_min = core_min - overlap + region_max = core_max + overlap + + touches_min = core_min <= _USER_BBOX_MIN + tolerance + touches_max = core_max >= _USER_BBOX_MAX - tolerance + region_min[touches_min] = coverage_min[touches_min] + region_max[touches_max] = coverage_max[touches_max] + + old_min = spec["bbox_min"].copy() + old_max = spec["bbox_max"].copy() + new_min = np.maximum(np.minimum(old_min, region_min), coverage_min) + new_max = np.minimum(np.maximum(old_max, region_max), coverage_max) + if not np.all(new_max > new_min): + raise RuntimeError(f"Invalid full-extent box for structural domain {label}.") + changed_faces += int(np.count_nonzero(np.abs(new_min - old_min) > tolerance)) + changed_faces += int(np.count_nonzero(np.abs(new_max - old_max) > tolerance)) + spec["bbox_min"] = new_min + spec["bbox_max"] = new_max + + added_supports = 0 + active_pairs = 0 + if len(_CONTACT_POINTS): + for spec in specs: + active = _strictly_inside( + _CONTACT_POINTS, + spec["bbox_min"], + spec["bbox_max"], + ) + contact_indices = _CONTACT_INDICES[active] + active_pairs += int(np.count_nonzero(active)) + original = np.unique(spec["support_indices"]) + support = np.unique(np.concatenate([original, contact_indices])).astype(np.int64) + added_supports += int(len(support) - len(original)) + spec["support_indices"] = support + + rebuilt = [ + polatory.StructuralDomain3( + spec["anisotropy"], + spec["bbox_min"], + spec["bbox_max"], + np.asarray(spec["support_indices"], dtype=np.int64).tolist(), + spec["model_parameters"], + ) + for spec in specs + ] + return rebuilt, changed_faces, added_supports, active_pairs + + +class FastUserExtentAutomaticBuilder: + """Cluster populated data first, then propagate domains to the user extent.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._builder = _ORIGINAL_AUTOMATIC_BUILDER(*args, **kwargs) + + def build_from_inputs( + self, + points: np.ndarray, + inputs: Sequence[object], + model_parameters: Sequence[float], + trend_type: object = polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) -> list[Any]: + points = np.asarray(points, dtype=float) + print( + "PROGRESS\tClustering automatic structural domains inside the populated " + "data envelope…", + flush=True, + ) + domains = list( + self._builder.build_from_inputs( + points, + inputs, + model_parameters, + trend_type, + ) + ) + point_labels = np.asarray(self._builder.labels_, dtype=np.int64) + if point_labels.shape != (len(points),): + raise RuntimeError("Automatic SubDomainer did not return one label per point.") + + span = _USER_BBOX_MAX - _USER_BBOX_MIN + active_axes = span > max(float(span.max()), 1.0) * 1.0e-12 + if not np.any(active_axes): + active_axes[:] = True + shape = automatic_builder_module._factor_grid_shape( + self._builder.centroid_count, + span, + active_axes, + ) + centroid_points, _ = automatic_builder_module._grid_centroids( + _USER_BBOX_MIN, + _USER_BBOX_MAX, + shape, + ) + + # Propagate already-resolved structural domains to the complete requested + # extent. This is O(N log N), unlike agglomerating thousands of empty cells. + nearest_points = _nearest_indices(points, centroid_points) + centroid_labels = point_labels[nearest_points] + + domains, changed_faces, added, active_pairs = _rebuild_full_extent_domains( + domains, + points, + point_labels, + centroid_points, + centroid_labels, + shape, + ) + + old_diagnostics = self._builder.diagnostics_ + if old_diagnostics is None: + raise RuntimeError("Automatic SubDomainer diagnostics are unavailable.") + self._builder.centroid_points_ = centroid_points.copy() + self._builder.centroid_labels_ = centroid_labels.copy() + self._builder.centroid_grid_shape_ = shape + self._builder.active_axes_ = active_axes.copy() + self._builder.diagnostics_ = ( + automatic_builder_module.AutomaticStructuralDomainDiagnostics3( + labels=point_labels.copy(), + centroid_points=centroid_points.copy(), + centroid_labels=centroid_labels.copy(), + centroid_grid_shape=shape, + active_axes=active_axes.copy(), + minimum_points=old_diagnostics.minimum_points, + maximum_points=old_diagnostics.maximum_points, + consistency_threshold=old_diagnostics.consistency_threshold, + merge_count=old_diagnostics.merge_count, + final_domain_count=len(domains), + postcluster=old_diagnostics.postcluster, + ) + ) + + print( + "PROGRESS\tPropagated the populated structural domains across the exact " + f"user extent on centroid grid {shape} ({changed_faces:,} domain faces " + "adjusted; one-cell external weighting halo).", + flush=True, + ) + if len(_CONTACT_POINTS): + print( + "PROGRESS\t" + f"Injected {len(_CONTACT_POINTS):,} Contact constraints into " + f"{active_pairs:,} active domain/contact overlaps " + f"({added:,} added support references).", + flush=True, + ) + return domains + + @property + def diagnostics_(self) -> Any: + return self._builder.diagnostics_ + + @property + def labels_(self) -> Any: + return self._builder.labels_ + + def __getattr__(self, name: str) -> Any: + return getattr(self._builder, name) + + +def main() -> int: + global _USER_BBOX_MIN, _USER_BBOX_MAX, _CONTACT_POINTS, _CONTACT_INDICES + + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True) + parser.add_argument("--result", required=True) + parser.add_argument("--obj", required=True) + args = parser.parse_args() + + input_path = Path(args.input) + result_path = Path(args.result) + output_obj = Path(args.obj) + with input_path.open("rb") as stream: + payload = pickle.load(stream) + + points = np.asarray(payload["points"], dtype=float) + _USER_BBOX_MIN = np.asarray(payload["bbox_min"], dtype=float) + _USER_BBOX_MAX = np.asarray(payload["bbox_max"], dtype=float) + if _USER_BBOX_MIN.shape != (3,) or _USER_BBOX_MAX.shape != (3,): + raise ValueError("The user-defined extent must contain three minima and maxima.") + if not np.all(_USER_BBOX_MAX > _USER_BBOX_MIN): + raise ValueError("Every user-defined extent maximum must exceed its minimum.") + + tolerance = max( + 1.0e-9 * float(np.linalg.norm(_USER_BBOX_MAX - _USER_BBOX_MIN)), + 1.0e-9, + ) + outside = np.any(points < _USER_BBOX_MIN - tolerance, axis=1) | np.any( + points > _USER_BBOX_MAX + tolerance, + axis=1, + ) + if np.any(outside): + raise ValueError( + f"The user-defined extent excludes {int(np.count_nonzero(outside)):,} " + "mapped modelling points. Expand it before running the model." + ) + + indicators = np.asarray(payload["indicators"], dtype=float) + _CONTACT_INDICES = np.flatnonzero(indicators == 0.0).astype(np.int64) + _CONTACT_POINTS = points[_CONTACT_INDICES] + polatory.AutomaticStructuralDomainBuilder3 = FastUserExtentAutomaticBuilder + + holder: dict[str, Any] = {} + + def progress(message: Any) -> None: + print(f"PROGRESS\t{message}", flush=True) + + runner = SimpleNamespace( + payload=payload, + progress=CallbackSignal(progress), + finished=CallbackSignal(lambda result: holder.__setitem__("result", result)), + failed=CallbackSignal(lambda details: holder.__setitem__("error", str(details))), + ) + + worker_run = getattr(v8.v5, "scalable_worker_run", None) + if not callable(worker_run): + raise RuntimeError("The v5 launcher does not expose scalable_worker_run.") + worker_run(runner) + + if "error" in holder: + print(holder["error"], file=sys.stderr, flush=True) + return 2 + if "result" not in holder: + print("Worker returned neither a result nor an error.", file=sys.stderr, flush=True) + return 3 + + result = holder["result"] + plane_points = result.get("lva_points") + plane_dimension = getattr(plane_points, "dimension", None) + if plane_dimension is not None: + result["lva_plane_dimension"] = int(plane_dimension) + result["lva_points"] = np.asarray(plane_points) + + source_obj = Path(result["temp_obj"]) + output_obj.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_obj, output_obj) + try: + source_obj.unlink() + except OSError: + pass + + result["temp_obj"] = str(output_obj) + with result_path.open("wb") as stream: + pickle.dump(result, stream, protocol=pickle.HIGHEST_PROTOCOL) + print("RESULT_READY", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except BaseException: + traceback.print_exc() + os._exit(4) diff --git a/examples/polatory_lva_worker_process_v3.py b/examples/polatory_lva_worker_process_v3.py new file mode 100644 index 000000000..5e4dc5107 --- /dev/null +++ b/examples/polatory_lva_worker_process_v3.py @@ -0,0 +1,369 @@ +"""Finite LVA-geodesic domain coverage for the isolated Polatory worker. + +Leapfrog's automatic SubDomainer does not make every local RBF domain active to the +model boundary. The synthetic blending benchmarks show that changing only the model +extent leaves the output unchanged and that the zero surface closes roughly one local +spheroidal support radius beyond the populated observations. + +This worker therefore keeps the recovered automatic point clustering and the original +Leapfrog-compatible local boxes, but allows each box to bend through a varying LVA +field by no more than that domain's recovered internal support radius. In a constant +anisotropy field this reduces to the existing analytical box expansion. Around folds +it can follow structural continuity without propagating a domain indefinitely through +the complete user extent. +""" + +from __future__ import annotations + +from heapq import heappop, heappush +from typing import Any, Sequence + +import numpy as np +import polatory +import polatory.automatic_domain_builder as automatic_builder_module + +import polatory_lva_worker_process_v2 as v2 + + +_ORIGINAL_AUTOMATIC_BUILDER = polatory.AutomaticStructuralDomainBuilder3 +_MAX_PROPAGATION_CELLS = 100_000 + + +def _sample_grid_anisotropies( + points: np.ndarray, + inputs: Sequence[object], + trend_type: object, +) -> np.ndarray: + inputs = list(inputs) + if len(inputs) == 1: + return np.asarray( + polatory.sample_single_input_anisotropies3( + points, + inputs[0], + non_decaying=( + trend_type == polatory.StructuralTrendType.NON_DECAYING + ), + ), + dtype=float, + ) + + samples = polatory.StructuralDomainBuilder3().sample( + points, + inputs, + trend_type, + ) + return np.asarray(samples.anisotropies, dtype=float) + + +def _grid_neighbours(index: int, shape: tuple[int, int, int]): + nx, ny, nz = (int(value) for value in shape) + yz = ny * nz + ix = index // yz + remainder = index - ix * yz + iy = remainder // nz + iz = remainder - iy * nz + + for dx in (-1, 0, 1): + xx = ix + dx + if xx < 0 or xx >= nx: + continue + for dy in (-1, 0, 1): + yy = iy + dy + if yy < 0 or yy >= ny: + continue + for dz in (-1, 0, 1): + if dx == 0 and dy == 0 and dz == 0: + continue + zz = iz + dz + if zz < 0 or zz >= nz: + continue + yield (xx * ny + yy) * nz + zz + + +def _bounded_geodesic_region( + centroid_points: np.ndarray, + anisotropies: np.ndarray, + shape: tuple[int, int, int], + seed_indices: np.ndarray, + maximum_distance: float, +) -> np.ndarray: + """Return cells within one local support radius of a domain's core points.""" + centroid_points = np.asarray(centroid_points, dtype=float) + anisotropies = np.asarray(anisotropies, dtype=float) + seed_indices = np.unique(np.asarray(seed_indices, dtype=np.int64)) + maximum_distance = float(maximum_distance) + + if anisotropies.shape != (len(centroid_points), 3, 3): + raise ValueError("Propagation LVA samples must have shape (n, 3, 3).") + if len(seed_indices) == 0: + raise ValueError("Every structural domain needs at least one propagation seed.") + if not maximum_distance > 0.0: + raise ValueError("Every structural domain needs a positive internal radius.") + + distances = np.full(len(centroid_points), np.inf, dtype=float) + queue: list[tuple[float, int]] = [] + for index in seed_indices: + if index < 0 or index >= len(centroid_points): + raise IndexError("A propagation seed is outside the centroid grid.") + distances[index] = 0.0 + heappush(queue, (0.0, int(index))) + + tolerance = max(1.0e-12 * maximum_distance, 1.0e-12) + while queue: + current_distance, current = heappop(queue) + if current_distance > distances[current] + tolerance: + continue + if current_distance > maximum_distance + tolerance: + break + + for neighbour in _grid_neighbours(current, shape): + delta = centroid_points[neighbour] - centroid_points[current] + metric = 0.5 * (anisotropies[current] + anisotropies[neighbour]) + metric = 0.5 * (metric + metric.T) + step = float(np.linalg.norm(delta @ metric)) + if not np.isfinite(step) or step <= 0.0: + step = float(np.linalg.norm(delta)) + + candidate = current_distance + step + if candidate > maximum_distance + tolerance: + continue + if candidate < distances[neighbour] - tolerance: + distances[neighbour] = candidate + heappush(queue, (candidate, int(neighbour))) + + return distances <= maximum_distance + tolerance + + +def _propagation_grid( + points: np.ndarray, + domains: Sequence[Any], + source_shape: tuple[int, int, int], +) -> tuple[np.ndarray, tuple[int, int, int], np.ndarray]: + """Build an extent-independent grid around the recovered finite domain boxes.""" + specs = v2._domain_specs(domains) + minimum = np.min(np.vstack([spec["bbox_min"] for spec in specs]), axis=0) + maximum = np.max(np.vstack([spec["bbox_max"] for spec in specs]), axis=0) + + data_min = points.min(axis=0) + data_max = points.max(axis=0) + data_span = data_max - data_min + source_shape_array = np.maximum(np.asarray(source_shape, dtype=np.int64), 1) + + widths = np.zeros(3, dtype=float) + valid = (source_shape_array > 1) & (data_span > 0.0) + widths[valid] = data_span[valid] / source_shape_array[valid] + positive = widths[widths > 0.0] + fallback = float(np.median(positive)) if len(positive) else 1.0 + widths[~valid] = fallback + widths = np.maximum(widths, np.finfo(float).eps) + + span = maximum - minimum + shape_array = np.maximum(np.ceil(span / widths).astype(np.int64), 1) + total = int(np.prod(shape_array, dtype=np.int64)) + if total > _MAX_PROPAGATION_CELLS: + factor = (total / float(_MAX_PROPAGATION_CELLS)) ** (1.0 / 3.0) + widths *= factor + shape_array = np.maximum(np.ceil(span / widths).astype(np.int64), 1) + + shape = tuple(int(value) for value in shape_array) + centroid_points, _ = automatic_builder_module._grid_centroids( + minimum, + maximum, + shape, + ) + cell_width = span / np.maximum(shape_array.astype(float), 1.0) + return centroid_points, shape, cell_width + + +def _rebuild_finite_domains( + domains: Sequence[Any], + points: np.ndarray, + point_labels: np.ndarray, + centroid_points: np.ndarray, + centroid_anisotropies: np.ndarray, + shape: tuple[int, int, int], + internal_radii: np.ndarray, +) -> tuple[list[Any], int, int, int]: + specs = v2._domain_specs(domains) + if len(specs) != len(internal_radii): + raise RuntimeError("Automatic domain diagnostics do not match domain count.") + + tolerance = max( + 1.0e-9 * float(np.linalg.norm(points.max(axis=0) - points.min(axis=0))), + 1.0e-9, + ) + changed_faces = 0 + + for label, (spec, internal_radius) in enumerate(zip(specs, internal_radii)): + owned = points[point_labels == label] + if len(owned) == 0: + raise RuntimeError(f"Automatic structural domain {label} owns no data points.") + + seed_indices = v2._nearest_indices(centroid_points, owned) + active = _bounded_geodesic_region( + centroid_points, + centroid_anisotropies, + shape, + seed_indices, + float(internal_radius), + ) + region = centroid_points[active] + if len(region) == 0: + raise RuntimeError(f"Structural domain {label} has no finite LVA region.") + + old_min = spec["bbox_min"].copy() + old_max = spec["bbox_max"].copy() + + # The recovered analytical box remains authoritative in locally constant + # fields. Geodesic cells may enlarge it only where a curved LVA path reaches + # farther within the same internal support radius. + new_min = np.minimum(old_min, region.min(axis=0)) + new_max = np.maximum(old_max, region.max(axis=0)) + if not np.all(new_max > new_min): + raise RuntimeError(f"Invalid finite LVA box for structural domain {label}.") + + changed_faces += int(np.count_nonzero(np.abs(new_min - old_min) > tolerance)) + changed_faces += int(np.count_nonzero(np.abs(new_max - old_max) > tolerance)) + spec["bbox_min"] = new_min + spec["bbox_max"] = new_max + + added_supports = 0 + active_pairs = 0 + if len(v2._CONTACT_POINTS): + for spec in specs: + active = v2._strictly_inside( + v2._CONTACT_POINTS, + spec["bbox_min"], + spec["bbox_max"], + ) + contact_indices = v2._CONTACT_INDICES[active] + active_pairs += int(np.count_nonzero(active)) + original = np.unique(spec["support_indices"]) + support = np.unique( + np.concatenate([original, contact_indices]) + ).astype(np.int64) + added_supports += int(len(support) - len(original)) + spec["support_indices"] = support + + rebuilt = [ + polatory.StructuralDomain3( + spec["anisotropy"], + spec["bbox_min"], + spec["bbox_max"], + np.asarray(spec["support_indices"], dtype=np.int64).tolist(), + spec["model_parameters"], + ) + for spec in specs + ] + return rebuilt, changed_faces, added_supports, active_pairs + + +class FiniteLvaGeodesicAutomaticBuilder: + """Cluster input points, then curve each finite box by one support radius.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._builder = _ORIGINAL_AUTOMATIC_BUILDER(*args, **kwargs) + + def build_from_inputs( + self, + points: np.ndarray, + inputs: Sequence[object], + model_parameters: Sequence[float], + trend_type: object = polatory.StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) -> list[Any]: + points = np.asarray(points, dtype=float) + inputs = list(inputs) + if not inputs: + raise ValueError("inputs must not be empty") + + print( + "PROGRESS\tClustering automatic structural domains inside the populated " + "data envelope…", + flush=True, + ) + domains = list( + self._builder.build_from_inputs( + points, + inputs, + model_parameters, + trend_type, + ) + ) + point_labels = np.asarray(self._builder.labels_, dtype=np.int64) + if point_labels.shape != (len(points),): + raise RuntimeError("Automatic SubDomainer did not return one label per point.") + + diagnostics = self._builder.diagnostics_ + if diagnostics is None: + raise RuntimeError("Automatic SubDomainer diagnostics are unavailable.") + internal_radii = np.asarray( + [item.internal_radius for item in diagnostics.postcluster], + dtype=float, + ) + + centroid_points, shape, _ = _propagation_grid( + points, + domains, + self._builder.centroid_grid_shape_, + ) + print( + "PROGRESS\tSampling the structural LVA metric on a finite, " + f"extent-independent propagation grid {shape}…", + flush=True, + ) + centroid_anisotropies = _sample_grid_anisotropies( + centroid_points, + inputs, + trend_type, + ) + + print( + "PROGRESS\tCurving each automatic domain through the LVA field, bounded " + "by its recovered local support radius…", + flush=True, + ) + domains, changed_faces, added, active_pairs = _rebuild_finite_domains( + domains, + points, + point_labels, + centroid_points, + centroid_anisotropies, + shape, + internal_radii, + ) + + print( + "PROGRESS\tBuilt finite LVA-geodesic structural coverage " + f"({changed_faces:,} domain faces extended; no domain was propagated " + "to the model boundary).", + flush=True, + ) + if len(v2._CONTACT_POINTS): + print( + "PROGRESS\t" + f"Injected {len(v2._CONTACT_POINTS):,} Contact constraints into " + f"{active_pairs:,} active domain/contact overlaps " + f"({added:,} added support references).", + flush=True, + ) + return domains + + @property + def diagnostics_(self) -> Any: + return self._builder.diagnostics_ + + @property + def labels_(self) -> Any: + return self._builder.labels_ + + def __getattr__(self, name: str) -> Any: + return getattr(self._builder, name) + + +def main() -> int: + v2.FastUserExtentAutomaticBuilder = FiniteLvaGeodesicAutomaticBuilder + return v2.main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/standalone_automatic_lva.ipynb b/examples/standalone_automatic_lva.ipynb new file mode 100644 index 000000000..4704826cd --- /dev/null +++ b/examples/standalone_automatic_lva.ipynb @@ -0,0 +1,359 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Standalone automatic structural LVA\n", + "\n", + "This notebook uses only your point CSV, structural trend mesh and modeling parameters. It does **not** read Leapfrog projects, decoded labels, benchmark case folders, or `point_clusters.csv`. The automatic SubDomainer creates and merges its own structural centroids, then uses the recovered Leapfrog-compatible value preprocessing and post-cluster domain rules.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "import pyvista as pv\n", + "from IPython.display import display\n", + "\n", + "import polatory\n", + "from polatory import three as p3\n", + "\n", + "print(\"Python package:\", polatory.__file__)\n", + "print(\"Automatic builder available:\", hasattr(polatory, \"AutomaticStructuralDomainBuilder3\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Change only this cell for another dataset, strength, range or model. The CSV must contain one row per interpolation point and a binary indicator column with positive and negative values.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "DATA_CSV = Path(r\"D:\\\\path\\\\to\\\\points.csv\")\n", + "TREND_MESH_OBJ = Path(r\"D:\\\\path\\\\to\\\\trend_mesh.obj\")\n", + "OUTPUT_OBJ = Path(r\"D:\\\\path\\\\to\\\\automatic_lva_result.obj\")\n", + "\n", + "X_COLUMN = \"x\"\n", + "Y_COLUMN = \"y\"\n", + "Z_COLUMN = \"z\"\n", + "INDICATOR_COLUMN = \"SDF\"\n", + "\n", + "# Structural trend parameters: arbitrary positive values are supported.\n", + "STRENGTH = 5.0\n", + "TREND_RANGE = 100.0\n", + "\n", + "# RBF model.\n", + "SILL = 100.0\n", + "BASE_RANGE = 400.0\n", + "NUGGET = 0.0\n", + "POLY_DEGREE = 0\n", + "OUTSIDE_VALUE = -1.0\n", + "BLEND_POWER = 1.0\n", + "MAX_ITERATIONS = 100\n", + "\n", + "# Automatic SubDomainer controls. These mirror the recovered Leapfrog UI defaults.\n", + "CENTROID_COUNT = 6000\n", + "MINIMUM_CLUSTER_FRACTION = 0.001\n", + "MAXIMUM_CLUSTER_FRACTION = 0.10\n", + "CONSISTENCY_THRESHOLD = 0.60\n", + "\n", + "# Mesh generation.\n", + "ISOSURFACE_RESOLUTION = 25.0\n", + "ISOSURFACE_REFINE = 1\n", + "BBOX_PADDING_FRACTION = 0.10\n", + "\n", + "# Set both to 3-vectors to override the automatic data bounds.\n", + "BBOX_MIN_OVERRIDE = None\n", + "BBOX_MAX_OVERRIDE = None\n", + "\n", + "if STRENGTH <= 0 or TREND_RANGE <= 0 or BASE_RANGE <= 0:\n", + " raise ValueError(\"STRENGTH, TREND_RANGE and BASE_RANGE must be positive\")\n", + "if not DATA_CSV.exists():\n", + " raise FileNotFoundError(DATA_CSV)\n", + "if not TREND_MESH_OBJ.exists():\n", + " raise FileNotFoundError(TREND_MESH_OBJ)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def read_obj_triangles(path: Path):\n", + " vertices = []\n", + " faces = []\n", + " with path.open(\"r\", encoding=\"utf-8\", errors=\"ignore\") as handle:\n", + " for line in handle:\n", + " if line.startswith(\"v \"):\n", + " vertices.append([float(value) for value in line.split()[1:4]])\n", + " elif line.startswith(\"f \"):\n", + " indices = [int(value.split(\"/\")[0]) - 1 for value in line.split()[1:]]\n", + " if len(indices) == 3:\n", + " faces.append(indices)\n", + " elif len(indices) > 3:\n", + " for offset in range(1, len(indices) - 1):\n", + " faces.append([indices[0], indices[offset], indices[offset + 1]])\n", + " vertices = np.asarray(vertices, dtype=float)\n", + " faces = np.asarray(faces, dtype=np.int64)\n", + " if vertices.ndim != 2 or vertices.shape[1] != 3 or len(vertices) == 0:\n", + " raise ValueError(f\"No OBJ vertices found in {path}\")\n", + " if faces.ndim != 2 or faces.shape[1] != 3 or len(faces) == 0:\n", + " raise ValueError(f\"No triangular OBJ faces found in {path}\")\n", + " return vertices, faces\n", + "\n", + "frame = pd.read_csv(DATA_CSV)\n", + "required_columns = [X_COLUMN, Y_COLUMN, Z_COLUMN, INDICATOR_COLUMN]\n", + "missing = [column for column in required_columns if column not in frame.columns]\n", + "if missing:\n", + " raise ValueError(f\"Missing CSV columns: {missing}\")\n", + "\n", + "points = frame[[X_COLUMN, Y_COLUMN, Z_COLUMN]].to_numpy(dtype=float)\n", + "raw_indicators = frame[INDICATOR_COLUMN].to_numpy(dtype=float)\n", + "trend_vertices, trend_faces = read_obj_triangles(TREND_MESH_OBJ)\n", + "\n", + "if len(points) < 2 or not np.all(np.isfinite(points)):\n", + " raise ValueError(\"Point coordinates must contain at least two finite rows\")\n", + "if not (np.any(raw_indicators > 0) and np.any(raw_indicators < 0)):\n", + " raise ValueError(\"The indicator column must contain positive and negative classes\")\n", + "\n", + "value_info = polatory.leapfrog_indicator_values3(points, raw_indicators)\n", + "values = value_info.values\n", + "FIT_TOLERANCE = value_info.fit_accuracy\n", + "\n", + "print(f\"Input points: {len(points):,}\")\n", + "print(f\"Trend mesh: {len(trend_vertices):,} vertices, {len(trend_faces):,} triangles\")\n", + "print(\"Data diagonal:\", value_info.data_diagonal)\n", + "print(\"Automatic fit tolerance:\", FIT_TOLERANCE)\n", + "print(\"Processed value range:\", float(values.min()), float(values.max()))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Build automatic domains\n", + "\n", + "This cell calculates the LVA field, generates the centroid grid, performs deterministic neighbouring-region merges, assigns every interpolation point to an automatic domain, and applies the exact recovered post-cluster support/range rules.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trend_input = polatory.StructuralTrendInput3(\n", + " trend_vertices,\n", + " trend_faces,\n", + " float(STRENGTH),\n", + " float(TREND_RANGE),\n", + ")\n", + "\n", + "rbf = p3.CovSpheroidal3([SILL, BASE_RANGE])\n", + "model = p3.Model(rbf, POLY_DEGREE)\n", + "model.nugget = NUGGET\n", + "model_parameters = np.asarray(model.parameters, dtype=float).reshape(-1).tolist()\n", + "\n", + "builder = polatory.AutomaticStructuralDomainBuilder3(\n", + " centroid_count=CENTROID_COUNT,\n", + " minimum_cluster_fraction=MINIMUM_CLUSTER_FRACTION,\n", + " maximum_cluster_fraction=MAXIMUM_CLUSTER_FRACTION,\n", + " consistency_threshold=CONSISTENCY_THRESHOLD,\n", + " base_range=BASE_RANGE,\n", + " minimum_support_points=1,\n", + ")\n", + "domains = builder.build_from_inputs(\n", + " points,\n", + " [trend_input],\n", + " model_parameters=model_parameters,\n", + ")\n", + "labels = builder.labels_\n", + "diagnostics = builder.diagnostics_\n", + "\n", + "domain_table = pd.DataFrame([\n", + " {\n", + " \"domain\": item.label,\n", + " \"core_points\": len(item.core_indices),\n", + " \"support_points\": len(item.support_indices),\n", + " \"local_kernel_range\": item.local_kernel_range,\n", + " \"internal_radius\": item.internal_radius,\n", + " }\n", + " for item in diagnostics.postcluster\n", + "])\n", + "\n", + "print(\"Centroid grid shape:\", diagnostics.centroid_grid_shape)\n", + "print(\"Centroids:\", len(diagnostics.centroid_points))\n", + "print(\"Automatic merge operations:\", diagnostics.merge_count)\n", + "print(\"Final automatic domains:\", diagnostics.final_domain_count)\n", + "print(\"Resolved point limits:\", diagnostics.minimum_points, diagnostics.maximum_points)\n", + "display(domain_table)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inspect the automatic partition and LVA field\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "point_cloud = pv.PolyData(points)\n", + "point_cloud[\"automatic_domain\"] = labels\n", + "centroid_cloud = pv.PolyData(diagnostics.centroid_points)\n", + "centroid_cloud[\"automatic_domain\"] = diagnostics.centroid_labels\n", + "\n", + "vtk_faces = np.hstack([\n", + " np.full((len(trend_faces), 1), 3, dtype=np.int64),\n", + " trend_faces,\n", + "]).ravel()\n", + "trend_surface = pv.PolyData(trend_vertices, vtk_faces)\n", + "\n", + "plotter = pv.Plotter()\n", + "plotter.add_mesh(centroid_cloud, scalars=\"automatic_domain\", point_size=3, render_points_as_spheres=True, opacity=0.30)\n", + "plotter.add_mesh(point_cloud, scalars=\"automatic_domain\", point_size=8, render_points_as_spheres=True)\n", + "plotter.add_mesh(trend_surface, opacity=0.25, show_edges=True)\n", + "plotter.show_grid()\n", + "plotter.show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data_min = points.min(axis=0)\n", + "data_max = points.max(axis=0)\n", + "data_span = data_max - data_min\n", + "safe_span = np.where(data_span > 0, data_span, max(np.linalg.norm(data_span), 1.0) * 0.05)\n", + "\n", + "lva_min = data_min - 0.05 * safe_span\n", + "lva_max = data_max + 0.05 * safe_span\n", + "lva_dimensions = (25, 25, 25)\n", + "axes = [np.linspace(lva_min[i], lva_max[i], lva_dimensions[i]) for i in range(3)]\n", + "xx, yy, zz = np.meshgrid(*axes, indexing=\"ij\")\n", + "lva_grid = pv.StructuredGrid(xx, yy, zz)\n", + "lva_matrices = polatory.sample_single_input_anisotropies3(lva_grid.points, trend_input)\n", + "eigenvalues, eigenvectors = np.linalg.eigh(lva_matrices)\n", + "lva_ratio = eigenvalues[:, -1] / eigenvalues[:, 0]\n", + "lva_grid[\"LVA ratio\"] = lva_ratio\n", + "centre = 0.5 * (lva_min + lva_max)\n", + "slices = lva_grid.slice_orthogonal(x=centre[0], y=centre[1], z=centre[2])\n", + "\n", + "plotter = pv.Plotter()\n", + "plotter.add_mesh(slices, scalars=\"LVA ratio\", clim=(1.0, max(1.01, STRENGTH)), opacity=0.85)\n", + "plotter.add_mesh(trend_surface, opacity=0.30, show_edges=True)\n", + "plotter.add_mesh(point_cloud, point_size=5, render_points_as_spheres=True)\n", + "plotter.show_grid()\n", + "plotter.show()\n", + "print(\"LVA ratio range:\", float(lva_ratio.min()), float(lva_ratio.max()))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fit and export the implicit surface\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "structural = polatory.StructuralInterpolant3(\n", + " model,\n", + " outside_value=OUTSIDE_VALUE,\n", + " blend_power=BLEND_POWER,\n", + ")\n", + "structural.fit(\n", + " points,\n", + " values,\n", + " domains,\n", + " tolerance=FIT_TOLERANCE,\n", + " max_iter=MAX_ITERATIONS,\n", + ")\n", + "\n", + "training_predictions = structural.evaluate(points)\n", + "training_errors = training_predictions - values\n", + "print(\"Training RMSE:\", float(np.sqrt(np.mean(training_errors**2))))\n", + "print(\"Training maximum absolute error:\", float(np.max(np.abs(training_errors))))\n", + "\n", + "if BBOX_MIN_OVERRIDE is None or BBOX_MAX_OVERRIDE is None:\n", + " minimum = points.min(axis=0)\n", + " maximum = points.max(axis=0)\n", + " span = maximum - minimum\n", + " fallback = max(float(np.linalg.norm(span)), BASE_RANGE, 1.0)\n", + " padding = BBOX_PADDING_FRACTION * np.where(span > 0.0, span, fallback)\n", + " bbox_min = minimum - padding\n", + " bbox_max = maximum + padding\n", + "else:\n", + " bbox_min = np.asarray(BBOX_MIN_OVERRIDE, dtype=float)\n", + " bbox_max = np.asarray(BBOX_MAX_OVERRIDE, dtype=float)\n", + "\n", + "bbox = p3.Bbox(bbox_min.reshape(1, 3), bbox_max.reshape(1, 3))\n", + "field = polatory.StructuralRbfFieldFunction(structural)\n", + "result_mesh = polatory.Isosurface(\n", + " bbox,\n", + " ISOSURFACE_RESOLUTION,\n", + " np.eye(3),\n", + ").generate(field, isovalue=0.0, refine=ISOSURFACE_REFINE)\n", + "\n", + "OUTPUT_OBJ.parent.mkdir(parents=True, exist_ok=True)\n", + "result_mesh.export_obj(str(OUTPUT_OBJ))\n", + "print(\"Exported:\", OUTPUT_OBJ)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "generated = pv.read(OUTPUT_OBJ).extract_surface().triangulate().clean()\n", + "plotter = pv.Plotter()\n", + "plotter.add_mesh(generated, opacity=0.80, show_edges=True, label=\"Automatic LVA surface\")\n", + "plotter.add_mesh(trend_surface, opacity=0.20, label=\"Structural trend mesh\")\n", + "plotter.add_mesh(point_cloud, scalars=values, point_size=6, render_points_as_spheres=True, label=\"Interpolation points\")\n", + "plotter.add_legend()\n", + "plotter.show_grid()\n", + "plotter.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Polatory LVA)", + "language": "python", + "name": "polatory-lva" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/include/polatory/isosurface/structural_rbf_field_function.hpp b/include/polatory/isosurface/structural_rbf_field_function.hpp index d17343844..a0a0ff5af 100644 --- a/include/polatory/isosurface/structural_rbf_field_function.hpp +++ b/include/polatory/isosurface/structural_rbf_field_function.hpp @@ -18,11 +18,18 @@ class StructuralRbfFieldFunction : public FieldFunction { : interpolant_(interpolant), accuracy_(accuracy) {} VecX operator()(const geometry::Points3& points) const override { - return interpolant_.evaluate_impl(points); + // The regular Polatory lattice evaluates the field one working batch at a + // time and releases old lattice layers. Preparing every local structural + // interpolant for the complete model bbox defeats that streaming behavior + // and can multiply evaluator memory by the number of structural domains. + // Prepare the local evaluators for the current lattice batch instead. + return interpolant_.evaluate(points, accuracy_); } - void set_evaluation_bbox(const geometry::Bbox3& bbox) override { - interpolant_.set_evaluation_bbox_impl(bbox, accuracy_); + void set_evaluation_bbox(const geometry::Bbox3& /*bbox*/) override { + // Intentionally deferred to operator(). See the comment above. This keeps + // fine-resolution structural meshing on the same bounded-memory path as the + // original single-interpolant Polatory workflow. } private: diff --git a/include/polatory/structural/interpolant.hpp b/include/polatory/structural/interpolant.hpp index 7323861e8..62e5c4e43 100644 --- a/include/polatory/structural/interpolant.hpp +++ b/include/polatory/structural/interpolant.hpp @@ -31,11 +31,13 @@ class StructuralInterpolant3 { explicit StructuralInterpolant3(const Model& base_model, double outside_value = -1.0, double blend_power = 1.0, - double alignment_strength = 0.0) + double alignment_strength = 0.0, + bool background_blending = false) : base_model_(base_model), outside_value_(outside_value), blend_power_(blend_power), - alignment_strength_(alignment_strength) { + alignment_strength_(alignment_strength), + background_blending_(background_blending) { if (!(blend_power_ > 0.0)) { throw std::invalid_argument("blend_power must be positive"); } @@ -53,6 +55,8 @@ class StructuralInterpolant3 { double alignment_strength() const { return alignment_strength_; } + bool background_blending() const { return background_blending_; } + Index num_domains() const { return static_cast(domains_.size()); } double outside_value() const { return outside_value_; } @@ -120,8 +124,8 @@ class StructuralInterpolant3 { local_interpolant->fit(local_points, local_values, tolerance, max_iter, accuracy); - domains_.push_back( - Domain{spec, std::move(local_interpolant), 0.0}); + domains_.push_back(Domain{spec, std::move(local_interpolant), 0.0, + Bbox::from_points(local_points)}); bbox_ = bbox_.is_empty() ? spec.bbox() : bbox_.convex_hull(spec.bbox()); } @@ -159,7 +163,7 @@ class StructuralInterpolant3 { active_weights.reserve(static_cast(points.rows())); for (Index i = 0; i < points.rows(); ++i) { - auto weight = box_weight(points.row(i), domain.spec.bbox()); + auto weight = domain_weight(points.row(i), domain); if (weight > 0.0) { active_indices.push_back(i); active_weights.push_back(weight); @@ -190,7 +194,17 @@ class StructuralInterpolant3 { VecX result = VecX::Constant(points.rows(), outside_value_); for (Index i = 0; i < points.rows(); ++i) { - if (denominator(i) > 0.0) { + if (!(denominator(i) > 0.0)) { + continue; + } + + if (background_blending_ && denominator(i) < 1.0) { + // Complete the local weights to a partition of unity with the outside + // field. A lone local interpolant therefore fades continuously to the + // outside value rather than remaining unchanged until its box face and + // jumping abruptly there. + result(i) = numerator(i) + (1.0 - denominator(i)) * outside_value_; + } else { result(i) = numerator(i) / denominator(i); } } @@ -214,9 +228,10 @@ class StructuralInterpolant3 { DomainSpec3 spec; std::unique_ptr interpolant; double offset; + Bbox support_bbox; }; - double box_weight(const Point& point, const Bbox& bbox) const { + double legacy_box_weight(const Point& point, const Bbox& bbox) const { if (!bbox.contains(point)) { return 0.0; } @@ -240,6 +255,52 @@ class StructuralInterpolant3 { return std::pow(weight, blend_power_); } + double support_taper_weight(const Point& point, const Bbox& support_bbox, + const Bbox& outer_bbox) const { + if (!outer_bbox.contains(point)) { + return 0.0; + } + + double weight = 1.0; + for (Index axis = 0; axis < 3; ++axis) { + auto outer_min = outer_bbox.min()(axis); + auto outer_max = outer_bbox.max()(axis); + auto support_min = std::clamp(support_bbox.min()(axis), + outer_min, outer_max); + auto support_max = std::clamp(support_bbox.max()(axis), + outer_min, outer_max); + + double u = 1.0; + if (point(axis) < support_min) { + auto width = support_min - outer_min; + if (!(width > 0.0)) { + return 0.0; + } + u = (point(axis) - outer_min) / width; + } else if (point(axis) > support_max) { + auto width = outer_max - support_max; + if (!(width > 0.0)) { + return 0.0; + } + u = (outer_max - point(axis)) / width; + } + + u = std::clamp(u, 0.0, 1.0); + auto smooth = u * u * (3.0 - 2.0 * u); + weight *= smooth; + } + + return std::pow(weight, blend_power_); + } + + double domain_weight(const Point& point, const Domain& domain) const { + if (background_blending_) { + return support_taper_weight(point, domain.support_bbox, + domain.spec.bbox()); + } + return legacy_box_weight(point, domain.spec.bbox()); + } + static bool overlap_bbox(const Bbox& a, const Bbox& b, Point& overlap_min, Point& overlap_max) { overlap_min = a.min().cwiseMax(b.min()); @@ -313,12 +374,12 @@ class StructuralInterpolant3 { auto weighted_difference = 0.0; auto weight_sum = 0.0; for (Index sample_i = 0; sample_i < samples.rows(); ++sample_i) { - auto wi = box_weight( + auto wi = domain_weight( samples.row(sample_i), - domains_.at(static_cast(i)).spec.bbox()); - auto wj = box_weight( + domains_.at(static_cast(i))); + auto wj = domain_weight( samples.row(sample_i), - domains_.at(static_cast(j)).spec.bbox()); + domains_.at(static_cast(j))); auto overlap_weight = std::sqrt(wi * wj); auto level_distance = @@ -391,6 +452,7 @@ class StructuralInterpolant3 { double outside_value_; double blend_power_; double alignment_strength_; + bool background_blending_; bool fitted_{}; std::vector domains_; Bbox bbox_; diff --git a/python/src/polatory/__init__.py b/python/src/polatory/__init__.py index 96305aa69..d8ac4097f 100644 --- a/python/src/polatory/__init__.py +++ b/python/src/polatory/__init__.py @@ -1,6 +1,11 @@ from ._core import * from ._core import __doc__, __version__ from ._structural import * +from .automatic_domain_builder import AutomaticStructuralDomainDiagnostics3 +from .leapfrog_automatic_domain_builder_fixed import ( + AutomaticStructuralDomainBuilder3, + LeapfrogAutomaticDomainBuilder3, +) from .clustered_domain_builder import ( ClusteredStructuralDomainBuilder3, fit_from_meshes_clustered, diff --git a/python/src/polatory/automatic_domain_builder.py b/python/src/polatory/automatic_domain_builder.py new file mode 100644 index 000000000..672f77f7f --- /dev/null +++ b/python/src/polatory/automatic_domain_builder.py @@ -0,0 +1,632 @@ +"""Automatic structural SubDomainer reconstructed from Leapfrog evidence. + +The builder is independent of decoded Leapfrog labels. It creates a deterministic +regular cloud of structural centroids, samples the LVA matrix field on that cloud, +and performs adjacency-constrained agglomeration. The resulting point labels are +then passed through :class:`LabeledStructuralDomainBuilder3`, so the recovered +post-cluster support, local-range and bounding-box rules remain shared with the +oracle-label path. + +The public parameters mirror the controls observed in Leapfrog projects: + +* ``centroid_count`` (normally 6000), +* minimum and maximum cluster fractions, +* a matrix-consistency threshold. + +No benchmark coordinates, case names, point labels or domain counts are stored in +this module. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from heapq import heappop, heappush +from itertools import product +from math import ceil, exp, floor, log +from typing import Sequence + +import numpy as np + +from ._structural import StructuralDomain3, StructuralDomainBuilder3, StructuralTrendType +from .labeled_domain_builder import ( + LabeledStructuralDomainBuilder3, + LabeledStructuralDomainDiagnostics3, + sample_single_input_anisotropies3, +) + + +@dataclass(frozen=True) +class AutomaticStructuralDomainDiagnostics3: + """Diagnostics from one automatic SubDomainer build.""" + + labels: np.ndarray + centroid_points: np.ndarray + centroid_labels: np.ndarray + centroid_grid_shape: tuple[int, int, int] + active_axes: np.ndarray + minimum_points: int + maximum_points: int + consistency_threshold: float + merge_count: int + final_domain_count: int + postcluster: tuple[LabeledStructuralDomainDiagnostics3, ...] + + +def _positive_divisors(value: int) -> list[int]: + result: list[int] = [] + limit = int(value**0.5) + for candidate in range(1, limit + 1): + if value % candidate != 0: + continue + result.append(candidate) + other = value // candidate + if other != candidate: + result.append(other) + return sorted(result) + + +def _factor_grid_shape( + count: int, + spans: np.ndarray, + active_axes: np.ndarray, +) -> tuple[int, int, int]: + """Factor ``count`` into an aspect-ratio-aware three-dimensional grid.""" + + if count <= 0: + raise ValueError("centroid_count must be positive") + + active_indices = np.flatnonzero(active_axes) + dimensions = np.ones(3, dtype=np.int64) + active_count = len(active_indices) + if active_count == 0: + active_indices = np.arange(3, dtype=np.int64) + active_count = 3 + + active_spans = np.asarray(spans[active_indices], dtype=float) + positive = active_spans[active_spans > 0.0] + if len(positive) == 0: + active_spans = np.ones(active_count, dtype=float) + else: + active_spans = np.maximum(active_spans, positive.min() * 1e-12) + + span_log = np.log(active_spans) + span_log -= span_log.mean() + + candidates: list[tuple[int, ...]] = [] + if active_count == 1: + candidates = [(count,)] + elif active_count == 2: + for first in _positive_divisors(count): + candidates.append((first, count // first)) + else: + for first in _positive_divisors(count): + remaining = count // first + for second in _positive_divisors(remaining): + if remaining % second == 0: + candidates.append((first, second, remaining // second)) + + def candidate_score(candidate: tuple[int, ...]) -> tuple[float, tuple[int, ...]]: + candidate_log = np.log(np.asarray(candidate, dtype=float)) + candidate_log -= candidate_log.mean() + score = float(np.sum((candidate_log - span_log) ** 2)) + return score, candidate + + best = min(candidates, key=candidate_score) + dimensions[active_indices] = np.asarray(best, dtype=np.int64) + return tuple(int(value) for value in dimensions) + + +def _grid_centroids( + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> tuple[np.ndarray, tuple[np.ndarray, np.ndarray, np.ndarray]]: + axes: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1 or not maximum[axis] > minimum[axis]: + axes.append(np.asarray([(minimum[axis] + maximum[axis]) * 0.5])) + else: + step = (maximum[axis] - minimum[axis]) / size + axes.append(minimum[axis] + (np.arange(size, dtype=float) + 0.5) * step) + + xx, yy, zz = np.meshgrid(*axes, indexing="ij") + points = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + return points, (axes[0], axes[1], axes[2]) + + +def _point_cell_indices( + points: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> np.ndarray: + indices = np.zeros((len(points), 3), dtype=np.int64) + for axis, size in enumerate(shape): + span = maximum[axis] - minimum[axis] + if size <= 1 or not span > 0.0: + continue + normalized = (points[:, axis] - minimum[axis]) / span + indices[:, axis] = np.clip( + np.floor(normalized * size).astype(np.int64), + 0, + size - 1, + ) + return (indices[:, 0] * shape[1] + indices[:, 1]) * shape[2] + indices[:, 2] + + +def _grid_edges(shape: tuple[int, int, int]) -> np.ndarray: + grid = np.arange(np.prod(shape), dtype=np.int64).reshape(shape) + edges: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1: + continue + left = [slice(None), slice(None), slice(None)] + right = [slice(None), slice(None), slice(None)] + left[axis] = slice(0, size - 1) + right[axis] = slice(1, size) + edges.append( + np.column_stack( + [grid[tuple(left)].ravel(), grid[tuple(right)].ravel()] + ) + ) + if not edges: + return np.empty((0, 2), dtype=np.int64) + return np.vstack(edges) + + +def _determinant_normalized(matrix: np.ndarray) -> np.ndarray: + symmetric = 0.5 * (matrix + matrix.T) + eigenvalues = np.linalg.eigvalsh(symmetric) + if not np.all(eigenvalues > 0.0): + raise ValueError("anisotropy matrices must be positive definite") + determinant = float(np.prod(eigenvalues)) + return symmetric / determinant ** (1.0 / 3.0) + + +def _matrix_similarity(first: np.ndarray, second: np.ndarray) -> float: + """Affine-invariant SPD similarity in the interval ``(0, 1]``.""" + + first = _determinant_normalized(first) + second = _determinant_normalized(second) + eigenvalues, eigenvectors = np.linalg.eigh(first) + inverse_sqrt = (eigenvectors * eigenvalues ** -0.5) @ eigenvectors.T + relative = inverse_sqrt @ second @ inverse_sqrt + relative_eigenvalues = np.linalg.eigvalsh(0.5 * (relative + relative.T)) + relative_eigenvalues = np.maximum(relative_eigenvalues, np.finfo(float).tiny) + distance = float(np.linalg.norm(np.log(relative_eigenvalues)) / np.sqrt(3.0)) + return exp(-distance) + + +class AutomaticStructuralDomainBuilder3: + """Build structural domains without external or decoded cluster labels. + + Parameters + ---------- + centroid_count: + Number of deterministic structural centroids. The observed Leapfrog + default is 6000, but any positive factorable count is supported. + minimum_cluster_fraction, maximum_cluster_fraction: + Minimum and maximum final core populations as fractions of the input + interpolation points. Values are resolved with ``ceil`` and ``floor``. + consistency_threshold: + Minimum affine-invariant similarity for an adjacent merge. + base_range: + Base CovSpheroidal3 range used by the exact post-cluster builder. + support_multiplier: + Maximum support-to-core population used by the exact post-cluster rule. + minimum_support_points: + Minimum support population for every local solve. + """ + + def __init__( + self, + centroid_count: int = 6000, + minimum_cluster_fraction: float = 0.001, + maximum_cluster_fraction: float = 0.10, + consistency_threshold: float = 0.60, + base_range: float = 0.0, + support_multiplier: int = 5, + minimum_support_points: int = 1, + ) -> None: + if centroid_count <= 0: + raise ValueError("centroid_count must be positive") + if not 0.0 < minimum_cluster_fraction <= 1.0: + raise ValueError("minimum_cluster_fraction must be in (0, 1]") + if not 0.0 < maximum_cluster_fraction <= 1.0: + raise ValueError("maximum_cluster_fraction must be in (0, 1]") + if minimum_cluster_fraction > maximum_cluster_fraction: + raise ValueError( + "minimum_cluster_fraction must not exceed maximum_cluster_fraction" + ) + if not 0.0 < consistency_threshold <= 1.0: + raise ValueError("consistency_threshold must be in (0, 1]") + if base_range < 0.0: + raise ValueError("base_range must be non-negative") + if support_multiplier <= 0: + raise ValueError("support_multiplier must be positive") + if minimum_support_points <= 0: + raise ValueError("minimum_support_points must be positive") + + self.centroid_count = int(centroid_count) + self.minimum_cluster_fraction = float(minimum_cluster_fraction) + self.maximum_cluster_fraction = float(maximum_cluster_fraction) + self.consistency_threshold = float(consistency_threshold) + self.base_range = float(base_range) + self.support_multiplier = int(support_multiplier) + self.minimum_support_points = int(minimum_support_points) + + self.labels_: np.ndarray | None = None + self.centroid_labels_: np.ndarray | None = None + self.centroid_points_: np.ndarray | None = None + self.centroid_grid_shape_: tuple[int, int, int] | None = None + self.active_axes_: np.ndarray | None = None + self.diagnostics_: AutomaticStructuralDomainDiagnostics3 | None = None + + @staticmethod + def _validate( + points: np.ndarray, + anisotropies: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + points = np.asarray(points, dtype=float) + anisotropies = np.asarray(anisotropies, dtype=float) + if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0: + raise ValueError("points must have shape (n, 3) and must not be empty") + if anisotropies.shape != (len(points), 3, 3): + raise ValueError("anisotropies must have shape (n, 3, 3)") + if not np.all(np.isfinite(points)) or not np.all(np.isfinite(anisotropies)): + raise ValueError("points and anisotropies must be finite") + for matrix in anisotropies: + _determinant_normalized(matrix) + return points, anisotropies + + def _automatic_labels( + self, + points: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + ) -> tuple[np.ndarray, np.ndarray, int, int, int]: + centroid_total = len(centroid_anisotropies) + point_cells = _point_cell_indices(points, minimum, maximum, shape) + point_counts = np.bincount(point_cells, minlength=centroid_total).astype(np.int64) + + minimum_points = max(1, int(ceil(self.minimum_cluster_fraction * len(points)))) + maximum_points = max( + minimum_points, + int(floor(self.maximum_cluster_fraction * len(points))), + ) + + parent = np.arange(centroid_total, dtype=np.int64) + version = np.zeros(centroid_total, dtype=np.int64) + centroid_counts = np.ones(centroid_total, dtype=np.int64) + matrix_sums = centroid_anisotropies.copy() + component_points = point_counts.copy() + active = np.ones(centroid_total, dtype=bool) + neighbours: list[set[int]] = [set() for _ in range(centroid_total)] + + def root(value: int) -> int: + current = value + while parent[current] != current: + parent[current] = parent[parent[current]] + current = int(parent[current]) + return current + + def mean_matrix(value: int) -> np.ndarray: + return matrix_sums[value] / centroid_counts[value] + + def similarity(first: int, second: int) -> float: + return _matrix_similarity(mean_matrix(first), mean_matrix(second)) + + edges = _grid_edges(shape) + for first, second in edges: + neighbours[int(first)].add(int(second)) + neighbours[int(second)].add(int(first)) + + heap: list[tuple[float, int, int, int, int, int]] = [] + + def push_edge(first: int, second: int) -> None: + first = root(first) + second = root(second) + if first == second or not active[first] or not active[second]: + return + if first > second: + first, second = second, first + score = similarity(first, second) + combined_points = int(component_points[first] + component_points[second]) + heappush( + heap, + ( + -score, + combined_points, + first, + second, + int(version[first]), + int(version[second]), + ), + ) + + for first, second in edges: + push_edge(int(first), int(second)) + + merge_count = 0 + + def merge(first: int, second: int) -> int: + nonlocal merge_count + first = root(first) + second = root(second) + if first == second: + return first + # The smaller persistent ID wins, making ties reproducible. + if first > second: + first, second = second, first + parent[second] = first + active[second] = False + centroid_counts[first] += centroid_counts[second] + matrix_sums[first] += matrix_sums[second] + component_points[first] += component_points[second] + version[first] += 1 + version[second] += 1 + + combined_neighbours = neighbours[first] | neighbours[second] + combined_neighbours.discard(first) + combined_neighbours.discard(second) + neighbours[first] = set() + neighbours[second] = set() + for neighbour in combined_neighbours: + neighbour = root(neighbour) + if neighbour == first or not active[neighbour]: + continue + neighbours[first].add(neighbour) + neighbours[neighbour].discard(second) + neighbours[neighbour].discard(first) + neighbours[neighbour].add(first) + merge_count += 1 + for neighbour in sorted(neighbours[first]): + push_edge(first, neighbour) + return first + + # Main adjacency-constrained merge pass. Empty geometric cells may merge + # freely, but a populated component may never exceed the requested maximum. + while heap: + negative_score, _, first, second, first_version, second_version = heappop(heap) + first = root(first) + second = root(second) + if first == second or not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + push_edge(first, second) + continue + score = -negative_score + if score < self.consistency_threshold: + break + combined_points = int(component_points[first] + component_points[second]) + if combined_points > maximum_points: + continue + merge(first, second) + + # Absorb undersized populated components into their most compatible + # neighbour. This mirrors the UI minimum-population control while keeping + # the operation deterministic and spatially local. + changed = True + while changed: + changed = False + roots = [index for index in range(centroid_total) if active[index]] + roots.sort(key=lambda item: (component_points[item], item)) + for first in roots: + if not active[first] or component_points[first] == 0: + continue + if component_points[first] >= minimum_points: + continue + candidates: list[tuple[int, float, int]] = [] + for neighbour in sorted(neighbours[first]): + neighbour = root(neighbour) + if neighbour == first or not active[neighbour]: + continue + combined = int(component_points[first] + component_points[neighbour]) + overflow = max(0, combined - maximum_points) + candidates.append((overflow, -similarity(first, neighbour), neighbour)) + if not candidates: + continue + _, _, second = min(candidates) + merge(first, second) + changed = True + break + + roots_for_cells = np.asarray([root(index) for index in range(centroid_total)]) + roots_for_points = roots_for_cells[point_cells] + populated_roots = np.unique(roots_for_points) + + # Canonical domain numbering is based on the spatial centroid of owned + # interpolation points, not on transient merge IDs. + ordering: list[tuple[tuple[float, float, float], int]] = [] + for component in populated_roots: + owned = points[roots_for_points == component] + centre = tuple(float(value) for value in owned.mean(axis=0)) + ordering.append((centre, int(component))) + ordering.sort() + label_for_root = {component: label for label, (_, component) in enumerate(ordering)} + + labels = np.asarray([label_for_root[int(value)] for value in roots_for_points], dtype=np.int64) + centroid_labels = np.full(centroid_total, -1, dtype=np.int64) + for index, component in enumerate(roots_for_cells): + if int(component) in label_for_root: + centroid_labels[index] = label_for_root[int(component)] + + return labels, centroid_labels, minimum_points, maximum_points, merge_count + + def build( + self, + points: np.ndarray, + anisotropies: np.ndarray, + model_parameters: Sequence[float], + ) -> list[StructuralDomain3]: + """Build automatic domains from already sampled point anisotropies.""" + + points, anisotropies = self._validate(points, anisotropies) + minimum = points.min(axis=0) + maximum = points.max(axis=0) + spans = maximum - minimum + active_axes = spans > max(float(spans.max()), 1.0) * 1e-12 + if not np.any(active_axes): + active_axes[:] = True + + shape = _factor_grid_shape(self.centroid_count, spans, active_axes) + centroid_points, _ = _grid_centroids(minimum, maximum, shape) + + # Interpolate point matrices to centroids only when callers provide no + # structural inputs. Nearest input-point sampling preserves SPD matrices + # and keeps this lower-level API dependency-free. + try: + from scipy.spatial import cKDTree # type: ignore + except ImportError: + nearest = np.empty(len(centroid_points), dtype=np.int64) + best = np.full(len(centroid_points), np.inf) + for start in range(0, len(points), 1024): + stop = min(start + 1024, len(points)) + difference = centroid_points[:, None, :] - points[None, start:stop, :] + squared = np.einsum("cpi,cpi->cp", difference, difference, optimize=True) + local = np.argmin(squared, axis=1) + local_squared = squared[np.arange(len(centroid_points)), local] + replace = local_squared < best + best[replace] = local_squared[replace] + nearest[replace] = start + local[replace] + else: + nearest = np.asarray(cKDTree(points).query(centroid_points, k=1)[1], dtype=np.int64) + centroid_anisotropies = anisotropies[nearest] + + labels, centroid_labels, minimum_points, maximum_points, merge_count = self._automatic_labels( + points, + centroid_anisotropies, + minimum, + maximum, + shape, + ) + + postcluster_builder = LabeledStructuralDomainBuilder3( + base_range=self.base_range, + support_multiplier=self.support_multiplier, + minimum_support_points=self.minimum_support_points, + ) + domains = postcluster_builder.build( + points, + labels, + anisotropies, + model_parameters, + ) + postcluster = tuple(postcluster_builder.diagnostics_ or ()) + + self.labels_ = labels.copy() + self.centroid_labels_ = centroid_labels.copy() + self.centroid_points_ = centroid_points.copy() + self.centroid_grid_shape_ = shape + self.active_axes_ = active_axes.copy() + self.diagnostics_ = AutomaticStructuralDomainDiagnostics3( + labels=labels.copy(), + centroid_points=centroid_points.copy(), + centroid_labels=centroid_labels.copy(), + centroid_grid_shape=shape, + active_axes=active_axes.copy(), + minimum_points=minimum_points, + maximum_points=maximum_points, + consistency_threshold=self.consistency_threshold, + merge_count=merge_count, + final_domain_count=len(domains), + postcluster=postcluster, + ) + return domains + + def build_from_inputs( + self, + points: np.ndarray, + inputs: Sequence[object], + model_parameters: Sequence[float], + trend_type: object = StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) -> list[StructuralDomain3]: + """Sample the LVA field and build automatic structural domains.""" + + points = np.asarray(points, dtype=float) + inputs = list(inputs) + if not inputs: + raise ValueError("inputs must not be empty") + + if len(inputs) == 1: + non_decaying = trend_type == StructuralTrendType.NON_DECAYING + point_anisotropies = sample_single_input_anisotropies3( + points, + inputs[0], + non_decaying=non_decaying, + ) + else: + samples = StructuralDomainBuilder3().sample(points, inputs, trend_type) + point_anisotropies = np.asarray(samples.anisotropies, dtype=float) + + minimum = points.min(axis=0) + maximum = points.max(axis=0) + spans = maximum - minimum + active_axes = spans > max(float(spans.max()), 1.0) * 1e-12 + if not np.any(active_axes): + active_axes[:] = True + shape = _factor_grid_shape(self.centroid_count, spans, active_axes) + centroid_points, _ = _grid_centroids(minimum, maximum, shape) + + if len(inputs) == 1: + centroid_anisotropies = sample_single_input_anisotropies3( + centroid_points, + inputs[0], + non_decaying=trend_type == StructuralTrendType.NON_DECAYING, + ) + else: + centroid_samples = StructuralDomainBuilder3().sample( + centroid_points, + inputs, + trend_type, + ) + centroid_anisotropies = np.asarray( + centroid_samples.anisotropies, + dtype=float, + ) + + labels, centroid_labels, minimum_points, maximum_points, merge_count = self._automatic_labels( + points, + centroid_anisotropies, + minimum, + maximum, + shape, + ) + + postcluster_builder = LabeledStructuralDomainBuilder3( + base_range=self.base_range, + support_multiplier=self.support_multiplier, + minimum_support_points=self.minimum_support_points, + ) + domains = postcluster_builder.build( + points, + labels, + point_anisotropies, + model_parameters, + ) + postcluster = tuple(postcluster_builder.diagnostics_ or ()) + + self.labels_ = labels.copy() + self.centroid_labels_ = centroid_labels.copy() + self.centroid_points_ = centroid_points.copy() + self.centroid_grid_shape_ = shape + self.active_axes_ = active_axes.copy() + self.diagnostics_ = AutomaticStructuralDomainDiagnostics3( + labels=labels.copy(), + centroid_points=centroid_points.copy(), + centroid_labels=centroid_labels.copy(), + centroid_grid_shape=shape, + active_axes=active_axes.copy(), + minimum_points=minimum_points, + maximum_points=maximum_points, + consistency_threshold=self.consistency_threshold, + merge_count=merge_count, + final_domain_count=len(domains), + postcluster=postcluster, + ) + return domains + + +LeapfrogAutomaticDomainBuilder3 = AutomaticStructuralDomainBuilder3 diff --git a/python/src/polatory/leapfrog_automatic_domain_builder.py b/python/src/polatory/leapfrog_automatic_domain_builder.py new file mode 100644 index 000000000..0756636b6 --- /dev/null +++ b/python/src/polatory/leapfrog_automatic_domain_builder.py @@ -0,0 +1,435 @@ +"""Leapfrog-style automatic structural domain builder. + +This implementation replaces the earlier pairwise-SPD-similarity approximation +with the grid-seeded region-growing algorithm recovered from Leapfrog Geo +2026.1 runtime profiles: + +* an approximately requested structured centroid grid; +* one initial domain per centroid; +* six-connected adjacency; +* point-count-weighted arithmetic matrix means; +* determinant consistency of the proposed merged matrix; +* global greedy heap ordering with lazy invalidation; +* hard centroid-population maximum during merging; +* real interpolation points transferred to final grid domains afterward. +""" +from __future__ import annotations + +from heapq import heappop, heappush +from math import floor +from typing import Sequence + +import numpy as np + +from ._structural import StructuralDomain3, StructuralDomainBuilder3, StructuralTrendType +from .automatic_domain_builder import AutomaticStructuralDomainDiagnostics3 +from .labeled_domain_builder import ( + LabeledStructuralDomainBuilder3, + sample_single_input_anisotropies3, +) + + +def _leapfrog_grid_shape( + target_count: int, + spans: np.ndarray, + active_axes: np.ndarray, +) -> tuple[int, int, int]: + """Return a near-isotropic grid with at least ``target_count`` centroids.""" + if target_count <= 0: + raise ValueError("centroid_count must be positive") + dimensions = np.ones(3, dtype=np.int64) + indices = np.flatnonzero(active_axes) + if len(indices) == 0: + indices = np.arange(3, dtype=np.int64) + active_spans = np.asarray(spans[indices], dtype=float) + positive = active_spans[active_spans > 0.0] + if len(positive) == 0: + active_spans = np.ones(len(indices), dtype=float) + else: + active_spans = np.maximum(active_spans, positive.min() * 1.0e-12) + + density = (float(target_count) / float(np.prod(active_spans))) ** (1.0 / len(indices)) + counts = np.maximum(np.rint(active_spans * density).astype(np.int64), 2) + while int(np.prod(counts)) < target_count: + cell_sizes = active_spans / np.maximum(counts - 1, 1) + counts[int(np.argmax(cell_sizes))] += 1 + dimensions[indices] = counts + return tuple(int(value) for value in dimensions) + + +def _grid_centroids( + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> np.ndarray: + axes: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1 or not maximum[axis] > minimum[axis]: + axes.append(np.asarray([(minimum[axis] + maximum[axis]) * 0.5])) + else: + step = (maximum[axis] - minimum[axis]) / size + axes.append(minimum[axis] + (np.arange(size, dtype=float) + 0.5) * step) + xx, yy, zz = np.meshgrid(*axes, indexing="ij") + return np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + + +def _point_cell_indices( + points: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], +) -> np.ndarray: + indices = np.zeros((len(points), 3), dtype=np.int64) + for axis, size in enumerate(shape): + span = maximum[axis] - minimum[axis] + if size <= 1 or not span > 0.0: + continue + normalized = (points[:, axis] - minimum[axis]) / span + indices[:, axis] = np.clip( + np.floor(normalized * size).astype(np.int64), 0, size - 1 + ) + return (indices[:, 0] * shape[1] + indices[:, 1]) * shape[2] + indices[:, 2] + + +def _grid_edges(shape: tuple[int, int, int]) -> np.ndarray: + grid = np.arange(np.prod(shape), dtype=np.int64).reshape(shape) + edges: list[np.ndarray] = [] + for axis, size in enumerate(shape): + if size <= 1: + continue + left = [slice(None), slice(None), slice(None)] + right = [slice(None), slice(None), slice(None)] + left[axis] = slice(0, size - 1) + right[axis] = slice(1, size) + edges.append( + np.column_stack([grid[tuple(left)].ravel(), grid[tuple(right)].ravel()]) + ) + return np.vstack(edges) if edges else np.empty((0, 2), dtype=np.int64) + + +def _symmetric_determinant(matrix: np.ndarray) -> float: + matrix = 0.5 * (matrix + matrix.T) + a, b, c = float(matrix[0, 0]), float(matrix[0, 1]), float(matrix[0, 2]) + e, f, i = float(matrix[1, 1]), float(matrix[1, 2]), float(matrix[2, 2]) + return a * e * i + 2.0 * b * c * f - a * f * f - e * c * c - i * b * b + + +def _normalise_determinant(matrix: np.ndarray) -> np.ndarray: + symmetric = 0.5 * (matrix + matrix.T) + determinant = _symmetric_determinant(symmetric) + if not np.isfinite(determinant) or determinant <= 0.0: + raise ValueError("anisotropy matrices must be positive definite") + return symmetric / determinant ** (1.0 / 3.0) + + +def _merged_matrix_and_consistency( + first_matrix: np.ndarray, + first_size: int, + second_matrix: np.ndarray, + second_size: int, +) -> tuple[np.ndarray, float]: + total = first_size + second_size + weight = first_size / float(total) + merged = weight * first_matrix + (1.0 - weight) * second_matrix + merged = 0.5 * (merged + merged.T) + determinant = _symmetric_determinant(merged) + consistency = float("-inf") if determinant <= 0.0 else float(1.0 / determinant) + return merged, consistency + + +class AutomaticStructuralDomainBuilder3: + """Build structural domains using Leapfrog-style grid region growing.""" + + def __init__( + self, + centroid_count: int = 6000, + minimum_cluster_fraction: float = 0.001, + maximum_cluster_fraction: float = 0.10, + consistency_threshold: float = 0.60, + base_range: float = 0.0, + support_multiplier: int = 5, + minimum_support_points: int = 1, + ) -> None: + if centroid_count <= 0: + raise ValueError("centroid_count must be positive") + if not 0.0 < minimum_cluster_fraction <= maximum_cluster_fraction <= 1.0: + raise ValueError("cluster fractions must satisfy 0 < minimum <= maximum <= 1") + if not 0.0 < consistency_threshold <= 1.0: + raise ValueError("consistency_threshold must be in (0, 1]") + if base_range < 0.0: + raise ValueError("base_range must be non-negative") + if support_multiplier <= 0 or minimum_support_points <= 0: + raise ValueError("support settings must be positive") + self.centroid_count = int(centroid_count) + self.minimum_cluster_fraction = float(minimum_cluster_fraction) + self.maximum_cluster_fraction = float(maximum_cluster_fraction) + self.consistency_threshold = float(consistency_threshold) + self.base_range = float(base_range) + self.support_multiplier = int(support_multiplier) + self.minimum_support_points = int(minimum_support_points) + self.labels_: np.ndarray | None = None + self.centroid_labels_: np.ndarray | None = None + self.centroid_points_: np.ndarray | None = None + self.centroid_grid_shape_: tuple[int, int, int] | None = None + self.active_axes_: np.ndarray | None = None + self.diagnostics_: AutomaticStructuralDomainDiagnostics3 | None = None + + @staticmethod + def _validate( + points: np.ndarray, + anisotropies: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + points = np.asarray(points, dtype=float) + anisotropies = np.asarray(anisotropies, dtype=float) + if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0: + raise ValueError("points must have shape (n, 3) and must not be empty") + if anisotropies.shape != (len(points), 3, 3): + raise ValueError("anisotropies must have shape (n, 3, 3)") + if not np.all(np.isfinite(points)) or not np.all(np.isfinite(anisotropies)): + raise ValueError("points and anisotropies must be finite") + anisotropies = np.asarray([_normalise_determinant(m) for m in anisotropies]) + return points, anisotropies + + def _automatic_labels( + self, + points: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + ) -> tuple[np.ndarray, np.ndarray, int, int, int]: + total = len(centroid_anisotropies) + point_cells = _point_cell_indices(points, minimum, maximum, shape) + minimum_points = max(1, int(floor(self.minimum_cluster_fraction * total))) + maximum_points = max( + minimum_points, + int(floor(self.maximum_cluster_fraction * total)), + ) + + active = np.ones(total, dtype=bool) + version = np.zeros(total, dtype=np.int64) + sizes = np.ones(total, dtype=np.int64) + matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in centroid_anisotropies], + dtype=float, + ) + leaves: list[list[int]] = [[index] for index in range(total)] + neighbours: list[set[int]] = [set() for _ in range(2 * total + 1)] + edges = _grid_edges(shape) + for first, second in edges: + neighbours[int(first)].add(int(second)) + neighbours[int(second)].add(int(first)) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if not active[first] or not active[second] or first == second: + return + if sizes[first] + sizes[second] > maximum_points: + return + _, consistency = _merged_matrix_and_consistency( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + if not np.isfinite(consistency): + return + low, high = sorted((first, second)) + heappush( + heap, + (-consistency, low, high, int(version[low]), int(version[high])), + ) + + for first, second in edges: + push(int(first), int(second)) + + next_id = total + merge_count = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if first >= len(active) or second >= len(active): + continue + if not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + continue + if second not in neighbours[first] or first not in neighbours[second]: + continue + consistency = -negative + if consistency < self.consistency_threshold: + break + if sizes[first] + sizes[second] > maximum_points: + continue + + if next_id >= len(active): + grow = max(total, next_id - len(active) + 1) + active = np.pad(active, (0, grow)) + version = np.pad(version, (0, grow)) + sizes = np.pad(sizes, (0, grow)) + matrices = np.pad(matrices, ((0, grow), (0, 0), (0, 0))) + leaves.extend([] for _ in range(grow)) + neighbours.extend(set() for _ in range(grow)) + + merged_matrix, _ = _merged_matrix_and_consistency( + matrices[first], int(sizes[first]), matrices[second], int(sizes[second]) + ) + merged_neighbours = (neighbours[first] | neighbours[second]) - {first, second} + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + + active[next_id] = True + sizes[next_id] = sizes[first] + sizes[second] + matrices[next_id] = merged_matrix + leaves[next_id] = leaves[first] + leaves[second] + neighbours[next_id] = set() + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + version[neighbour] += 1 + neighbours[next_id].add(neighbour) + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + next_id += 1 + merge_count += 1 + + owner = np.empty(total, dtype=np.int64) + surviving_ids: list[int] = [] + for domain_id in range(next_id): + if not active[domain_id]: + continue + surviving_ids.append(domain_id) + owner[np.asarray(leaves[domain_id], dtype=np.int64)] = domain_id + + point_domains = owner[point_cells] + populated = np.unique(point_domains) + ordering: list[tuple[tuple[float, float, float], int]] = [] + for domain_id in populated: + owned = points[point_domains == domain_id] + ordering.append((tuple(float(v) for v in owned.mean(axis=0)), int(domain_id))) + ordering.sort() + labels_by_id = {domain_id: label for label, (_, domain_id) in enumerate(ordering)} + labels = np.asarray([labels_by_id[int(v)] for v in point_domains], dtype=np.int64) + centroid_labels = np.full(total, -1, dtype=np.int64) + for index, domain_id in enumerate(owner): + label = labels_by_id.get(int(domain_id)) + if label is not None: + centroid_labels[index] = label + return labels, centroid_labels, minimum_points, maximum_points, merge_count + + def _prepare_grid(self, points: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[int, int, int], np.ndarray]: + minimum = points.min(axis=0) + maximum = points.max(axis=0) + spans = maximum - minimum + active_axes = spans > max(float(spans.max()), 1.0) * 1.0e-12 + if not np.any(active_axes): + active_axes[:] = True + shape = _leapfrog_grid_shape(self.centroid_count, spans, active_axes) + centroids = _grid_centroids(minimum, maximum, shape) + return minimum, maximum, active_axes, shape, centroids + + def _finish( + self, + points: np.ndarray, + point_anisotropies: np.ndarray, + centroid_anisotropies: np.ndarray, + model_parameters: Sequence[float], + minimum: np.ndarray, + maximum: np.ndarray, + active_axes: np.ndarray, + shape: tuple[int, int, int], + centroid_points: np.ndarray, + ) -> list[StructuralDomain3]: + labels, centroid_labels, minimum_points, maximum_points, merge_count = self._automatic_labels( + points, centroid_anisotropies, minimum, maximum, shape + ) + postcluster_builder = LabeledStructuralDomainBuilder3( + base_range=self.base_range, + support_multiplier=self.support_multiplier, + minimum_support_points=self.minimum_support_points, + ) + domains = postcluster_builder.build( + points, labels, point_anisotropies, model_parameters + ) + self.labels_ = labels.copy() + self.centroid_labels_ = centroid_labels.copy() + self.centroid_points_ = centroid_points.copy() + self.centroid_grid_shape_ = shape + self.active_axes_ = active_axes.copy() + self.diagnostics_ = AutomaticStructuralDomainDiagnostics3( + labels=labels.copy(), + centroid_points=centroid_points.copy(), + centroid_labels=centroid_labels.copy(), + centroid_grid_shape=shape, + active_axes=active_axes.copy(), + minimum_points=minimum_points, + maximum_points=maximum_points, + consistency_threshold=self.consistency_threshold, + merge_count=merge_count, + final_domain_count=len(domains), + postcluster=tuple(postcluster_builder.diagnostics_ or ()), + ) + return domains + + def build( + self, + points: np.ndarray, + anisotropies: np.ndarray, + model_parameters: Sequence[float], + ) -> list[StructuralDomain3]: + points, anisotropies = self._validate(points, anisotropies) + minimum, maximum, active_axes, shape, centroids = self._prepare_grid(points) + try: + from scipy.spatial import cKDTree # type: ignore + except ImportError: + squared = np.sum((centroids[:, None, :] - points[None, :, :]) ** 2, axis=2) + nearest = np.argmin(squared, axis=1) + else: + nearest = np.asarray(cKDTree(points).query(centroids, k=1)[1], dtype=np.int64) + centroid_anisotropies = anisotropies[nearest] + return self._finish( + points, anisotropies, centroid_anisotropies, model_parameters, + minimum, maximum, active_axes, shape, centroids, + ) + + def build_from_inputs( + self, + points: np.ndarray, + inputs: Sequence[object], + model_parameters: Sequence[float], + trend_type: object = StructuralTrendType.STRONGEST_ALONG_INPUTS, + ) -> list[StructuralDomain3]: + points = np.asarray(points, dtype=float) + inputs = list(inputs) + if not inputs: + raise ValueError("inputs must not be empty") + minimum, maximum, active_axes, shape, centroids = self._prepare_grid(points) + if len(inputs) == 1: + non_decaying = trend_type == StructuralTrendType.NON_DECAYING + point_anisotropies = sample_single_input_anisotropies3( + points, inputs[0], non_decaying=non_decaying + ) + centroid_anisotropies = sample_single_input_anisotropies3( + centroids, inputs[0], non_decaying=non_decaying + ) + else: + sampler = StructuralDomainBuilder3() + point_anisotropies = np.asarray( + sampler.sample(points, inputs, trend_type).anisotropies, dtype=float + ) + centroid_anisotropies = np.asarray( + sampler.sample(centroids, inputs, trend_type).anisotropies, dtype=float + ) + _, point_anisotropies = self._validate(points, point_anisotropies) + centroid_anisotropies = np.asarray( + [_normalise_determinant(matrix) for matrix in centroid_anisotropies], dtype=float + ) + return self._finish( + points, point_anisotropies, centroid_anisotropies, model_parameters, + minimum, maximum, active_axes, shape, centroids, + ) + + +LeapfrogAutomaticDomainBuilder3 = AutomaticStructuralDomainBuilder3 diff --git a/python/src/polatory/leapfrog_automatic_domain_builder_fixed.py b/python/src/polatory/leapfrog_automatic_domain_builder_fixed.py new file mode 100644 index 000000000..c3ca6573f --- /dev/null +++ b/python/src/polatory/leapfrog_automatic_domain_builder_fixed.py @@ -0,0 +1,184 @@ +"""Corrected Leapfrog-style automatic structural domain builder. + +This module keeps the recovered grid, adjacency, merge-matrix, size-limit, and +point-transfer behaviour from :mod:`leapfrog_automatic_domain_builder`, while +fixing the lazy-heap invalidation semantics. + +When two domains merge, an unchanged neighbouring domain changes only one +adjacency: edges to the two retired domains are replaced by an edge to the new +merged domain. Its matrix, population, and edges to every other active domain +remain unchanged. Incrementing that neighbour's version invalidates all of +those still-valid heap pairs and can make region growing stop prematurely. +Only retired or otherwise numerically changed domains should invalidate their +existing heap entries. +""" +from __future__ import annotations + +from heapq import heappop, heappush +from math import floor + +import numpy as np + +from .leapfrog_automatic_domain_builder import ( + AutomaticStructuralDomainBuilder3 as _PreviousAutomaticStructuralDomainBuilder3, + _grid_centroids, + _grid_edges, + _leapfrog_grid_shape, + _merged_matrix_and_consistency, + _normalise_determinant, + _point_cell_indices, + _symmetric_determinant, +) + + +class AutomaticStructuralDomainBuilder3(_PreviousAutomaticStructuralDomainBuilder3): + """Grid-seeded region grower with correct lazy-heap preservation.""" + + def _automatic_labels( + self, + points: np.ndarray, + centroid_anisotropies: np.ndarray, + minimum: np.ndarray, + maximum: np.ndarray, + shape: tuple[int, int, int], + ) -> tuple[np.ndarray, np.ndarray, int, int, int]: + total = len(centroid_anisotropies) + point_cells = _point_cell_indices(points, minimum, maximum, shape) + minimum_points = max(1, int(floor(self.minimum_cluster_fraction * total))) + maximum_points = max( + minimum_points, + int(floor(self.maximum_cluster_fraction * total)), + ) + + active = np.ones(total, dtype=bool) + version = np.zeros(total, dtype=np.int64) + sizes = np.ones(total, dtype=np.int64) + matrices = np.asarray( + [_normalise_determinant(matrix) for matrix in centroid_anisotropies], + dtype=float, + ) + leaves: list[list[int]] = [[index] for index in range(total)] + neighbours: list[set[int]] = [set() for _ in range(2 * total + 1)] + edges = _grid_edges(shape) + for first, second in edges: + neighbours[int(first)].add(int(second)) + neighbours[int(second)].add(int(first)) + + heap: list[tuple[float, int, int, int, int]] = [] + + def push(first: int, second: int) -> None: + if not active[first] or not active[second] or first == second: + return + if sizes[first] + sizes[second] > maximum_points: + return + _, consistency = _merged_matrix_and_consistency( + matrices[first], + int(sizes[first]), + matrices[second], + int(sizes[second]), + ) + if not np.isfinite(consistency): + return + low, high = sorted((first, second)) + heappush( + heap, + (-consistency, low, high, int(version[low]), int(version[high])), + ) + + for first, second in edges: + push(int(first), int(second)) + + next_id = total + merge_count = 0 + while heap: + negative, first, second, first_version, second_version = heappop(heap) + if first >= len(active) or second >= len(active): + continue + if not active[first] or not active[second]: + continue + if version[first] != first_version or version[second] != second_version: + continue + if second not in neighbours[first] or first not in neighbours[second]: + continue + consistency = -negative + if consistency < self.consistency_threshold: + break + if sizes[first] + sizes[second] > maximum_points: + continue + + if next_id >= len(active): + grow = max(total, next_id - len(active) + 1) + active = np.pad(active, (0, grow)) + version = np.pad(version, (0, grow)) + sizes = np.pad(sizes, (0, grow)) + matrices = np.pad(matrices, ((0, grow), (0, 0), (0, 0))) + leaves.extend([] for _ in range(grow)) + neighbours.extend(set() for _ in range(grow)) + + merged_matrix, _ = _merged_matrix_and_consistency( + matrices[first], + int(sizes[first]), + matrices[second], + int(sizes[second]), + ) + merged_neighbours = (neighbours[first] | neighbours[second]) - { + first, + second, + } + active[first] = False + active[second] = False + version[first] += 1 + version[second] += 1 + + active[next_id] = True + sizes[next_id] = sizes[first] + sizes[second] + matrices[next_id] = merged_matrix + leaves[next_id] = leaves[first] + leaves[second] + neighbours[next_id] = set() + for neighbour in sorted(merged_neighbours): + if not active[neighbour]: + continue + neighbours[neighbour].discard(first) + neighbours[neighbour].discard(second) + neighbours[neighbour].add(next_id) + # Preserve the neighbour's version. Its matrix, size, and all + # unrelated active edges are unchanged, so their heap entries + # remain valid. Edges to first/second are rejected because those + # domains are inactive; the new edge is pushed below. + neighbours[next_id].add(neighbour) + for neighbour in sorted(neighbours[next_id]): + push(next_id, neighbour) + next_id += 1 + merge_count += 1 + + owner = np.empty(total, dtype=np.int64) + for domain_id in range(next_id): + if not active[domain_id]: + continue + owner[np.asarray(leaves[domain_id], dtype=np.int64)] = domain_id + + point_domains = owner[point_cells] + populated = np.unique(point_domains) + ordering: list[tuple[tuple[float, float, float], int]] = [] + for domain_id in populated: + owned = points[point_domains == domain_id] + ordering.append( + (tuple(float(value) for value in owned.mean(axis=0)), int(domain_id)) + ) + ordering.sort() + labels_by_id = { + domain_id: label for label, (_, domain_id) in enumerate(ordering) + } + labels = np.asarray( + [labels_by_id[int(domain_id)] for domain_id in point_domains], + dtype=np.int64, + ) + centroid_labels = np.full(total, -1, dtype=np.int64) + for index, domain_id in enumerate(owner): + label = labels_by_id.get(int(domain_id)) + if label is not None: + centroid_labels[index] = label + return labels, centroid_labels, minimum_points, maximum_points, merge_count + + +LeapfrogAutomaticDomainBuilder3 = AutomaticStructuralDomainBuilder3 diff --git a/python/structural_binding.cpp b/python/structural_binding.cpp index 6ac54e3ff..3ae48d3e1 100644 --- a/python/structural_binding.cpp +++ b/python/structural_binding.cpp @@ -169,11 +169,12 @@ PYBIND11_MODULE(_structural, m) { "model_parameters"_a = std::vector{}); py::class_(m, "StructuralInterpolant3") - .def(py::init&, double, double, double>(), + .def(py::init&, double, double, double, bool>(), "base_model"_a, "outside_value"_a = -1.0, "blend_power"_a = 1.0, - "alignment_strength"_a = 0.0) + "alignment_strength"_a = 0.0, + "background_blending"_a = false) .def_property_readonly("bbox_min", [](const StructuralInterpolant& interpolant) { return Eigen::Vector3d(interpolant.bbox().min().transpose()); }) @@ -183,6 +184,8 @@ PYBIND11_MODULE(_structural, m) { .def_property_readonly("blend_power", &StructuralInterpolant::blend_power) .def_property_readonly("alignment_strength", &StructuralInterpolant::alignment_strength) + .def_property_readonly("background_blending", + &StructuralInterpolant::background_blending) .def_property_readonly("domain_offsets", &StructuralInterpolant::domain_offsets) .def_property_readonly("num_domains", &StructuralInterpolant::num_domains) diff --git a/python/tests/test_automatic_domain_builder.py b/python/tests/test_automatic_domain_builder.py new file mode 100644 index 000000000..1567b47b3 --- /dev/null +++ b/python/tests/test_automatic_domain_builder.py @@ -0,0 +1,144 @@ +import numpy as np + +from polatory.automatic_domain_builder import AutomaticStructuralDomainBuilder3 +from polatory.labeled_domain_builder import LabeledStructuralDomainBuilder3 + + +class _TrendInput: + vertices = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 20.0, 0.0], + [20.0, 0.0, 5.0], + [20.0, 20.0, 5.0], + ] + ) + faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int64) + strength = 5.0 + range = 10.0 + + +def _points() -> np.ndarray: + x = np.linspace(-5.0, 35.0, 8) + y = np.linspace(-2.0, 22.0, 5) + z = np.linspace(-8.0, 18.0, 4) + xx, yy, zz = np.meshgrid(x, y, z, indexing="ij") + return np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + + +def _builder() -> AutomaticStructuralDomainBuilder3: + return AutomaticStructuralDomainBuilder3( + centroid_count=120, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.20, + consistency_threshold=0.60, + base_range=30.0, + minimum_support_points=2, + ) + + +def test_automatic_builder_is_deterministic_and_assigns_every_point(): + points = _points() + first = _builder() + second = _builder() + + first_domains = first.build_from_inputs( + points, + [_TrendInput()], + model_parameters=[0.0, 10.0, 30.0], + ) + second_domains = second.build_from_inputs( + points, + [_TrendInput()], + model_parameters=[0.0, 10.0, 30.0], + ) + + np.testing.assert_array_equal(first.labels_, second.labels_) + np.testing.assert_array_equal(first.centroid_labels_, second.centroid_labels_) + assert len(first.labels_) == len(points) + assert np.all(first.labels_ >= 0) + assert len(np.unique(first.labels_)) == len(first_domains) + assert len(first_domains) == len(second_domains) + assert np.prod(first.centroid_grid_shape_) == first.centroid_count + + +def test_maximum_population_and_arbitrary_strength_range(): + points = _points() + + class OtherInput(_TrendInput): + strength = 2.75 + range = 17.5 + + builder = AutomaticStructuralDomainBuilder3( + centroid_count=150, + minimum_cluster_fraction=0.001, + maximum_cluster_fraction=0.25, + consistency_threshold=0.55, + base_range=42.0, + ) + domains = builder.build_from_inputs( + points, + [OtherInput()], + model_parameters=[0.0, 12.0, 42.0], + ) + + counts = np.bincount(builder.labels_) + assert counts.max() <= int(np.floor(0.25 * len(points))) + assert len(domains) == len(counts) + assert builder.diagnostics_.maximum_points == int(np.floor(0.25 * len(points))) + assert builder.diagnostics_.final_domain_count == len(domains) + + +def test_generated_labels_use_exact_postcluster_builder(): + points = _points() + builder = _builder() + builder.build_from_inputs( + points, + [_TrendInput()], + model_parameters=[0.0, 10.0, 30.0], + ) + + from polatory.labeled_domain_builder import sample_single_input_anisotropies3 + + matrices = sample_single_input_anisotropies3(points, _TrendInput()) + exact = LabeledStructuralDomainBuilder3( + base_range=30.0, + minimum_support_points=2, + ) + expected = exact.compute(points, builder.labels_, matrices) + actual = builder.diagnostics_.postcluster + + assert len(actual) == len(expected) + for observed, reference in zip(actual, expected): + assert observed.label == reference.label + np.testing.assert_array_equal(observed.core_indices, reference.core_indices) + np.testing.assert_array_equal(observed.support_indices, reference.support_indices) + np.testing.assert_allclose(observed.anisotropy, reference.anisotropy) + np.testing.assert_allclose(observed.bbox_min, reference.bbox_min) + np.testing.assert_allclose(observed.bbox_max, reference.bbox_max) + np.testing.assert_allclose( + observed.local_kernel_range, + reference.local_kernel_range, + ) + + +def test_point_order_does_not_change_spatial_partition(): + points = _points() + permutation = np.random.default_rng(1234).permutation(len(points)) + + original = _builder() + shuffled = _builder() + original.build_from_inputs( + points, + [_TrendInput()], + model_parameters=[0.0, 10.0, 30.0], + ) + shuffled.build_from_inputs( + points[permutation], + [_TrendInput()], + model_parameters=[0.0, 10.0, 30.0], + ) + + restored = np.empty_like(shuffled.labels_) + restored[permutation] = shuffled.labels_ + np.testing.assert_array_equal(original.labels_, restored)