from __future__ import annotations

import heapq
import importlib
import math
import random
import warnings
from dataclasses import dataclass
from heapq import heappop, heappush, nsmallest
from typing import Any, Iterable, Literal, Sequence, cast

from .equations import accessibility_index, area_similarity, clamp, coordinate_distance, corridor_strength, directional_alignment, exponential_kernel, gaussian_kernel, gravity_model, inverse_distance_weight, linear_interpolate, log_likelihood_ratio, manhattan_distance_2d, min_max_scale, prompt_decay, prompt_influence, prompt_interaction, row_normalize, semivariance, shannon_entropy, sigmoid, thin_plate_spline_basis, variogram_spherical
from .geometry import Geometry, geometry_area, geometry_bounds, geometry_centroid, geometry_contains, geometry_convex_hull, geometry_distance, geometry_envelope, geometry_intersects, geometry_intersects_bounds, geometry_length, geometry_type, geometry_vertices, geometry_within, geometry_within_bounds, normalize_geometry, transform_geometry
from .overlay import buffer_geometries, clip_geometries, dissolve_geometries, geometry_from_shapely, overlay_intersections, overlay_union_faces, polygon_split_faces
from .spatial_index import SpatialIndex


Record = dict[str, Any]
BoundsQueryMode = Literal["intersects", "within", "centroid"]
SpatialJoinPredicate = Literal["intersects", "within", "contains"]
SpatialJoinMode = Literal["inner", "left"]
AggregationName = Literal["sum", "mean", "min", "max", "first", "count"]
OverlayNormalizeMode = Literal["left", "right", "both"]
ZoneGroupAggregation = Literal["max", "mean", "sum"]
CorridorDistanceMode = Literal["direct", "network"]
CorridorScoreMode = Literal["distance", "strength", "alignment", "combined"]
ClusterRecommendMetric = Literal["silhouette", "sse"]
CorridorPathAnchor = Literal["start", "end", "nearest"]
GridShape = Literal["fishnet", "hexbin"]
SpatialLagMode = Literal["k_nearest", "distance_band", "intersects"]
SpatialLagWeightMode = Literal["binary", "inverse_distance"]
ChangeClass = Literal["unchanged", "moved", "modified", "split", "merge", "removed", "added"]
TrajectoryTransitionMode = Literal["network_cost", "hmm"]


Coordinate = tuple[float, float]

_equation_coordinate_distance = coordinate_distance
_geometry_distance = geometry_distance


def coordinate_distance(origin: Coordinate, destination: Coordinate, method: str = "euclidean") -> float:
    return _equation_coordinate_distance(origin, destination, method)


def geometry_distance(origin: Geometry, destination: Geometry, method: str = "euclidean") -> float:
    return _geometry_distance(origin, destination, method)


def _row_sort_key(row: Record) -> str:
    return str(row.get("site_id", row.get("region_id", "")))


def _bounds_intersect(left: tuple[float, float, float, float], right: tuple[float, float, float, float]) -> bool:
    return not (
        left[2] < right[0]
        or left[0] > right[2]
        or left[3] < right[1]
        or left[1] > right[3]
    )


def _bounds_within(candidate: tuple[float, float, float, float], container: tuple[float, float, float, float]) -> bool:
    return (
        candidate[0] >= container[0]
        and candidate[1] >= container[1]
        and candidate[2] <= container[2]
        and candidate[3] <= container[3]
    )


def _pivot_row_index(matrix: Sequence[Sequence[float]], start_row: int, column: int, stop_row: int | None = None) -> int:
    pivot_row = start_row
    row_limit = len(matrix) if stop_row is None else stop_row
    for row_index in range(start_row + 1, row_limit):
        if abs(matrix[row_index][column]) > abs(matrix[pivot_row][column]):
            pivot_row = row_index
    return pivot_row


@dataclass(frozen=True)
class Bounds:
    min_x: float
    min_y: float
    max_x: float
    max_y: float


class SpatialWeights:
    """Reusable spatial weights matrix.

    Construct via class methods, then pass to compatible tools or
    transform and inspect directly.
    """

    def __init__(self, neighbors: dict[int, list[tuple[int, float]]]) -> None:
        self._neighbors = neighbors

    # ------------------------------------------------------------------
    # Construction
    # ------------------------------------------------------------------

    @classmethod
    def from_knn(
        cls,
        frame: "GeoPromptFrame",
        k: int = 4,
        distance_method: str = "euclidean",
    ) -> "SpatialWeights":
        """Build *k*-nearest-neighbor weights."""
        centroids = frame._centroids()
        n = len(centroids)
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        neighbors: dict[int, list[tuple[int, float]]] = {}
        for i in range(n):
            dists = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
            neighbors[i] = [(j, d) for j, d in dists[:k_actual]]
        return cls(neighbors)

    @classmethod
    def from_distance_band(
        cls,
        frame: "GeoPromptFrame",
        max_distance: float,
        distance_method: str = "euclidean",
    ) -> "SpatialWeights":
        """Build distance-band (fixed-threshold) weights."""
        centroids = frame._centroids()
        n = len(centroids)
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        neighbors: dict[int, list[tuple[int, float]]] = {}
        for i in range(n):
            neighbors[i] = [
                (j, dist_matrix[i][j])
                for j in range(n)
                if j != i and dist_matrix[i][j] <= max_distance
            ]
        return cls(neighbors)

    # ------------------------------------------------------------------
    # Transformations
    # ------------------------------------------------------------------

    def transform(self, mode: str = "R") -> "SpatialWeights":
        """Return a new weights object with transformed values.

        ``mode`` is one of:

        * ``'R'`` — row-standardize (each row sums to 1)
        * ``'B'`` — binary (all non-zero become 1)
        * ``'D'`` — inverse-distance (weight = 1/d)
        """
        new_neighbors: dict[int, list[tuple[int, float]]] = {}
        for i, nbs in self._neighbors.items():
            if mode == "R":
                total = sum(w for _, w in nbs) if nbs else 1.0
                new_neighbors[i] = [(j, w / total) for j, w in nbs] if total > 0 else list(nbs)
            elif mode == "B":
                new_neighbors[i] = [(j, 1.0) for j, _ in nbs]
            elif mode == "D":
                new_neighbors[i] = [(j, 1.0 / max(d, 1e-12)) for j, d in nbs]
            else:
                raise ValueError(f"unsupported transform mode: {mode!r}")
        return SpatialWeights(new_neighbors)

    # ------------------------------------------------------------------
    # Access
    # ------------------------------------------------------------------

    @property
    def n(self) -> int:
        return len(self._neighbors)

    def neighbors_of(self, i: int) -> list[tuple[int, float]]:
        return self._neighbors.get(i, [])

    def to_dense(self) -> list[list[float]]:
        """Return dense n×n weight matrix."""
        n = self.n
        mat = [[0.0] * n for _ in range(n)]
        for i, nbs in self._neighbors.items():
            for j, w in nbs:
                mat[i][j] = w
        return mat

    def to_dict(self) -> dict[int, list[tuple[int, float]]]:
        return dict(self._neighbors)

    def __repr__(self) -> str:
        edges = sum(len(v) for v in self._neighbors.values())
        return f"SpatialWeights(n={self.n}, edges={edges})"


class GeoPromptFrame:
    def __init__(self, rows: Sequence[Record], geometry_column: str = "geometry", crs: str | None = None) -> None:
        self.geometry_column = geometry_column
        self.crs = crs
        self._rows = [dict(row) for row in rows]
        self._cache: dict[tuple[Any, ...], Any] = {}
        for row in self._rows:
            row[self.geometry_column] = normalize_geometry(row[self.geometry_column])

    @classmethod
    def from_records(cls, records: Iterable[Record], geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        return cls(list(records), geometry_column=geometry, crs=crs)

    @classmethod
    def _from_internal_rows(
        cls,
        rows: Sequence[Record],
        geometry_column: str = "geometry",
        crs: str | None = None,
    ) -> "GeoPromptFrame":
        frame = cls.__new__(cls)
        frame.geometry_column = geometry_column
        frame.crs = crs
        frame._rows = [dict(row) for row in rows]
        frame._cache = {}
        return frame

    def __len__(self) -> int:
        return len(self._rows)

    def __iter__(self):
        return iter(self._rows)

    def __repr__(self) -> str:
        geometry_col = self.geometry_column
        row_count = len(self._rows)
        col_count = len(self.columns)
        crs_label = self.crs or "None"
        return f"GeoPromptFrame({row_count} rows, {col_count} columns, crs={crs_label})"

    def __getitem__(self, name: str) -> list[Any]:
        return [row.get(name) for row in self._rows]

    @property
    def columns(self) -> list[str]:
        return list(self._rows[0].keys()) if self._rows else []

    def head(self, count: int = 5) -> list[Record]:
        return [dict(row) for row in self._rows[:count]]

    def to_records(self) -> list[Record]:
        return [dict(row) for row in self._rows]

    def copy(self) -> "GeoPromptFrame":
        return GeoPromptFrame._from_internal_rows(self.to_records(), geometry_column=self.geometry_column, crs=self.crs)

    def bounds(self) -> Bounds:
        xs: list[float] = []
        ys: list[float] = []
        for row in self._rows:
            min_x, min_y, max_x, max_y = geometry_bounds(row[self.geometry_column])
            xs.extend([min_x, max_x])
            ys.extend([min_y, max_y])
        return Bounds(min_x=min(xs), min_y=min(ys), max_x=max(xs), max_y=max(ys))

    def centroid(self) -> Coordinate:
        centroids = [geometry_centroid(row[self.geometry_column]) for row in self._rows]
        xs = [coord[0] for coord in centroids]
        ys = [coord[1] for coord in centroids]
        return (sum(xs) / len(xs), sum(ys) / len(ys))

    def _centroids(self, rows: Sequence[Record] | None = None, geometry_column: str | None = None) -> list[Coordinate]:
        active_rows = rows if rows is not None else self._rows
        active_geometry_column = geometry_column or self.geometry_column
        return [geometry_centroid(row[active_geometry_column]) for row in active_rows]

    def spatial_index(self, mode: Literal["geometry", "centroid"] = "geometry", cell_size: float | None = None) -> SpatialIndex:
        cache_key = ("spatial_index", mode, None if cell_size is None else float(cell_size))
        if cache_key in self._cache:
            return self._cache[cache_key]
        if mode == "geometry":
            index = SpatialIndex([geometry_bounds(row[self.geometry_column]) for row in self._rows], cell_size=cell_size)
            self._cache[cache_key] = index
            return index
        if mode == "centroid":
            index = SpatialIndex.from_points(self._centroids(), cell_size=cell_size)
            self._cache[cache_key] = index
            return index
        raise ValueError("mode must be 'geometry' or 'centroid'")

    def _cached_distance_matrix(self, method: str = "euclidean") -> list[list[float]]:
        """Return a pairwise distance matrix, cached for reuse across tools."""
        cache_key = ("distance_matrix", method)
        if cache_key in self._cache:
            return self._cache[cache_key]
        centroids = self._centroids()
        n = len(centroids)
        if method == "euclidean":
            matrix = [[0.0] * n for _ in range(n)]
            for i in range(n):
                for j in range(i + 1, n):
                    d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                    matrix[i][j] = d
                    matrix[j][i] = d
        else:
            matrix = [[0.0] * n for _ in range(n)]
            for i in range(n):
                for j in range(i + 1, n):
                    d = coordinate_distance(centroids[i], centroids[j], method=method)
                    matrix[i][j] = d
                    matrix[j][i] = d
        self._cache[cache_key] = matrix
        return matrix

    def _cached_knn(self, k: int, method: str = "euclidean") -> list[list[int]]:
        """Return k-nearest neighbor indices for each feature, cached for reuse."""
        cache_key = ("knn", k, method)
        if cache_key in self._cache:
            return self._cache[cache_key]
        dm = self._cached_distance_matrix(method)
        n = len(dm)
        knn: list[list[int]] = []
        for i in range(n):
            neighbors = sorted(range(n), key=lambda j: dm[i][j])
            knn.append([j for j in neighbors if j != i][:k])
        self._cache[cache_key] = knn
        return knn

    def _crs_is_geographic(self) -> bool:
        """Return True when the frame CRS appears to be geographic (degrees)."""
        if not self.crs:
            return False
        crs_label = str(self.crs).upper()
        return any(token in crs_label for token in ("4326", "4269", "CRS84", "GEOGCS", "GEOGRAPHICCRS"))

    def _hydrology_flow_model(
        self,
        elevation_column: str,
        k: int = 8,
    ) -> tuple[list[Coordinate], list[float], list[int | None], list[float], list[float], list[float]]:
        """Build a simple D8-like steepest-descent flow model over point centroids.

        The model uses the steepest downhill neighbour among the *k* nearest
        centroid neighbours as a proxy for raster D8 routing when working with
        sampled grid points.
        """
        self._require_column(elevation_column)
        centroids = self._centroids()
        n = len(centroids)
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        if n == 0:
            return centroids, elevations, [], [], [], []
        dm = self._cached_distance_matrix("euclidean")
        k_actual = min(max(k, 1), max(n - 1, 0))
        flow_to: list[int | None] = [None] * n
        flow_slope = [0.0] * n
        flow_drop = [0.0] * n
        flow_distance = [0.0] * n
        for i in range(n):
            if k_actual <= 0:
                continue
            dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda item: item[1])
            best_j: int | None = None
            best_slope = 0.0
            best_drop = 0.0
            best_distance = float("inf")
            for j, distance in dists[:k_actual]:
                if distance < 1e-12:
                    continue
                drop = elevations[i] - elevations[j]
                if drop <= 0:
                    continue
                slope = drop / distance
                if (
                    slope > best_slope + 1e-12
                    or (abs(slope - best_slope) <= 1e-12 and drop > best_drop + 1e-12)
                    or (abs(slope - best_slope) <= 1e-12 and abs(drop - best_drop) <= 1e-12 and distance < best_distance)
                ):
                    best_j = j
                    best_slope = slope
                    best_drop = drop
                    best_distance = distance
            flow_to[i] = best_j
            flow_slope[i] = best_slope
            flow_drop[i] = best_drop
            flow_distance[i] = 0.0 if best_j is None else best_distance
        accumulation = [1.0] * n
        order = sorted(range(n), key=lambda idx: (-elevations[idx], idx))
        for i in order:
            if flow_to[i] is not None:
                accumulation[flow_to[i]] += accumulation[i]  # type: ignore[index]
        return centroids, elevations, flow_to, accumulation, flow_slope, flow_distance

    def _resolve_anchor_geometry(self, anchor: str | Geometry | Coordinate, id_column: str) -> tuple[Geometry, str | None]:
        if isinstance(anchor, str):
            self._require_column(id_column)
            anchor_row = next((row for row in self._rows if str(row[id_column]) == anchor), None)
            if anchor_row is None:
                raise KeyError(f"anchor '{anchor}' was not found in column '{id_column}'")
            return anchor_row[self.geometry_column], anchor
        return normalize_geometry(anchor), None

    def _aggregate_rows(
        self,
        rows: Sequence[Record],
        aggregations: dict[str, AggregationName] | None,
        suffix: str,
    ) -> dict[str, Any]:
        aggregate_values: dict[str, Any] = {}
        for column, operation in (aggregations or {}).items():
            values = [row[column] for row in rows if column in row and row[column] is not None]
            output_name = f"{column}_{operation}_{suffix}"
            if not values:
                aggregate_values[output_name] = None
                continue
            if operation == "sum":
                aggregate_values[output_name] = sum(float(value) for value in values)
            elif operation == "mean":
                aggregate_values[output_name] = sum(float(value) for value in values) / len(values)
            elif operation == "min":
                aggregate_values[output_name] = min(values)
            elif operation == "max":
                aggregate_values[output_name] = max(values)
            elif operation == "first":
                aggregate_values[output_name] = values[0]
            elif operation == "count":
                aggregate_values[output_name] = len(values)
            else:
                raise ValueError(f"unsupported aggregation: {operation}")
        return aggregate_values

    def distance_matrix(self, distance_method: str = "euclidean") -> list[list[float]]:
        return [
            [
                geometry_distance(origin=row[self.geometry_column], destination=other[self.geometry_column], method=distance_method)
                for other in self._rows
            ]
            for row in self._rows
        ]

    def geometry_types(self) -> list[str]:
        return [geometry_type(row[self.geometry_column]) for row in self._rows]

    def geometry_lengths(self) -> list[float]:
        return [geometry_length(row[self.geometry_column]) for row in self._rows]

    def geometry_areas(self) -> list[float]:
        return [geometry_area(row[self.geometry_column]) for row in self._rows]

    def nearest_neighbors(
        self,
        id_column: str = "site_id",
        k: int = 1,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        self._require_column(id_column)
        if k <= 0:
            raise ValueError("k must be greater than zero")

        centroids = self._centroids()
        geometry_types = self.geometry_types()
        nearest: list[Record] = []
        for origin_index, origin in enumerate(self._rows):
            candidates = [
                (
                    destination,
                    geometry_types[destination_index],
                    coordinate_distance(centroids[origin_index], centroids[destination_index], method=distance_method),
                )
                for destination_index, destination in enumerate(self._rows)
                if destination_index != origin_index
            ]
            for rank, (destination, destination_geometry_type, distance_value) in enumerate(
                nsmallest(k, candidates, key=lambda item: (float(item[2]), _row_sort_key(item[0]))),
                start=1,
            ):
                nearest.append(
                    {
                        "origin": origin[id_column],
                        "neighbor": destination[id_column],
                        "distance": distance_value,
                        "origin_geometry_type": geometry_types[origin_index],
                        "neighbor_geometry_type": destination_geometry_type,
                        "rank": rank,
                        "distance_method": distance_method,
                    }
                )
        return nearest

    def _nearest_row_matches(
        self,
        origin_centroid: Coordinate,
        right_rows: Sequence[Record],
        right_centroids: Sequence[Coordinate],
        k: int,
        distance_method: str,
        max_distance: float | None = None,
        candidate_indexes: Sequence[int] | None = None,
    ) -> list[tuple[Record, float]]:
        candidates: list[tuple[Record, float]] = []
        indexes = candidate_indexes if candidate_indexes is not None else range(len(right_rows))
        for index in indexes:
            right_row = right_rows[index]
            right_centroid = right_centroids[index]
            if max_distance is not None and distance_method == "euclidean":
                if abs(origin_centroid[0] - right_centroid[0]) > max_distance or abs(origin_centroid[1] - right_centroid[1]) > max_distance:
                    continue
            distance_value = coordinate_distance(origin_centroid, right_centroid, method=distance_method)
            if max_distance is None or distance_value <= max_distance:
                candidates.append((right_row, distance_value))
        return nsmallest(k, candidates, key=lambda item: (float(item[1]), _row_sort_key(item[0])))

    def nearest_join(
        self,
        other: "GeoPromptFrame",
        k: int = 1,
        how: SpatialJoinMode = "inner",
        lsuffix: str = "left",
        rsuffix: str = "right",
        max_distance: float | None = None,
        distance_method: str = "euclidean",
    ) -> "GeoPromptFrame":
        if k <= 0:
            raise ValueError("k must be greater than zero")
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before nearest joins")

        right_rows = list(other._rows)
        right_columns = [column for column in other.columns if column != other.geometry_column]
        left_centroids = self._centroids()
        right_centroids = other._centroids()
        right_index = other.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and max_distance is not None and right_rows else None
        joined_rows: list[Record] = []

        for left_row, left_centroid in zip(self._rows, left_centroids, strict=True):
            candidate_indexes = None
            if right_index is not None and max_distance is not None:
                candidate_indexes = right_index.query(
                    (
                        left_centroid[0] - max_distance,
                        left_centroid[1] - max_distance,
                        left_centroid[0] + max_distance,
                        left_centroid[1] + max_distance,
                    )
                )
            row_matches = self._nearest_row_matches(
                origin_centroid=left_centroid,
                right_rows=right_rows,
                right_centroids=right_centroids,
                k=k,
                distance_method=distance_method,
                max_distance=max_distance,
                candidate_indexes=candidate_indexes,
            )

            if not row_matches and how == "left":
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = None
                merged_row[f"{other.geometry_column}_{rsuffix}"] = None
                merged_row[f"distance_{rsuffix}"] = None
                merged_row[f"distance_method_{rsuffix}"] = distance_method
                merged_row[f"nearest_rank_{rsuffix}"] = None
                joined_rows.append(merged_row)

            for rank, (right_row, distance_value) in enumerate(row_matches, start=1):
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = right_row[column]
                merged_row[f"{other.geometry_column}_{rsuffix}"] = right_row[other.geometry_column]
                merged_row[f"distance_{rsuffix}"] = distance_value
                merged_row[f"distance_method_{rsuffix}"] = distance_method
                merged_row[f"nearest_rank_{rsuffix}"] = rank
                joined_rows.append(merged_row)

        return GeoPromptFrame._from_internal_rows(joined_rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def assign_nearest(
        self,
        targets: "GeoPromptFrame",
        how: SpatialJoinMode = "inner",
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        origin_suffix: str = "origin",
    ) -> "GeoPromptFrame":
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        return targets.nearest_join(
            self,
            k=1,
            how=how,
            rsuffix=origin_suffix,
            max_distance=max_distance,
            distance_method=distance_method,
        )

    def summarize_assignments(
        self,
        targets: "GeoPromptFrame",
        origin_id_column: str = "site_id",
        target_id_column: str = "site_id",
        aggregations: dict[str, AggregationName] | None = None,
        how: SpatialJoinMode = "left",
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        assignment_suffix: str = "assigned",
    ) -> "GeoPromptFrame":
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if self.crs and targets.crs and self.crs != targets.crs:
            raise ValueError("frames must share the same CRS before assignment summaries")

        self._require_column(origin_id_column)
        targets._require_column(target_id_column)

        origin_centroids = self._centroids()
        target_rows = list(targets._rows)
        target_centroids = targets._centroids()
        origin_index = self.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and max_distance is not None and self._rows else None
        assignments: dict[str, list[tuple[Record, float]]] = {}

        for target_row, target_centroid in zip(target_rows, target_centroids, strict=True):
            candidate_indexes = None
            if origin_index is not None and max_distance is not None:
                candidate_indexes = origin_index.query(
                    (
                        target_centroid[0] - max_distance,
                        target_centroid[1] - max_distance,
                        target_centroid[0] + max_distance,
                        target_centroid[1] + max_distance,
                    )
                )
            row_matches = self._nearest_row_matches(
                origin_centroid=target_centroid,
                right_rows=self._rows,
                right_centroids=origin_centroids,
                k=1,
                distance_method=distance_method,
                max_distance=max_distance,
                candidate_indexes=candidate_indexes,
            )
            if not row_matches:
                continue
            origin_row, distance_value = row_matches[0]
            origin_key = str(origin_row[origin_id_column])
            assignments.setdefault(origin_key, []).append((target_row, distance_value))

        rows: list[Record] = []
        for origin_row in self._rows:
            origin_key = str(origin_row[origin_id_column])
            assigned_matches = assignments.get(origin_key, [])
            if not assigned_matches and how == "inner":
                continue

            assigned_rows = [row for row, _ in assigned_matches]
            assigned_distances = [distance for _, distance in assigned_matches]
            resolved_row = dict(origin_row)
            resolved_row[f"{target_id_column}s_{assignment_suffix}"] = [str(row[target_id_column]) for row in assigned_rows]
            resolved_row[f"count_{assignment_suffix}"] = len(assigned_rows)
            resolved_row[f"distance_method_{assignment_suffix}"] = distance_method
            resolved_row[f"distance_min_{assignment_suffix}"] = min(assigned_distances) if assigned_distances else None
            resolved_row[f"distance_max_{assignment_suffix}"] = max(assigned_distances) if assigned_distances else None
            resolved_row[f"distance_mean_{assignment_suffix}"] = (
                sum(assigned_distances) / len(assigned_distances) if assigned_distances else None
            )
            resolved_row.update(self._aggregate_rows(assigned_rows, aggregations=aggregations, suffix=assignment_suffix))
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or targets.crs)

    def catchment_competition(
        self,
        targets: "GeoPromptFrame",
        max_distance: float,
        origin_id_column: str = "site_id",
        target_id_column: str = "site_id",
        aggregations: dict[str, AggregationName] | None = None,
        how: SpatialJoinMode = "left",
        distance_method: str = "euclidean",
        competition_suffix: str = "catchment",
    ) -> "GeoPromptFrame":
        if max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if self.crs and targets.crs and self.crs != targets.crs:
            raise ValueError("frames must share the same CRS before catchment competition summaries")

        self._require_column(origin_id_column)
        targets._require_column(target_id_column)

        origin_centroids = self._centroids()
        target_rows = list(targets._rows)
        target_centroids = targets._centroids()
        origin_index = self.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and self._rows else None

        coverage_buckets: dict[str, dict[str, list[Record]]] = {
            str(origin_row[origin_id_column]): {
                "covered": [],
                "exclusive": [],
                "shared": [],
                "won": [],
            }
            for origin_row in self._rows
        }
        unserved_target_ids: list[str] = []

        for target_row, target_centroid in zip(target_rows, target_centroids, strict=True):
            candidate_indexes = None
            if origin_index is not None:
                candidate_indexes = origin_index.query(
                    (
                        target_centroid[0] - max_distance,
                        target_centroid[1] - max_distance,
                        target_centroid[0] + max_distance,
                        target_centroid[1] + max_distance,
                    )
                )
            row_matches = self._nearest_row_matches(
                origin_centroid=target_centroid,
                right_rows=self._rows,
                right_centroids=origin_centroids,
                k=len(self._rows),
                distance_method=distance_method,
                max_distance=max_distance,
                candidate_indexes=candidate_indexes,
            )
            if not row_matches:
                unserved_target_ids.append(str(target_row[target_id_column]))
                continue

            is_shared = len(row_matches) > 1
            for origin_row, _distance_value in row_matches:
                origin_key = str(origin_row[origin_id_column])
                coverage_buckets[origin_key]["covered"].append(target_row)
                coverage_buckets[origin_key]["shared" if is_shared else "exclusive"].append(target_row)

            winning_origin, _winning_distance = row_matches[0]
            coverage_buckets[str(winning_origin[origin_id_column])]["won"].append(target_row)

        rows: list[Record] = []
        for origin_row in self._rows:
            origin_key = str(origin_row[origin_id_column])
            bucket = coverage_buckets[origin_key]
            covered_rows = bucket["covered"]
            if not covered_rows and how == "inner":
                continue

            resolved_row = dict(origin_row)
            resolved_row[f"{target_id_column}s_{competition_suffix}"] = [str(row[target_id_column]) for row in covered_rows]
            resolved_row[f"count_{competition_suffix}"] = len(covered_rows)
            resolved_row[f"{target_id_column}s_exclusive_{competition_suffix}"] = [
                str(row[target_id_column]) for row in bucket["exclusive"]
            ]
            resolved_row[f"count_exclusive_{competition_suffix}"] = len(bucket["exclusive"])
            resolved_row[f"{target_id_column}s_shared_{competition_suffix}"] = [
                str(row[target_id_column]) for row in bucket["shared"]
            ]
            resolved_row[f"count_shared_{competition_suffix}"] = len(bucket["shared"])
            resolved_row[f"{target_id_column}s_won_{competition_suffix}"] = [
                str(row[target_id_column]) for row in bucket["won"]
            ]
            resolved_row[f"count_won_{competition_suffix}"] = len(bucket["won"])
            resolved_row[f"{target_id_column}s_unserved_{competition_suffix}"] = list(unserved_target_ids)
            resolved_row[f"count_unserved_{competition_suffix}"] = len(unserved_target_ids)
            resolved_row[f"distance_limit_{competition_suffix}"] = max_distance
            resolved_row[f"distance_method_{competition_suffix}"] = distance_method
            resolved_row.update(self._aggregate_rows(covered_rows, aggregations=aggregations, suffix=competition_suffix))
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or targets.crs)

    def query_radius(
        self,
        anchor: str | Geometry | Coordinate,
        max_distance: float,
        id_column: str = "site_id",
        include_anchor: bool = False,
        distance_method: str = "euclidean",
    ) -> "GeoPromptFrame":
        if max_distance < 0:
            raise ValueError("max_distance must be zero or greater")

        anchor_geometry, anchor_id = self._resolve_anchor_geometry(anchor, id_column=id_column)
        anchor_centroid = geometry_centroid(anchor_geometry)
        centroid_index = self.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and self._rows else None
        candidate_indexes = (
            centroid_index.query(
                (
                    anchor_centroid[0] - max_distance,
                    anchor_centroid[1] - max_distance,
                    anchor_centroid[0] + max_distance,
                    anchor_centroid[1] + max_distance,
                )
            )
            if centroid_index is not None
            else list(range(len(self._rows)))
        )

        rows: list[Record] = []
        for index in candidate_indexes:
            row = self._rows[index]
            row_id = str(row.get(id_column)) if id_column in row else None
            distance_value = coordinate_distance(anchor_centroid, geometry_centroid(row[self.geometry_column]), method=distance_method)
            if anchor_id is not None and not include_anchor and row_id == anchor_id:
                continue
            if distance_value <= max_distance:
                resolved_row = dict(row)
                resolved_row["distance"] = distance_value
                resolved_row["distance_method"] = distance_method
                if anchor_id is not None:
                    resolved_row["anchor_id"] = anchor_id
                rows.append(resolved_row)

        rows.sort(key=lambda item: (float(item["distance"]), str(item.get(id_column, ""))))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def within_distance(
        self,
        anchor: str | Geometry | Coordinate,
        max_distance: float,
        id_column: str = "site_id",
        include_anchor: bool = False,
        distance_method: str = "euclidean",
    ) -> list[bool]:
        if max_distance < 0:
            raise ValueError("max_distance must be zero or greater")

        anchor_geometry, anchor_id = self._resolve_anchor_geometry(anchor, id_column=id_column)
        anchor_centroid = geometry_centroid(anchor_geometry)
        centroids = self._centroids()
        centroid_index = self.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and self._rows else None
        candidate_indexes = set(
            centroid_index.query(
                (
                    anchor_centroid[0] - max_distance,
                    anchor_centroid[1] - max_distance,
                    anchor_centroid[0] + max_distance,
                    anchor_centroid[1] + max_distance,
                )
            )
        ) if centroid_index is not None else set(range(len(self._rows)))

        mask: list[bool] = []
        for index, (row, centroid) in enumerate(zip(self._rows, centroids, strict=True)):
            if anchor_id is not None and not include_anchor and id_column in row and str(row[id_column]) == anchor_id:
                mask.append(False)
                continue
            if index not in candidate_indexes:
                mask.append(False)
                continue
            mask.append(coordinate_distance(anchor_centroid, centroid, method=distance_method) <= max_distance)
        return mask

    def proximity_join(
        self,
        other: "GeoPromptFrame",
        max_distance: float,
        how: SpatialJoinMode = "inner",
        lsuffix: str = "left",
        rsuffix: str = "right",
        distance_method: str = "euclidean",
    ) -> "GeoPromptFrame":
        if max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before proximity joins")

        right_rows = list(other._rows)
        right_columns = [column for column in other.columns if column != other.geometry_column]
        left_centroids = self._centroids()
        right_centroids = other._centroids()
        right_index = other.spatial_index(mode="centroid", cell_size=max_distance) if distance_method == "euclidean" and right_rows else None
        joined_rows: list[Record] = []

        for left_row, left_centroid in zip(self._rows, left_centroids, strict=True):
            row_matches: list[tuple[Record, float]] = []
            candidate_indexes = (
                right_index.query(
                    (
                        left_centroid[0] - max_distance,
                        left_centroid[1] - max_distance,
                        left_centroid[0] + max_distance,
                        left_centroid[1] + max_distance,
                    )
                )
                if right_index is not None
                else range(len(right_rows))
            )
            for index in candidate_indexes:
                right_row = right_rows[index]
                right_centroid = right_centroids[index]
                if distance_method == "euclidean":
                    if abs(left_centroid[0] - right_centroid[0]) > max_distance or abs(left_centroid[1] - right_centroid[1]) > max_distance:
                        continue
                distance_value = coordinate_distance(left_centroid, right_centroid, method=distance_method)
                if distance_value <= max_distance:
                    row_matches.append((right_row, distance_value))

            row_matches.sort(key=lambda item: (float(item[1]), str(item[0].get("site_id", item[0].get("region_id", "")))))

            if not row_matches and how == "left":
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = None
                merged_row[f"{other.geometry_column}_{rsuffix}"] = None
                merged_row[f"distance_{rsuffix}"] = None
                merged_row[f"distance_method_{rsuffix}"] = distance_method
                joined_rows.append(merged_row)

            for right_row, distance_value in row_matches:
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = right_row[column]
                merged_row[f"{other.geometry_column}_{rsuffix}"] = right_row[other.geometry_column]
                merged_row[f"distance_{rsuffix}"] = distance_value
                merged_row[f"distance_method_{rsuffix}"] = distance_method
                joined_rows.append(merged_row)

        return GeoPromptFrame._from_internal_rows(joined_rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def buffer(self, distance: float, resolution: int = 16) -> "GeoPromptFrame":
        buffered_groups = buffer_geometries(
            [row[self.geometry_column] for row in self._rows],
            distance=distance,
            resolution=resolution,
        )
        rows: list[Record] = []
        for row, buffered_geometries in zip(self._rows, buffered_groups, strict=True):
            for buffered_geometry in buffered_geometries:
                buffered_row = dict(row)
                buffered_row[self.geometry_column] = buffered_geometry
                rows.append(buffered_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def buffer_join(
        self,
        other: "GeoPromptFrame",
        distance: float,
        how: SpatialJoinMode = "inner",
        lsuffix: str = "left",
        rsuffix: str = "right",
        resolution: int = 16,
    ) -> "GeoPromptFrame":
        if distance < 0:
            raise ValueError("distance must be zero or greater")
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before buffer joins")

        right_rows = list(other._rows)
        right_columns = [column for column in other.columns if column != other.geometry_column]
        right_index = other.spatial_index(mode="geometry") if right_rows else None
        buffered_groups = buffer_geometries(
            [row[self.geometry_column] for row in self._rows],
            distance=distance,
            resolution=resolution,
        )

        joined_rows: list[Record] = []
        for left_row, buffered_geometries in zip(self._rows, buffered_groups, strict=True):
            row_matches: list[tuple[Record, Geometry]] = []
            for buffered_geometry in buffered_geometries:
                buffered_bounds = geometry_bounds(buffered_geometry)
                candidate_indexes = right_index.query(buffered_bounds) if right_index is not None else []
                for index in candidate_indexes:
                    right_row = right_rows[index]
                    right_bound = geometry_bounds(right_row[other.geometry_column])
                    if not _bounds_intersect(buffered_bounds, right_bound):
                        continue
                    if geometry_intersects(buffered_geometry, right_row[other.geometry_column]):
                        row_matches.append((right_row, buffered_geometry))

            if not row_matches and how == "left":
                merged_row = dict(left_row)
                merged_row[f"buffer_geometry_{lsuffix}"] = None
                merged_row[f"buffer_distance_{lsuffix}"] = distance
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = None
                merged_row[f"{other.geometry_column}_{rsuffix}"] = None
                joined_rows.append(merged_row)

            for right_row, buffered_geometry in row_matches:
                merged_row = dict(left_row)
                merged_row[f"buffer_geometry_{lsuffix}"] = buffered_geometry
                merged_row[f"buffer_distance_{lsuffix}"] = distance
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = right_row[column]
                merged_row[f"{other.geometry_column}_{rsuffix}"] = right_row[other.geometry_column]
                joined_rows.append(merged_row)

        return GeoPromptFrame._from_internal_rows(joined_rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def coverage_summary(
        self,
        targets: "GeoPromptFrame",
        predicate: SpatialJoinPredicate = "intersects",
        target_id_column: str = "site_id",
        aggregations: dict[str, AggregationName] | None = None,
        rsuffix: str = "covered",
    ) -> "GeoPromptFrame":
        if self.crs and targets.crs and self.crs != targets.crs:
            raise ValueError("frames must share the same CRS before coverage summaries")
        if predicate not in {"intersects", "within", "contains"}:
            raise ValueError(f"unsupported spatial join predicate: {predicate}")

        targets._require_column(target_id_column)
        target_rows = list(targets._rows)
        target_index = targets.spatial_index(mode="geometry") if target_rows else None
        predicate_bounds_filter = {
            "intersects": _bounds_intersect,
            "within": _bounds_within,
            "contains": lambda left, right: _bounds_within(right, left),
        }

        def matches(left_geometry: Geometry, right_geometry: Geometry) -> bool:
            if predicate == "intersects":
                return geometry_intersects(left_geometry, right_geometry)
            if predicate == "within":
                return geometry_within(left_geometry, right_geometry)
            return geometry_contains(left_geometry, right_geometry)

        rows: list[Record] = []
        for row in self._rows:
            left_geometry = row[self.geometry_column]
            left_bounds = geometry_bounds(left_geometry)
            matched_rows: list[Record] = []
            candidate_indexes = target_index.query(left_bounds) if target_index is not None else []
            for index in candidate_indexes:
                target_row = target_rows[index]
                target_bound = geometry_bounds(target_row[targets.geometry_column])
                if not predicate_bounds_filter[predicate](left_bounds, target_bound):
                    continue
                if matches(left_geometry, target_row[targets.geometry_column]):
                    matched_rows.append(target_row)

            resolved_row = dict(row)
            resolved_row[f"{target_id_column}s_{rsuffix}"] = [str(item[target_id_column]) for item in matched_rows]
            resolved_row[f"count_{rsuffix}"] = len(matched_rows)
            resolved_row[f"predicate_{rsuffix}"] = predicate
            resolved_row.update(self._aggregate_rows(matched_rows, aggregations=aggregations, suffix=rsuffix))
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or targets.crs)

    def query_bounds(
        self,
        min_x: float,
        min_y: float,
        max_x: float,
        max_y: float,
        mode: BoundsQueryMode = "intersects",
    ) -> "GeoPromptFrame":
        if min_x > max_x or min_y > max_y:
            raise ValueError("query bounds must be ordered from minimum to maximum")

        rows: list[Record] = []
        active_index = self.spatial_index(mode="centroid") if mode == "centroid" else self.spatial_index(mode="geometry")
        query_mode = "within" if mode == "within" else "intersects"
        candidate_indexes = active_index.query((min_x, min_y, max_x, max_y), mode=query_mode)
        for index in candidate_indexes:
            row = self._rows[index]
            geometry = row[self.geometry_column]
            if mode == "intersects":
                include_row = geometry_intersects_bounds(geometry, min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y)
            elif mode == "within":
                include_row = geometry_within_bounds(geometry, min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y)
            elif mode == "centroid":
                centroid_x, centroid_y = geometry_centroid(geometry)
                include_row = min_x <= centroid_x <= max_x and min_y <= centroid_y <= max_y
            else:
                raise ValueError(f"unsupported bounds query mode: {mode}")

            if include_row:
                rows.append(dict(row))

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def set_crs(self, crs: str, allow_override: bool = False) -> "GeoPromptFrame":
        if self.crs is not None and self.crs != crs and not allow_override:
            raise ValueError("frame already has a CRS; pass allow_override=True to replace it")
        return GeoPromptFrame(rows=self.to_records(), geometry_column=self.geometry_column, crs=crs)

    def to_crs(self, target_crs: str) -> "GeoPromptFrame":
        if self.crs is None:
            raise ValueError("frame CRS is not set; call set_crs before reprojecting")
        if self.crs == target_crs:
            return GeoPromptFrame(rows=self.to_records(), geometry_column=self.geometry_column, crs=self.crs)

        try:
            pyproj = importlib.import_module("pyproj")
        except ImportError as exc:
            raise RuntimeError("Install projection support with 'pip install -e .[projection]' before calling to_crs.") from exc

        transformer = pyproj.Transformer.from_crs(self.crs, target_crs, always_xy=True)

        def reproject_coordinate(coordinate: Coordinate) -> Coordinate:
            x_value, y_value = transformer.transform(coordinate[0], coordinate[1])
            return (float(x_value), float(y_value))

        rows = self.to_records()
        for row in rows:
            row[self.geometry_column] = transform_geometry(row[self.geometry_column], reproject_coordinate)
        return GeoPromptFrame(rows=rows, geometry_column=self.geometry_column, crs=target_crs)

    def spatial_join(
        self,
        other: "GeoPromptFrame",
        predicate: SpatialJoinPredicate = "intersects",
        how: SpatialJoinMode = "inner",
        lsuffix: str = "left",
        rsuffix: str = "right",
        include_diagnostics: bool = False,
    ) -> "GeoPromptFrame":
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before spatial joins")

        right_columns = [column for column in other.columns if column != other.geometry_column]
        joined_rows: list[Record] = []
        right_rows = list(other._rows)
        right_index = other.spatial_index(mode="geometry") if right_rows else None

        predicate_bounds_filter = {
            "intersects": _bounds_intersect,
            "within": _bounds_within,
            "contains": lambda left, right: _bounds_within(right, left),
        }
        if predicate not in predicate_bounds_filter:
            raise ValueError(f"unsupported spatial join predicate: {predicate}")

        def matches(left_geometry: Geometry, right_geometry: Geometry) -> bool:
            if predicate == "intersects":
                return geometry_intersects(left_geometry, right_geometry)
            if predicate == "within":
                return geometry_within(left_geometry, right_geometry)
            if predicate == "contains":
                return geometry_contains(left_geometry, right_geometry)
            raise ValueError(f"unsupported spatial join predicate: {predicate}")

        for left_index, left_row in enumerate(self._rows):
            left_geometry = left_row[self.geometry_column]
            left_bounds = geometry_bounds(left_geometry)
            row_matches = []
            candidate_indexes = right_index.query(left_bounds) if right_index is not None else []
            for candidate_index in candidate_indexes:
                right_row = right_rows[candidate_index]
                right_bound = geometry_bounds(right_row[other.geometry_column])
                if not predicate_bounds_filter[predicate](left_bounds, right_bound):
                    continue
                if matches(left_geometry, right_row[other.geometry_column]):
                    row_matches.append(right_row)

            if not row_matches and how == "left":
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = None
                merged_row[f"join_predicate_{rsuffix}"] = predicate
                if include_diagnostics:
                    merged_row[f"candidate_count_{rsuffix}"] = len(candidate_indexes)
                    merged_row[f"pruning_ratio_{rsuffix}"] = 1.0 - (len(candidate_indexes) / len(right_rows) if right_rows else 0.0)
                    merged_row[f"match_count_{rsuffix}"] = 0
                joined_rows.append(merged_row)

            for right_row in row_matches:
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = right_row[column]
                merged_row[f"{other.geometry_column}_{rsuffix}"] = right_row[other.geometry_column]
                merged_row[f"join_predicate_{rsuffix}"] = predicate
                if include_diagnostics:
                    merged_row[f"candidate_count_{rsuffix}"] = len(candidate_indexes)
                    merged_row[f"pruning_ratio_{rsuffix}"] = 1.0 - (len(candidate_indexes) / len(right_rows) if right_rows else 0.0)
                    merged_row[f"match_count_{rsuffix}"] = len(row_matches)
                joined_rows.append(merged_row)

        return GeoPromptFrame._from_internal_rows(joined_rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def overlay_summary(
        self,
        other: "GeoPromptFrame",
        right_id_column: str = "region_id",
        aggregations: dict[str, AggregationName] | None = None,
        how: SpatialJoinMode = "left",
        group_by: str | None = None,
        normalize_by: OverlayNormalizeMode = "left",
        top_n_groups: int | None = None,
        summary_suffix: str = "overlay",
    ) -> "GeoPromptFrame":
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before overlay summaries")
        if normalize_by not in {"left", "right", "both"}:
            raise ValueError("normalize_by must be 'left', 'right', or 'both'")
        if top_n_groups is not None and top_n_groups <= 0:
            raise ValueError("top_n_groups must be greater than zero")

        other._require_column(right_id_column)
        if group_by is not None:
            other._require_column(group_by)
        other_rows = list(other._rows)
        right_geometries = [row[other.geometry_column] for row in other_rows]
        right_ids = [str(row[right_id_column]) for row in other_rows]
        right_areas = [geometry_area(geometry) for geometry in right_geometries]
        right_lengths = [geometry_length(geometry) for geometry in right_geometries]
        right_groups = [str(row[group_by]) for row in other_rows] if group_by is not None else []
        left_geometries = [row[self.geometry_column] for row in self._rows]
        left_areas = [geometry_area(geometry) for geometry in left_geometries]
        left_lengths = [geometry_length(geometry) for geometry in left_geometries]
        grouped: dict[int, list[tuple[int, list[Geometry]]]] = {}
        for left_index, right_index, geometries in overlay_intersections(
            left_geometries,
            right_geometries,
        ):
            grouped.setdefault(left_index, []).append((right_index, geometries))

        rows: list[Record] = []
        for left_index, left_row in enumerate(self._rows):
            matches = grouped.get(left_index, [])
            if not matches and how == "inner":
                continue

            matched_right_indexes: list[int] = []
            overlap_area = 0.0
            overlap_length = 0.0
            intersection_count = 0
            right_area_total = 0.0
            right_length_total = 0.0
            grouped_matches: dict[str, dict[str, Any]] | None = {} if group_by is not None else None
            for right_index, geometries in matches:
                matched_right_indexes.append(right_index)
                right_area_total += right_areas[right_index]
                right_length_total += right_lengths[right_index]
                pair_area = 0.0
                pair_length = 0.0
                for geometry in geometries:
                    pair_area += geometry_area(geometry)
                    pair_length += geometry_length(geometry)
                overlap_area += pair_area
                overlap_length += pair_length
                intersection_count += len(geometries)
                if grouped_matches is not None:
                    group_value = right_groups[right_index]
                    bucket = grouped_matches.setdefault(
                        group_value,
                        {
                            "group": group_value,
                            "count": 0,
                            "intersection_count": 0,
                            "area_overlap": 0.0,
                            "length_overlap": 0.0,
                            f"{right_id_column}s": [],
                            "right_area_total": 0.0,
                            "right_length_total": 0.0,
                        },
                    )
                    bucket["count"] += 1
                    bucket["intersection_count"] += len(geometries)
                    bucket["area_overlap"] += pair_area
                    bucket["length_overlap"] += pair_length
                    bucket[f"{right_id_column}s"].append(right_ids[right_index])
                    bucket["right_area_total"] += right_areas[right_index]
                    bucket["right_length_total"] += right_lengths[right_index]

            matched_rows = [other_rows[right_index] for right_index in matched_right_indexes]
            left_area = left_areas[left_index]
            left_length = left_lengths[left_index]

            resolved_row = dict(left_row)
            resolved_row[f"{right_id_column}s_{summary_suffix}"] = [right_ids[right_index] for right_index in matched_right_indexes]
            resolved_row[f"count_{summary_suffix}"] = len(matched_rows)
            resolved_row[f"intersection_count_{summary_suffix}"] = intersection_count
            resolved_row[f"area_overlap_{summary_suffix}"] = overlap_area
            resolved_row[f"length_overlap_{summary_suffix}"] = overlap_length
            resolved_row[f"area_share_{summary_suffix}"] = (overlap_area / left_area) if left_area > 0 and normalize_by in {"left", "both"} else None
            resolved_row[f"length_share_{summary_suffix}"] = (overlap_length / left_length) if left_length > 0 and normalize_by in {"left", "both"} else None
            resolved_row[f"area_share_right_{summary_suffix}"] = (overlap_area / right_area_total) if right_area_total > 0 and normalize_by in {"right", "both"} else None
            resolved_row[f"length_share_right_{summary_suffix}"] = (overlap_length / right_length_total) if right_length_total > 0 and normalize_by in {"right", "both"} else None
            if grouped_matches is not None:
                group_summaries = []
                for group_summary in grouped_matches.values():
                    group_record = dict(group_summary)
                    group_record["area_share_left"] = (group_summary["area_overlap"] / left_area) if left_area > 0 and normalize_by in {"left", "both"} else None
                    group_record["length_share_left"] = (group_summary["length_overlap"] / left_length) if left_length > 0 and normalize_by in {"left", "both"} else None
                    group_record["area_share_right"] = (group_summary["area_overlap"] / group_summary["right_area_total"]) if group_summary["right_area_total"] > 0 and normalize_by in {"right", "both"} else None
                    group_record["length_share_right"] = (group_summary["length_overlap"] / group_summary["right_length_total"]) if group_summary["right_length_total"] > 0 and normalize_by in {"right", "both"} else None
                    group_record.pop("right_area_total")
                    group_record.pop("right_length_total")
                    group_summaries.append(group_record)

                group_summaries.sort(key=lambda item: (-float(item["area_overlap"]), -float(item["length_overlap"]), str(item["group"])))
                if top_n_groups is not None:
                    group_summaries = group_summaries[:top_n_groups]
                resolved_row[f"groups_{summary_suffix}"] = group_summaries
                resolved_row[f"best_group_{summary_suffix}"] = group_summaries[0]["group"] if group_summaries else None
            resolved_row.update(self._aggregate_rows(matched_rows, aggregations=aggregations, suffix=summary_suffix))
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def clip(self, mask: "GeoPromptFrame") -> "GeoPromptFrame":
        if self.crs and mask.crs and self.crs != mask.crs:
            raise ValueError("frames must share the same CRS before clip operations")

        mask_rows = list(mask)
        clipped_groups = clip_geometries(
            [row[self.geometry_column] for row in self._rows],
            [row[mask.geometry_column] for row in mask_rows],
        )
        rows: list[Record] = []
        for row, clipped_geometries in zip(self._rows, clipped_groups, strict=True):
            for clipped_geometry in clipped_geometries:
                clipped_row = dict(row)
                clipped_row[self.geometry_column] = clipped_geometry
                rows.append(clipped_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or mask.crs)

    def overlay_intersections(
        self,
        other: "GeoPromptFrame",
        lsuffix: str = "left",
        rsuffix: str = "right",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before overlay operations")

        intersections = overlay_intersections(
            [row[self.geometry_column] for row in self._rows],
            [row[other.geometry_column] for row in other],
        )
        right_columns = [column for column in other.columns if column != other.geometry_column]
        rows: list[Record] = []
        for left_index, right_index, geometries in intersections:
            left_row = self._rows[left_index]
            right_row = other.to_records()[right_index]
            for geometry in geometries:
                merged_row = dict(left_row)
                for column in right_columns:
                    target_name = column if column not in merged_row else f"{column}_{rsuffix}"
                    merged_row[target_name] = right_row[column]
                merged_row[f"{other.geometry_column}_{rsuffix}"] = right_row[other.geometry_column]
                merged_row[self.geometry_column] = geometry
                rows.append(merged_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def overlay_union(
        self,
        other: "GeoPromptFrame",
        left_id_column: str = "site_id",
        right_id_column: str = "site_id",
        rsuffix: str = "right",
        union_suffix: str = "union",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before overlay operations")
        if any(geometry_type(row[self.geometry_column]) != "Polygon" for row in self._rows):
            raise ValueError("overlay_union currently requires Polygon geometries on the left frame")
        if any(geometry_type(row[other.geometry_column]) != "Polygon" for row in other._rows):
            raise ValueError("overlay_union currently requires Polygon geometries on the right frame")
        self._require_column(left_id_column)
        other._require_column(right_id_column)

        faces = overlay_union_faces(
            [row[self.geometry_column] for row in self._rows],
            [row[other.geometry_column] for row in other._rows],
        )
        left_columns = [column for column in self.columns if column != self.geometry_column]
        right_columns = [column for column in other.columns if column != other.geometry_column]
        right_rows = list(other._rows)
        rows: list[Record] = []

        for left_indexes, right_indexes, geometry in faces:
            resolved: Record = {}
            for column in left_columns:
                resolved[column] = self._rows[left_indexes[0]][column] if len(left_indexes) == 1 else None
            for column in right_columns:
                target_name = column if column not in resolved else f"{column}_{rsuffix}"
                resolved[target_name] = right_rows[right_indexes[0]][column] if len(right_indexes) == 1 else None
            resolved[f"{left_id_column}s_{union_suffix}"] = [str(self._rows[index][left_id_column]) for index in left_indexes]
            resolved[f"{right_id_column}s_{union_suffix}"] = [str(right_rows[index][right_id_column]) for index in right_indexes]
            resolved[f"left_count_{union_suffix}"] = len(left_indexes)
            resolved[f"right_count_{union_suffix}"] = len(right_indexes)
            resolved[f"source_side_{union_suffix}"] = "both" if left_indexes and right_indexes else ("left" if left_indexes else "right")
            resolved[f"area_{union_suffix}"] = geometry_area(geometry)
            resolved[self.geometry_column] = geometry
            rows.append(resolved)

        rows.sort(
            key=lambda row: (
                str(row[f"source_side_{union_suffix}"]),
                tuple(row[f"{left_id_column}s_{union_suffix}"]),
                tuple(row[f"{right_id_column}s_{union_suffix}"]),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def overlay_difference(
        self,
        other: "GeoPromptFrame",
        left_id_column: str = "site_id",
        right_id_column: str = "site_id",
        difference_suffix: str = "difference",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before overlay operations")
        if any(geometry_type(row[self.geometry_column]) != "Polygon" for row in self._rows):
            raise ValueError("overlay_difference currently requires Polygon geometries on the left frame")
        if any(geometry_type(row[other.geometry_column]) != "Polygon" for row in other._rows):
            raise ValueError("overlay_difference currently requires Polygon geometries on the right frame")
        self._require_column(left_id_column)
        other._require_column(right_id_column)

        faces = overlay_union_faces(
            [row[self.geometry_column] for row in self._rows],
            [row[other.geometry_column] for row in other._rows],
        )
        retained_faces = [(left_indexes, geometry) for left_indexes, right_indexes, geometry in faces if left_indexes and not right_indexes]
        retained_totals: dict[int, float] = {}
        part_counts: dict[int, int] = {}
        for left_indexes, geometry in retained_faces:
            if len(left_indexes) != 1:
                continue
            left_index = left_indexes[0]
            retained_totals[left_index] = retained_totals.get(left_index, 0.0) + geometry_area(geometry)
            part_counts[left_index] = part_counts.get(left_index, 0) + 1

        rows: list[Record] = []
        for left_indexes, geometry in retained_faces:
            resolved: Record = {}
            for column in self.columns:
                if column == self.geometry_column:
                    continue
                resolved[column] = self._rows[left_indexes[0]][column] if len(left_indexes) == 1 else None
            left_ids = [str(self._rows[index][left_id_column]) for index in left_indexes]
            resolved[f"{left_id_column}s_{difference_suffix}"] = left_ids
            resolved[f"{right_id_column}s_{difference_suffix}"] = []
            resolved[f"left_count_{difference_suffix}"] = len(left_indexes)
            resolved[f"right_count_{difference_suffix}"] = 0
            resolved[f"source_side_{difference_suffix}"] = "left"
            resolved[f"area_{difference_suffix}"] = geometry_area(geometry)
            if len(left_indexes) == 1:
                left_index = left_indexes[0]
                source_area = geometry_area(self._rows[left_index][self.geometry_column])
                retained_area = retained_totals.get(left_index, 0.0)
                removed_area = max(0.0, source_area - retained_area)
                resolved[f"source_area_{difference_suffix}"] = source_area
                resolved[f"retained_area_{difference_suffix}"] = retained_area
                resolved[f"removed_area_{difference_suffix}"] = removed_area
                resolved[f"removed_share_{difference_suffix}"] = (removed_area / source_area) if source_area > 0.0 else None
                resolved[f"part_count_{difference_suffix}"] = part_counts.get(left_index, 0)
            else:
                resolved[f"source_area_{difference_suffix}"] = None
                resolved[f"retained_area_{difference_suffix}"] = None
                resolved[f"removed_area_{difference_suffix}"] = None
                resolved[f"removed_share_{difference_suffix}"] = None
                resolved[f"part_count_{difference_suffix}"] = None
            resolved[self.geometry_column] = geometry
            rows.append(resolved)

        rows.sort(
            key=lambda row: (
                tuple(row[f"{left_id_column}s_{difference_suffix}"]),
                round(geometry_centroid(row[self.geometry_column])[0], 12),
                round(geometry_centroid(row[self.geometry_column])[1], 12),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def overlay_symmetric_difference(
        self,
        other: "GeoPromptFrame",
        left_id_column: str = "site_id",
        right_id_column: str = "site_id",
        rsuffix: str = "right",
        difference_suffix: str = "symdiff",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before overlay operations")
        if any(geometry_type(row[self.geometry_column]) != "Polygon" for row in self._rows):
            raise ValueError("overlay_symmetric_difference currently requires Polygon geometries on the left frame")
        if any(geometry_type(row[other.geometry_column]) != "Polygon" for row in other._rows):
            raise ValueError("overlay_symmetric_difference currently requires Polygon geometries on the right frame")
        self._require_column(left_id_column)
        other._require_column(right_id_column)

        faces = overlay_union_faces(
            [row[self.geometry_column] for row in self._rows],
            [row[other.geometry_column] for row in other._rows],
        )
        left_columns = [column for column in self.columns if column != self.geometry_column]
        right_columns = [column for column in other.columns if column != other.geometry_column]
        right_rows = list(other._rows)
        rows: list[Record] = []

        for left_indexes, right_indexes, geometry in faces:
            if bool(left_indexes) == bool(right_indexes):
                continue
            resolved: Record = {}
            for column in left_columns:
                resolved[column] = self._rows[left_indexes[0]][column] if len(left_indexes) == 1 else None
            for column in right_columns:
                target_name = column if column not in resolved else f"{column}_{rsuffix}"
                resolved[target_name] = right_rows[right_indexes[0]][column] if len(right_indexes) == 1 else None
            resolved[f"{left_id_column}s_{difference_suffix}"] = [str(self._rows[index][left_id_column]) for index in left_indexes]
            resolved[f"{right_id_column}s_{difference_suffix}"] = [str(right_rows[index][right_id_column]) for index in right_indexes]
            resolved[f"left_count_{difference_suffix}"] = len(left_indexes)
            resolved[f"right_count_{difference_suffix}"] = len(right_indexes)
            resolved[f"source_side_{difference_suffix}"] = "left" if left_indexes else "right"
            resolved[f"area_{difference_suffix}"] = geometry_area(geometry)
            resolved[self.geometry_column] = geometry
            rows.append(resolved)

        rows.sort(
            key=lambda row: (
                str(row[f"source_side_{difference_suffix}"]),
                tuple(row[f"{left_id_column}s_{difference_suffix}"]),
                tuple(row[f"{right_id_column}s_{difference_suffix}"]),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def dissolve(
        self,
        by: str,
        aggregations: dict[str, AggregationName] | None = None,
    ) -> "GeoPromptFrame":
        self._require_column(by)
        grouped_rows: dict[Any, list[Record]] = {}
        for row in self._rows:
            grouped_rows.setdefault(row[by], []).append(row)

        rows: list[Record] = []
        for group_value, group_rows in grouped_rows.items():
            dissolved_geometries = dissolve_geometries([row[self.geometry_column] for row in group_rows])
            aggregate_values: dict[str, Any] = {by: group_value}
            for column in self.columns:
                if column in {by, self.geometry_column}:
                    continue
                operation = (aggregations or {}).get(column)
                values = [row[column] for row in group_rows if column in row]
                if not values:
                    continue
                if operation is None:
                    aggregate_values[column] = values[0]
                elif operation == "sum":
                    aggregate_values[column] = sum(float(value) for value in values)
                elif operation == "mean":
                    aggregate_values[column] = sum(float(value) for value in values) / len(values)
                elif operation == "min":
                    aggregate_values[column] = min(values)
                elif operation == "max":
                    aggregate_values[column] = max(values)
                elif operation == "first":
                    aggregate_values[column] = values[0]
                elif operation == "count":
                    aggregate_values[column] = len(values)
                else:
                    raise ValueError(f"unsupported aggregation: {operation}")

            for geometry in dissolved_geometries:
                rows.append({**aggregate_values, self.geometry_column: geometry})

        return GeoPromptFrame(rows=rows, geometry_column=self.geometry_column, crs=self.crs)

    def with_column(self, name: str, values: Sequence[Any]) -> "GeoPromptFrame":
        if len(values) != len(self._rows):
            raise ValueError("column length must match the frame length")
        rows = self.to_records()
        for row, value in zip(rows, values, strict=True):
            row[name] = value
        return GeoPromptFrame(rows=rows, geometry_column=self.geometry_column, crs=self.crs)

    def assign(self, **columns: Any) -> "GeoPromptFrame":
        frame = self
        for name, value in columns.items():
            if callable(value):
                resolved = value(frame)
            else:
                resolved = value

            if isinstance(resolved, Sequence) and not isinstance(resolved, (str, bytes)):
                values = list(resolved)
            else:
                values = [resolved for _ in frame._rows]

            frame = frame.with_column(name=name, values=values)
        return frame

    def _require_column(self, name: str) -> None:
        if name not in self.columns:
            raise KeyError(f"column '{name}' is not present")

    def neighborhood_pressure(
        self,
        weight_column: str,
        scale: float = 1.0,
        power: float = 2.0,
        include_self: bool = False,
        distance_method: str = "euclidean",
    ) -> list[float]:
        self._require_column(weight_column)
        pressures: list[float] = []
        for row in self._rows:
            total = 0.0
            for other in self._rows:
                if not include_self and row is other:
                    continue
                distance_value = geometry_distance(row[self.geometry_column], other[self.geometry_column], method=distance_method)
                total += prompt_influence(
                    weight=float(other[weight_column]),
                    distance_value=distance_value,
                    scale=scale,
                    power=power,
                )
            pressures.append(total)
        return pressures

    def anchor_influence(
        self,
        weight_column: str,
        anchor: str,
        id_column: str = "site_id",
        scale: float = 1.0,
        power: float = 2.0,
        distance_method: str = "euclidean",
    ) -> list[float]:
        self._require_column(weight_column)
        self._require_column(id_column)
        anchor_row = next((row for row in self._rows if row[id_column] == anchor), None)
        if anchor_row is None:
            raise KeyError(f"anchor '{anchor}' was not found in column '{id_column}'")

        return [
            prompt_influence(
                weight=float(row[weight_column]),
                distance_value=geometry_distance(anchor_row[self.geometry_column], row[self.geometry_column], method=distance_method),
                scale=scale,
                power=power,
            )
            for row in self._rows
        ]

    def corridor_accessibility(
        self,
        weight_column: str,
        anchor: str,
        id_column: str = "site_id",
        scale: float = 1.0,
        power: float = 2.0,
        distance_method: str = "euclidean",
    ) -> list[float]:
        self._require_column(weight_column)
        self._require_column(id_column)
        anchor_row = next((row for row in self._rows if row[id_column] == anchor), None)
        if anchor_row is None:
            raise KeyError(f"anchor '{anchor}' was not found in column '{id_column}'")

        return [
            corridor_strength(
                weight=float(row[weight_column]),
                corridor_length=geometry_length(row[self.geometry_column]),
                distance_value=geometry_distance(anchor_row[self.geometry_column], row[self.geometry_column], method=distance_method),
                scale=scale,
                power=power,
            )
            for row in self._rows
        ]

    def area_similarity_table(
        self,
        id_column: str = "site_id",
        scale: float = 1.0,
        power: float = 1.0,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        self._require_column(id_column)
        interactions: list[Record] = []
        for origin in self._rows:
            for destination in self._rows:
                if origin is destination:
                    continue
                interactions.append(
                    {
                        "origin": origin[id_column],
                        "destination": destination[id_column],
                        "area_similarity": area_similarity(
                            origin_area=geometry_area(origin[self.geometry_column]),
                            destination_area=geometry_area(destination[self.geometry_column]),
                            distance_value=geometry_distance(origin[self.geometry_column], destination[self.geometry_column], method=distance_method),
                            scale=scale,
                            power=power,
                        ),
                        "distance_method": distance_method,
                    }
                )
        return interactions

    def interaction_table(
        self,
        origin_weight: str,
        destination_weight: str,
        id_column: str = "site_id",
        scale: float = 1.0,
        power: float = 2.0,
        preferred_bearing: float | None = None,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        self._require_column(origin_weight)
        self._require_column(destination_weight)
        self._require_column(id_column)

        interactions: list[Record] = []
        for origin in self._rows:
            for destination in self._rows:
                if origin is destination:
                    continue
                distance_value = geometry_distance(origin[self.geometry_column], destination[self.geometry_column], method=distance_method)
                interaction = prompt_interaction(
                    origin_weight=float(origin[origin_weight]),
                    destination_weight=float(destination[destination_weight]),
                    distance_value=distance_value,
                    scale=scale,
                    power=power,
                )
                record: Record = {
                    "origin": origin[id_column],
                    "destination": destination[id_column],
                    "distance": distance_value,
                    "interaction": interaction,
                    "distance_method": distance_method,
                }
                if preferred_bearing is not None:
                    record["directional_alignment"] = directional_alignment(
                        origin=geometry_centroid(origin[self.geometry_column]),
                        destination=geometry_centroid(destination[self.geometry_column]),
                        preferred_bearing=preferred_bearing,
                    )
                interactions.append(record)
        return interactions

    def select(self, *columns: str) -> "GeoPromptFrame":
        missing = [col for col in columns if col not in self.columns]
        if missing:
            raise KeyError(f"columns not found: {missing}")
        selected_columns = list(columns)
        if self.geometry_column not in selected_columns:
            selected_columns.append(self.geometry_column)
        rows = [{col: row[col] for col in selected_columns if col in row} for row in self._rows]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def rename_columns(self, mapping: dict[str, str]) -> "GeoPromptFrame":
        new_geometry_column = mapping.get(self.geometry_column, self.geometry_column)
        rows: list[Record] = []
        for row in self._rows:
            new_row = {mapping.get(key, key): value for key, value in row.items()}
            rows.append(new_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=new_geometry_column, crs=self.crs)

    def filter(self, predicate: Any) -> "GeoPromptFrame":
        if callable(predicate):
            rows = [row for row in self._rows if predicate(row)]
        elif isinstance(predicate, Sequence) and not isinstance(predicate, (str, bytes)) and all(isinstance(v, bool) for v in predicate):
            if len(predicate) != len(self._rows):
                raise ValueError("boolean mask length must match frame length")
            rows = [row for row, keep in zip(self._rows, predicate, strict=True) if keep]
        else:
            raise TypeError("predicate must be a callable or a boolean sequence")
        return GeoPromptFrame._from_internal_rows([dict(r) for r in rows], geometry_column=self.geometry_column, crs=self.crs)

    def sort(self, by: str, descending: bool = False) -> "GeoPromptFrame":
        self._require_column(by)
        non_null_rows = [row for row in self._rows if row.get(by) is not None]
        null_rows = [row for row in self._rows if row.get(by) is None]
        sorted_rows = sorted(non_null_rows, key=lambda row: cast(Any, row[by]), reverse=descending) + null_rows
        return GeoPromptFrame._from_internal_rows([dict(r) for r in sorted_rows], geometry_column=self.geometry_column, crs=self.crs)

    def describe(self) -> dict[str, dict[str, Any]]:
        stats: dict[str, dict[str, Any]] = {}
        for column in self.columns:
            if column == self.geometry_column:
                continue
            values = [row[column] for row in self._rows if column in row and row[column] is not None]
            numeric = [float(v) for v in values if isinstance(v, (int, float))]
            if not numeric:
                continue
            stats[column] = {
                "count": len(numeric),
                "min": min(numeric),
                "max": max(numeric),
                "mean": sum(numeric) / len(numeric),
                "sum": sum(numeric),
            }
        return stats

    def spatial_lag(
        self,
        value_column: str,
        id_column: str = "site_id",
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        include_self: bool = False,
        distance_method: str = "euclidean",
        weight_mode: SpatialLagWeightMode = "binary",
        include_diagnostics: bool = False,
        lag_suffix: str = "lag",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        self._require_column(id_column)
        if mode not in {"k_nearest", "distance_band", "intersects"}:
            raise ValueError("mode must be 'k_nearest', 'distance_band', or 'intersects'")
        if weight_mode not in {"binary", "inverse_distance"}:
            raise ValueError("weight_mode must be 'binary' or 'inverse_distance'")
        if k <= 0:
            raise ValueError("k must be greater than zero")
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if mode == "distance_band" and max_distance is None:
            raise ValueError("max_distance is required when mode='distance_band'")

        row_count = len(self._rows)
        centroids = self._centroids()
        geometry_index = self.spatial_index(mode="geometry") if mode == "intersects" and self._rows else None
        centroid_index = self.spatial_index(mode="centroid", cell_size=max_distance) if mode == "distance_band" and distance_method == "euclidean" and max_distance is not None and self._rows else None
        rows: list[Record] = []

        for origin_index, origin_row in enumerate(self._rows):
            candidate_indexes: list[int]
            if mode == "distance_band" and centroid_index is not None and max_distance is not None:
                origin_centroid = centroids[origin_index]
                candidate_indexes = centroid_index.query(
                    (
                        origin_centroid[0] - max_distance,
                        origin_centroid[1] - max_distance,
                        origin_centroid[0] + max_distance,
                        origin_centroid[1] + max_distance,
                    )
                )
            elif mode == "intersects" and geometry_index is not None:
                candidate_indexes = geometry_index.query(geometry_bounds(origin_row[self.geometry_column]))
            else:
                candidate_indexes = list(range(row_count))

            neighbor_candidates: list[tuple[int, float]] = []
            for candidate_index in candidate_indexes:
                if not include_self and candidate_index == origin_index:
                    continue
                candidate_row = self._rows[candidate_index]
                if mode == "intersects":
                    if not geometry_intersects(origin_row[self.geometry_column], candidate_row[self.geometry_column]):
                        continue
                    distance_value = coordinate_distance(centroids[origin_index], centroids[candidate_index], method=distance_method)
                else:
                    distance_value = coordinate_distance(centroids[origin_index], centroids[candidate_index], method=distance_method)
                    if mode == "distance_band" and max_distance is not None and distance_value > max_distance:
                        continue
                neighbor_candidates.append((candidate_index, distance_value))

            if mode == "k_nearest":
                neighbor_candidates = nsmallest(
                    k,
                    neighbor_candidates,
                    key=lambda item: (float(item[1]), _row_sort_key(self._rows[item[0]])),
                )
            else:
                neighbor_candidates.sort(key=lambda item: (float(item[1]), _row_sort_key(self._rows[item[0]])))

            weights: list[float] = []
            weighted_values: list[float] = []
            neighbor_ids: list[str] = []
            for candidate_index, distance_value in neighbor_candidates:
                if weight_mode == "binary":
                    weight = 1.0
                elif distance_value <= 1e-12:
                    weight = 1.0
                else:
                    weight = 1.0 / distance_value
                weights.append(weight)
                weighted_values.append(float(self._rows[candidate_index][value_column]) * weight)
                neighbor_ids.append(str(self._rows[candidate_index][id_column]))

            weight_sum = sum(weights)
            lag_value = (sum(weighted_values) / weight_sum) if weight_sum > 0.0 else None
            resolved = dict(origin_row)
            resolved[f"{value_column}_{lag_suffix}"] = lag_value
            resolved[f"neighbor_count_{lag_suffix}"] = len(neighbor_candidates)
            resolved[f"neighbor_ids_{lag_suffix}"] = neighbor_ids
            if include_diagnostics:
                denominator = row_count if include_self else max(0, row_count - 1)
                resolved[f"candidate_count_{lag_suffix}"] = len(candidate_indexes)
                resolved[f"pruning_ratio_{lag_suffix}"] = 1.0 - (len(candidate_indexes) / denominator if denominator else 0.0)
                resolved[f"weight_sum_{lag_suffix}"] = weight_sum
                resolved[f"neighbor_weights_{lag_suffix}"] = weights
                resolved[f"mode_{lag_suffix}"] = mode
                resolved[f"weight_mode_{lag_suffix}"] = weight_mode
                resolved[f"distance_method_{lag_suffix}"] = distance_method
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def spatial_autocorrelation(
        self,
        value_column: str,
        id_column: str = "site_id",
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        include_self: bool = False,
        distance_method: str = "euclidean",
        weight_mode: SpatialLagWeightMode = "binary",
        permutations: int = 0,
        random_seed: int = 0,
        significance_level: float = 0.05,
        include_diagnostics: bool = False,
        autocorr_suffix: str = "autocorr",
    ) -> "GeoPromptFrame":
        if permutations < 0:
            raise ValueError("permutations must be zero or greater")
        if not 0.0 <= significance_level <= 1.0:
            raise ValueError("significance_level must be between zero and one")
        lagged = self.spatial_lag(
            value_column=value_column,
            id_column=id_column,
            mode=mode,
            k=k,
            max_distance=max_distance,
            include_self=include_self,
            distance_method=distance_method,
            weight_mode=weight_mode,
            include_diagnostics=True,
            lag_suffix=autocorr_suffix,
        )
        lagged_rows = lagged.to_records()
        values = [float(row[value_column]) for row in lagged_rows]
        row_count = len(values)
        mean_value = (sum(values) / row_count) if row_count else 0.0
        centered_values = [value - mean_value for value in values]
        variance_sum = sum(value * value for value in centered_values)
        m2 = (variance_sum / row_count) if row_count else 0.0
        id_to_index = {str(row[id_column]): index for index, row in enumerate(lagged_rows)}
        neighbor_indexes = [
            [id_to_index[neighbor_id] for neighbor_id in row[f"neighbor_ids_{autocorr_suffix}"]]
            for row in lagged_rows
        ]
        neighbor_weights = [
            [float(weight) for weight in row[f"neighbor_weights_{autocorr_suffix}"]]
            for row in lagged_rows
        ]
        autocorr_stats = _autocorrelation_statistics(values, neighbor_indexes, neighbor_weights)
        global_moran = autocorr_stats["global_moran"]
        global_geary = autocorr_stats["global_geary"]
        total_weight = autocorr_stats["total_weight"]
        local_moran_values = autocorr_stats["local_moran_values"]
        local_geary_values = autocorr_stats["local_geary_values"]

        global_moran_p_value = None
        global_geary_p_value = None
        local_moran_p_values: list[float | None] = [None] * row_count
        local_geary_p_values: list[float | None] = [None] * row_count
        if permutations > 0 and row_count > 1 and total_weight > 0.0 and variance_sum > 0.0:
            rng = random.Random(random_seed)
            global_moran_hits = 0
            global_geary_hits = 0
            local_moran_hits = [0] * row_count
            local_geary_hits = [0] * row_count
            observed_global_moran = global_moran
            observed_global_geary = global_geary
            for _ in range(permutations):
                permuted_values = rng.sample(values, row_count)
                permutation_stats = _autocorrelation_statistics(permuted_values, neighbor_indexes, neighbor_weights)
                permutation_global_moran = permutation_stats["global_moran"]
                permutation_global_geary = permutation_stats["global_geary"]
                if observed_global_moran is not None and permutation_global_moran is not None and abs(permutation_global_moran) >= abs(observed_global_moran):
                    global_moran_hits += 1
                if observed_global_geary is not None and permutation_global_geary is not None and abs(permutation_global_geary - 1.0) >= abs(observed_global_geary - 1.0):
                    global_geary_hits += 1
                for origin_index, permutation_local_moran in enumerate(permutation_stats["local_moran_values"]):
                    observed_local_moran = local_moran_values[origin_index]
                    if observed_local_moran is not None and permutation_local_moran is not None and abs(permutation_local_moran) >= abs(observed_local_moran):
                        local_moran_hits[origin_index] += 1
                for origin_index, permutation_local_geary in enumerate(permutation_stats["local_geary_values"]):
                    observed_local_geary = local_geary_values[origin_index]
                    if observed_local_geary is not None and permutation_local_geary is not None and abs(permutation_local_geary - 1.0) >= abs(observed_local_geary - 1.0):
                        local_geary_hits[origin_index] += 1

            if observed_global_moran is not None:
                global_moran_p_value = (global_moran_hits + 1) / (permutations + 1)
            if observed_global_geary is not None:
                global_geary_p_value = (global_geary_hits + 1) / (permutations + 1)
            local_moran_p_values = [
                ((hits + 1) / (permutations + 1)) if local_moran_values[index] is not None else None
                for index, hits in enumerate(local_moran_hits)
            ]
            local_geary_p_values = [
                ((hits + 1) / (permutations + 1)) if local_geary_values[index] is not None else None
                for index, hits in enumerate(local_geary_hits)
            ]

        rows: list[Record] = []
        for index, (row, local_moran, local_geary) in enumerate(zip(lagged_rows, local_moran_values, local_geary_values, strict=True)):
            resolved = dict(row)
            lag_value = row.get(f"{value_column}_{autocorr_suffix}")
            lag_centered_value = (float(lag_value) - mean_value) if lag_value is not None else None
            resolved[f"mean_{autocorr_suffix}"] = mean_value
            resolved[f"global_moran_i_{autocorr_suffix}"] = global_moran
            resolved[f"global_geary_c_{autocorr_suffix}"] = global_geary
            resolved[f"global_moran_p_value_{autocorr_suffix}"] = global_moran_p_value
            resolved[f"global_geary_p_value_{autocorr_suffix}"] = global_geary_p_value
            resolved[f"local_moran_i_{autocorr_suffix}"] = local_moran
            resolved[f"local_geary_c_{autocorr_suffix}"] = local_geary
            resolved[f"local_moran_p_value_{autocorr_suffix}"] = local_moran_p_values[index]
            resolved[f"local_geary_p_value_{autocorr_suffix}"] = local_geary_p_values[index]
            cluster_label = _local_cluster_label(
                centered_values[index],
                lag_centered_value,
                local_moran,
                local_moran_p_values[index],
                significance_level,
            )
            cluster_family = _local_cluster_family(cluster_label)
            resolved[f"local_cluster_label_{autocorr_suffix}"] = cluster_label
            resolved[f"local_cluster_code_{autocorr_suffix}"] = _local_cluster_code(cluster_label)
            resolved[f"local_cluster_family_{autocorr_suffix}"] = cluster_family
            local_p_value = local_moran_p_values[index]
            resolved[f"significant_cluster_{autocorr_suffix}"] = bool(local_p_value is not None and local_p_value <= significance_level)
            resolved[f"hotspot_{autocorr_suffix}"] = cluster_family == "hotspot"
            resolved[f"coldspot_{autocorr_suffix}"] = cluster_family == "coldspot"
            resolved[f"spatial_outlier_{autocorr_suffix}"] = cluster_family == "outlier"
            if not include_diagnostics:
                resolved.pop(f"candidate_count_{autocorr_suffix}", None)
                resolved.pop(f"pruning_ratio_{autocorr_suffix}", None)
                resolved.pop(f"weight_sum_{autocorr_suffix}", None)
                resolved.pop(f"neighbor_weights_{autocorr_suffix}", None)
                resolved.pop(f"mode_{autocorr_suffix}", None)
                resolved.pop(f"weight_mode_{autocorr_suffix}", None)
                resolved.pop(f"distance_method_{autocorr_suffix}", None)
            else:
                resolved[f"variance_sum_{autocorr_suffix}"] = variance_sum
                resolved[f"total_weight_{autocorr_suffix}"] = total_weight
                resolved[f"permutations_{autocorr_suffix}"] = permutations
                resolved[f"significance_level_{autocorr_suffix}"] = significance_level
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def summarize_autocorrelation(
        self,
        value_column: str,
        id_column: str = "site_id",
        autocorr_suffix: str = "autocorr",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        self._require_column(id_column)
        self._require_column(f"local_cluster_family_{autocorr_suffix}")
        self._require_column(f"local_cluster_label_{autocorr_suffix}")
        self._require_column(f"local_moran_i_{autocorr_suffix}")
        self._require_column(f"local_geary_c_{autocorr_suffix}")
        self._require_column(f"significant_cluster_{autocorr_suffix}")

        grouped_rows: dict[str, list[Record]] = {}
        for row in self._rows:
            family = str(row.get(f"local_cluster_family_{autocorr_suffix}") or "unclassified")
            grouped_rows.setdefault(family, []).append(row)

        rows: list[Record] = []
        for family, family_rows in sorted(grouped_rows.items(), key=lambda item: (item[0], len(item[1]))):
            label_counts: dict[str, int] = {}
            for row in family_rows:
                label = str(row.get(f"local_cluster_label_{autocorr_suffix}") or "unclassified")
                label_counts[label] = label_counts.get(label, 0) + 1
            mean_centroid = _mean_centroid_geometry(family_rows, self.geometry_column)
            local_moran_values = [
                float(row[f"local_moran_i_{autocorr_suffix}"])
                for row in family_rows
                if row.get(f"local_moran_i_{autocorr_suffix}") is not None
            ]
            local_geary_values = [
                float(row[f"local_geary_c_{autocorr_suffix}"])
                for row in family_rows
                if row.get(f"local_geary_c_{autocorr_suffix}") is not None
            ]
            significant_count = sum(1 for row in family_rows if bool(row.get(f"significant_cluster_{autocorr_suffix}")))
            summary_row: Record = {
                f"local_cluster_family_{autocorr_suffix}": family,
                f"feature_count_{autocorr_suffix}": len(family_rows),
                f"{id_column}s_{autocorr_suffix}": [str(row[id_column]) for row in family_rows],
                f"label_counts_{autocorr_suffix}": label_counts,
                f"significant_count_{autocorr_suffix}": significant_count,
                f"significant_share_{autocorr_suffix}": (significant_count / len(family_rows)) if family_rows else 0.0,
                f"{value_column}_mean_{autocorr_suffix}": sum(float(row[value_column]) for row in family_rows) / len(family_rows),
                f"local_moran_i_mean_{autocorr_suffix}": (sum(local_moran_values) / len(local_moran_values)) if local_moran_values else None,
                f"local_geary_c_mean_{autocorr_suffix}": (sum(local_geary_values) / len(local_geary_values)) if local_geary_values else None,
                f"global_moran_i_{autocorr_suffix}": family_rows[0].get(f"global_moran_i_{autocorr_suffix}"),
                f"global_geary_c_{autocorr_suffix}": family_rows[0].get(f"global_geary_c_{autocorr_suffix}"),
                self.geometry_column: mean_centroid,
            }
            rows.append(summary_row)

        rows.sort(
            key=lambda row: (
                -int(row[f"feature_count_{autocorr_suffix}"]),
                str(row[f"local_cluster_family_{autocorr_suffix}"]),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def report_autocorrelation_patterns(
        self,
        value_column: str,
        id_column: str = "site_id",
        autocorr_suffix: str = "autocorr",
        include_families: Sequence[str] = ("hotspot", "coldspot", "outlier"),
        top_ids: int = 5,
    ) -> "GeoPromptFrame":
        if top_ids <= 0:
            raise ValueError("top_ids must be greater than zero")

        summary = self.summarize_autocorrelation(
            value_column=value_column,
            id_column=id_column,
            autocorr_suffix=autocorr_suffix,
        )
        summary_rows = summary.to_records()
        family_filter = {str(value) for value in include_families}
        if not family_filter:
            raise ValueError("include_families must contain at least one family")

        total_feature_count = sum(int(row[f"feature_count_{autocorr_suffix}"]) for row in summary_rows)
        report_rows: list[Record] = []
        for row in summary_rows:
            family = str(row[f"local_cluster_family_{autocorr_suffix}"])
            if family not in family_filter:
                continue
            label_counts = dict(row[f"label_counts_{autocorr_suffix}"])
            primary_labels = [
                label
                for label, _count in sorted(label_counts.items(), key=lambda item: (-int(item[1]), str(item[0])))
            ]
            feature_count = int(row[f"feature_count_{autocorr_suffix}"])
            feature_share = (feature_count / total_feature_count) if total_feature_count else 0.0
            mean_local_moran = row.get(f"local_moran_i_mean_{autocorr_suffix}")
            intensity_score = (
                abs(float(mean_local_moran)) * float(row[f"significant_share_{autocorr_suffix}"]) * feature_share
                if mean_local_moran is not None
                else 0.0
            )
            report_row: Record = {
                f"local_cluster_family_{autocorr_suffix}": family,
                f"report_label_{autocorr_suffix}": _autocorr_report_label(family),
                f"feature_count_{autocorr_suffix}": feature_count,
                f"feature_share_{autocorr_suffix}": feature_share,
                f"significant_count_{autocorr_suffix}": row[f"significant_count_{autocorr_suffix}"],
                f"significant_share_{autocorr_suffix}": row[f"significant_share_{autocorr_suffix}"],
                f"primary_labels_{autocorr_suffix}": primary_labels,
                f"representative_ids_{autocorr_suffix}": list(row[f"{id_column}s_{autocorr_suffix}"][:top_ids]),
                f"intensity_score_{autocorr_suffix}": intensity_score,
                f"report_priority_{autocorr_suffix}": _autocorr_report_priority(family, intensity_score),
                f"{value_column}_mean_{autocorr_suffix}": row[f"{value_column}_mean_{autocorr_suffix}"],
                f"local_moran_i_mean_{autocorr_suffix}": row[f"local_moran_i_mean_{autocorr_suffix}"],
                f"local_geary_c_mean_{autocorr_suffix}": row[f"local_geary_c_mean_{autocorr_suffix}"],
                self.geometry_column: row[self.geometry_column],
            }
            report_rows.append(report_row)

        report_rows.sort(
            key=lambda row: (
                -float(row[f"intensity_score_{autocorr_suffix}"]),
                -int(row[f"feature_count_{autocorr_suffix}"]),
                str(row[f"local_cluster_family_{autocorr_suffix}"]),
            )
        )
        for index, row in enumerate(report_rows, start=1):
            row[f"report_rank_{autocorr_suffix}"] = index
        return GeoPromptFrame._from_internal_rows(report_rows, geometry_column=self.geometry_column, crs=self.crs)

    def snap_geometries(
        self,
        tolerance: float,
        include_diagnostics: bool = False,
        snap_suffix: str = "snap",
    ) -> "GeoPromptFrame":
        if tolerance <= 0:
            raise ValueError("tolerance must be greater than zero")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        unique_vertices: dict[str, Coordinate] = {}
        for row in self._rows:
            geometry = row[self.geometry_column]
            geometry_kind = geometry_type(geometry)
            coordinates = geometry_vertices(geometry)
            if geometry_kind == "Polygon":
                coordinates = coordinates[:-1]
            for coordinate in coordinates:
                unique_vertices.setdefault(_coordinate_key(coordinate), coordinate)

        vertex_values = list(unique_vertices.values())
        vertex_index = SpatialIndex.from_points(vertex_values, cell_size=tolerance)
        snap_lookup = _build_snap_coordinate_lookup(vertex_values, vertex_index, tolerance)

        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            snapped_geometry, changed_vertex_count, collapsed = _snap_geometry(
                row[self.geometry_column],
                snap_lookup,
            )
            resolved[self.geometry_column] = snapped_geometry
            resolved[f"changed_{snap_suffix}"] = changed_vertex_count > 0
            resolved[f"changed_vertex_count_{snap_suffix}"] = changed_vertex_count
            if include_diagnostics:
                vertex_count = len(geometry_vertices(row[self.geometry_column]))
                if geometry_type(row[self.geometry_column]) == "Polygon":
                    vertex_count -= 1
                resolved[f"vertex_count_{snap_suffix}"] = vertex_count
                resolved[f"collapsed_{snap_suffix}"] = collapsed
                resolved[f"unique_vertex_count_{snap_suffix}"] = len(unique_vertices)
                resolved[f"tolerance_{snap_suffix}"] = tolerance
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def clean_topology(
        self,
        tolerance: float | None = None,
        min_segment_length: float = 0.0,
        include_diagnostics: bool = False,
        clean_suffix: str = "clean",
    ) -> "GeoPromptFrame":
        if tolerance is not None and tolerance <= 0:
            raise ValueError("tolerance must be greater than zero")
        if min_segment_length < 0:
            raise ValueError("min_segment_length must be zero or greater")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        working_rows = self.snap_geometries(tolerance=tolerance, include_diagnostics=False)._rows if tolerance is not None else self._rows
        rows: list[Record] = []
        for original_row, working_row in zip(self._rows, working_rows, strict=True):
            cleaned_geometry, diagnostics = _clean_geometry(
                original_geometry=original_row[self.geometry_column],
                working_geometry=working_row[self.geometry_column],
                min_segment_length=min_segment_length,
            )
            resolved = dict(original_row)
            resolved[self.geometry_column] = cleaned_geometry
            resolved[f"changed_{clean_suffix}"] = diagnostics["changed"]
            resolved[f"removed_vertex_count_{clean_suffix}"] = diagnostics["removed_vertex_count"]
            resolved[f"removed_short_segment_count_{clean_suffix}"] = diagnostics["removed_short_segment_count"]
            if include_diagnostics:
                resolved[f"input_vertex_count_{clean_suffix}"] = diagnostics["input_vertex_count"]
                resolved[f"output_vertex_count_{clean_suffix}"] = diagnostics["output_vertex_count"]
                resolved[f"collapsed_{clean_suffix}"] = diagnostics["collapsed"]
                resolved[f"tolerance_{clean_suffix}"] = tolerance
                resolved[f"min_segment_length_{clean_suffix}"] = min_segment_length
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def line_split(
        self,
        splitters: "GeoPromptFrame | None" = None,
        id_column: str = "site_id",
        splitter_id_column: str = "site_id",
        split_at_intersections: bool = True,
        include_diagnostics: bool = False,
        split_suffix: str = "split",
    ) -> "GeoPromptFrame":
        self._require_column(id_column)
        if any(geometry_type(row[self.geometry_column]) != "LineString" for row in self._rows):
            raise ValueError("line_split requires all geometries to be LineString")
        if splitters is not None and self.crs and splitters.crs and self.crs != splitters.crs:
            raise ValueError("frames must share the same CRS before line splitting")
        if splitters is not None and splitter_id_column not in splitters.columns:
            raise KeyError(f"column '{splitter_id_column}' is not present")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        line_infos = [
            _build_linestring_info(row[self.geometry_column])
            for row in self._rows
        ]
        cut_points: list[dict[str, Coordinate]] = [
            {
                _coordinate_key(line_info["coordinates"][0]): line_info["coordinates"][0],
                _coordinate_key(line_info["coordinates"][-1]): line_info["coordinates"][-1],
            }
            for line_info in line_infos
        ]
        splitter_sources: list[set[str]] = [set() for _ in self._rows]
        splitter_point_counts = [0 for _ in self._rows]
        line_splitter_counts = [0 for _ in self._rows]
        self_intersection_counts = [0 for _ in self._rows]

        source_segments = _line_segment_records(self._rows, self.geometry_column)
        source_segment_index = SpatialIndex([record["bounds"] for record in source_segments]) if source_segments else SpatialIndex([])

        if split_at_intersections and source_segments:
            for segment_index, segment in enumerate(source_segments):
                for other_index in source_segment_index.query(segment["bounds"]):
                    if other_index <= segment_index:
                        continue
                    other = source_segments[other_index]
                    if segment["source_index"] == other["source_index"] and abs(segment["segment_index"] - other["segment_index"]) <= 1:
                        continue
                    intersection_points = _segment_intersection_points(segment["start"], segment["end"], other["start"], other["end"])
                    for point in intersection_points:
                        added_left = _register_cut_point(cut_points[segment["source_index"]], point)
                        added_right = _register_cut_point(cut_points[other["source_index"]], point)
                        if added_left:
                            self_intersection_counts[segment["source_index"]] += 1
                        if added_right:
                            self_intersection_counts[other["source_index"]] += 1

        if splitters is not None and splitters._rows:
            line_index = self.spatial_index(mode="geometry")
            point_splitters = [row for row in splitters._rows if geometry_type(row[splitters.geometry_column]) == "Point"]
            line_splitters = [row for row in splitters._rows if geometry_type(row[splitters.geometry_column]) == "LineString"]
            unsupported_types = [geometry_type(row[splitters.geometry_column]) for row in splitters._rows if geometry_type(row[splitters.geometry_column]) not in {"Point", "LineString"}]
            if unsupported_types:
                raise ValueError("line_split splitters must contain only Point or LineString geometries")

            for splitter_row in point_splitters:
                point = _as_coordinate(splitter_row[splitters.geometry_column]["coordinates"])
                point_bounds = (point[0], point[1], point[0], point[1])
                splitter_id = str(splitter_row[splitter_id_column])
                for line_index_value in line_index.query(point_bounds):
                    coordinates = line_infos[line_index_value]["coordinates"]
                    if any(_point_on_segment(point, start, end) for start, end in zip(coordinates, coordinates[1:])):
                        if _register_cut_point(cut_points[line_index_value], point):
                            splitter_point_counts[line_index_value] += 1
                        splitter_sources[line_index_value].add(splitter_id)

            if line_splitters:
                splitter_segments = _line_segment_records(line_splitters, splitters.geometry_column)
                for splitter_segment in splitter_segments:
                    splitter_id = str(line_splitters[splitter_segment["source_index"]][splitter_id_column])
                    for source_segment_index_value in source_segment_index.query(splitter_segment["bounds"]):
                        source_segment = source_segments[source_segment_index_value]
                        for point in _segment_intersection_points(
                            source_segment["start"],
                            source_segment["end"],
                            splitter_segment["start"],
                            splitter_segment["end"],
                        ):
                            if _register_cut_point(cut_points[source_segment["source_index"]], point):
                                line_splitter_counts[source_segment["source_index"]] += 1
                            splitter_sources[source_segment["source_index"]].add(splitter_id)

        rows: list[Record] = []
        for row_index, row in enumerate(self._rows):
            line_info = line_infos[row_index]
            fractions = sorted(
                {
                    0.0,
                    1.0,
                    *(
                        _locate_point_fraction_on_linestring(line_info, point)
                        for point in cut_points[row_index].values()
                    ),
                }
            )
            split_ranges = [
                (start_fraction, end_fraction)
                for start_fraction, end_fraction in zip(fractions, fractions[1:])
                if end_fraction > start_fraction + 1e-9
            ]
            part_rows: list[Record] = []
            source_id = str(row[id_column])
            ordered_splitter_ids = sorted(splitter_sources[row_index])
            for part_index, (start_fraction, end_fraction) in enumerate(split_ranges, start=1):
                split_geometry = _linestring_subgeometry(row[self.geometry_column], start_fraction, end_fraction)
                if split_geometry is None:
                    continue
                resolved = dict(row)
                resolved[self.geometry_column] = split_geometry
                resolved[f"source_id_{split_suffix}"] = source_id
                resolved[f"part_id_{split_suffix}"] = f"{source_id}-part-{part_index:05d}"
                resolved[f"part_index_{split_suffix}"] = part_index
                resolved[f"start_fraction_{split_suffix}"] = start_fraction
                resolved[f"end_fraction_{split_suffix}"] = end_fraction
                resolved[f"split_point_count_{split_suffix}"] = max(0, len(fractions) - 2)
                resolved[f"splitter_ids_{split_suffix}"] = ordered_splitter_ids
                if include_diagnostics:
                    resolved[f"self_intersection_count_{split_suffix}"] = self_intersection_counts[row_index]
                    resolved[f"point_splitter_count_{split_suffix}"] = splitter_point_counts[row_index]
                    resolved[f"line_splitter_count_{split_suffix}"] = line_splitter_counts[row_index]
                part_rows.append(resolved)
            for resolved in part_rows:
                resolved[f"part_count_{split_suffix}"] = len(part_rows)
                rows.append(resolved)

        rows.sort(key=lambda item: (str(item[f"source_id_{split_suffix}"]), int(item[f"part_index_{split_suffix}"])))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or (splitters.crs if splitters is not None else None))

    def polygon_split(
        self,
        splitters: "GeoPromptFrame",
        id_column: str = "site_id",
        splitter_id_column: str = "site_id",
        include_diagnostics: bool = False,
        split_suffix: str = "split",
    ) -> "GeoPromptFrame":
        self._require_column(id_column)
        splitters._require_column(splitter_id_column)
        if any(geometry_type(row[self.geometry_column]) != "Polygon" for row in self._rows):
            raise ValueError("polygon_split requires all geometries to be Polygon")
        if self.crs and splitters.crs and self.crs != splitters.crs:
            raise ValueError("frames must share the same CRS before polygon splitting")

        splitter_rows = list(splitters._rows)
        unsupported_types = [
            geometry_type(row[splitters.geometry_column])
            for row in splitter_rows
            if geometry_type(row[splitters.geometry_column]) not in {"LineString", "Polygon"}
        ]
        if unsupported_types:
            raise ValueError("polygon_split splitters must contain only LineString or Polygon geometries")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        splitter_index = splitters.spatial_index(mode="geometry") if splitter_rows else SpatialIndex([])
        rows: list[Record] = []

        for row in self._rows:
            source_geometry = row[self.geometry_column]
            source_id = str(row[id_column])
            source_area = geometry_area(source_geometry)
            candidate_indexes = sorted(splitter_index.query(geometry_bounds(source_geometry)))
            applicable_indexes = [
                index
                for index in candidate_indexes
                if geometry_intersects(source_geometry, splitter_rows[index][splitters.geometry_column])
            ]
            faces = polygon_split_faces(
                source_geometry,
                [splitter_rows[index][splitters.geometry_column] for index in applicable_indexes],
            )
            ordered_faces = sorted(
                faces,
                key=lambda item: (
                    round(geometry_centroid(item[1])[0], 12),
                    round(geometry_centroid(item[1])[1], 12),
                    round(geometry_area(item[1]), 12),
                    str(item[1]["coordinates"]),
                ),
            )
            part_rows: list[Record] = []
            for part_index, (local_splitter_indexes, geometry) in enumerate(ordered_faces, start=1):
                splitter_ids = sorted(
                    {
                        str(splitter_rows[applicable_indexes[local_index]][splitter_id_column])
                        for local_index in local_splitter_indexes
                    }
                )
                resolved = dict(row)
                resolved[self.geometry_column] = geometry
                resolved[f"source_id_{split_suffix}"] = source_id
                resolved[f"part_id_{split_suffix}"] = f"{source_id}-part-{part_index:05d}"
                resolved[f"part_index_{split_suffix}"] = part_index
                resolved[f"splitter_ids_{split_suffix}"] = splitter_ids
                resolved[f"splitter_count_{split_suffix}"] = len(splitter_ids)
                resolved[f"area_{split_suffix}"] = geometry_area(geometry)
                if include_diagnostics:
                    resolved[f"candidate_splitter_count_{split_suffix}"] = len(candidate_indexes)
                    resolved[f"applied_splitter_count_{split_suffix}"] = len(applicable_indexes)
                    resolved[f"split_detected_{split_suffix}"] = len(ordered_faces) > 1
                    resolved[f"area_ratio_{split_suffix}"] = 0.0 if source_area <= 0.0 else geometry_area(geometry) / source_area
                part_rows.append(resolved)

            for resolved in part_rows:
                resolved[f"part_count_{split_suffix}"] = len(part_rows)
                rows.append(resolved)

        rows.sort(key=lambda item: (str(item[f"source_id_{split_suffix}"]), int(item[f"part_index_{split_suffix}"])))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or splitters.crs)

    def fishnet(
        self,
        cell_width: float,
        cell_height: float | None = None,
        include_empty: bool = False,
        grid_id_column: str = "grid_id",
    ) -> "GeoPromptFrame":
        if cell_width <= 0:
            raise ValueError("cell_width must be greater than zero")
        resolved_cell_height = cell_height if cell_height is not None else cell_width
        if resolved_cell_height <= 0:
            raise ValueError("cell_height must be greater than zero")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        bounds = self.bounds()
        geometry_index = self.spatial_index(mode="geometry")
        rows: list[Record] = []

        row_index = 0
        y_value = bounds.min_y
        while y_value < bounds.max_y or math.isclose(y_value, bounds.max_y):
            column_index = 0
            x_value = bounds.min_x
            while x_value < bounds.max_x or math.isclose(x_value, bounds.max_x):
                cell_geometry = _rectangle_polygon(
                    x_value,
                    y_value,
                    min(x_value + cell_width, bounds.max_x),
                    min(y_value + resolved_cell_height, bounds.max_y),
                )
                cell_bounds = geometry_bounds(cell_geometry)
                candidate_indexes = geometry_index.query(cell_bounds)
                if include_empty or any(
                    geometry_intersects(cell_geometry, self._rows[index][self.geometry_column])
                    for index in candidate_indexes
                ):
                    rows.append(
                        {
                            grid_id_column: f"cell-{row_index:04d}-{column_index:04d}",
                            "grid_row": row_index,
                            "grid_column": column_index,
                            "cell_width": cell_width,
                            "cell_height": resolved_cell_height,
                            self.geometry_column: cell_geometry,
                        }
                    )
                column_index += 1
                x_value += cell_width
                if x_value > bounds.max_x and not math.isclose(x_value, bounds.max_x):
                    break
            row_index += 1
            y_value += resolved_cell_height
            if y_value > bounds.max_y and not math.isclose(y_value, bounds.max_y):
                break

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def hexbin(
        self,
        size: float,
        include_empty: bool = False,
        grid_id_column: str = "grid_id",
    ) -> "GeoPromptFrame":
        if size <= 0:
            raise ValueError("size must be greater than zero")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        bounds = self.bounds()
        geometry_index = self.spatial_index(mode="geometry")
        hex_width = math.sqrt(3.0) * size
        vertical_step = 1.5 * size
        rows: list[Record] = []

        row_index = 0
        center_y = bounds.min_y + size
        while center_y <= bounds.max_y + size:
            offset_x = hex_width / 2.0 if row_index % 2 else 0.0
            column_index = 0
            center_x = bounds.min_x + offset_x
            while center_x <= bounds.max_x + hex_width:
                hex_geometry = _hexagon_polygon(center_x, center_y, size)
                hex_bounds = geometry_bounds(hex_geometry)
                candidate_indexes = geometry_index.query(hex_bounds)
                if include_empty or any(
                    geometry_intersects(hex_geometry, self._rows[index][self.geometry_column])
                    for index in candidate_indexes
                ):
                    rows.append(
                        {
                            grid_id_column: f"hex-{row_index:04d}-{column_index:04d}",
                            "grid_row": row_index,
                            "grid_column": column_index,
                            "hex_size": size,
                            self.geometry_column: hex_geometry,
                        }
                    )
                column_index += 1
                center_x += hex_width
            row_index += 1
            center_y += vertical_step

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def hotspot_grid(
        self,
        cell_size: float,
        shape: GridShape = "fishnet",
        value_column: str | None = None,
        aggregation: AggregationName = "count",
        include_empty: bool = False,
        include_diagnostics: bool = False,
        hotspot_suffix: str = "hotspot",
    ) -> "GeoPromptFrame":
        if cell_size <= 0:
            raise ValueError("cell_size must be greater than zero")
        if aggregation not in {"count", "sum", "mean", "min", "max"}:
            raise ValueError("aggregation must be 'count', 'sum', 'mean', 'min', or 'max'")
        if value_column is None and aggregation != "count":
            raise ValueError("value_column is required when aggregation is not 'count'")
        if value_column is not None:
            self._require_column(value_column)

        grid = self.fishnet(cell_size, include_empty=include_empty) if shape == "fishnet" else self.hexbin(cell_size, include_empty=include_empty)
        centroid_index = self.spatial_index(mode="centroid", cell_size=cell_size) if self._rows else None
        centroids = self._centroids()
        rows: list[Record] = []

        for cell_row in grid.to_records():
            cell_geometry = cell_row[self.geometry_column]
            cell_bounds = geometry_bounds(cell_geometry)
            candidate_indexes = centroid_index.query(cell_bounds) if centroid_index is not None else []
            matched_indexes = [
                index
                for index in candidate_indexes
                if geometry_within({"type": "Point", "coordinates": centroids[index]}, cell_geometry)
            ]
            matched_rows = [self._rows[index] for index in matched_indexes]

            resolved = dict(cell_row)
            resolved[f"count_{hotspot_suffix}"] = len(matched_rows)
            resolved[f"aggregation_{hotspot_suffix}"] = aggregation
            if include_diagnostics:
                resolved[f"candidate_count_{hotspot_suffix}"] = len(candidate_indexes)
                resolved[f"pruning_ratio_{hotspot_suffix}"] = 1.0 - (len(candidate_indexes) / len(self._rows) if self._rows else 0.0)
            if value_column is None:
                resolved[f"value_{hotspot_suffix}"] = float(len(matched_rows))
            else:
                values = [float(row[value_column]) for row in matched_rows if row.get(value_column) is not None]
                if not values:
                    resolved[f"{value_column}_{aggregation}_{hotspot_suffix}"] = None
                    resolved[f"value_{hotspot_suffix}"] = None
                elif aggregation == "sum":
                    resolved[f"{value_column}_sum_{hotspot_suffix}"] = sum(values)
                    resolved[f"value_{hotspot_suffix}"] = sum(values)
                elif aggregation == "mean":
                    resolved[f"{value_column}_mean_{hotspot_suffix}"] = sum(values) / len(values)
                    resolved[f"value_{hotspot_suffix}"] = sum(values) / len(values)
                elif aggregation == "min":
                    resolved[f"{value_column}_min_{hotspot_suffix}"] = min(values)
                    resolved[f"value_{hotspot_suffix}"] = min(values)
                elif aggregation == "max":
                    resolved[f"{value_column}_max_{hotspot_suffix}"] = max(values)
                    resolved[f"value_{hotspot_suffix}"] = max(values)
                else:
                    resolved[f"{value_column}_count_{hotspot_suffix}"] = len(values)
                    resolved[f"value_{hotspot_suffix}"] = float(len(values))
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def network_build(
        self,
        id_column: str = "site_id",
        edge_id_prefix: str = "edge",
        node_id_prefix: str = "node",
        distance_method: str = "euclidean",
    ) -> "GeoPromptFrame":
        cache_key = ("network_build", id_column, edge_id_prefix, node_id_prefix, distance_method)
        cached_network = self._cache.get(cache_key)
        if cached_network is not None:
            return cached_network
        if distance_method not in {"euclidean", "haversine"}:
            raise ValueError("distance_method must be 'euclidean' or 'haversine'")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        self._require_column(id_column)
        if any(geometry_type(row[self.geometry_column]) != "LineString" for row in self._rows):
            raise ValueError("network_build requires all geometries to be LineString")

        segment_records: list[dict[str, Any]] = []
        for row_index, row in enumerate(self._rows):
            vertices = list(row[self.geometry_column]["coordinates"])
            for segment_index in range(1, len(vertices)):
                start = _as_coordinate(vertices[segment_index - 1])
                end = _as_coordinate(vertices[segment_index])
                segment_records.append(
                    {
                        "source_row": row,
                        "source_index": row_index,
                        "source_id": str(row[id_column]),
                        "segment_index": segment_index - 1,
                        "start": start,
                        "end": end,
                        "bounds": (
                            min(start[0], end[0]),
                            min(start[1], end[1]),
                            max(start[0], end[0]),
                            max(start[1], end[1]),
                        ),
                    }
                )

        segment_index = SpatialIndex([segment["bounds"] for segment in segment_records])
        cut_points: list[set[Coordinate]] = [
            {segment["start"], segment["end"]}
            for segment in segment_records
        ]
        for index, segment in enumerate(segment_records):
            for other_index in segment_index.query(segment["bounds"]):
                if other_index <= index:
                    continue
                other = segment_records[other_index]
                for point in _segment_intersection_points(segment["start"], segment["end"], other["start"], other["end"]):
                    cut_points[index].add(point)
                    cut_points[other_index].add(point)

        edge_parts: list[dict[str, Any]] = []
        for segment, points in zip(segment_records, cut_points, strict=True):
            ordered_points = sorted(points, key=lambda point: _point_parameter(point, segment["start"], segment["end"]))
            for start_point, end_point in zip(ordered_points, ordered_points[1:]):
                if _same_coordinate(start_point, end_point):
                    continue
                edge_parts.append(
                    {
                        "source_id": segment["source_id"],
                        "source_segment_index": segment["segment_index"],
                        "start": start_point,
                        "end": end_point,
                    }
                )

        unique_nodes = sorted(
            {_coordinate_key(edge["start"]): edge["start"] for edge in edge_parts} | {_coordinate_key(edge["end"]): edge["end"] for edge in edge_parts}
        )
        node_lookup = {
            node_key: f"{node_id_prefix}-{index:05d}"
            for index, node_key in enumerate(unique_nodes)
        }

        rows: list[Record] = []
        for edge_index, edge in enumerate(edge_parts):
            start_key = _coordinate_key(edge["start"])
            end_key = _coordinate_key(edge["end"])
            start_node_id = node_lookup[start_key]
            end_node_id = node_lookup[end_key]
            line_geometry: Geometry = {"type": "LineString", "coordinates": (edge["start"], edge["end"])}
            rows.append(
                {
                    "edge_id": f"{edge_id_prefix}-{edge_index:05d}",
                    "source_id": edge["source_id"],
                    "source_segment_index": edge["source_segment_index"],
                    "from_node_id": start_node_id,
                    "to_node_id": end_node_id,
                    "from_node": edge["start"],
                    "to_node": edge["end"],
                    "edge_length": geometry_length(line_geometry) if distance_method == "euclidean" else coordinate_distance(edge["start"], edge["end"], method=distance_method),
                    self.geometry_column: line_geometry,
                }
            )

        rows.sort(key=lambda row: (str(row["source_id"]), int(row["source_segment_index"]), str(row["from_node_id"]), str(row["to_node_id"])))
        network_frame = GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        self._cache[cache_key] = network_frame
        return network_frame

    def service_area(
        self,
        origins: str | Coordinate | Sequence[str | Coordinate],
        max_cost: float,
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        directed: bool = False,
        include_partial_edges: bool = False,
        include_diagnostics: bool = False,
        service_suffix: str = "service",
    ) -> "GeoPromptFrame":
        if max_cost < 0:
            raise ValueError("max_cost must be zero or greater")
        for column in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        origin_values: list[str | Coordinate]
        if isinstance(origins, str) or _is_coordinate_value(origins):
            origin_values = [cast(str | Coordinate, origins)]
        else:
            origin_values = [
                cast(str | Coordinate, origin)
                for origin in origins
                if isinstance(origin, str) or _is_coordinate_value(origin)
            ]

        origin_node_ids = [
            self._resolve_network_node(origin, from_node_id_column, to_node_id_column, from_node_column, to_node_column)
            for origin in origin_values
        ]
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        distances = _dijkstra_distances(adjacency, origin_node_ids)

        rows: list[Record] = []
        partial_edge_count = 0
        for row in self._rows:
            from_node_id = str(row[from_node_id_column])
            to_node_id = str(row[to_node_id_column])
            from_cost = distances.get(from_node_id)
            to_cost = distances.get(to_node_id)
            edge_cost = float(row[cost_column])

            intervals = _reachable_edge_intervals(
                from_cost=from_cost,
                to_cost=to_cost,
                edge_cost=edge_cost,
                max_cost=max_cost,
                directed=directed,
            )
            if not intervals:
                continue

            interval_rows = intervals if include_partial_edges else [intervals[0]]
            for interval_index, (coverage_start, coverage_end) in enumerate(interval_rows, start=1):
                if not include_partial_edges and (coverage_start > 1e-9 or coverage_end < 1.0 - 1e-9):
                    continue
                clipped_geometry = row[self.geometry_column]
                if coverage_start > 1e-9 or coverage_end < 1.0 - 1e-9:
                    clipped_geometry = _linestring_subgeometry(row[self.geometry_column], coverage_start, coverage_end)
                    if clipped_geometry is None:
                        continue
                    partial_edge_count += 1

                resolved = dict(row)
                resolved[self.geometry_column] = clipped_geometry
                resolved[f"origin_nodes_{service_suffix}"] = list(origin_node_ids)
                resolved[f"max_cost_{service_suffix}"] = max_cost
                resolved[f"cost_from_node_{service_suffix}"] = from_cost
                resolved[f"cost_to_node_{service_suffix}"] = to_cost
                resolved[f"cost_min_{service_suffix}"] = min(value for value in (from_cost, to_cost) if value is not None)
                resolved[f"segment_index_{service_suffix}"] = interval_index
                resolved[f"segment_count_{service_suffix}"] = len(interval_rows)
                resolved[f"coverage_start_{service_suffix}"] = coverage_start
                resolved[f"coverage_end_{service_suffix}"] = coverage_end
                resolved[f"coverage_ratio_{service_suffix}"] = coverage_end - coverage_start
                resolved[f"partial_{service_suffix}"] = coverage_start > 1e-9 or coverage_end < 1.0 - 1e-9
                if include_diagnostics:
                    resolved[f"origin_count_{service_suffix}"] = len(origin_node_ids)
                    resolved[f"reached_node_count_{service_suffix}"] = len(distances)
                    resolved[f"network_node_count_{service_suffix}"] = len(adjacency)
                    resolved[f"input_edge_count_{service_suffix}"] = len(self._rows)
                rows.append(resolved)

        if include_diagnostics:
            for row in rows:
                row[f"reachable_segment_count_{service_suffix}"] = len(rows)
                row[f"partial_edge_count_{service_suffix}"] = partial_edge_count

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def shortest_path(
        self,
        origin: str | Coordinate,
        destination: str | Coordinate,
        edge_id_column: str = "edge_id",
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        directed: bool = False,
        include_diagnostics: bool = False,
        path_suffix: str = "path",
    ) -> "GeoPromptFrame":
        for column in (edge_id_column, from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        origin_node_id = self._resolve_network_node(origin, from_node_id_column, to_node_id_column, from_node_column, to_node_column)
        destination_node_id = self._resolve_network_node(destination, from_node_id_column, to_node_id_column, from_node_column, to_node_column)

        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        distances: dict[str, float] = {origin_node_id: 0.0}
        previous: dict[str, tuple[str, int]] = {}
        queue: list[tuple[float, str]] = [(0.0, origin_node_id)]
        visited: set[str] = set()
        relaxation_count = 0

        while queue:
            current_cost, current_node = heappop(queue)
            if current_node in visited:
                continue
            visited.add(current_node)
            if current_node == destination_node_id:
                break
            for next_node, edge_cost, edge_index in adjacency.get(current_node, []):
                path_cost = current_cost + edge_cost
                if path_cost < distances.get(next_node, float("inf")):
                    distances[next_node] = path_cost
                    previous[next_node] = (current_node, edge_index)
                    heappush(queue, (path_cost, next_node))
                    relaxation_count += 1

        if destination_node_id not in distances:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        path_edge_indexes: list[int] = []
        node_sequence: list[str] = [destination_node_id]
        current_node = destination_node_id
        while current_node != origin_node_id:
            previous_node, edge_index = previous[current_node]
            path_edge_indexes.append(edge_index)
            node_sequence.append(previous_node)
            current_node = previous_node

        path_edge_indexes.reverse()
        node_sequence.reverse()
        total_cost = distances[destination_node_id]
        rows: list[Record] = []
        for step_index, edge_index in enumerate(path_edge_indexes, start=1):
            resolved = dict(self._rows[edge_index])
            resolved[f"step_{path_suffix}"] = step_index
            resolved[f"origin_node_{path_suffix}"] = origin_node_id
            resolved[f"destination_node_{path_suffix}"] = destination_node_id
            resolved[f"total_cost_{path_suffix}"] = total_cost
            resolved[f"edge_count_{path_suffix}"] = len(path_edge_indexes)
            resolved[f"node_sequence_{path_suffix}"] = list(node_sequence)
            if include_diagnostics:
                resolved[f"visited_node_count_{path_suffix}"] = len(visited)
                resolved[f"relaxation_count_{path_suffix}"] = relaxation_count
                resolved[f"network_node_count_{path_suffix}"] = len(adjacency)
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def location_allocate(
        self,
        facilities: "GeoPromptFrame",
        demands: "GeoPromptFrame",
        facility_id_column: str = "site_id",
        demand_id_column: str = "site_id",
        demand_weight_column: str | None = None,
        facility_capacity_column: str | None = None,
        aggregations: dict[str, AggregationName] | None = None,
        how: SpatialJoinMode = "left",
        max_cost: float | None = None,
        facility_node_column: str | None = None,
        demand_node_column: str | None = None,
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        directed: bool = False,
        include_diagnostics: bool = False,
        allocation_suffix: str = "allocate",
    ) -> "GeoPromptFrame":
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if max_cost is not None and max_cost < 0:
            raise ValueError("max_cost must be zero or greater")
        for column in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(column)
        if self.crs and facilities.crs and self.crs != facilities.crs:
            raise ValueError("frames must share the same CRS before location allocation")
        if self.crs and demands.crs and self.crs != demands.crs:
            raise ValueError("frames must share the same CRS before location allocation")

        facilities._require_column(facility_id_column)
        demands._require_column(demand_id_column)
        if demand_weight_column is not None:
            demands._require_column(demand_weight_column)
        if facility_capacity_column is not None:
            facilities._require_column(facility_capacity_column)
        if facility_node_column is not None:
            facilities._require_column(facility_node_column)
        if demand_node_column is not None:
            demands._require_column(demand_node_column)
        if not facilities._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=facilities.geometry_column, crs=facilities.crs or demands.crs)

        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)

        def resolve_row_node(row: Record, geometry_column: str, node_column: str | None) -> str:
            if node_column is None:
                return self._resolve_network_node(
                    geometry_centroid(row[geometry_column]),
                    from_node_id_column,
                    to_node_id_column,
                    from_node_column,
                    to_node_column,
                )
            node_value = row[node_column]
            if isinstance(node_value, str):
                return self._resolve_network_node(
                    node_value,
                    from_node_id_column,
                    to_node_id_column,
                    from_node_column,
                    to_node_column,
                )
            if isinstance(node_value, dict):
                geometry = normalize_geometry(node_value)
                if geometry_type(geometry) == "Point":
                    return self._resolve_network_node(
                        _as_coordinate(geometry["coordinates"]),
                        from_node_id_column,
                        to_node_id_column,
                        from_node_column,
                        to_node_column,
                    )
                return self._resolve_network_node(
                    geometry_centroid(geometry),
                    from_node_id_column,
                    to_node_id_column,
                    from_node_column,
                    to_node_column,
                )
            if _is_coordinate_value(node_value):
                return self._resolve_network_node(
                    _as_coordinate(node_value),
                    from_node_id_column,
                    to_node_id_column,
                    from_node_column,
                    to_node_column,
                )
            raise TypeError("network node references must be node ids, point geometries, or coordinate pairs")

        facility_rows = list(facilities._rows)
        demand_rows = list(demands._rows)
        facility_node_ids = [
            resolve_row_node(row, facilities.geometry_column, facility_node_column)
            for row in facility_rows
        ]
        demand_node_ids = [
            resolve_row_node(row, demands.geometry_column, demand_node_column)
            for row in demand_rows
        ]
        demand_weights = [
            float(row[demand_weight_column]) if demand_weight_column is not None else 1.0
            for row in demand_rows
        ]
        for weight in demand_weights:
            if weight < 0:
                raise ValueError("demand weights must be zero or greater")

        facility_capacities = [
            float(row[facility_capacity_column]) if facility_capacity_column is not None else float("inf")
            for row in facility_rows
        ]
        for capacity in facility_capacities:
            if capacity < 0:
                raise ValueError("facility capacities must be zero or greater")
        remaining_capacities = list(facility_capacities)

        unique_demand_nodes = {node_id for node_id in demand_node_ids}
        demand_candidates: dict[int, list[tuple[float, int]]] = {index: [] for index in range(len(demand_rows))}
        candidate_route_count = 0

        for facility_index, facility_node_id in enumerate(facility_node_ids):
            distances = _dijkstra_distances(adjacency, [facility_node_id], stop_nodes=unique_demand_nodes)
            for demand_index, demand_node_id in enumerate(demand_node_ids):
                distance_value = distances.get(demand_node_id)
                if distance_value is None:
                    continue
                if max_cost is not None and distance_value > max_cost:
                    continue
                demand_candidates[demand_index].append((distance_value, facility_index))
                candidate_route_count += 1

        for demand_index, candidates in demand_candidates.items():
            candidates.sort(
                key=lambda item: (
                    item[0],
                    str(facility_rows[item[1]][facility_id_column]),
                    item[1],
                )
            )

        demand_order = sorted(
            range(len(demand_rows)),
            key=lambda index: (
                demand_candidates[index][0][0] if demand_candidates[index] else float("inf"),
                str(demand_rows[index][demand_id_column]),
            ),
        )

        assigned_matches: dict[int, list[tuple[Record, float, float, str]]] = {
            index: [] for index in range(len(facility_rows))
        }
        unallocated_rows: list[Record] = []
        for demand_index in demand_order:
            demand_row = demand_rows[demand_index]
            demand_weight = demand_weights[demand_index]
            demand_id = str(demand_row[demand_id_column])
            selected_facility_index: int | None = None
            selected_cost: float | None = None
            for distance_value, facility_index in demand_candidates[demand_index]:
                if remaining_capacities[facility_index] + 1e-9 < demand_weight:
                    continue
                selected_facility_index = facility_index
                selected_cost = distance_value
                break

            if selected_facility_index is None or selected_cost is None:
                unallocated_rows.append(demand_row)
                continue

            if facility_capacity_column is not None:
                remaining_capacities[selected_facility_index] -= demand_weight
            assigned_matches[selected_facility_index].append(
                (demand_row, selected_cost, demand_weight, demand_id)
            )

        rows: list[Record] = []
        unallocated_demand_ids = [str(row[demand_id_column]) for row in unallocated_rows]
        total_allocated = sum(len(matches) for matches in assigned_matches.values())
        for facility_index, facility_row in enumerate(facility_rows):
            matches = assigned_matches[facility_index]
            if not matches and how == "inner":
                continue

            costs = [cost for _row, cost, _weight, _id in matches]
            weights = [weight for _row, _cost, weight, _id in matches]
            allocated_rows = [row for row, _cost, _weight, _id in matches]
            resolved = dict(facility_row)
            resolved[f"{demand_id_column}s_{allocation_suffix}"] = [demand_id for _row, _cost, _weight, demand_id in matches]
            resolved[f"count_{allocation_suffix}"] = len(matches)
            resolved[f"cost_min_{allocation_suffix}"] = min(costs) if costs else None
            resolved[f"cost_max_{allocation_suffix}"] = max(costs) if costs else None
            resolved[f"cost_mean_{allocation_suffix}"] = sum(costs) / len(costs) if costs else None
            resolved[f"allocated_weight_{allocation_suffix}"] = sum(weights)
            resolved[f"facility_node_{allocation_suffix}"] = facility_node_ids[facility_index]
            resolved[f"{demand_id_column}s_unallocated_{allocation_suffix}"] = list(unallocated_demand_ids)
            resolved[f"count_unallocated_{allocation_suffix}"] = len(unallocated_demand_ids)
            if facility_capacity_column is not None:
                resolved[f"capacity_used_{allocation_suffix}"] = facility_capacities[facility_index] - remaining_capacities[facility_index]
                resolved[f"capacity_remaining_{allocation_suffix}"] = remaining_capacities[facility_index]
            else:
                resolved[f"capacity_used_{allocation_suffix}"] = None
                resolved[f"capacity_remaining_{allocation_suffix}"] = None
            resolved.update(self._aggregate_rows(allocated_rows, aggregations=aggregations, suffix=allocation_suffix))
            if include_diagnostics:
                resolved[f"facility_count_{allocation_suffix}"] = len(facility_rows)
                resolved[f"demand_count_{allocation_suffix}"] = len(demand_rows)
                resolved[f"allocated_count_{allocation_suffix}"] = total_allocated
                resolved[f"candidate_route_count_{allocation_suffix}"] = candidate_route_count
                resolved[f"facility_node_count_{allocation_suffix}"] = len(set(facility_node_ids))
                resolved[f"demand_node_count_{allocation_suffix}"] = len(unique_demand_nodes)
                resolved[f"network_node_count_{allocation_suffix}"] = len(adjacency)
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=facilities.geometry_column, crs=facilities.crs or demands.crs or self.crs)

    def _resolve_network_node(
        self,
        node: Any,
        from_node_id_column: str,
        to_node_id_column: str,
        from_node_column: str,
        to_node_column: str,
    ) -> str:
        if isinstance(node, str):
            node_ids = {str(row[from_node_id_column]) for row in self._rows} | {str(row[to_node_id_column]) for row in self._rows}
            if node not in node_ids:
                raise KeyError(f"network node '{node}' was not found")
            return node

        if node is None:
            raise TypeError("network node reference cannot be None")
        if not _is_coordinate_value(node):
            raise TypeError("network node reference must be a node id or coordinate")
        target_coordinate = _as_coordinate(normalize_geometry(node)["coordinates"])
        node_coordinates: dict[str, Coordinate] = {}
        for row in self._rows:
            node_coordinates[str(row[from_node_id_column])] = _as_coordinate(row[from_node_column])
            node_coordinates[str(row[to_node_id_column])] = _as_coordinate(row[to_node_column])
        return min(
            node_coordinates,
            key=lambda node_id: (
                coordinate_distance(target_coordinate, node_coordinates[node_id], method="euclidean"),
                node_id,
            ),
        )

    def _network_graph(
        self,
        from_node_id_column: str,
        to_node_id_column: str,
        cost_column: str,
        directed: bool,
    ) -> dict[str, list[tuple[str, float, int]]]:
        cache_key = ("network_graph", from_node_id_column, to_node_id_column, cost_column, directed)
        cached_graph = self._cache.get(cache_key)
        if cached_graph is not None:
            return cached_graph

        adjacency: dict[str, list[tuple[str, float, int]]] = {}
        for index, row in enumerate(self._rows):
            from_node_id = str(row[from_node_id_column])
            to_node_id = str(row[to_node_id_column])
            edge_cost = float(row[cost_column])
            adjacency.setdefault(from_node_id, []).append((to_node_id, edge_cost, index))
            if not directed:
                adjacency.setdefault(to_node_id, []).append((from_node_id, edge_cost, index))
        self._cache[cache_key] = adjacency
        return adjacency

    def trajectory_match(
        self,
        observations: "GeoPromptFrame",
        track_id_column: str = "track_id",
        sequence_column: str = "sequence",
        observation_id_column: str = "site_id",
        edge_id_column: str = "edge_id",
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        cost_column: str = "edge_length",
        candidate_k: int = 3,
        max_distance: float | None = None,
        transition_weight: float = 1.25,
        transition_mode: TrajectoryTransitionMode = "hmm",
        gap_penalty: float = 7.5,
        allow_gaps: bool = True,
        distance_method: str = "euclidean",
        include_diagnostics: bool = False,
        match_suffix: str = "match",
    ) -> "GeoPromptFrame":
        if candidate_k <= 0:
            raise ValueError("candidate_k must be greater than zero")
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if transition_weight < 0:
            raise ValueError("transition_weight must be zero or greater")
        if transition_mode not in {"network_cost", "hmm"}:
            raise ValueError("transition_mode must be 'network_cost' or 'hmm'")
        if gap_penalty < 0:
            raise ValueError("gap_penalty must be zero or greater")
        if self.crs and observations.crs and self.crs != observations.crs:
            raise ValueError("frames must share the same CRS before trajectory matching")
        for column in (edge_id_column, from_node_id_column, to_node_id_column, cost_column):
            self._require_column(column)
        for column in (track_id_column, sequence_column, observation_id_column):
            observations._require_column(column)
        if any(geometry_type(row[self.geometry_column]) != "LineString" for row in self._rows):
            raise ValueError("trajectory_match requires LineString geometries on the network frame")
        if any(geometry_type(row[observations.geometry_column]) != "Point" for row in observations._rows):
            raise ValueError("trajectory_match requires Point geometries on the observations frame")

        edge_rows = list(self._rows)
        observation_rows = list(observations._rows)
        edge_index = self.spatial_index(mode="geometry") if edge_rows else SpatialIndex([])
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=False)
        edge_columns = [column for column in self.columns if column != self.geometry_column]
        node_distance_cache: dict[str, dict[str, float]] = {}
        transition_cache: dict[tuple[int, int, float, float], float] = {}

        grouped_tracks: dict[str, list[Record]] = {}
        for row in observation_rows:
            grouped_tracks.setdefault(str(row[track_id_column]), []).append(row)

        rows: list[Record] = []
        for track_id, track_rows in sorted(grouped_tracks.items(), key=lambda item: item[0]):
            ordered_track_rows = sorted(
                track_rows,
                key=lambda row: (row[sequence_column], str(row[observation_id_column])),
            )
            candidate_sets: list[list[dict[str, Any]]] = []
            for row in ordered_track_rows:
                point = _as_coordinate(row[observations.geometry_column]["coordinates"])
                if max_distance is not None:
                    candidate_indexes = edge_index.query(
                        (
                            point[0] - max_distance,
                            point[1] - max_distance,
                            point[0] + max_distance,
                            point[1] + max_distance,
                        )
                    )
                else:
                    candidate_indexes = list(range(len(edge_rows)))

                candidates: list[dict[str, Any]] = []
                for edge_index_value in candidate_indexes:
                    edge_row = edge_rows[edge_index_value]
                    offset_distance, along_distance = _point_to_polyline_distance_details(
                        point,
                        tuple(_as_coordinate(value) for value in edge_row[self.geometry_column]["coordinates"]),
                        method=distance_method,
                    )
                    if max_distance is not None and offset_distance > max_distance:
                        continue
                    candidates.append(
                        {
                            "edge_index": edge_index_value,
                            "offset_distance": offset_distance,
                            "along_distance": along_distance,
                            "edge_id": str(edge_row[edge_id_column]),
                        }
                    )
                candidates.sort(key=lambda item: (float(item["offset_distance"]), str(item["edge_id"])))
                candidate_sets.append(candidates[:candidate_k])

            previous_scores: list[dict[str, Any]] | None = None
            track_states: list[list[dict[str, Any]]] = []
            previous_point: tuple[float, float] | None = None
            for row, candidates in zip(ordered_track_rows, candidate_sets, strict=True):
                current_scores: list[dict[str, Any]] = []
                point = _as_coordinate(row[observations.geometry_column]["coordinates"])
                observed_step_distance = coordinate_distance(previous_point, point, method=distance_method) if previous_point is not None else 0.0
                state_candidates = list(candidates)
                if allow_gaps:
                    state_candidates.append(
                        {
                            "edge_index": None,
                            "offset_distance": gap_penalty,
                            "along_distance": None,
                            "edge_id": None,
                            "gap_state": True,
                        }
                    )
                if not state_candidates:
                    track_states.append([])
                    previous_scores = None
                    previous_point = point
                    continue
                for candidate in state_candidates:
                    gap_state = bool(candidate.get("gap_state", False))
                    best_score = candidate["offset_distance"]
                    best_previous_state_index: int | None = None
                    best_transition_cost = 0.0
                    best_transition_penalty = 0.0
                    best_transition_mismatch = 0.0
                    if previous_scores is not None:
                        best_score = float("inf")
                        for previous_state_index, previous in enumerate(previous_scores):
                            if gap_state or previous.get("gap_state", False):
                                transition_cost = 0.0
                                transition_penalty = _gap_transition_penalty(
                                    previous_gap_state=bool(previous.get("gap_state", False)),
                                    current_gap_state=gap_state,
                                    gap_penalty=gap_penalty,
                                )
                                transition_mismatch = observed_step_distance if transition_penalty > 0.0 else 0.0
                            else:
                                transition_cost = _edge_transition_cost(
                                    previous["edge_index"],
                                    candidate["edge_index"],
                                    float(previous["along_distance"]),
                                    float(candidate["along_distance"]),
                                    edge_rows,
                                    adjacency,
                                    from_node_id_column,
                                    to_node_id_column,
                                    cost_column,
                                    node_distance_cache,
                                    transition_cache,
                                )
                                transition_mismatch = abs(transition_cost - observed_step_distance)
                                if transition_mode == "hmm":
                                    transition_penalty = (0.5 * transition_cost) + transition_mismatch
                                else:
                                    transition_penalty = transition_cost
                            total_score = previous["score"] + candidate["offset_distance"] + (transition_weight * transition_penalty)
                            if total_score < best_score:
                                best_score = total_score
                                best_previous_state_index = previous_state_index
                                best_transition_cost = transition_cost
                                best_transition_penalty = transition_penalty
                                best_transition_mismatch = transition_mismatch
                    current_scores.append(
                        {
                            **candidate,
                            "gap_state": gap_state,
                            "score": best_score,
                            "previous_state_index": best_previous_state_index,
                            "transition_cost": best_transition_cost,
                            "transition_penalty": best_transition_penalty,
                            "transition_mismatch": best_transition_mismatch,
                            "observed_step_distance": observed_step_distance,
                        }
                    )
                track_states.append(current_scores)
                previous_scores = current_scores
                previous_point = point

            chosen_state_indexes: list[int | None] = [None] * len(ordered_track_rows)
            next_state_index: int | None = None
            for observation_index in range(len(ordered_track_rows) - 1, -1, -1):
                states = track_states[observation_index]
                if not states:
                    next_state_index = None
                    continue
                if next_state_index is None or next_state_index >= len(states):
                    best_state_index = min(
                        range(len(states)),
                        key=lambda state_index: (
                            float(states[state_index]["score"]),
                            int(bool(states[state_index].get("gap_state", False))),
                            str(states[state_index].get("edge_id")),
                        ),
                    )
                else:
                    best_state_index = next_state_index
                chosen_state_indexes[observation_index] = best_state_index
                next_state_index = states[best_state_index]["previous_state_index"]

            continuity_states: list[str] = []
            segment_indexes: list[int | None] = []
            current_segment_index = 0
            previous_was_gap = True
            for row_index, state_index in enumerate(chosen_state_indexes):
                states = track_states[row_index]
                if state_index is None or not states:
                    continuity_states.append("unmatched")
                    segment_indexes.append(None)
                    previous_was_gap = True
                    continue
                selected_state = states[state_index]
                if selected_state.get("gap_state", False):
                    continuity_states.append("gap")
                    segment_indexes.append(None)
                    previous_was_gap = True
                    continue
                if current_segment_index == 0:
                    current_segment_index = 1
                    continuity_states.append("start")
                elif previous_was_gap:
                    current_segment_index += 1
                    continuity_states.append("resume")
                else:
                    continuity_states.append("continuation")
                segment_indexes.append(current_segment_index)
                previous_was_gap = False

            for row_index, row in enumerate(ordered_track_rows):
                states = track_states[row_index]
                resolved = dict(row)
                resolved[f"track_{match_suffix}"] = track_id
                resolved[f"candidate_count_{match_suffix}"] = sum(1 for state in states if not state.get("gap_state", False))
                resolved[f"continuity_state_{match_suffix}"] = continuity_states[row_index]
                resolved[f"segment_index_{match_suffix}"] = segment_indexes[row_index]
                selected_state_index = chosen_state_indexes[row_index]
                if not states or selected_state_index is None:
                    resolved[f"matched_{match_suffix}"] = False
                    resolved[f"{edge_id_column}_{match_suffix}"] = None
                    resolved[f"distance_{match_suffix}"] = None
                    resolved[f"along_distance_{match_suffix}"] = None
                    resolved[f"transition_cost_{match_suffix}"] = None
                    if include_diagnostics:
                        resolved[f"gap_state_{match_suffix}"] = False
                    rows.append(resolved)
                    continue

                selected_state = states[selected_state_index]
                if selected_state.get("gap_state", False):
                    resolved[f"matched_{match_suffix}"] = False
                    resolved[f"{edge_id_column}_{match_suffix}"] = None
                    resolved[f"distance_{match_suffix}"] = None
                    resolved[f"along_distance_{match_suffix}"] = None
                    resolved[f"transition_cost_{match_suffix}"] = None
                    if include_diagnostics:
                        resolved[f"candidate_{edge_id_column}s_{match_suffix}"] = [state["edge_id"] for state in states if not state.get("gap_state", False)]
                        resolved[f"score_{match_suffix}"] = selected_state["score"]
                        resolved[f"candidate_k_{match_suffix}"] = candidate_k
                        resolved[f"gap_state_{match_suffix}"] = True
                        resolved[f"transition_penalty_{match_suffix}"] = selected_state["transition_penalty"]
                        resolved[f"transition_mismatch_{match_suffix}"] = selected_state["transition_mismatch"]
                        resolved[f"observed_step_distance_{match_suffix}"] = selected_state["observed_step_distance"]
                        resolved[f"transition_mode_{match_suffix}"] = transition_mode
                    rows.append(resolved)
                    continue

                matched_edge = edge_rows[selected_state["edge_index"]]
                for column in edge_columns:
                    target_name = column if column not in resolved else f"{column}_{match_suffix}"
                    resolved[target_name] = matched_edge[column]
                resolved[f"matched_{match_suffix}"] = True
                resolved[f"{edge_id_column}_{match_suffix}"] = str(matched_edge[edge_id_column])
                resolved[f"distance_{match_suffix}"] = selected_state["offset_distance"]
                resolved[f"along_distance_{match_suffix}"] = selected_state["along_distance"]
                resolved[f"transition_cost_{match_suffix}"] = selected_state["transition_cost"]
                if include_diagnostics:
                    resolved[f"candidate_{edge_id_column}s_{match_suffix}"] = [state["edge_id"] for state in states if not state.get("gap_state", False)]
                    resolved[f"score_{match_suffix}"] = selected_state["score"]
                    resolved[f"candidate_k_{match_suffix}"] = candidate_k
                    resolved[f"gap_state_{match_suffix}"] = False
                    resolved[f"transition_penalty_{match_suffix}"] = selected_state["transition_penalty"]
                    resolved[f"transition_mismatch_{match_suffix}"] = selected_state["transition_mismatch"]
                    resolved[f"observed_step_distance_{match_suffix}"] = selected_state["observed_step_distance"]
                    resolved[f"transition_mode_{match_suffix}"] = transition_mode
                rows.append(resolved)

        rows.sort(key=lambda row: (str(row[track_id_column]), row[sequence_column], str(row[observation_id_column])))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=observations.geometry_column, crs=observations.crs or self.crs)

    def summarize_trajectory_segments(
        self,
        track_id_column: str = "track_id",
        sequence_column: str = "sequence",
        observation_id_column: str = "site_id",
        edge_id_column: str = "edge_id",
        match_suffix: str = "match",
    ) -> "GeoPromptFrame":
        self._require_column(track_id_column)
        self._require_column(sequence_column)
        self._require_column(observation_id_column)
        self._require_column(f"matched_{match_suffix}")
        self._require_column(f"continuity_state_{match_suffix}")
        self._require_column(f"segment_index_{match_suffix}")
        self._require_column(f"{edge_id_column}_{match_suffix}")

        grouped_rows: dict[tuple[str, int], list[Record]] = {}
        for row in self._rows:
            if not bool(row.get(f"matched_{match_suffix}")):
                continue
            segment_index = row.get(f"segment_index_{match_suffix}")
            if segment_index is None:
                continue
            grouped_rows.setdefault((str(row[track_id_column]), int(segment_index)), []).append(row)

        rows: list[Record] = []
        for (track_id, segment_index), segment_rows in sorted(grouped_rows.items(), key=lambda item: (item[0][0], item[0][1])):
            ordered_rows = sorted(segment_rows, key=lambda row: (row[sequence_column], str(row[observation_id_column])))
            observation_ids = [str(row[observation_id_column]) for row in ordered_rows]
            path_edge_ids = _consecutive_distinct_values(str(row[f"{edge_id_column}_{match_suffix}"]) for row in ordered_rows if row.get(f"{edge_id_column}_{match_suffix}") is not None)
            coordinates = [
                list(_as_coordinate(row[self.geometry_column]["coordinates"]))
                for row in ordered_rows
            ]
            summary_geometry: Geometry
            if len(coordinates) == 1:
                summary_geometry = {"type": "Point", "coordinates": coordinates[0]}
            else:
                summary_geometry = {"type": "LineString", "coordinates": coordinates}
            transition_costs = [
                float(row[f"transition_cost_{match_suffix}"])
                for row in ordered_rows
                if row.get(f"transition_cost_{match_suffix}") is not None
            ]
            distances = [
                float(row[f"distance_{match_suffix}"])
                for row in ordered_rows
                if row.get(f"distance_{match_suffix}") is not None
            ]
            sequence_span = int(ordered_rows[-1][sequence_column]) - int(ordered_rows[0][sequence_column]) + 1
            rows.append(
                {
                    track_id_column: track_id,
                    f"segment_index_{match_suffix}": segment_index,
                    f"observation_ids_{match_suffix}": observation_ids,
                    f"edge_ids_{match_suffix}": path_edge_ids,
                    f"observation_count_{match_suffix}": len(ordered_rows),
                    f"edge_count_{match_suffix}": len(path_edge_ids),
                    f"start_sequence_{match_suffix}": ordered_rows[0][sequence_column],
                    f"end_sequence_{match_suffix}": ordered_rows[-1][sequence_column],
                    f"sequence_span_{match_suffix}": sequence_span,
                    f"continuity_start_{match_suffix}": ordered_rows[0].get(f"continuity_state_{match_suffix}"),
                    f"continuity_end_{match_suffix}": ordered_rows[-1].get(f"continuity_state_{match_suffix}"),
                    f"gap_before_{match_suffix}": ordered_rows[0].get(f"continuity_state_{match_suffix}") == "resume",
                    f"mean_distance_{match_suffix}": (sum(distances) / len(distances)) if distances else None,
                    f"max_distance_{match_suffix}": max(distances) if distances else None,
                    f"transition_count_{match_suffix}": len(transition_costs),
                    f"mean_transition_cost_{match_suffix}": (sum(transition_costs) / len(transition_costs)) if transition_costs else 0.0,
                    f"total_transition_cost_{match_suffix}": sum(transition_costs),
                    f"observation_density_{match_suffix}": (len(ordered_rows) / sequence_span) if sequence_span > 0 else None,
                    self.geometry_column: summary_geometry,
                }
            )

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def score_trajectory_segments(
        self,
        track_id_column: str = "track_id",
        match_suffix: str = "match",
        score_suffix: str = "trajectory",
        distance_threshold: float = 0.05,
        transition_cost_threshold: float = 1.0,
        density_threshold: float = 0.75,
    ) -> "GeoPromptFrame":
        self._require_column(track_id_column)
        self._require_column(f"segment_index_{match_suffix}")
        self._require_column(f"observation_count_{match_suffix}")
        self._require_column(f"edge_count_{match_suffix}")
        self._require_column(f"mean_distance_{match_suffix}")
        self._require_column(f"max_distance_{match_suffix}")
        self._require_column(f"mean_transition_cost_{match_suffix}")
        self._require_column(f"observation_density_{match_suffix}")
        if distance_threshold <= 0.0:
            raise ValueError("distance_threshold must be greater than zero")
        if transition_cost_threshold <= 0.0:
            raise ValueError("transition_cost_threshold must be greater than zero")
        if not 0.0 < density_threshold <= 1.0:
            raise ValueError("density_threshold must be between zero and one")

        rows: list[Record] = []
        for row in self._rows:
            mean_distance = float(row[f"mean_distance_{match_suffix}"] or 0.0)
            max_distance = float(row[f"max_distance_{match_suffix}"] or 0.0)
            mean_transition_cost = float(row[f"mean_transition_cost_{match_suffix}"] or 0.0)
            observation_density = float(row[f"observation_density_{match_suffix}"] or 0.0)
            observation_count = int(row[f"observation_count_{match_suffix}"])
            edge_count = int(row[f"edge_count_{match_suffix}"])
            anomaly_flags: list[str] = []
            if bool(row.get(f"gap_before_{match_suffix}")):
                anomaly_flags.append("resumed_after_gap")
            if observation_count <= 1:
                anomaly_flags.append("single_observation_segment")
            if mean_distance > distance_threshold:
                anomaly_flags.append("high_mean_offset_distance")
            if max_distance > (distance_threshold * 1.5):
                anomaly_flags.append("high_max_offset_distance")
            if mean_transition_cost > transition_cost_threshold:
                anomaly_flags.append("high_transition_cost")
            if observation_density < density_threshold:
                anomaly_flags.append("sparse_observation_density")
            if edge_count > max(1, observation_count):
                anomaly_flags.append("excess_edge_changes")

            distance_penalty = min(1.0, mean_distance / distance_threshold)
            transition_penalty = min(1.0, mean_transition_cost / transition_cost_threshold)
            density_penalty = max(0.0, 1.0 - observation_density)
            gap_penalty = 0.2 if bool(row.get(f"gap_before_{match_suffix}")) else 0.0
            single_penalty = 0.2 if observation_count <= 1 else 0.0
            confidence_score = max(
                0.0,
                min(
                    1.0,
                    1.0 - ((0.35 * distance_penalty) + (0.35 * transition_penalty) + (0.1 * density_penalty) + gap_penalty + single_penalty),
                ),
            )
            anomaly_level = _trajectory_anomaly_level(confidence_score, len(anomaly_flags))
            resolved = dict(row)
            resolved[f"confidence_score_{score_suffix}"] = confidence_score
            resolved[f"anomaly_flags_{score_suffix}"] = anomaly_flags
            resolved[f"anomaly_count_{score_suffix}"] = len(anomaly_flags)
            resolved[f"anomaly_level_{score_suffix}"] = anomaly_level
            resolved[f"review_segment_{score_suffix}"] = anomaly_level in {"moderate", "high"}
            rows.append(resolved)

        rows.sort(
            key=lambda row: (
                int(row[f"review_segment_{score_suffix}"]),
                int(row[f"anomaly_count_{score_suffix}"]),
                -float(row[f"confidence_score_{score_suffix}"]),
                str(row[track_id_column]),
                int(row[f"segment_index_{match_suffix}"]),
            ),
            reverse=True,
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def change_detection(
        self,
        other: "GeoPromptFrame",
        id_column: str = "site_id",
        other_id_column: str | None = None,
        attribute_columns: Sequence[str] | None = None,
        max_distance: float | None = None,
        min_similarity: float = 0.35,
        geometry_tolerance: float = 1e-9,
        include_diagnostics: bool = False,
        change_suffix: str = "change",
    ) -> "GeoPromptFrame":
        resolved_other_id_column = other_id_column or id_column
        self._require_column(id_column)
        other._require_column(resolved_other_id_column)
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if not 0.0 <= min_similarity <= 1.0:
            raise ValueError("min_similarity must be between zero and one")
        if geometry_tolerance < 0:
            raise ValueError("geometry_tolerance must be zero or greater")
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before change detection")

        left_rows = list(self._rows)
        right_rows = list(other._rows)
        right_index = other.spatial_index(mode="geometry") if right_rows else SpatialIndex([])
        left_centroids = self._centroids()
        right_centroids = other._centroids()
        left_geometries = [row[self.geometry_column] for row in left_rows]
        right_geometries = [row[other.geometry_column] for row in right_rows]
        left_bounds = [geometry_bounds(geometry) for geometry in left_geometries]
        right_bounds = [geometry_bounds(geometry) for geometry in right_geometries]
        left_types = [geometry_type(geometry) for geometry in left_geometries]
        right_types = [geometry_type(geometry) for geometry in right_geometries]
        left_sizes = [_geometry_size_metric(geometry) for geometry in left_geometries]
        right_sizes = [_geometry_size_metric(geometry) for geometry in right_geometries]
        left_ids = [str(row[id_column]) for row in left_rows]
        right_ids = [str(row[resolved_other_id_column]) for row in right_rows]
        shared_attribute_columns = list(attribute_columns) if attribute_columns is not None else sorted(
            (set(self.columns) & set(other.columns)) - {self.geometry_column, other.geometry_column, id_column, resolved_other_id_column}
        )

        pair_scores: list[dict[str, Any]] = []
        left_candidates: dict[int, list[dict[str, Any]]] = {index: [] for index in range(len(left_rows))}
        right_candidates: dict[int, list[dict[str, Any]]] = {index: [] for index in range(len(right_rows))}

        for left_index, left_row in enumerate(left_rows):
            left_geometry = left_geometries[left_index]
            left_centroid = left_centroids[left_index]
            if max_distance is None:
                candidate_indexes = list(range(len(right_rows)))
            else:
                candidate_indexes = right_index.query(_expand_bounds(left_bounds[left_index], max_distance))
            for right_index_value in candidate_indexes:
                right_row = right_rows[right_index_value]
                right_geometry = right_geometries[right_index_value]
                centroid_distance = coordinate_distance(left_centroid, right_centroids[right_index_value], method="euclidean")
                bounds_intersect = _bounds_intersect(left_bounds[left_index], right_bounds[right_index_value])
                intersects = geometry_intersects(left_geometry, right_geometry) if bounds_intersect else False
                if max_distance is not None and centroid_distance > max_distance and not intersects:
                    continue
                attribute_match_count = sum(1 for column in shared_attribute_columns if left_row.get(column) == right_row.get(column))
                attribute_similarity = (attribute_match_count / len(shared_attribute_columns)) if shared_attribute_columns else 1.0
                left_size = left_sizes[left_index]
                right_size = right_sizes[right_index_value]
                size_ratio = _size_ratio(left_size, right_size)
                overlap_size = _geometry_overlap_size(left_geometry, right_geometry) if bounds_intersect else 0.0
                left_area_share = _coverage_share(overlap_size, left_size, intersects)
                right_area_share = _coverage_share(overlap_size, right_size, intersects)
                area_share_score = (left_area_share + right_area_share) / 2.0
                distance_score = 1.0 if centroid_distance <= geometry_tolerance else 1.0 / (1.0 + centroid_distance)
                id_score = 1.0 if left_ids[left_index] == right_ids[right_index_value] else 0.0
                type_factor = 1.0 if left_types[left_index] == right_types[right_index_value] else 0.5
                similarity_score = type_factor * (
                    (0.25 * area_share_score)
                    + (0.15 * (1.0 if intersects else 0.0))
                    + (0.2 * distance_score)
                    + (0.15 * size_ratio)
                    + (0.15 * attribute_similarity)
                    + (0.15 * id_score)
                )
                if similarity_score < min_similarity:
                    continue
                pair_record = {
                    "left_index": left_index,
                    "right_index": right_index_value,
                    "similarity_score": similarity_score,
                    "centroid_distance": centroid_distance,
                    "attribute_match_count": attribute_match_count,
                    "attribute_similarity": attribute_similarity,
                    "intersects": intersects,
                    "size_ratio": size_ratio,
                    "overlap_size": overlap_size,
                    "left_area_share": left_area_share,
                    "right_area_share": right_area_share,
                    "area_share_score": area_share_score,
                }
                pair_scores.append(pair_record)
                left_candidates[left_index].append(pair_record)
                right_candidates[right_index_value].append(pair_record)

        for candidate_bucket in left_candidates.values():
            candidate_bucket.sort(key=lambda item: (-float(item["similarity_score"]), float(item["centroid_distance"]), str(right_rows[item["right_index"]][resolved_other_id_column])))
        for candidate_bucket in right_candidates.values():
            candidate_bucket.sort(key=lambda item: (-float(item["similarity_score"]), float(item["centroid_distance"]), str(left_rows[item["left_index"]][id_column])))

        left_matches = {
            left_index: _primary_change_matches(candidate_bucket)
            for left_index, candidate_bucket in left_candidates.items()
        }
        right_matches = {
            right_index_value: _primary_change_matches(candidate_bucket, key_name="left_index")
            for right_index_value, candidate_bucket in right_candidates.items()
        }

        rows: list[Record] = []
        matched_right_indexes: set[int] = set()
        for left_index, left_row in enumerate(left_rows):
            matched_pairs = left_matches[left_index]
            if not matched_pairs:
                resolved = dict(left_row)
                resolved[f"left_ids_{change_suffix}"] = [str(left_row[id_column])]
                resolved[f"right_ids_{change_suffix}"] = []
                resolved[f"change_class_{change_suffix}"] = "removed"
                resolved[f"event_side_{change_suffix}"] = "left"
                resolved[f"similarity_score_{change_suffix}"] = None
                resolved[f"centroid_distance_{change_suffix}"] = None
                resolved[f"attribute_change_count_{change_suffix}"] = None
                resolved[f"attribute_changes_{change_suffix}"] = None
                resolved[f"area_share_score_{change_suffix}"] = None
                resolved[f"match_area_shares_{change_suffix}"] = []
                if include_diagnostics:
                    resolved[f"candidate_count_{change_suffix}"] = len(left_candidates[left_index])
                rows.append(resolved)
                continue

            matched_right_indexes.update(pair["right_index"] for pair in matched_pairs)
            matched_right_ids = [str(right_rows[pair["right_index"]][resolved_other_id_column]) for pair in matched_pairs]
            best_pair = matched_pairs[0]
            matched_right_rows = [right_rows[pair["right_index"]] for pair in matched_pairs]
            attribute_changes = _attribute_change_summary([left_row], matched_right_rows, shared_attribute_columns)
            area_share_details = [
                {
                    "left_id": str(left_row[id_column]),
                    "right_id": str(right_rows[pair["right_index"]][resolved_other_id_column]),
                    "left_share": pair["left_area_share"],
                    "right_share": pair["right_area_share"],
                    "similarity_score": pair["similarity_score"],
                }
                for pair in matched_pairs
            ]
            if len(matched_pairs) > 1:
                change_class: ChangeClass = "split"
                attribute_change_count = len(attribute_changes)
                geometry_changed = None
            else:
                right_row = right_rows[best_pair["right_index"]]
                attribute_change_count = len(attribute_changes)
                geometry_changed = left_row[self.geometry_column] != right_row[other.geometry_column]
                if not geometry_changed and attribute_change_count == 0:
                    change_class = "unchanged"
                elif attribute_change_count == 0 and best_pair["centroid_distance"] > geometry_tolerance:
                    change_class = "moved"
                else:
                    change_class = "modified"

            resolved = dict(left_row)
            resolved[f"left_ids_{change_suffix}"] = [str(left_row[id_column])]
            resolved[f"right_ids_{change_suffix}"] = matched_right_ids
            resolved[f"change_class_{change_suffix}"] = change_class
            resolved[f"event_side_{change_suffix}"] = "left"
            resolved[f"similarity_score_{change_suffix}"] = best_pair["similarity_score"]
            resolved[f"centroid_distance_{change_suffix}"] = best_pair["centroid_distance"]
            resolved[f"attribute_change_count_{change_suffix}"] = attribute_change_count
            resolved[f"attribute_changes_{change_suffix}"] = attribute_changes
            resolved[f"geometry_changed_{change_suffix}"] = geometry_changed
            resolved[f"area_share_score_{change_suffix}"] = sum(pair["area_share_score"] for pair in matched_pairs) / len(matched_pairs)
            resolved[f"match_area_shares_{change_suffix}"] = area_share_details
            if include_diagnostics:
                resolved[f"candidate_count_{change_suffix}"] = len(left_candidates[left_index])
                resolved[f"attribute_similarity_{change_suffix}"] = best_pair["attribute_similarity"]
                resolved[f"size_ratio_{change_suffix}"] = best_pair["size_ratio"]
            rows.append(resolved)

        for right_index_value, right_row in enumerate(right_rows):
            matched_left_pairs = right_matches[right_index_value]
            if not matched_left_pairs:
                resolved = {key: value for key, value in right_row.items()}
                resolved[self.geometry_column] = right_row[other.geometry_column]
                resolved[f"left_ids_{change_suffix}"] = []
                resolved[f"right_ids_{change_suffix}"] = [str(right_row[resolved_other_id_column])]
                resolved[f"change_class_{change_suffix}"] = "added"
                resolved[f"event_side_{change_suffix}"] = "right"
                resolved[f"similarity_score_{change_suffix}"] = None
                resolved[f"centroid_distance_{change_suffix}"] = None
                resolved[f"attribute_change_count_{change_suffix}"] = None
                resolved[f"attribute_changes_{change_suffix}"] = None
                resolved[f"area_share_score_{change_suffix}"] = None
                resolved[f"match_area_shares_{change_suffix}"] = []
                if include_diagnostics:
                    resolved[f"candidate_count_{change_suffix}"] = len(right_candidates[right_index_value])
                rows.append(resolved)
                continue
            if len(matched_left_pairs) <= 1:
                continue
            resolved = {key: value for key, value in right_row.items()}
            resolved[self.geometry_column] = right_row[other.geometry_column]
            matched_left_rows = [left_rows[pair["left_index"]] for pair in matched_left_pairs]
            attribute_changes = _attribute_change_summary(matched_left_rows, [right_row], shared_attribute_columns)
            resolved[f"left_ids_{change_suffix}"] = [str(left_rows[pair["left_index"]][id_column]) for pair in matched_left_pairs]
            resolved[f"right_ids_{change_suffix}"] = [str(right_row[resolved_other_id_column])]
            resolved[f"change_class_{change_suffix}"] = "merge"
            resolved[f"event_side_{change_suffix}"] = "right"
            resolved[f"similarity_score_{change_suffix}"] = matched_left_pairs[0]["similarity_score"]
            resolved[f"centroid_distance_{change_suffix}"] = matched_left_pairs[0]["centroid_distance"]
            resolved[f"attribute_change_count_{change_suffix}"] = len(attribute_changes)
            resolved[f"attribute_changes_{change_suffix}"] = attribute_changes
            resolved[f"area_share_score_{change_suffix}"] = sum(pair["area_share_score"] for pair in matched_left_pairs) / len(matched_left_pairs)
            resolved[f"match_area_shares_{change_suffix}"] = [
                {
                    "left_id": str(left_rows[pair["left_index"]][id_column]),
                    "right_id": str(right_row[resolved_other_id_column]),
                    "left_share": pair["left_area_share"],
                    "right_share": pair["right_area_share"],
                    "similarity_score": pair["similarity_score"],
                }
                for pair in matched_left_pairs
            ]
            if include_diagnostics:
                resolved[f"candidate_count_{change_suffix}"] = len(right_candidates[right_index_value])
            rows.append(resolved)

        _annotate_change_event_groups(rows, change_suffix)

        rows.sort(
            key=lambda row: (
                str(row.get(f"change_class_{change_suffix}")),
                tuple(row.get(f"left_ids_{change_suffix}", [])),
                tuple(row.get(f"right_ids_{change_suffix}", [])),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def extract_change_events(
        self,
        change_suffix: str = "change",
    ) -> "GeoPromptFrame":
        self._require_column(f"event_group_id_{change_suffix}")
        self._require_column(f"event_summary_{change_suffix}")
        self._require_column(f"change_class_{change_suffix}")
        self._require_column(f"event_side_{change_suffix}")

        grouped_rows: dict[str, list[Record]] = {}
        for row in self._rows:
            grouped_rows.setdefault(str(row[f"event_group_id_{change_suffix}"]), []).append(row)

        rows: list[Record] = []
        for event_group_id, event_rows in sorted(grouped_rows.items(), key=lambda item: item[0]):
            ordered_rows = sorted(
                event_rows,
                key=lambda row: (
                    tuple(row.get(f"left_ids_{change_suffix}", [])),
                    tuple(row.get(f"right_ids_{change_suffix}", [])),
                    str(row.get(f"event_side_{change_suffix}")),
                ),
            )
            summary = dict(ordered_rows[0][f"event_summary_{change_suffix}"])
            rows.append(
                {
                    f"event_group_id_{change_suffix}": event_group_id,
                    f"change_class_{change_suffix}": ordered_rows[0].get(f"change_class_{change_suffix}"),
                    f"event_side_{change_suffix}": ordered_rows[0].get(f"event_side_{change_suffix}"),
                    f"left_ids_{change_suffix}": list(summary.get("left_ids", [])),
                    f"right_ids_{change_suffix}": list(summary.get("right_ids", [])),
                    f"event_row_count_{change_suffix}": ordered_rows[0].get(f"event_row_count_{change_suffix}", len(ordered_rows)),
                    f"event_feature_count_{change_suffix}": ordered_rows[0].get(f"event_feature_count_{change_suffix}"),
                    f"member_geometry_types_{change_suffix}": _distinct_values(geometry_type(row[self.geometry_column]) for row in ordered_rows),
                    f"event_summary_{change_suffix}": summary,
                    self.geometry_column: _mean_centroid_geometry(ordered_rows, self.geometry_column),
                }
            )

        rows.sort(
            key=lambda row: (
                str(row[f"change_class_{change_suffix}"]),
                tuple(row.get(f"left_ids_{change_suffix}", [])),
                tuple(row.get(f"right_ids_{change_suffix}", [])),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def compare_change_events(
        self,
        other: "GeoPromptFrame",
        change_suffix: str = "change",
        diff_suffix: str = "eventdiff",
        match_mode: str = "exact",
        min_similarity: float = 0.6,
    ) -> "GeoPromptFrame":
        required_columns = (
            f"change_class_{change_suffix}",
            f"left_ids_{change_suffix}",
            f"right_ids_{change_suffix}",
            f"event_summary_{change_suffix}",
        )
        for column in required_columns:
            self._require_column(column)
            other._require_column(column)
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS before change event comparison")

        if match_mode not in {"exact", "equivalent"}:
            raise ValueError("match_mode must be 'exact' or 'equivalent'")
        if not 0.0 <= min_similarity <= 1.0:
            raise ValueError("min_similarity must be between zero and one")

        matched_pairs: list[tuple[tuple[str, tuple[str, ...], tuple[str, ...]], Record | None, Record | None, float | None]]
        if match_mode == "exact":
            left_events = {
                _change_event_signature(row, change_suffix): row
                for row in self._rows
            }
            right_events = {
                _change_event_signature(row, change_suffix): row
                for row in other._rows
            }
            matched_pairs = [
                (
                    signature,
                    left_events.get(signature),
                    right_events.get(signature),
                    1.0 if signature in left_events and signature in right_events else None,
                )
                for signature in sorted(set(left_events) | set(right_events))
            ]
        else:
            left_exact = {
                _change_event_signature(row, change_suffix): row
                for row in self._rows
            }
            right_exact = {
                _change_event_signature(row, change_suffix): row
                for row in other._rows
            }
            exact_signatures = sorted(set(left_exact) & set(right_exact))
            matched_pairs = [
                (signature, left_exact[signature], right_exact[signature], 1.0)
                for signature in exact_signatures
            ]
            unmatched_left = [row for signature, row in left_exact.items() if signature not in right_exact]
            unmatched_right = [row for signature, row in right_exact.items() if signature not in left_exact]
            matched_pairs.extend(
                _match_equivalent_change_events(
                    unmatched_left,
                    unmatched_right,
                    change_suffix=change_suffix,
                    geometry_column=self.geometry_column,
                    min_similarity=min_similarity,
                )
            )

        rows: list[Record] = []
        for signature, baseline_row, current_row, match_similarity in matched_pairs:
            if baseline_row is None:
                event_status = "emerged"
            elif current_row is None:
                event_status = "resolved"
            else:
                event_status = "persisted"

            baseline_summary = dict(baseline_row[f"event_summary_{change_suffix}"]) if baseline_row is not None else {}
            current_summary = dict(current_row[f"event_summary_{change_suffix}"]) if current_row is not None else {}
            baseline_attribute_columns = list(baseline_summary.get("attribute_columns", []))
            current_attribute_columns = list(current_summary.get("attribute_columns", []))
            source_row = current_row if current_row is not None else baseline_row
            if source_row is None:
                continue
            geometry = source_row[self.geometry_column]
            resolved: Record = {
                f"event_status_{diff_suffix}": event_status,
                f"change_class_{change_suffix}": signature[0],
                f"left_ids_{change_suffix}": list(signature[1]),
                f"right_ids_{change_suffix}": list(signature[2]),
                f"event_signature_{diff_suffix}": _format_change_event_signature(signature),
                f"match_mode_{diff_suffix}": match_mode,
                f"event_similarity_{diff_suffix}": match_similarity,
                f"baseline_event_group_id_{diff_suffix}": baseline_row.get(f"event_group_id_{change_suffix}") if baseline_row is not None else None,
                f"current_event_group_id_{diff_suffix}": current_row.get(f"event_group_id_{change_suffix}") if current_row is not None else None,
                f"baseline_row_count_{diff_suffix}": baseline_summary.get("row_count"),
                f"current_row_count_{diff_suffix}": current_summary.get("row_count"),
                f"row_count_delta_{diff_suffix}": _numeric_delta(current_summary.get("row_count"), baseline_summary.get("row_count")),
                f"baseline_feature_count_{diff_suffix}": baseline_summary.get("feature_count"),
                f"current_feature_count_{diff_suffix}": current_summary.get("feature_count"),
                f"feature_count_delta_{diff_suffix}": _numeric_delta(current_summary.get("feature_count"), baseline_summary.get("feature_count")),
                f"baseline_mean_similarity_score_{diff_suffix}": baseline_summary.get("mean_similarity_score"),
                f"current_mean_similarity_score_{diff_suffix}": current_summary.get("mean_similarity_score"),
                f"mean_similarity_score_delta_{diff_suffix}": _numeric_delta(current_summary.get("mean_similarity_score"), baseline_summary.get("mean_similarity_score")),
                f"baseline_attribute_columns_{diff_suffix}": baseline_attribute_columns,
                f"current_attribute_columns_{diff_suffix}": current_attribute_columns,
                f"added_attribute_columns_{diff_suffix}": [column for column in current_attribute_columns if column not in baseline_attribute_columns],
                f"removed_attribute_columns_{diff_suffix}": [column for column in baseline_attribute_columns if column not in current_attribute_columns],
                f"baseline_event_summary_{change_suffix}": baseline_summary or None,
                f"current_event_summary_{change_suffix}": current_summary or None,
                self.geometry_column: geometry,
            }
            rows.append(resolved)

        rows.sort(
            key=lambda row: (
                _change_event_status_rank(str(row[f"event_status_{diff_suffix}"])),
                str(row[f"change_class_{change_suffix}"]),
                tuple(row[f"left_ids_{change_suffix}"]),
                tuple(row[f"right_ids_{change_suffix}"]),
            )
        )
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def corridor_reach(
        self,
        corridors: "GeoPromptFrame",
        max_distance: float,
        corridor_id_column: str = "site_id",
        aggregations: dict[str, AggregationName] | None = None,
        how: SpatialJoinMode = "left",
        distance_method: str = "euclidean",
        distance_mode: CorridorDistanceMode = "direct",
        score_mode: CorridorScoreMode = "distance",
        weight_column: str | None = None,
        preferred_bearing: float | None = None,
        path_anchor: CorridorPathAnchor = "start",
        scale: float = 1.0,
        power: float = 2.0,
        reach_suffix: str = "reach",
    ) -> "GeoPromptFrame":
        if max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if how not in {"inner", "left"}:
            raise ValueError("how must be 'inner' or 'left'")
        if distance_method not in {"euclidean", "haversine"}:
            raise ValueError("distance_method must be 'euclidean' or 'haversine'")
        if distance_mode not in {"direct", "network"}:
            raise ValueError("distance_mode must be 'direct' or 'network'")
        if score_mode not in {"distance", "strength", "alignment", "combined"}:
            raise ValueError("score_mode must be 'distance', 'strength', 'alignment', or 'combined'")
        if path_anchor not in {"start", "end", "nearest"}:
            raise ValueError("path_anchor must be 'start', 'end', or 'nearest'")
        if scale <= 0:
            raise ValueError("scale must be greater than zero")
        if power <= 0:
            raise ValueError("power must be greater than zero")
        if self.crs and corridors.crs and self.crs != corridors.crs:
            raise ValueError("frames must share the same CRS before corridor reach analysis")

        corridors._require_column(corridor_id_column)
        if weight_column is not None:
            corridors._require_column(weight_column)
        corridor_rows = list(corridors._rows)

        rows: list[Record] = []
        for left_row in self._rows:
            left_centroid = geometry_centroid(left_row[self.geometry_column])
            matched_corridors: list[Record] = []
            matched_distances: list[float] = []
            corridor_scores: list[Record] = []
            for corridor_row in corridor_rows:
                corridor_geometry = corridor_row[corridors.geometry_column]
                corridor_vertices = list(corridor_geometry.get("coordinates", []))  # type: ignore[union-attr]
                if not corridor_vertices:
                    continue
                min_dist, along_distance = _point_to_polyline_distance_details(left_centroid, corridor_vertices, method=distance_method)
                corridor_length_value = _polyline_length(corridor_vertices, method=distance_method)
                anchor_distance = _resolve_anchor_distance(along_distance, corridor_length_value, path_anchor)
                if distance_mode == "network":
                    min_dist = min_dist + anchor_distance
                if min_dist <= max_distance:
                    matched_corridors.append(corridor_row)
                    matched_distances.append(min_dist)

                    alignment_score = None
                    if preferred_bearing is not None and len(corridor_vertices) >= 2:
                        alignment_score = (directional_alignment(corridor_vertices[0], corridor_vertices[-1], preferred_bearing) + 1.0) / 2.0
                    weight_value = float(corridor_row[weight_column]) if weight_column is not None else 1.0
                    distance_score = prompt_decay(distance_value=min_dist, scale=scale, power=power)
                    if score_mode == "distance":
                        corridor_score = weight_value * distance_score
                    elif score_mode == "strength":
                        corridor_score = corridor_strength(weight=weight_value, corridor_length=corridor_length_value, distance_value=min_dist, scale=scale, power=power)
                    elif score_mode == "alignment":
                        corridor_score = weight_value * distance_score * (alignment_score if alignment_score is not None else 1.0)
                    else:
                        corridor_score = corridor_strength(weight=weight_value, corridor_length=corridor_length_value, distance_value=min_dist, scale=scale, power=power) * (alignment_score if alignment_score is not None else 1.0)

                    corridor_scores.append(
                        {
                            "corridor_id": str(corridor_row[corridor_id_column]),
                            "distance": min_dist,
                            "along_distance": along_distance,
                            "anchor_distance": anchor_distance,
                            "corridor_length": corridor_length_value,
                            "alignment_score": alignment_score,
                            "score": corridor_score,
                        }
                    )

            if not matched_corridors and how == "inner":
                continue

            corridor_scores.sort(key=lambda item: (-float(item["score"]), float(item["distance"]), str(item["corridor_id"])))

            resolved_row = dict(left_row)
            resolved_row[f"{corridor_id_column}s_{reach_suffix}"] = [
                str(row[corridor_id_column]) for row in matched_corridors
            ]
            resolved_row[f"count_{reach_suffix}"] = len(matched_corridors)
            resolved_row[f"distance_min_{reach_suffix}"] = min(matched_distances) if matched_distances else None
            resolved_row[f"distance_max_{reach_suffix}"] = max(matched_distances) if matched_distances else None
            resolved_row[f"distance_mean_{reach_suffix}"] = (
                sum(matched_distances) / len(matched_distances) if matched_distances else None
            )
            resolved_row[f"distance_limit_{reach_suffix}"] = max_distance
            resolved_row[f"distance_method_{reach_suffix}"] = distance_method
            resolved_row[f"distance_mode_{reach_suffix}"] = distance_mode
            resolved_row[f"path_anchor_{reach_suffix}"] = path_anchor
            resolved_row[f"score_mode_{reach_suffix}"] = score_mode
            resolved_row[f"corridor_scores_{reach_suffix}"] = corridor_scores
            resolved_row[f"best_corridor_{reach_suffix}"] = corridor_scores[0]["corridor_id"] if corridor_scores else None
            resolved_row[f"best_score_{reach_suffix}"] = corridor_scores[0]["score"] if corridor_scores else None

            total_corridor_length = sum(
                _polyline_length(list(row[corridors.geometry_column].get("coordinates", [])), method=distance_method) if row[corridors.geometry_column].get("type") == "LineString" else geometry_length(row[corridors.geometry_column])
                for row in matched_corridors
            )
            resolved_row[f"corridor_length_total_{reach_suffix}"] = total_corridor_length

            resolved_row.update(
                self._aggregate_rows(matched_corridors, aggregations=aggregations, suffix=reach_suffix)
            )
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or corridors.crs)

    def zone_fit_score(
        self,
        zones: "GeoPromptFrame",
        zone_id_column: str = "region_id",
        weight_column: str | None = None,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        score_weights: dict[str, float] | None = None,
        preferred_bearing: float | None = None,
        group_by: str | None = None,
        group_aggregation: ZoneGroupAggregation = "max",
        top_n: int | None = None,
        score_callback: Any | None = None,
        score_suffix: str = "fit",
    ) -> "GeoPromptFrame":
        if max_distance is not None and max_distance < 0:
            raise ValueError("max_distance must be zero or greater")
        if self.crs and zones.crs and self.crs != zones.crs:
            raise ValueError("frames must share the same CRS before zone fit scoring")
        if group_aggregation not in {"max", "mean", "sum"}:
            raise ValueError("group_aggregation must be 'max', 'mean', or 'sum'")
        if top_n is not None and top_n <= 0:
            raise ValueError("top_n must be greater than zero")

        zones._require_column(zone_id_column)
        if group_by is not None:
            zones._require_column(group_by)
        component_weights = _resolve_zone_fit_weights(score_weights)
        zone_rows = list(zones._rows)
        zone_centroids = zones._centroids()
        zone_areas = [geometry_area(row[zones.geometry_column]) for row in zone_rows]

        rows: list[Record] = []
        for left_row in self._rows:
            left_centroid = geometry_centroid(left_row[self.geometry_column])
            left_area = geometry_area(left_row[self.geometry_column])

            best_score = -1.0
            best_zone_id: str | None = None
            zone_scores: list[Record] = []

            for zone_row, zone_centroid, zone_area in zip(zone_rows, zone_centroids, zone_areas, strict=True):
                dist = coordinate_distance(left_centroid, zone_centroid, method=distance_method)
                if max_distance is not None and dist > max_distance:
                    continue

                containment = 1.0 if geometry_within(left_row[self.geometry_column], zone_row[zones.geometry_column]) else 0.0
                overlap = 1.0 if geometry_intersects(left_row[self.geometry_column], zone_row[zones.geometry_column]) else 0.0
                size_ratio = area_similarity(
                    origin_area=left_area,
                    destination_area=zone_area,
                    distance_value=dist,
                    scale=1.0,
                    power=1.0,
                )
                access_score = prompt_decay(distance_value=dist, scale=max_distance or 1.0, power=1.0)
                alignment_score = None
                if preferred_bearing is not None:
                    alignment_score = (directional_alignment(left_centroid, zone_centroid, preferred_bearing) + 1.0) / 2.0

                component_scores = {
                    "containment": containment,
                    "overlap": overlap,
                    "size": size_ratio,
                    "access": access_score,
                    "alignment": alignment_score if alignment_score is not None else 0.0,
                }

                weight = float(left_row.get(weight_column, 1.0)) if weight_column else 1.0
                weighted_total = sum(component_scores[name] * component_weights[name] for name in component_weights)
                total_weight = sum(component_weights.values())
                score = weight * (weighted_total / total_weight)
                if score_callback is not None:
                    score = float(score_callback(left_row, zone_row, dict(component_scores), score))

                zone_scores.append({
                    "zone_id": str(zone_row[zone_id_column]),
                    "group": str(zone_row[group_by]) if group_by is not None else None,
                    "score": score,
                    "distance": dist,
                    "containment": containment,
                    "overlap": overlap,
                    "size_ratio": size_ratio,
                    "access_score": access_score,
                    "alignment_score": alignment_score,
                })
                if score > best_score:
                    best_score = score
                    best_zone_id = str(zone_row[zone_id_column])

            zone_scores.sort(key=lambda item: (-float(item["score"]), float(item["distance"]), str(item["zone_id"])))
            if top_n is not None:
                zone_scores = zone_scores[:top_n]

            resolved_row = dict(left_row)
            resolved_row[f"best_zone_{score_suffix}"] = best_zone_id
            resolved_row[f"best_score_{score_suffix}"] = best_score if best_zone_id is not None else None
            resolved_row[f"zone_count_{score_suffix}"] = len(zone_scores)
            resolved_row[f"score_weights_{score_suffix}"] = dict(component_weights)
            resolved_row[f"zone_scores_{score_suffix}"] = zone_scores
            if group_by is not None:
                grouped_scores: dict[str, dict[str, Any]] = {}
                for zone_score in zone_scores:
                    group_value = str(zone_score["group"])
                    bucket = grouped_scores.setdefault(group_value, {"group": group_value, "zone_ids": [], "scores": []})
                    bucket["zone_ids"].append(zone_score["zone_id"])
                    bucket["scores"].append(zone_score["score"])
                group_rankings = []
                for group_score in grouped_scores.values():
                    if group_aggregation == "max":
                        score_value = max(group_score["scores"])
                    elif group_aggregation == "mean":
                        score_value = sum(group_score["scores"]) / len(group_score["scores"])
                    else:
                        score_value = sum(group_score["scores"])
                    group_rankings.append(
                        {
                            "group": group_score["group"],
                            "score": score_value,
                            "zone_ids": group_score["zone_ids"],
                            "zone_count": len(group_score["zone_ids"]),
                        }
                    )
                group_rankings.sort(key=lambda item: (-float(item["score"]), str(item["group"])))
                resolved_row[f"group_scores_{score_suffix}"] = group_rankings
                resolved_row[f"best_group_{score_suffix}"] = group_rankings[0]["group"] if group_rankings else None
            rows.append(resolved_row)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or zones.crs)

    def centroid_cluster(
        self,
        k: int,
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        max_iterations: int = 50,
    ) -> "GeoPromptFrame":
        if k <= 0:
            raise ValueError("k must be greater than zero")
        if k > len(self._rows):
            raise ValueError("k must not exceed frame length")
        if max_iterations <= 0:
            raise ValueError("max_iterations must be greater than zero")

        centroids_list = self._centroids()
        sorted_indices = sorted(range(len(self._rows)), key=lambda index: _cluster_sort_key(self._rows[index], index, id_column))
        seed_positions = [int(step * len(sorted_indices) / k) for step in range(k)]
        seed_indices = [sorted_indices[min(position, len(sorted_indices) - 1)] for position in seed_positions]
        cluster_centers = [centroids_list[i] for i in seed_indices]

        assignments = [0] * len(centroids_list)

        for _ in range(max_iterations):
            changed = False
            for point_index, point in enumerate(centroids_list):
                best_cluster = assignments[point_index]
                best_dist = float("inf")
                for cluster_index, center in enumerate(cluster_centers):
                    dist = coordinate_distance(point, center, method=distance_method)
                    if dist < best_dist - 1e-12 or (abs(dist - best_dist) <= 1e-12 and cluster_index < best_cluster):
                        best_dist = dist
                        best_cluster = cluster_index
                if assignments[point_index] != best_cluster:
                    assignments[point_index] = best_cluster
                    changed = True

            if not changed:
                break

            new_centers: list[Coordinate] = []
            for cluster_index in range(k):
                members = [centroids_list[i] for i, a in enumerate(assignments) if a == cluster_index]
                if members:
                    cx = sum(p[0] for p in members) / len(members)
                    cy = sum(p[1] for p in members) / len(members)
                    new_centers.append((cx, cy))
                else:
                    new_centers.append(cluster_centers[cluster_index])
            cluster_centers = new_centers

        cluster_member_indices = {
            cluster_index: [index for index, assignment in enumerate(assignments) if assignment == cluster_index]
            for cluster_index in range(k)
        }
        point_distances = [
            coordinate_distance(centroid_point, cluster_centers[assignments[index]], method=distance_method)
            for index, centroid_point in enumerate(centroids_list)
        ]
        cluster_sse = {
            cluster_index: sum(point_distances[index] ** 2 for index in members)
            for cluster_index, members in cluster_member_indices.items()
        }
        cluster_mean_distance = {
            cluster_index: (sum(point_distances[index] for index in members) / len(members)) if members else 0.0
            for cluster_index, members in cluster_member_indices.items()
        }
        silhouette_scores = _cluster_silhouette_scores(centroids_list, assignments, distance_method=distance_method)
        overall_sse = sum(cluster_sse.values())
        overall_silhouette = sum(silhouette_scores) / len(silhouette_scores) if silhouette_scores else 0.0

        rows: list[Record] = []
        for index, (row, cluster_id, centroid_point) in enumerate(zip(self._rows, assignments, centroids_list, strict=True)):
            resolved = dict(row)
            resolved["cluster_id"] = cluster_id
            resolved["cluster_center"] = cluster_centers[cluster_id]
            resolved["cluster_distance"] = point_distances[index]
            resolved["cluster_size"] = len(cluster_member_indices[cluster_id])
            resolved["cluster_mean_distance"] = cluster_mean_distance[cluster_id]
            resolved["cluster_sse"] = cluster_sse[cluster_id]
            resolved["cluster_silhouette"] = silhouette_scores[index]
            resolved["cluster_sse_total"] = overall_sse
            resolved["cluster_silhouette_mean"] = overall_silhouette
            rows.append(resolved)

        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def envelopes(self) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            new_row = dict(row)
            new_row[self.geometry_column] = geometry_envelope(row[self.geometry_column])
            rows.append(new_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def convex_hulls(self) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            new_row = dict(row)
            new_row[self.geometry_column] = geometry_convex_hull(row[self.geometry_column])
            rows.append(new_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def gravity_table(
        self,
        origin_weight: str,
        destination_weight: str,
        id_column: str = "site_id",
        friction: float = 2.0,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        self._require_column(origin_weight)
        self._require_column(destination_weight)
        self._require_column(id_column)

        interactions: list[Record] = []
        for origin in self._rows:
            for destination in self._rows:
                if origin is destination:
                    continue
                distance_value = geometry_distance(
                    origin[self.geometry_column],
                    destination[self.geometry_column],
                    method=distance_method,
                )
                interactions.append({
                    "origin": origin[id_column],
                    "destination": destination[id_column],
                    "distance": distance_value,
                    "gravity": gravity_model(
                        origin_weight=float(origin[origin_weight]),
                        destination_weight=float(destination[destination_weight]),
                        distance_value=distance_value,
                        friction=friction,
                    ),
                    "distance_method": distance_method,
                })
        return interactions

    def accessibility_scores(
        self,
        targets: "GeoPromptFrame",
        weight_column: str,
        friction: float = 2.0,
        id_column: str = "site_id",
        distance_method: str = "euclidean",
    ) -> list[float]:
        targets._require_column(weight_column)
        target_centroids = targets._centroids()
        target_weights = [float(row[weight_column]) for row in targets._rows]
        origin_centroids = self._centroids()

        scores: list[float] = []
        for origin_centroid in origin_centroids:
            distances = [
                coordinate_distance(origin_centroid, tc, method=distance_method)
                for tc in target_centroids
            ]
            scores.append(accessibility_index(target_weights, distances, friction=friction))
        return scores

    def cluster_diagnostics(
        self,
        k_values: Sequence[int],
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        max_iterations: int = 50,
    ) -> list[Record]:
        if not k_values:
            raise ValueError("k_values must contain at least one cluster count")

        unique_k_values = sorted(set(int(value) for value in k_values))
        diagnostics: list[Record] = []
        previous_sse: float | None = None
        for k_value in unique_k_values:
            clustered = self.centroid_cluster(
                k=k_value,
                id_column=id_column,
                distance_method=distance_method,
                max_iterations=max_iterations,
            )
            records = clustered.to_records()
            cluster_sizes = sorted({int(record["cluster_size"]) for record in records}, reverse=True)
            total_sse = float(records[0]["cluster_sse_total"]) if records else 0.0
            silhouette_mean = float(records[0]["cluster_silhouette_mean"]) if records else 0.0
            diagnostics.append(
                {
                    "k": k_value,
                    "cluster_count": len({int(record["cluster_id"]) for record in records}),
                    "cluster_sizes": cluster_sizes,
                    "sse_total": total_sse,
                    "sse_improvement": (previous_sse - total_sse) if previous_sse is not None else None,
                    "silhouette_mean": silhouette_mean,
                }
            )
            previous_sse = total_sse

        recommended_by_silhouette = max(diagnostics, key=lambda item: (float(item["silhouette_mean"]), -int(item["k"])))
        recommended_by_sse = min(diagnostics, key=lambda item: (float(item["sse_total"]), int(item["k"])))
        for item in diagnostics:
            item["recommended_silhouette"] = int(item["k"]) == int(recommended_by_silhouette["k"])
            item["recommended_sse"] = int(item["k"]) == int(recommended_by_sse["k"])
        return diagnostics

    def recommend_cluster_count(
        self,
        k_values: Sequence[int],
        metric: ClusterRecommendMetric = "silhouette",
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        max_iterations: int = 50,
    ) -> Record:
        diagnostics = self.cluster_diagnostics(
            k_values=k_values,
            id_column=id_column,
            distance_method=distance_method,
            max_iterations=max_iterations,
        )
        if metric == "silhouette":
            return next(item for item in diagnostics if item["recommended_silhouette"])
        if metric == "sse":
            return next(item for item in diagnostics if item["recommended_sse"])
        raise ValueError("metric must be 'silhouette' or 'sse'")

    def summarize_clusters(
        self,
        cluster_column: str = "cluster_id",
        group_by: str | None = None,
        aggregations: dict[str, AggregationName] | None = None,
    ) -> "GeoPromptFrame":
        self._require_column(cluster_column)
        if group_by is not None:
            self._require_column(group_by)

        grouped_rows: dict[int, list[Record]] = {}
        for row in self._rows:
            grouped_rows.setdefault(int(row[cluster_column]), []).append(row)

        rows: list[Record] = []
        for cluster_id, cluster_rows in grouped_rows.items():
            centroid_x = sum(float(row["cluster_center"][0]) for row in cluster_rows if "cluster_center" in row) / len(cluster_rows)
            centroid_y = sum(float(row["cluster_center"][1]) for row in cluster_rows if "cluster_center" in row) / len(cluster_rows)
            summary_row: Record = {
                cluster_column: cluster_id,
                "cluster_member_count": len(cluster_rows),
                "cluster_site_ids": [str(row.get("site_id", row.get("region_id", ""))) for row in cluster_rows],
                "cluster_center_summary": (centroid_x, centroid_y),
                "cluster_sse_total": sum(float(row.get("cluster_sse", 0.0)) for row in cluster_rows),
                "cluster_mean_distance_summary": sum(float(row.get("cluster_distance", 0.0)) for row in cluster_rows) / len(cluster_rows),
                self.geometry_column: {"type": "Point", "coordinates": (centroid_x, centroid_y)},
            }
            if group_by is not None:
                group_counts: dict[str, int] = {}
                for row in cluster_rows:
                    group_counts[str(row[group_by])] = group_counts.get(str(row[group_by]), 0) + 1
                summary_row[f"{group_by}_counts"] = group_counts
                summary_row[f"dominant_{group_by}"] = max(group_counts.items(), key=lambda item: (int(item[1]), str(item[0])))[0] if group_counts else None
            summary_row.update(self._aggregate_rows(cluster_rows, aggregations=aggregations, suffix="cluster"))
            rows.append(summary_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def overlay_group_comparison(
        self,
        other: "GeoPromptFrame",
        group_by: str,
        right_id_column: str = "region_id",
        normalize_by: OverlayNormalizeMode = "both",
        comparison_suffix: str = "overlay_compare",
    ) -> "GeoPromptFrame":
        summary = self.overlay_summary(
            other,
            right_id_column=right_id_column,
            group_by=group_by,
            normalize_by=normalize_by,
            how="left",
            summary_suffix=comparison_suffix,
        )

        rows: list[Record] = []
        for row in summary._rows:
            groups = list(row.get(f"groups_{comparison_suffix}", []))
            top_group = groups[0] if len(groups) >= 1 else None
            second_group = groups[1] if len(groups) >= 2 else None
            resolved = dict(row)
            resolved[f"top_group_{comparison_suffix}"] = top_group["group"] if top_group else None
            resolved[f"runner_up_group_{comparison_suffix}"] = second_group["group"] if second_group else None
            resolved[f"area_gap_{comparison_suffix}"] = (top_group["area_overlap"] - second_group["area_overlap"]) if top_group and second_group else None
            resolved[f"length_gap_{comparison_suffix}"] = (top_group["length_overlap"] - second_group["length_overlap"]) if top_group and second_group else None
            resolved[f"comparison_strength_{comparison_suffix}"] = (
                (top_group["area_overlap"] + top_group["length_overlap"]) - (second_group["area_overlap"] + second_group["length_overlap"])
            ) if top_group and second_group else None
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    def corridor_diagnostics(
        self,
        corridors: "GeoPromptFrame",
        max_distance: float,
        corridor_id_column: str = "site_id",
        distance_method: str = "euclidean",
        distance_mode: CorridorDistanceMode = "network",
        score_mode: CorridorScoreMode = "combined",
        path_anchor: CorridorPathAnchor = "start",
        weight_column: str | None = None,
        preferred_bearing: float | None = None,
        scale: float = 1.0,
        power: float = 2.0,
    ) -> "GeoPromptFrame":
        reach = self.corridor_reach(
            corridors,
            max_distance=max_distance,
            corridor_id_column=corridor_id_column,
            distance_method=distance_method,
            distance_mode=distance_mode,
            score_mode=score_mode,
            weight_column=weight_column,
            preferred_bearing=preferred_bearing,
            path_anchor=path_anchor,
            scale=scale,
            power=power,
            how="left",
            reach_suffix="diagnostics",
        )
        corridor_scores_by_id: dict[str, list[Record]] = {}
        for row in reach.to_records():
            for item in row.get("corridor_scores_diagnostics", []):
                corridor_scores_by_id.setdefault(str(item["corridor_id"]), []).append(item)

        rows: list[Record] = []
        for corridor_row in corridors.to_records():
            corridor_id = str(corridor_row[corridor_id_column])
            items = corridor_scores_by_id.get(corridor_id, [])
            diagnostic_row = dict(corridor_row)
            diagnostic_row["served_feature_count"] = len(items)
            diagnostic_row["best_match_count"] = sum(1 for row in reach.to_records() if row.get("best_corridor_diagnostics") == corridor_id)
            diagnostic_row["score_sum"] = sum(float(item["score"]) for item in items)
            diagnostic_row["score_mean"] = (sum(float(item["score"]) for item in items) / len(items)) if items else None
            diagnostic_row["distance_mean"] = (sum(float(item["distance"]) for item in items) / len(items)) if items else None
            diagnostic_row["anchor_distance_mean"] = (sum(float(item["anchor_distance"]) for item in items) / len(items)) if items else None
            diagnostic_row["path_anchor"] = path_anchor
            diagnostic_row["distance_mode"] = distance_mode
            diagnostic_row["score_mode"] = score_mode
            rows.append(diagnostic_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=corridors.geometry_column, crs=self.crs or corridors.crs)

    # ------------------------------------------------------------------
    # Tool 1: raster_sample
    # ------------------------------------------------------------------
    def raster_sample(
        self,
        points: "GeoPromptFrame",
        value_column: str,
        k: int = 1,
        distance_method: str = "euclidean",
        sample_suffix: str = "sample",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if k <= 0:
            raise ValueError("k must be greater than zero")
        if self.crs and points.crs and self.crs != points.crs:
            raise ValueError("frames must share the same CRS")
        source_centroids = self._centroids()
        target_centroids = points._centroids()
        rows: list[Record] = []
        for target_index, target_row in enumerate(points._rows):
            distances = [
                (src_index, coordinate_distance(target_centroids[target_index], source_centroids[src_index], method=distance_method))
                for src_index in range(len(self._rows))
            ]
            distances.sort(key=lambda item: item[1])
            nearest = distances[:k]
            if k == 1 and nearest:
                sampled_value = self._rows[nearest[0][0]][value_column]
                sampled_distance = nearest[0][1]
            elif nearest:
                weight_sum = sum(1.0 / max(d, 1e-12) for _, d in nearest)
                sampled_value = sum(
                    float(self._rows[idx][value_column]) / max(d, 1e-12)
                    for idx, d in nearest
                ) / weight_sum
                sampled_distance = nearest[0][1]
            else:
                sampled_value = None
                sampled_distance = None
            resolved = dict(target_row)
            resolved[f"{value_column}_{sample_suffix}"] = sampled_value
            resolved[f"distance_{sample_suffix}"] = sampled_distance
            resolved[f"k_{sample_suffix}"] = k
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=points.geometry_column, crs=points.crs or self.crs)

    # ------------------------------------------------------------------
    # Tool 2: zonal_stats
    # ------------------------------------------------------------------
    def zonal_stats(
        self,
        zones: "GeoPromptFrame",
        value_column: str,
        zone_id_column: str = "zone_id",
        aggregations: Sequence[AggregationName] = ("count", "sum", "mean", "min", "max"),
        zonal_suffix: str = "zonal",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        zones._require_column(zone_id_column)
        if self.crs and zones.crs and self.crs != zones.crs:
            raise ValueError("frames must share the same CRS")
        point_centroids = self._centroids()
        rows: list[Record] = []
        for zone_row in zones._rows:
            zone_geometry = zone_row[zones.geometry_column]
            matched_values: list[float] = []
            for pt_index, pt_row in enumerate(self._rows):
                pt_geom = {"type": "Point", "coordinates": point_centroids[pt_index]}
                if geometry_within(pt_geom, zone_geometry):
                    val = pt_row.get(value_column)
                    if val is not None:
                        matched_values.append(float(val))
            resolved = dict(zone_row)
            resolved[f"point_count_{zonal_suffix}"] = len(matched_values)
            for agg in aggregations:
                if agg == "count":
                    resolved[f"{value_column}_count_{zonal_suffix}"] = len(matched_values)
                elif not matched_values:
                    resolved[f"{value_column}_{agg}_{zonal_suffix}"] = None
                elif agg == "sum":
                    resolved[f"{value_column}_sum_{zonal_suffix}"] = sum(matched_values)
                elif agg == "mean":
                    resolved[f"{value_column}_mean_{zonal_suffix}"] = sum(matched_values) / len(matched_values)
                elif agg == "min":
                    resolved[f"{value_column}_min_{zonal_suffix}"] = min(matched_values)
                elif agg == "max":
                    resolved[f"{value_column}_max_{zonal_suffix}"] = max(matched_values)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=zones.geometry_column, crs=zones.crs or self.crs)

    # ------------------------------------------------------------------
    # Tool 3: reclassify
    # ------------------------------------------------------------------
    def reclassify(
        self,
        value_column: str,
        breaks: dict[str, Any] | list[tuple[float, float, str]] | None = None,
        mapping: dict[Any, Any] | None = None,
        output_column: str | None = None,
        default_class: str = "unclassified",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if breaks is None and mapping is None:
            raise ValueError("either breaks or mapping must be provided")
        out_col = output_column or f"{value_column}_class"
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            val = row.get(value_column)
            if mapping is not None:
                resolved[out_col] = mapping.get(val, default_class)
            elif breaks is not None:
                if isinstance(breaks, dict):
                    resolved[out_col] = breaks.get(str(val), default_class)
                else:
                    classified = default_class
                    if val is not None:
                        numeric_val = float(val)
                        for low, high, label in breaks:
                            if low <= numeric_val < high:
                                classified = label
                                break
                    resolved[out_col] = classified
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 4: resample
    # ------------------------------------------------------------------
    def resample(
        self,
        method: Literal["every_nth", "spatial_thin", "random"] = "every_nth",
        n: int = 2,
        min_distance: float | None = None,
        sample_size: int | None = None,
        random_seed: int = 0,
        distance_method: str = "euclidean",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if method == "every_nth":
            if n <= 0:
                raise ValueError("n must be greater than zero")
            rows = [self._rows[i] for i in range(0, len(self._rows), n)]
        elif method == "random":
            count = sample_size if sample_size is not None else max(1, len(self._rows) // 2)
            count = min(count, len(self._rows))
            rng = random.Random(random_seed)
            indexes = rng.sample(range(len(self._rows)), count)
            rows = [self._rows[i] for i in sorted(indexes)]
        elif method == "spatial_thin":
            if min_distance is None or min_distance <= 0:
                raise ValueError("min_distance must be greater than zero for spatial_thin")
            centroids = self._centroids()
            selected: list[int] = []
            for i in range(len(self._rows)):
                too_close = False
                for j in selected:
                    if coordinate_distance(centroids[i], centroids[j], method=distance_method) < min_distance:
                        too_close = True
                        break
                if not too_close:
                    selected.append(i)
            rows = [self._rows[i] for i in selected]
        else:
            raise ValueError("method must be 'every_nth', 'spatial_thin', or 'random'")
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 5: raster_clip (bounds-based clip)
    # ------------------------------------------------------------------
    def raster_clip(
        self,
        min_x: float,
        min_y: float,
        max_x: float,
        max_y: float,
    ) -> "GeoPromptFrame":
        clip_bounds = (min_x, min_y, max_x, max_y)
        rows: list[Record] = []
        for row in self._rows:
            geom = row[self.geometry_column]
            geom_bounds = geometry_bounds(geom)
            if _bounds_intersect(geom_bounds, clip_bounds):
                rows.append(row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 6: mosaic
    # ------------------------------------------------------------------
    def mosaic(
        self,
        *others: "GeoPromptFrame",
        conflict_resolution: Literal["first", "last", "mean"] = "first",
        id_column: str = "site_id",
        mosaic_suffix: str = "mosaic",
    ) -> "GeoPromptFrame":
        all_rows: list[Record] = list(self._rows)
        for other in others:
            if self.crs and other.crs and self.crs != other.crs:
                raise ValueError("all frames must share the same CRS for mosaic")
            all_rows.extend(other._rows)
        if conflict_resolution == "first":
            seen_ids: set[str] = set()
            rows: list[Record] = []
            for row in all_rows:
                row_id = str(row.get(id_column, id(row)))
                if row_id not in seen_ids:
                    seen_ids.add(row_id)
                    resolved = dict(row)
                    resolved[f"source_{mosaic_suffix}"] = "keep"
                    rows.append(resolved)
        elif conflict_resolution == "last":
            seen_ids_map: dict[str, int] = {}
            for index, row in enumerate(all_rows):
                row_id = str(row.get(id_column, id(row)))
                seen_ids_map[row_id] = index
            rows = []
            for index in sorted(seen_ids_map.values()):
                resolved = dict(all_rows[index])
                resolved[f"source_{mosaic_suffix}"] = "keep"
                rows.append(resolved)
        else:
            rows = [dict(row) for row in all_rows]
            for row in rows:
                row[f"source_{mosaic_suffix}"] = "merged"
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 7: to_points
    # ------------------------------------------------------------------
    def to_points(self) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            centroid = geometry_centroid(row[self.geometry_column])
            resolved = dict(row)
            resolved[self.geometry_column] = {"type": "Point", "coordinates": centroid}
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 8: to_polygons
    # ------------------------------------------------------------------
    def to_polygons(self, buffer_distance: float = 0.001) -> "GeoPromptFrame":
        if buffer_distance <= 0:
            raise ValueError("buffer_distance must be greater than zero")
        rows: list[Record] = []
        for row in self._rows:
            geom = row[self.geometry_column]
            geom_type_value = geometry_type(geom)
            if geom_type_value == "Polygon":
                rows.append(dict(row))
            elif geom_type_value == "Point":
                cx, cy = geom["coordinates"]
                polygon = _rectangle_polygon(
                    cx - buffer_distance, cy - buffer_distance,
                    cx + buffer_distance, cy + buffer_distance,
                )
                resolved = dict(row)
                resolved[self.geometry_column] = polygon
                rows.append(resolved)
            elif geom_type_value == "LineString":
                coords = geometry_vertices(geom)
                min_x = min(c[0] for c in coords) - buffer_distance
                min_y = min(c[1] for c in coords) - buffer_distance
                max_x = max(c[0] for c in coords) + buffer_distance
                max_y = max(c[1] for c in coords) + buffer_distance
                polygon = _rectangle_polygon(min_x, min_y, max_x, max_y)
                resolved = dict(row)
                resolved[self.geometry_column] = polygon
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 9: contours
    # ------------------------------------------------------------------
    def contours(
        self,
        value_column: str,
        intervals: Sequence[float] | None = None,
        interval_count: int = 5,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        contour_suffix: str = "contour",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row[value_column]) for row in self._rows if row.get(value_column) is not None]
        if not values:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        b = self.bounds()
        if intervals is None:
            v_min, v_max = min(values), max(values)
            step = (v_max - v_min) / max(interval_count, 1)
            intervals = [v_min + step * i for i in range(1, interval_count)]
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        grid: list[list[float]] = []
        for gy in range(grid_resolution):
            row_vals: list[float] = []
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                weight_sum = 0.0
                value_sum = 0.0
                for ci, centroid in enumerate(centroids):
                    d = coordinate_distance((px, py), centroid, method=distance_method)
                    w = 1.0 / max(d * d, 1e-12)
                    weight_sum += w
                    value_sum += w * float(self._rows[ci].get(value_column, 0) or 0)
                row_vals.append(value_sum / weight_sum if weight_sum > 0 else 0.0)
            grid.append(row_vals)
        rows: list[Record] = []
        contour_id = 0
        for level in intervals:
            segments: list[tuple[Coordinate, Coordinate]] = []
            for gy in range(grid_resolution - 1):
                for gx in range(grid_resolution - 1):
                    cell_values = [
                        grid[gy][gx], grid[gy][gx + 1],
                        grid[gy + 1][gx + 1], grid[gy + 1][gx],
                    ]
                    cell_coords = [
                        (b.min_x + gx * dx, b.min_y + gy * dy),
                        (b.min_x + (gx + 1) * dx, b.min_y + gy * dy),
                        (b.min_x + (gx + 1) * dx, b.min_y + (gy + 1) * dy),
                        (b.min_x + gx * dx, b.min_y + (gy + 1) * dy),
                    ]
                    edge_points = _marching_square_edges(cell_values, cell_coords, level)
                    for i in range(0, len(edge_points) - 1, 2):
                        segments.append((edge_points[i], edge_points[i + 1]))
            for seg_start, seg_end in segments:
                contour_id += 1
                rows.append({
                    f"contour_id_{contour_suffix}": f"contour-{contour_id:04d}",
                    f"level_{contour_suffix}": level,
                    self.geometry_column: {"type": "LineString", "coordinates": (seg_start, seg_end)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 10: hillshade
    # ------------------------------------------------------------------
    def hillshade(
        self,
        elevation_column: str,
        azimuth: float = 315.0,
        altitude: float = 45.0,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        hillshade_suffix: str = "hillshade",
    ) -> "GeoPromptFrame":
        self._require_column(elevation_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx, dy = self._idw_grid(elevation_column, grid_resolution, distance_method)
        azimuth_rad = math.radians(360.0 - azimuth + 90.0)
        altitude_rad = math.radians(altitude)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(1, grid_resolution - 1):
            for gx in range(1, grid_resolution - 1):
                dzdx = (grid[gy][gx + 1] - grid[gy][gx - 1]) / (2.0 * max(dx, 1e-12))
                dzdy = (grid[gy + 1][gx] - grid[gy - 1][gx]) / (2.0 * max(dy, 1e-12))
                slope = math.atan(math.sqrt(dzdx * dzdx + dzdy * dzdy))
                aspect = math.atan2(-dzdy, dzdx)
                shade = max(0.0, math.cos(altitude_rad) * math.sin(slope) * math.cos(azimuth_rad - aspect) + math.sin(altitude_rad) * math.cos(slope))
                shade = min(1.0, shade)
                cx = b.min_x + gx * dx
                cy = b.min_y + gy * dy
                cell_id += 1
                rows.append({
                    f"cell_id_{hillshade_suffix}": f"hs-{cell_id:04d}",
                    f"value_{hillshade_suffix}": round(shade * 255.0),
                    f"shade_{hillshade_suffix}": shade,
                    self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 11: slope_aspect
    # ------------------------------------------------------------------
    def slope_aspect(
        self,
        elevation_column: str,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        slope_suffix: str = "terrain",
    ) -> "GeoPromptFrame":
        self._require_column(elevation_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx, dy = self._idw_grid(elevation_column, grid_resolution, distance_method)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(1, grid_resolution - 1):
            for gx in range(1, grid_resolution - 1):
                dzdx = (grid[gy][gx + 1] - grid[gy][gx - 1]) / (2.0 * max(dx, 1e-12))
                dzdy = (grid[gy + 1][gx] - grid[gy - 1][gx]) / (2.0 * max(dy, 1e-12))
                slope_rad = math.atan(math.sqrt(dzdx * dzdx + dzdy * dzdy))
                slope_deg = math.degrees(slope_rad)
                aspect_rad = math.atan2(-dzdy, dzdx)
                aspect_deg = (math.degrees(aspect_rad) + 360.0) % 360.0
                cx = b.min_x + gx * dx
                cy = b.min_y + gy * dy
                cell_id += 1
                rows.append({
                    f"cell_id_{slope_suffix}": f"sa-{cell_id:04d}",
                    f"slope_degrees_{slope_suffix}": slope_deg,
                    f"slope_radians_{slope_suffix}": slope_rad,
                    f"aspect_degrees_{slope_suffix}": aspect_deg,
                    f"aspect_radians_{slope_suffix}": aspect_rad,
                    f"elevation_{slope_suffix}": grid[gy][gx],
                    self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 12: idw_interpolation
    # ------------------------------------------------------------------
    def idw_interpolation(
        self,
        value_column: str,
        grid_resolution: int = 20,
        power: float = 2.0,
        distance_method: str = "euclidean",
        search_radius: float | None = None,
        k_neighbors: int | None = None,
        idw_suffix: str = "idw",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if power <= 0:
            raise ValueError("power must be greater than zero")
        if search_radius is not None and search_radius <= 0:
            raise ValueError("search_radius must be greater than zero")
        if k_neighbors is not None and k_neighbors <= 0:
            raise ValueError("k_neighbors must be greater than zero")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(self._rows[ci].get(value_column, 0) or 0) for ci in range(len(centroids))]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                # Compute distances to all sample points
                dists: list[tuple[int, float]] = []
                exact_match = None
                for ci, centroid in enumerate(centroids):
                    d = coordinate_distance((px, py), centroid, method=distance_method)
                    if d < 1e-12:
                        exact_match = vals[ci]
                        break
                    dists.append((ci, d))
                if exact_match is not None:
                    interpolated = exact_match
                else:
                    # Apply search_radius filter
                    if search_radius is not None:
                        dists = [(ci, d) for ci, d in dists if d <= search_radius]
                    # Apply k_neighbors filter (keep k closest)
                    if k_neighbors is not None and len(dists) > k_neighbors:
                        dists = nsmallest(k_neighbors, dists, key=lambda x: x[1])
                    weight_sum = 0.0
                    value_sum = 0.0
                    for ci, d in dists:
                        w = 1.0 / (d ** power)
                        weight_sum += w
                        value_sum += w * vals[ci]
                    interpolated = value_sum / weight_sum if weight_sum > 0 else 0.0
                cell_id += 1
                rows.append({
                    f"cell_id_{idw_suffix}": f"idw-{cell_id:04d}",
                    f"value_{idw_suffix}": interpolated,
                    f"grid_row_{idw_suffix}": gy,
                    f"grid_col_{idw_suffix}": gx,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 13: kriging_surface (simple/ordinary kriging)
    # ------------------------------------------------------------------
    def kriging_surface(
        self,
        value_column: str,
        grid_resolution: int = 20,
        variogram_range: float | None = None,
        variogram_sill: float | None = None,
        variogram_nugget: float | None = None,
        variogram_model: str | None = None,
        auto_fit_variogram: bool = False,
        distance_method: str = "euclidean",
        kriging_suffix: str = "kriging",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        sample_count = len(centroids)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        # Auto-fit variogram from data when requested or when no parameters supplied
        if auto_fit_variogram or (variogram_range is None and variogram_sill is None and variogram_model is None):
            fitted_model, fitted_range, fitted_sill, fitted_nugget = _fit_empirical_variogram(
                centroids, values, distance_method
            )
            if variogram_model is None:
                variogram_model = fitted_model
            if variogram_range is None:
                variogram_range = fitted_range
            if variogram_sill is None:
                variogram_sill = fitted_sill
            if variogram_nugget is None:
                variogram_nugget = fitted_nugget
        # Apply defaults for anything still None
        if variogram_sill is None:
            variogram_sill = 1.0
        if variogram_nugget is None:
            variogram_nugget = 0.0
        if variogram_model is None:
            variogram_model = "spherical"
        if variogram_sill <= 0.0:
            raise ValueError("variogram_sill must be greater than zero")
        if variogram_nugget < 0.0:
            raise ValueError("variogram_nugget must be zero or greater")
        if variogram_range is not None and variogram_range <= 0.0:
            raise ValueError("variogram_range must be greater than zero")
        normalized_variogram_model = variogram_model.replace("-", "_")
        if normalized_variogram_model not in {"spherical", "exponential", "gaussian", "hole_effect"}:
            raise ValueError("variogram_model must be 'spherical', 'exponential', 'gaussian', or 'hole_effect'")
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        sample_count = len(centroids)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        effective_range = variogram_range if variogram_range is not None else extent / 3.0
        sample_distances = _pairwise_distance_matrix(centroids, distance_method)
        kriging_matrix = [
            [
                _variogram_value(
                    normalized_variogram_model,
                    sample_distances[row_index][col_index],
                    effective_range,
                    variogram_sill,
                    variogram_nugget,
                )
                for col_index in range(sample_count)
            ]
            + [1.0]
            for row_index in range(sample_count)
        ]
        kriging_matrix.append([1.0] * sample_count + [0.0])
        inverse_matrix = _invert_matrix(kriging_matrix)
        if inverse_matrix is None:
            stabilized_matrix = [list(row) for row in kriging_matrix]
            diagonal_jitter = max(variogram_sill, 1.0) * 1e-9
            for index in range(sample_count):
                stabilized_matrix[index][index] += diagonal_jitter
            inverse_matrix = _invert_matrix(stabilized_matrix)
            if inverse_matrix is not None:
                kriging_matrix = stabilized_matrix
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                query_distances = [
                    coordinate_distance((px, py), centroid, method=distance_method)
                    for centroid in centroids
                ]
                exact_index = next(
                    (
                        index
                        for index, distance_value in enumerate(query_distances)
                        if distance_value <= 1e-12 and variogram_nugget <= 0.0
                    ),
                    None,
                )
                if exact_index is not None:
                    predicted = values[exact_index]
                    variance = 0.0
                else:
                    rhs = [
                        _variogram_value(
                            normalized_variogram_model,
                            distance_value,
                            effective_range,
                            variogram_sill,
                            variogram_nugget,
                            include_nugget_at_zero=variogram_nugget > 0.0,
                        )
                        for distance_value in query_distances
                    ] + [1.0]
                    solution = _matrix_vector_product(inverse_matrix, rhs) if inverse_matrix is not None else _solve_linear_system(kriging_matrix, rhs)
                    if solution is None:
                        weights = [max(0.0, variogram_sill - rhs[index]) for index in range(sample_count)]
                        weight_sum = sum(weights)
                        if weight_sum > 0.0:
                            predicted = sum(weight * value for weight, value in zip(weights, values, strict=True)) / weight_sum
                            variance = variogram_sill - sum(weight * weight for weight in weights) / (weight_sum * weight_sum) * variogram_sill
                        else:
                            predicted = sum(values) / len(values) if values else 0.0
                            variance = variogram_sill
                    else:
                        kriging_weights = solution[:sample_count]
                        lagrange_multiplier = float(solution[-1])
                        predicted = sum(weight * value for weight, value in zip(kriging_weights, values, strict=True))
                        variance = sum(weight * rhs[index] for index, weight in enumerate(kriging_weights)) + lagrange_multiplier
                cell_id += 1
                rows.append({
                    f"cell_id_{kriging_suffix}": f"krig-{cell_id:04d}",
                    f"value_{kriging_suffix}": predicted,
                    f"variance_{kriging_suffix}": max(0.0, variance),
                    f"method_{kriging_suffix}": "ordinary_kriging",
                    f"variogram_model_{kriging_suffix}": normalized_variogram_model,
                    f"grid_row_{kriging_suffix}": gy,
                    f"grid_col_{kriging_suffix}": gx,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 14: thiessen_polygons (Voronoi)
    # ------------------------------------------------------------------
    def thiessen_polygons(
        self,
        id_column: str = "site_id",
        grid_resolution: int = 50,
        distance_method: str = "euclidean",
        voronoi_suffix: str = "voronoi",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        margin = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9) * 0.1
        ext_min_x = b.min_x - margin
        ext_min_y = b.min_y - margin
        ext_max_x = b.max_x + margin
        ext_max_y = b.max_y + margin
        try:
            shapely_geometry = importlib.import_module("shapely.geometry")
            shapely_ops = importlib.import_module("shapely.ops")
            envelope = shapely_geometry.box(ext_min_x, ext_min_y, ext_max_x, ext_max_y)
            voronoi = shapely_ops.voronoi_diagram(
                shapely_geometry.MultiPoint(centroids),
                envelope=envelope,
                tolerance=0.0,
                edges=False,
            )
            candidate_cells = [
                geometry
                for geometry in getattr(voronoi, "geoms", [voronoi])
                if not geometry.is_empty and geometry.geom_type == "Polygon"
            ]
            rows: list[Record] = []
            assigned_cells: dict[str, Any] = {}
            for row, centroid in zip(self._rows, centroids, strict=True):
                centroid_key = _coordinate_key(centroid)
                if centroid_key not in assigned_cells:
                    point = shapely_geometry.Point(centroid)
                    matched_cell = next(
                        (
                            cell
                            for cell in candidate_cells
                            if cell.covers(point) or cell.buffer(1e-12).covers(point)
                        ),
                        None,
                    )
                    if matched_cell is None:
                        matched_cell = min(candidate_cells, key=lambda cell: float(cell.distance(point)))
                    assigned_cells[centroid_key] = matched_cell.intersection(envelope)
                geometries = geometry_from_shapely(assigned_cells[centroid_key])
                if not geometries:
                    continue
                polygon = geometries[0]
                area_value = geometry_area(polygon)
                resolved = dict(row)
                resolved[self.geometry_column] = polygon
                resolved[f"cell_count_{voronoi_suffix}"] = 1
                resolved[f"area_approx_{voronoi_suffix}"] = area_value
                resolved[f"area_{voronoi_suffix}"] = area_value
                resolved[f"method_{voronoi_suffix}"] = "shapely_exact"
                rows.append(resolved)
            if len(rows) == len(self._rows):
                return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        except ImportError:
            pass

        dx = (ext_max_x - ext_min_x) / max(grid_resolution - 1, 1)
        dy = (ext_max_y - ext_min_y) / max(grid_resolution - 1, 1)
        use_euclidean = distance_method == "euclidean"
        cell_owner: list[list[int]] = []
        for gy in range(grid_resolution):
            py = ext_min_y + gy * dy
            row_owners: list[int] = []
            for gx in range(grid_resolution):
                px = ext_min_x + gx * dx
                best_idx = 0
                best_dist = float("inf")
                for ci, centroid in enumerate(centroids):
                    if use_euclidean:
                        delta_x = px - centroid[0]
                        delta_y = py - centroid[1]
                        d = delta_x * delta_x + delta_y * delta_y
                    else:
                        d = coordinate_distance((px, py), centroid, method=distance_method)
                    if d < best_dist:
                        best_dist = d
                        best_idx = ci
                row_owners.append(best_idx)
            cell_owner.append(row_owners)
        owner_cells: dict[int, list[tuple[int, int]]] = {}
        for gy in range(grid_resolution):
            for gx in range(grid_resolution):
                owner_cells.setdefault(cell_owner[gy][gx], []).append((gx, gy))
        rows: list[Record] = []
        for owner_idx, cells in owner_cells.items():
            xs = [ext_min_x + gx * dx for gx, _ in cells]
            ys = [ext_min_y + gy * dy for _, gy in cells]
            poly_min_x, poly_max_x = min(xs), max(xs) + dx
            poly_min_y, poly_max_y = min(ys), max(ys) + dy
            polygon = _rectangle_polygon(poly_min_x, poly_min_y, poly_max_x, poly_max_y)
            resolved = dict(self._rows[owner_idx])
            resolved[self.geometry_column] = polygon
            resolved[f"cell_count_{voronoi_suffix}"] = len(cells)
            resolved[f"area_approx_{voronoi_suffix}"] = len(cells) * dx * dy
            resolved[f"area_{voronoi_suffix}"] = geometry_area(polygon)
            resolved[f"method_{voronoi_suffix}"] = "grid_approximation"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 15: spatial_weights_matrix
    # ------------------------------------------------------------------
    def spatial_weights_matrix(
        self,
        id_column: str = "site_id",
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        weight_mode: SpatialLagWeightMode = "binary",
        include_self: bool = False,
    ) -> dict[str, Any]:
        self._require_column(id_column)
        centroids = self._centroids()
        ids = [str(row[id_column]) for row in self._rows]
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        weights: dict[str, dict[str, float]] = {}
        for i, origin_id in enumerate(ids):
            neighbors: dict[str, float] = {}
            candidates: list[tuple[int, float]] = []
            for j in range(len(self._rows)):
                if not include_self and i == j:
                    continue
                d = distance_matrix[i][j]
                if mode == "distance_band" and max_distance is not None and d > max_distance:
                    continue
                candidates.append((j, d))
            if mode == "k_nearest":
                candidates = nsmallest(k, candidates, key=lambda x: x[1])
            for j, d in candidates:
                if weight_mode == "binary":
                    neighbors[ids[j]] = 1.0
                else:
                    neighbors[ids[j]] = 1.0 / max(d, 1e-12)
            weights[origin_id] = neighbors
        return {
            "ids": ids,
            "weights": weights,
            "mode": mode,
            "k": k,
            "max_distance": max_distance,
            "weight_mode": weight_mode,
            "n": len(ids),
        }

    # ------------------------------------------------------------------
    # Tool 16: hotspot_getis_ord (Gi* statistic)
    # ------------------------------------------------------------------
    def hotspot_getis_ord(
        self,
        value_column: str,
        id_column: str = "site_id",
        mode: SpatialLagMode = "distance_band",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        include_self: bool = True,
        alpha: float = 0.05,
        fdr_correction: bool = False,
        getis_suffix: str = "getis",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        self._require_column(id_column)
        if mode == "distance_band" and max_distance is None:
            raise ValueError("max_distance is required when mode='distance_band'")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        values = [float(row[value_column]) for row in self._rows]
        geometry_index = self.spatial_index(mode="geometry") if mode == "intersects" and self._rows else None
        mean_val = sum(values) / n
        s = math.sqrt(sum(v * v for v in values) / n - mean_val * mean_val) if n > 1 else 1.0
        if s < 1e-12:
            s = 1.0
        neighbor_indexes: list[list[int]] = []
        for i in range(n):
            candidates: list[tuple[int, float]] = []
            if mode == "intersects" and geometry_index is not None:
                candidate_indexes = geometry_index.query(geometry_bounds(self._rows[i][self.geometry_column]))
            else:
                candidate_indexes = list(range(n))
            for j in candidate_indexes:
                if i == j:
                    continue
                if mode == "intersects":
                    if not geometry_intersects(self._rows[i][self.geometry_column], self._rows[j][self.geometry_column]):
                        continue
                    distance_value = distance_matrix[i][j]
                else:
                    distance_value = distance_matrix[i][j]
                    if mode == "distance_band" and max_distance is not None and distance_value > max_distance:
                        continue
                candidates.append((j, distance_value))
            if mode == "k_nearest":
                candidates = nsmallest(
                    k,
                    candidates,
                    key=lambda item: (float(item[1]), _row_sort_key(self._rows[item[0]])),
                )
            else:
                candidates.sort(key=lambda item: (float(item[1]), _row_sort_key(self._rows[item[0]])))
            neighbor_indexes.append([index for index, _distance_value in candidates])

        reference_statistics = _reference_getis_ord_statistics(values, neighbor_indexes, include_self=include_self)
        rows: list[Record] = []
        for i in range(n):
            w_sum = 0.0
            wx_sum = 0.0
            w2_sum = 0.0
            neighbor_ids = neighbor_indexes[i]
            for j in neighbor_ids:
                w = 1.0
                w_sum += w
                wx_sum += w * values[j]
                w2_sum += w * w
            if reference_statistics is not None:
                z_score, p_value = reference_statistics[i]
            else:
                if include_self:
                    w_sum += 1.0
                    wx_sum += values[i]
                    w2_sum += 1.0
                if w_sum > 0 and n > 1:
                    numerator = wx_sum - mean_val * w_sum
                    denominator = s * math.sqrt((n * w2_sum - w_sum * w_sum) / max(n - 1, 1))
                    z_score = numerator / max(denominator, 1e-12)
                else:
                    z_score = 0.0
                p_value = 2.0 * (1.0 - _normal_cdf(abs(z_score)))
            resolved = dict(self._rows[i])
            resolved[f"gi_star_{getis_suffix}"] = z_score
            resolved[f"p_value_{getis_suffix}"] = p_value
            resolved[f"neighbor_count_{getis_suffix}"] = len(neighbor_ids) + (1 if include_self else 0)
            resolved[f"significant_{getis_suffix}"] = p_value <= alpha
            resolved[f"implementation_{getis_suffix}"] = "pysal" if reference_statistics is not None else "analytic"
            if p_value <= alpha and z_score > 0.0:
                resolved[f"classification_{getis_suffix}"] = "hotspot"
            elif p_value <= alpha and z_score < 0.0:
                resolved[f"classification_{getis_suffix}"] = "coldspot"
            else:
                resolved[f"classification_{getis_suffix}"] = "not_significant"
            rows.append(resolved)
        # Apply FDR correction if requested
        if fdr_correction and rows:
            p_values_list = [float(r[f"p_value_{getis_suffix}"]) for r in rows]
            sig_flags = _fdr_correction(p_values_list, alpha=alpha)
            for i, row in enumerate(rows):
                row[f"significant_{getis_suffix}"] = sig_flags[i]
                z = float(row[f"gi_star_{getis_suffix}"])
                if sig_flags[i] and z > 0.0:
                    row[f"classification_{getis_suffix}"] = "hotspot"
                elif sig_flags[i] and z < 0.0:
                    row[f"classification_{getis_suffix}"] = "coldspot"
                else:
                    row[f"classification_{getis_suffix}"] = "not_significant"
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 17: local_outlier_factor_spatial
    # ------------------------------------------------------------------
    def local_outlier_factor_spatial(
        self,
        value_column: str | None = None,
        id_column: str = "site_id",
        k: int = 4,
        outlier_threshold: float = 1.5,
        distance_method: str = "euclidean",
        lof_suffix: str = "lof",
    ) -> "GeoPromptFrame":
        self._require_column(id_column)
        if k <= 0:
            raise ValueError("k must be greater than zero")
        if outlier_threshold <= 0:
            raise ValueError("outlier_threshold must be greater than zero")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        if k_actual <= 0:
            rows = [dict(row) | {f"lof_{lof_suffix}": 1.0, f"outlier_{lof_suffix}": False} for row in self._rows]
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        knn: list[list[tuple[int, float]]] = []
        k_distances: list[float] = []
        for i in range(n):
            dists = []
            for j in range(n):
                if i == j:
                    continue
                d = distance_matrix[i][j]
                if value_column is not None:
                    vi = float(self._rows[i].get(value_column, 0) or 0)
                    vj = float(self._rows[j].get(value_column, 0) or 0)
                    d = math.sqrt(d * d + (vi - vj) * (vi - vj))
                dists.append((j, d))
            neighbors = nsmallest(k_actual, dists, key=lambda x: x[1])
            knn.append(neighbors)
            k_distances.append(neighbors[-1][1] if neighbors else 0.0)
        lrd: list[float] = []
        for i in range(n):
            reach_sum = sum(max(k_distances[j], d) for j, d in knn[i])
            lrd.append(len(knn[i]) / max(reach_sum, 1e-12))
        rows: list[Record] = []
        for i in range(n):
            if lrd[i] < 1e-12:
                lof_value = 1.0
            else:
                neighbor_lrd_sum = sum(lrd[j] for j, _ in knn[i])
                lof_value = (neighbor_lrd_sum / len(knn[i])) / lrd[i] if knn[i] else 1.0
            resolved = dict(self._rows[i])
            resolved[f"lof_{lof_suffix}"] = lof_value
            resolved[f"k_distance_{lof_suffix}"] = k_distances[i]
            resolved[f"lrd_{lof_suffix}"] = lrd[i]
            resolved[f"outlier_{lof_suffix}"] = lof_value > outlier_threshold
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 18: kernel_density
    # ------------------------------------------------------------------
    def kernel_density(
        self,
        bandwidth: float | None = None,
        weight_column: str | None = None,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        kernel: str = "epanechnikov",
        kernel_suffix: str = "kde",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        # Silverman bandwidth when not specified
        h = bandwidth if bandwidth is not None else _silverman_bandwidth(centroids)
        if h <= 0:
            raise ValueError("bandwidth must be greater than zero")
        kernel_norm = kernel.lower()
        if kernel_norm not in ("epanechnikov", "gaussian", "quartic"):
            raise ValueError("kernel must be 'epanechnikov', 'gaussian', or 'quartic'")
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        weights_list = [float(row[weight_column]) if weight_column and row.get(weight_column) is not None else 1.0 for row in self._rows]
        use_euclidean = distance_method == "euclidean"
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                density = 0.0
                for ci, centroid in enumerate(centroids):
                    if use_euclidean:
                        d = math.hypot(px - centroid[0], py - centroid[1])
                    else:
                        d = coordinate_distance((px, py), centroid, method=distance_method)
                    u = d / h
                    if kernel_norm == "gaussian":
                        k_val = (1.0 / (2.0 * math.pi)) * math.exp(-0.5 * u * u)
                        density += weights_list[ci] * k_val / (h * h)
                    elif kernel_norm == "quartic":
                        if u < 1.0:
                            k_val = (15.0 / (2.0 * math.pi)) * (1.0 - u * u) ** 2
                            density += weights_list[ci] * k_val / (h * h)
                    else:  # epanechnikov
                        if u < 1.0:
                            k_val = (3.0 / math.pi) * (1.0 - u * u) ** 2
                            density += weights_list[ci] * k_val / (h * h)
                cell_id += 1
                rows.append({
                    f"cell_id_{kernel_suffix}": f"kde-{cell_id:04d}",
                    f"density_{kernel_suffix}": density,
                    f"bandwidth_{kernel_suffix}": h,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 19: standard_deviational_ellipse
    # ------------------------------------------------------------------
    def standard_deviational_ellipse(
        self,
        weight_column: str | None = None,
        id_column: str = "site_id",
        ellipse_suffix: str = "sde",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        weights = [float(row[weight_column]) if weight_column and row.get(weight_column) is not None else 1.0 for row in self._rows]
        w_sum = sum(weights)
        if w_sum <= 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        cx = sum(w * c[0] for w, c in zip(weights, centroids)) / w_sum
        cy = sum(w * c[1] for w, c in zip(weights, centroids)) / w_sum
        dx_vals = [c[0] - cx for c in centroids]
        dy_vals = [c[1] - cy for c in centroids]
        sum_dx2 = sum(w * dx * dx for w, dx in zip(weights, dx_vals))
        sum_dy2 = sum(w * dy * dy for w, dy in zip(weights, dy_vals))
        sum_dxdy = sum(w * dx * dy for w, dx, dy in zip(weights, dx_vals, dy_vals))
        a = sum_dx2 - sum_dy2
        b_val = math.sqrt(a * a + 4.0 * sum_dxdy * sum_dxdy)
        theta = math.atan2(2.0 * sum_dxdy, a) / 2.0
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        sigma_x = math.sqrt(sum(w * (dx * cos_t + dy * sin_t) ** 2 for w, dx, dy in zip(weights, dx_vals, dy_vals)) / w_sum)
        sigma_y = math.sqrt(sum(w * (dx * sin_t - dy * cos_t) ** 2 for w, dx, dy in zip(weights, dx_vals, dy_vals)) / w_sum)
        n_pts = 32
        ellipse_coords: list[Coordinate] = []
        for i in range(n_pts + 1):
            angle = 2.0 * math.pi * i / n_pts
            ex = sigma_x * math.cos(angle)
            ey = sigma_y * math.sin(angle)
            rx = cx + ex * cos_t - ey * sin_t
            ry = cy + ex * sin_t + ey * cos_t
            ellipse_coords.append((rx, ry))
        ellipse_geom: Geometry = {"type": "Polygon", "coordinates": tuple(ellipse_coords)}
        rows: list[Record] = [{
            f"center_x_{ellipse_suffix}": cx,
            f"center_y_{ellipse_suffix}": cy,
            f"sigma_x_{ellipse_suffix}": sigma_x,
            f"sigma_y_{ellipse_suffix}": sigma_y,
            f"rotation_degrees_{ellipse_suffix}": math.degrees(theta),
            f"feature_count_{ellipse_suffix}": len(self._rows),
            self.geometry_column: ellipse_geom,
        }]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 20: center_of_minimum_distance (spatial median)
    # ------------------------------------------------------------------
    def center_of_minimum_distance(
        self,
        weight_column: str | None = None,
        max_iterations: int = 100,
        tolerance: float = 1e-8,
        distance_method: str = "euclidean",
        cmd_suffix: str = "cmd",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        weights = [float(row[weight_column]) if weight_column and row.get(weight_column) is not None else 1.0 for row in self._rows]
        w_sum = sum(weights)
        cx = sum(w * c[0] for w, c in zip(weights, centroids)) / max(w_sum, 1e-12)
        cy = sum(w * c[1] for w, c in zip(weights, centroids)) / max(w_sum, 1e-12)
        iteration = -1
        for iteration in range(max_iterations):
            wx_sum = 0.0
            wy_sum = 0.0
            denom = 0.0
            for ci, centroid in enumerate(centroids):
                d = coordinate_distance((cx, cy), centroid, method=distance_method)
                if d < 1e-12:
                    continue
                w = weights[ci] / d
                wx_sum += w * centroid[0]
                wy_sum += w * centroid[1]
                denom += w
            if denom < 1e-12:
                break
            new_cx = wx_sum / denom
            new_cy = wy_sum / denom
            if abs(new_cx - cx) + abs(new_cy - cy) < tolerance:
                cx, cy = new_cx, new_cy
                break
            cx, cy = new_cx, new_cy
        total_dist = sum(
            weights[ci] * coordinate_distance((cx, cy), centroids[ci], method=distance_method)
            for ci in range(len(centroids))
        )
        rows: list[Record] = [{
            f"center_x_{cmd_suffix}": cx,
            f"center_y_{cmd_suffix}": cy,
            f"total_weighted_distance_{cmd_suffix}": total_dist,
            f"feature_count_{cmd_suffix}": len(self._rows),
            f"iterations_{cmd_suffix}": min(iteration + 1, max_iterations),
            self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
        }]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 21: spatial_regression (OLS with spatial diagnostics)
    # ------------------------------------------------------------------
    def spatial_regression(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        id_column: str = "site_id",
        k_neighbors: int = 4,
        distance_method: str = "euclidean",
        regression_suffix: str = "reg",
    ) -> "GeoPromptFrame":
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        p = len(independent_columns) + 1
        y_vals = [float(row[dependent_column]) for row in self._rows]
        x_matrix = [[1.0] + [float(row.get(col, 0) or 0) for col in independent_columns] for row in self._rows]
        xtx_inverse: list[list[float]] | None = None
        x_pseudoinverse = _matrix_pseudoinverse(x_matrix)
        effective_rank = _matrix_rank(x_matrix)
        if x_pseudoinverse is not None:
            coefficients = _matrix_vector_product(x_pseudoinverse, y_vals)
            xtx_inverse = _matrix_product(x_pseudoinverse, _transpose_matrix(x_pseudoinverse))
        else:
            xtx = [[sum(x_matrix[i][a] * x_matrix[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
            xty = [sum(x_matrix[i][a] * y_vals[i] for i in range(n)) for a in range(p)]
            coefficients = _solve_linear_system(xtx, xty)
            xtx_inverse = _invert_matrix(xtx)
            if coefficients is None or xtx_inverse is None:
                xtx_pseudoinverse = _matrix_pseudoinverse(xtx)
                if xtx_pseudoinverse is not None:
                    coefficients = _matrix_vector_product(xtx_pseudoinverse, xty)
                    xtx_inverse = xtx_pseudoinverse
                elif coefficients is None:
                    coefficients = _solve_linear_system_with_regularization(xtx, xty)
                    xtx_inverse = _invert_matrix_with_regularization(xtx)
        if coefficients is None:
            coefficients = [0.0] * p
        predicted = _matrix_vector_product(x_matrix, coefficients)
        residuals = [y_vals[i] - predicted[i] for i in range(n)]
        y_mean = sum(y_vals) / n
        ss_res = sum(r * r for r in residuals)
        ss_tot = sum((y - y_mean) ** 2 for y in y_vals)
        if ss_tot <= 1e-12:
            r_squared = float("-inf")
            adj_r_squared = float("-inf")
        else:
            r_squared = 1.0 - ss_res / ss_tot
            adj_r_squared = 1.0 - (1.0 - r_squared) * (n - 1) / max(n - effective_rank, 1) if n > effective_rank else r_squared
        degrees_of_freedom = max(n - effective_rank, 0)
        sigma2 = ss_res / degrees_of_freedom if degrees_of_freedom > 0 else 0.0
        rmse = math.sqrt(ss_res / n) if n > 0 else 0.0
        coefficient_standard_errors: list[float | None]
        coefficient_t_statistics: list[float | None]
        coefficient_p_values: list[float | None]
        if xtx_inverse is None:
            coefficient_standard_errors = [None] * p
            coefficient_t_statistics = [None] * p
            coefficient_p_values = [None] * p
        else:
            coefficient_standard_errors = []
            coefficient_t_statistics = []
            coefficient_p_values = []
            for index in range(p):
                variance = max(xtx_inverse[index][index] * sigma2, 0.0)
                standard_error = math.sqrt(variance)
                coefficient_standard_errors.append(standard_error)
                coefficient = coefficients[index]
                if standard_error <= 0.0:
                    t_statistic = math.inf if abs(coefficient) > 1e-12 else 0.0
                    p_value = 0.0 if math.isinf(t_statistic) else 1.0
                else:
                    t_statistic = coefficient / standard_error
                    p_value = _two_sided_t_probability(abs(t_statistic), degrees_of_freedom)
                coefficient_t_statistics.append(t_statistic)
                coefficient_p_values.append(min(max(p_value, 0.0), 1.0))
        centroids = self._centroids()
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        moran_residuals = _moran_on_residuals(residuals, distance_matrix, k_neighbors)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{regression_suffix}"] = predicted[i]
            resolved[f"residual_{regression_suffix}"] = residuals[i]
            resolved[f"r_squared_{regression_suffix}"] = r_squared
            resolved[f"adj_r_squared_{regression_suffix}"] = adj_r_squared
            resolved[f"rmse_{regression_suffix}"] = rmse
            resolved[f"sigma2_{regression_suffix}"] = sigma2
            resolved[f"dof_{regression_suffix}"] = degrees_of_freedom
            resolved[f"coefficients_{regression_suffix}"] = coefficients
            resolved[f"coefficient_standard_errors_{regression_suffix}"] = coefficient_standard_errors
            resolved[f"coefficient_t_statistics_{regression_suffix}"] = coefficient_t_statistics
            resolved[f"coefficient_p_values_{regression_suffix}"] = coefficient_p_values
            resolved[f"intercept_{regression_suffix}"] = coefficients[0] if coefficients else None
            resolved[f"intercept_standard_error_{regression_suffix}"] = coefficient_standard_errors[0] if coefficient_standard_errors else None
            resolved[f"intercept_t_statistic_{regression_suffix}"] = coefficient_t_statistics[0] if coefficient_t_statistics else None
            resolved[f"intercept_p_value_{regression_suffix}"] = coefficient_p_values[0] if coefficient_p_values else None
            for coefficient_index, column_name in enumerate(independent_columns, start=1):
                resolved[f"coeff_{column_name}_{regression_suffix}"] = coefficients[coefficient_index] if coefficient_index < len(coefficients) else None
                resolved[f"std_err_{column_name}_{regression_suffix}"] = coefficient_standard_errors[coefficient_index] if coefficient_index < len(coefficient_standard_errors) else None
                resolved[f"t_stat_{column_name}_{regression_suffix}"] = coefficient_t_statistics[coefficient_index] if coefficient_index < len(coefficient_t_statistics) else None
                resolved[f"p_value_{column_name}_{regression_suffix}"] = coefficient_p_values[coefficient_index] if coefficient_index < len(coefficient_p_values) else None
            resolved[f"moran_residual_{regression_suffix}"] = moran_residuals
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 22: geographically_weighted_summary
    # ------------------------------------------------------------------
    def geographically_weighted_summary(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        bandwidth: float | None = None,
        distance_method: str = "euclidean",
        auto_bandwidth: bool = False,
        gwr_suffix: str = "gwr",
    ) -> "GeoPromptFrame":
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        p = len(independent_columns) + 1
        y_vals = [float(row[dependent_column]) for row in self._rows]
        x_matrix = [[1.0] + [float(row.get(col, 0) or 0) for col in independent_columns] for row in self._rows]

        def _gwr_cv_score(h: float) -> float:
            """Leave-one-out CV score for a given bandwidth."""
            sse = 0.0
            for i in range(n):
                local_weights = []
                for j in range(n):
                    if i == j:
                        local_weights.append(0.0)  # leave-one-out
                        continue
                    u = distance_matrix[i][j] / max(h, 1e-12)
                    w = math.exp(-0.5 * u * u) if u < 3.0 else 0.0
                    local_weights.append(w)
                wxtx = [[sum(local_weights[k_idx] * x_matrix[k_idx][a] * x_matrix[k_idx][b_idx] for k_idx in range(n)) for b_idx in range(p)] for a in range(p)]
                wxty = [sum(local_weights[k_idx] * x_matrix[k_idx][a] * y_vals[k_idx] for k_idx in range(n)) for a in range(p)]
                coeffs = _solve_linear_system(wxtx, wxty)
                if coeffs is None:
                    pred = sum(y_vals) / n
                else:
                    pred = sum(coeffs[j] * x_matrix[i][j] for j in range(p))
                sse += (y_vals[i] - pred) ** 2
            return sse

        if bandwidth is not None:
            h = bandwidth
        elif auto_bandwidth and n > 2:
            # Search over candidate bandwidths using golden section
            h_low = extent * 0.05
            h_high = extent * 2.0
            gr = (math.sqrt(5) + 1) / 2
            for _ in range(15):
                h1 = h_high - (h_high - h_low) / gr
                h2 = h_low + (h_high - h_low) / gr
                if _gwr_cv_score(h1) < _gwr_cv_score(h2):
                    h_high = h2
                else:
                    h_low = h1
            h = (h_low + h_high) / 2.0
        else:
            h = extent / 3.0
        rows: list[Record] = []
        for i in range(n):
            local_weights = []
            for j in range(n):
                d = distance_matrix[i][j]
                u = d / max(h, 1e-12)
                w = math.exp(-0.5 * u * u) if u < 3.0 else 0.0
                local_weights.append(w)
            wxtx = [[sum(local_weights[k_idx] * x_matrix[k_idx][a] * x_matrix[k_idx][b] for k_idx in range(n)) for b in range(p)] for a in range(p)]
            wxty = [sum(local_weights[k_idx] * x_matrix[k_idx][a] * y_vals[k_idx] for k_idx in range(n)) for a in range(p)]
            local_coefficients = _solve_linear_system(wxtx, wxty)
            if local_coefficients is None:
                local_coefficients = [0.0] * p
            local_predicted = sum(local_coefficients[j] * x_matrix[i][j] for j in range(p))
            local_residual = y_vals[i] - local_predicted
            # Local R-squared: weighted sum of squared residuals vs weighted TSS
            w_mean_y = sum(local_weights[j] * y_vals[j] for j in range(n)) / max(sum(local_weights), 1e-12)
            ss_tot = sum(local_weights[j] * (y_vals[j] - w_mean_y) ** 2 for j in range(n))
            ss_res = sum(local_weights[j] * (y_vals[j] - sum(local_coefficients[c] * x_matrix[j][c] for c in range(p))) ** 2 for j in range(n))
            local_r2 = 1.0 - ss_res / ss_tot if ss_tot > 1e-12 else None
            resolved = dict(self._rows[i])
            resolved[f"predicted_{gwr_suffix}"] = local_predicted
            resolved[f"residual_{gwr_suffix}"] = local_residual
            resolved[f"local_r_squared_{gwr_suffix}"] = local_r2
            resolved[f"bandwidth_{gwr_suffix}"] = h
            resolved[f"intercept_{gwr_suffix}"] = local_coefficients[0]
            for ci, col in enumerate(independent_columns):
                resolved[f"coeff_{col}_{gwr_suffix}"] = local_coefficients[ci + 1]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def weighted_local_summary(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        bandwidth: float | None = None,
        distance_method: str = "euclidean",
        summary_suffix: str = "local",
    ) -> "GeoPromptFrame":
        return self.geographically_weighted_summary(
            dependent_column=dependent_column,
            independent_columns=independent_columns,
            bandwidth=bandwidth,
            distance_method=distance_method,
            gwr_suffix=summary_suffix,
        )

    # ------------------------------------------------------------------
    # Tool 23: join_by_largest_overlap
    # ------------------------------------------------------------------
    def join_by_largest_overlap(
        self,
        other: "GeoPromptFrame",
        left_id_column: str = "site_id",
        right_id_column: str = "site_id",
        include_diagnostics: bool = False,
        overlap_suffix: str = "overlap",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS")
        left_geoms = [row[self.geometry_column] for row in self._rows]
        right_geoms = [row[other.geometry_column] for row in other._rows]
        rows: list[Record] = []
        for left_idx, left_row in enumerate(self._rows):
            best_overlap = 0.0
            best_right_idx: int | None = None
            for right_idx in range(len(other._rows)):
                overlap = _geometry_overlap_size(left_geoms[left_idx], right_geoms[right_idx])
                if overlap > best_overlap:
                    best_overlap = overlap
                    best_right_idx = right_idx
            resolved = dict(left_row)
            if best_right_idx is not None:
                for key, value in other._rows[best_right_idx].items():
                    if key != other.geometry_column:
                        resolved[f"{key}_{overlap_suffix}"] = value
                resolved[f"overlap_area_{overlap_suffix}"] = best_overlap
            else:
                resolved[f"overlap_area_{overlap_suffix}"] = 0.0
            if include_diagnostics:
                resolved[f"candidate_count_{overlap_suffix}"] = len(other._rows)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 24: erase
    # ------------------------------------------------------------------
    def erase(self, mask: "GeoPromptFrame") -> "GeoPromptFrame":
        if self.crs and mask.crs and self.crs != mask.crs:
            raise ValueError("frames must share the same CRS")
        from .overlay import clip_geometries as _clip, geometry_to_shapely, geometry_from_shapely, _load_shapely
        _, _, unary_union, _ = _load_shapely()
        mask_geoms = [row[mask.geometry_column] for row in mask._rows]
        if not mask_geoms:
            return GeoPromptFrame._from_internal_rows([dict(r) for r in self._rows], geometry_column=self.geometry_column, crs=self.crs)
        mask_shape = unary_union([geometry_to_shapely(g) for g in mask_geoms])
        rows: list[Record] = []
        for row in self._rows:
            source_shape = geometry_to_shapely(row[self.geometry_column])
            diff = source_shape.difference(mask_shape)
            result_geoms = geometry_from_shapely(diff)
            for geom in result_geoms:
                resolved = dict(row)
                resolved[self.geometry_column] = geom
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 25: identity_overlay
    # ------------------------------------------------------------------
    def identity_overlay(
        self,
        other: "GeoPromptFrame",
        left_id_column: str = "site_id",
        right_id_column: str = "site_id",
        identity_suffix: str = "identity",
    ) -> "GeoPromptFrame":
        if self.crs and other.crs and self.crs != other.crs:
            raise ValueError("frames must share the same CRS")
        from .overlay import geometry_to_shapely, geometry_from_shapely, _load_shapely
        _, _, unary_union, _ = _load_shapely()
        right_shapes = [geometry_to_shapely(row[other.geometry_column]) for row in other._rows]
        rows: list[Record] = []
        for left_row in self._rows:
            left_shape = geometry_to_shapely(left_row[self.geometry_column])
            remaining = left_shape
            matched = False
            for right_idx, right_shape in enumerate(right_shapes):
                intersection = left_shape.intersection(right_shape)
                if intersection.is_empty:
                    continue
                for geom in geometry_from_shapely(intersection):
                    resolved = dict(left_row)
                    resolved[self.geometry_column] = geom
                    for key, value in other._rows[right_idx].items():
                        if key != other.geometry_column:
                            resolved[f"{key}_{identity_suffix}"] = value
                    rows.append(resolved)
                    matched = True
                remaining = remaining.difference(right_shape)
            if not remaining.is_empty:
                for geom in geometry_from_shapely(remaining):
                    resolved = dict(left_row)
                    resolved[self.geometry_column] = geom
                    rows.append(resolved)
            elif not matched:
                rows.append(dict(left_row))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 26: multipart_to_singlepart
    # ------------------------------------------------------------------
    def multipart_to_singlepart(
        self,
        id_column: str = "site_id",
        part_suffix: str = "part",
    ) -> "GeoPromptFrame":
        from .overlay import geometry_to_shapely, geometry_from_shapely, _load_shapely
        _load_shapely()
        rows: list[Record] = []
        part_counter = 0
        for row in self._rows:
            multipart_members = next(
                (
                    value
                    for key, value in row.items()
                    if key.startswith("part_geometries_") and isinstance(value, (list, tuple))
                ),
                None,
            )
            part_geometries = [
                member
                for member in (multipart_members or ())
                if isinstance(member, dict) and "type" in member and "coordinates" in member
            ]
            if not part_geometries:
                geom = row[self.geometry_column]
                shape = geometry_to_shapely(geom)
                if hasattr(shape, "geoms"):
                    for child in shape.geoms:
                        part_geometries.extend(geometry_from_shapely(child))
                else:
                    part_geometries.append(geom)
            for cg in part_geometries:
                part_counter += 1
                resolved = dict(row)
                resolved[self.geometry_column] = cg
                resolved[f"part_id_{part_suffix}"] = part_counter
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 27: singlepart_to_multipart
    # ------------------------------------------------------------------
    def singlepart_to_multipart(
        self,
        group_column: str = "site_id",
        multipart_suffix: str = "multi",
    ) -> "GeoPromptFrame":
        self._require_column(group_column)
        from .overlay import geometry_to_shapely, geometry_from_shapely, _load_shapely
        _, _, unary_union, _ = _load_shapely()
        grouped: dict[str, list[Record]] = {}
        for row in self._rows:
            key = str(row[group_column])
            grouped.setdefault(key, []).append(row)
        rows: list[Record] = []
        for group_key, group_rows in grouped.items():
            shapes = [geometry_to_shapely(r[self.geometry_column]) for r in group_rows]
            merged = unary_union(shapes)
            merged_geoms = geometry_from_shapely(merged)
            first_row = dict(group_rows[0])
            if len(merged_geoms) == 1:
                first_row[self.geometry_column] = merged_geoms[0]
            first_row[f"part_geometries_{multipart_suffix}"] = tuple(
                dict(group_row[self.geometry_column]) for group_row in group_rows
            )
            first_row[f"part_count_{multipart_suffix}"] = len(group_rows)
            rows.append(first_row)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 28: eliminate_slivers
    # ------------------------------------------------------------------
    def eliminate_slivers(
        self,
        min_area: float = 0.0,
        min_vertices: int = 0,
        sliver_suffix: str = "sliver",
    ) -> "GeoPromptFrame":
        rows: list[Record] = []
        eliminated = 0
        for row in self._rows:
            geom = row[self.geometry_column]
            area = geometry_area(geom)
            vertex_count = len(geometry_vertices(geom))
            if area < min_area or (min_vertices > 0 and vertex_count < min_vertices):
                eliminated += 1
                continue
            resolved = dict(row)
            resolved[f"area_{sliver_suffix}"] = area
            rows.append(resolved)
        for row in rows:
            row[f"eliminated_count_{sliver_suffix}"] = eliminated
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 29: simplify (Douglas-Peucker)
    # ------------------------------------------------------------------
    def simplify(
        self,
        tolerance: float = 0.001,
        simplify_suffix: str = "simplified",
    ) -> "GeoPromptFrame":
        if tolerance < 0:
            raise ValueError("tolerance must be zero or greater")
        rows: list[Record] = []
        for row in self._rows:
            geom = row[self.geometry_column]
            geom_kind = geometry_type(geom)
            resolved = dict(row)
            if geom_kind == "Point":
                resolved[f"vertex_count_{simplify_suffix}"] = 1
            elif geom_kind == "LineString":
                coords = list(geometry_vertices(geom))
                simplified = _douglas_peucker(coords, tolerance)
                if len(simplified) < 2:
                    simplified = coords[:2] if len(coords) >= 2 else coords
                resolved[self.geometry_column] = {"type": "LineString", "coordinates": tuple(simplified)}
                resolved[f"vertex_count_{simplify_suffix}"] = len(simplified)
                resolved[f"original_vertex_count_{simplify_suffix}"] = len(coords)
            elif geom_kind == "Polygon":
                coords = list(geometry_vertices(geom))
                simplified = _douglas_peucker(coords, tolerance)
                if len(simplified) < 4:
                    simplified = coords
                resolved[self.geometry_column] = {"type": "Polygon", "coordinates": tuple(simplified)}
                resolved[f"vertex_count_{simplify_suffix}"] = len(simplified)
                resolved[f"original_vertex_count_{simplify_suffix}"] = len(coords)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 30: densify
    # ------------------------------------------------------------------
    def densify(
        self,
        max_segment_length: float = 0.01,
        densify_suffix: str = "densified",
    ) -> "GeoPromptFrame":
        if max_segment_length <= 0:
            raise ValueError("max_segment_length must be greater than zero")
        rows: list[Record] = []
        for row in self._rows:
            geom = row[self.geometry_column]
            geom_kind = geometry_type(geom)
            resolved = dict(row)
            if geom_kind in ("LineString", "Polygon"):
                coords = list(geometry_vertices(geom))
                dense_coords = _densify_coordinates(coords, max_segment_length)
                resolved[self.geometry_column] = {"type": geom_kind, "coordinates": tuple(dense_coords)}
                resolved[f"vertex_count_{densify_suffix}"] = len(dense_coords)
                resolved[f"original_vertex_count_{densify_suffix}"] = len(coords)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 31: smooth_geometry (Chaikin)
    # ------------------------------------------------------------------
    def smooth_geometry(
        self,
        iterations: int = 3,
        smooth_suffix: str = "smoothed",
    ) -> "GeoPromptFrame":
        if iterations < 1:
            raise ValueError("iterations must be at least 1")
        rows: list[Record] = []
        for row in self._rows:
            geom = row[self.geometry_column]
            geom_kind = geometry_type(geom)
            resolved = dict(row)
            if geom_kind in ("LineString", "Polygon"):
                coords = list(geometry_vertices(geom))
                smoothed = coords
                for _ in range(iterations):
                    smoothed = _chaikin_smooth(smoothed, is_closed=(geom_kind == "Polygon"))
                resolved[self.geometry_column] = {"type": geom_kind, "coordinates": tuple(smoothed)}
                resolved[f"vertex_count_{smooth_suffix}"] = len(smoothed)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 32: snap_to_network_nodes
    # ------------------------------------------------------------------
    def snap_to_network_nodes(
        self,
        points: "GeoPromptFrame",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        snap_suffix: str = "snapped",
    ) -> "GeoPromptFrame":
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        node_positions: dict[str, Coordinate] = {}
        for row in self._rows:
            from_node = row[from_node_column]
            to_node = row[to_node_column]
            verts = geometry_vertices(row[self.geometry_column])
            if verts:
                node_positions.setdefault(str(from_node), verts[0])
                node_positions.setdefault(str(to_node), verts[-1])
        node_list = list(node_positions.items())
        point_centroids = points._centroids()
        rows: list[Record] = []
        for pt_idx, pt_row in enumerate(points._rows):
            pt = point_centroids[pt_idx]
            best_node: str | None = None
            best_dist = float("inf")
            best_coord: Coordinate | None = None
            for node_id, node_coord in node_list:
                d = coordinate_distance(pt, node_coord, method=distance_method)
                if max_distance is not None and d > max_distance:
                    continue
                if d < best_dist:
                    best_dist = d
                    best_node = node_id
                    best_coord = node_coord
            resolved = dict(pt_row)
            resolved[f"node_id_{snap_suffix}"] = best_node
            resolved[f"snap_distance_{snap_suffix}"] = best_dist if best_node is not None else None
            if best_coord is not None:
                resolved[points.geometry_column] = {"type": "Point", "coordinates": best_coord}
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=points.geometry_column, crs=points.crs or self.crs)

    # ------------------------------------------------------------------
    # Tool 33: origin_destination_matrix
    # ------------------------------------------------------------------
    def origin_destination_matrix(
        self,
        origins: "GeoPromptFrame",
        destinations: "GeoPromptFrame",
        origin_id_column: str = "site_id",
        destination_id_column: str = "site_id",
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        origin_node_column: str | None = None,
        destination_node_column: str | None = None,
        directed: bool = False,
        max_cost: float | None = None,
        od_suffix: str = "od",
    ) -> "GeoPromptFrame":
        for col in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(col)
        origins._require_column(origin_id_column)
        destinations._require_column(destination_id_column)
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        rows: list[Record] = []
        for origin_row in origins._rows:
            origin_node = self._resolve_network_node(
                origin_row.get(origin_node_column) if origin_node_column else geometry_centroid(origin_row[origins.geometry_column]),
                from_node_id_column, to_node_id_column, from_node_column, to_node_column,
            )
            distances: dict[str, float] = {origin_node: 0.0}
            queue: list[tuple[float, str]] = [(0.0, origin_node)]
            visited: set[str] = set()
            while queue:
                current_cost, current_node = heappop(queue)
                if current_node in visited:
                    continue
                visited.add(current_node)
                if max_cost is not None and current_cost > max_cost:
                    continue
                for next_node, edge_cost, _ in adjacency.get(current_node, []):
                    path_cost = current_cost + edge_cost
                    if path_cost < distances.get(next_node, float("inf")):
                        distances[next_node] = path_cost
                        heappush(queue, (path_cost, next_node))
            for dest_row in destinations._rows:
                dest_node = self._resolve_network_node(
                    dest_row.get(destination_node_column) if destination_node_column else geometry_centroid(dest_row[destinations.geometry_column]),
                    from_node_id_column, to_node_id_column, from_node_column, to_node_column,
                )
                cost = distances.get(dest_node)
                if max_cost is not None and cost is not None and cost > max_cost:
                    cost = None
                origin_centroid = geometry_centroid(origin_row[origins.geometry_column])
                dest_centroid = geometry_centroid(dest_row[destinations.geometry_column])
                rows.append({
                    f"origin_id_{od_suffix}": str(origin_row[origin_id_column]),
                    f"destination_id_{od_suffix}": str(dest_row[destination_id_column]),
                    f"network_cost_{od_suffix}": cost,
                    f"reachable_{od_suffix}": cost is not None,
                    self.geometry_column: {"type": "LineString", "coordinates": (origin_centroid, dest_centroid)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 34: k_shortest_paths
    # ------------------------------------------------------------------
    def k_shortest_paths(
        self,
        origin: str | Coordinate,
        destination: str | Coordinate,
        k: int = 3,
        edge_id_column: str = "edge_id",
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        directed: bool = False,
        ksp_suffix: str = "ksp",
    ) -> "GeoPromptFrame":
        if k <= 0:
            raise ValueError("k must be greater than zero")
        for col in (edge_id_column, from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(col)
        origin_node = self._resolve_network_node(origin, from_node_id_column, to_node_id_column, from_node_column, to_node_column)
        dest_node = self._resolve_network_node(destination, from_node_id_column, to_node_id_column, from_node_column, to_node_column)
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        queue: list[tuple[float, str, tuple[int, ...], frozenset[str]]] = [
            (0.0, origin_node, (), frozenset({origin_node}))
        ]
        found_paths: list[tuple[float, tuple[int, ...]]] = []
        seen_paths: set[tuple[int, ...]] = set()
        while queue and len(found_paths) < k:
            path_cost, node_id, edge_path, visited_nodes = heappop(queue)
            if node_id == dest_node:
                if edge_path not in seen_paths:
                    seen_paths.add(edge_path)
                    found_paths.append((path_cost, edge_path))
                continue
            for next_node, edge_cost, edge_index in adjacency.get(node_id, []):
                if next_node in visited_nodes:
                    continue
                heappush(
                    queue,
                    (
                        path_cost + edge_cost,
                        next_node,
                        (*edge_path, edge_index),
                        visited_nodes | {next_node},
                    ),
                )
        if not found_paths:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        rows: list[Record] = []
        for path_rank, (path_cost, path_edges) in enumerate(found_paths, start=1):
            for step, edge_idx in enumerate(path_edges, start=1):
                resolved = dict(self._rows[edge_idx])
                resolved[f"path_rank_{ksp_suffix}"] = path_rank
                resolved[f"step_{ksp_suffix}"] = step
                resolved[f"total_cost_{ksp_suffix}"] = path_cost
                resolved[f"edge_count_{ksp_suffix}"] = len(path_edges)
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def _dijkstra_path(
        self,
        origin_node: str,
        dest_node: str,
        adjacency: dict[str, list[tuple[str, float, int]]],
    ) -> tuple[float, list[int]] | None:
        distances: dict[str, float] = {origin_node: 0.0}
        previous: dict[str, tuple[str, int]] = {}
        queue: list[tuple[float, str]] = [(0.0, origin_node)]
        visited: set[str] = set()
        while queue:
            cost, node = heappop(queue)
            if node in visited:
                continue
            visited.add(node)
            if node == dest_node:
                break
            for next_node, edge_cost, edge_idx in adjacency.get(node, []):
                new_cost = cost + edge_cost
                if new_cost < distances.get(next_node, float("inf")):
                    distances[next_node] = new_cost
                    previous[next_node] = (node, edge_idx)
                    heappush(queue, (new_cost, next_node))
        if dest_node not in distances:
            return None
        edges: list[int] = []
        current = dest_node
        while current != origin_node:
            prev_node, edge_idx = previous[current]
            edges.append(edge_idx)
            current = prev_node
        edges.reverse()
        return (distances[dest_node], edges)

    # ------------------------------------------------------------------
    # Tool 35: network_trace
    # ------------------------------------------------------------------
    def network_trace(
        self,
        start: str | Coordinate,
        direction: Literal["upstream", "downstream", "both"] = "both",
        max_cost: float | None = None,
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        trace_suffix: str = "trace",
    ) -> "GeoPromptFrame":
        for col in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(col)
        start_node = self._resolve_network_node(start, from_node_id_column, to_node_id_column, from_node_column, to_node_column)
        forward_adj: dict[str, list[tuple[str, float, int]]] = {}
        reverse_adj: dict[str, list[tuple[str, float, int]]] = {}
        for idx, row in enumerate(self._rows):
            from_node = str(row[from_node_id_column])
            to_node = str(row[to_node_id_column])
            cost = float(row[cost_column])
            forward_adj.setdefault(from_node, []).append((to_node, cost, idx))
            reverse_adj.setdefault(to_node, []).append((from_node, cost, idx))
        traced_edges: set[int] = set()
        edge_costs: dict[int, float] = {}
        if direction in ("downstream", "both"):
            self._bfs_trace(start_node, forward_adj, max_cost, traced_edges, edge_costs)
        if direction in ("upstream", "both"):
            self._bfs_trace(start_node, reverse_adj, max_cost, traced_edges, edge_costs)
        rows: list[Record] = []
        for edge_idx in sorted(traced_edges):
            resolved = dict(self._rows[edge_idx])
            resolved[f"trace_cost_{trace_suffix}"] = edge_costs.get(edge_idx, 0.0)
            resolved[f"direction_{trace_suffix}"] = direction
            resolved[f"start_node_{trace_suffix}"] = start_node
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    @staticmethod
    def _bfs_trace(
        start_node: str,
        adjacency: dict[str, list[tuple[str, float, int]]],
        max_cost: float | None,
        traced_edges: set[int],
        edge_costs: dict[int, float],
    ) -> None:
        visited: set[str] = set()
        queue: list[tuple[float, str]] = [(0.0, start_node)]
        while queue:
            cost, node = heappop(queue)
            if node in visited:
                continue
            visited.add(node)
            for next_node, edge_cost, edge_idx in adjacency.get(node, []):
                new_cost = cost + edge_cost
                if max_cost is not None and new_cost > max_cost:
                    continue
                traced_edges.add(edge_idx)
                edge_costs[edge_idx] = min(edge_costs.get(edge_idx, float("inf")), new_cost)
                if next_node not in visited:
                    heappush(queue, (new_cost, next_node))

    # ------------------------------------------------------------------
    # Tool 36: route_sequence_optimize (greedy TSP)
    # ------------------------------------------------------------------
    def route_sequence_optimize(
        self,
        stops: "GeoPromptFrame",
        stop_id_column: str = "site_id",
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        stop_node_column: str | None = None,
        directed: bool = False,
        route_suffix: str = "route",
    ) -> "GeoPromptFrame":
        for col in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(col)
        stops._require_column(stop_id_column)
        stop_count = len(stops._rows)
        if stop_count == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        stop_nodes: list[str] = []
        for stop_row in stops._rows:
            node = self._resolve_network_node(
                stop_row.get(stop_node_column) if stop_node_column else geometry_centroid(stop_row[stops.geometry_column]),
                from_node_id_column, to_node_id_column, from_node_column, to_node_column,
            )
            stop_nodes.append(node)
        cost_matrix: list[list[float]] = []
        path_matrix: list[list[list[int] | None]] = []
        for i in range(stop_count):
            dists, paths = self._dijkstra_all(stop_nodes[i], adjacency)
            row_costs: list[float] = []
            row_paths: list[list[int] | None] = []
            for j in range(stop_count):
                row_costs.append(dists.get(stop_nodes[j], float("inf")))
                if stop_nodes[j] in dists:
                    edge_list: list[int] = []
                    current = stop_nodes[j]
                    while current != stop_nodes[i]:
                        prev_node, edge_idx = paths[current]
                        edge_list.append(edge_idx)
                        current = prev_node
                    edge_list.reverse()
                    row_paths.append(edge_list)
                else:
                    row_paths.append(None)
            cost_matrix.append(row_costs)
            path_matrix.append(row_paths)
        visited_set = {0}
        sequence = [0]
        current = 0
        reachable_sequence = {0}
        for _ in range(stop_count - 1):
            best_next: int | None = None
            best_cost = float("inf")
            for j in range(stop_count):
                if j not in visited_set and cost_matrix[current][j] < best_cost:
                    best_cost = cost_matrix[current][j]
                    best_next = j
            if best_next is None:
                break
            visited_set.add(best_next)
            sequence.append(best_next)
            current = best_next
            reachable_sequence.add(best_next)
        reachable_route = _two_opt_open_path(sequence, cost_matrix)
        total_cost = _path_sequence_cost(reachable_route, cost_matrix)
        sequence = list(reachable_route)
        visited_set = set(sequence)
        reachable_sequence = set(sequence)
        for stop_idx in range(stop_count):
            if stop_idx not in visited_set:
                sequence.append(stop_idx)
        rows: list[Record] = []
        for order, stop_idx in enumerate(sequence, start=1):
            resolved = dict(stops._rows[stop_idx])
            resolved[f"visit_order_{route_suffix}"] = order
            resolved[f"stop_node_{route_suffix}"] = stop_nodes[stop_idx]
            resolved[f"total_cost_{route_suffix}"] = total_cost
            resolved[f"stop_count_{route_suffix}"] = len(sequence)
            resolved[f"reachable_{route_suffix}"] = stop_idx in reachable_sequence
            resolved[f"method_{route_suffix}"] = "greedy_2opt"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=stops.geometry_column, crs=self.crs or stops.crs)

    def _dijkstra_all(
        self,
        origin_node: str,
        adjacency: dict[str, list[tuple[str, float, int]]],
    ) -> tuple[dict[str, float], dict[str, tuple[str, int]]]:
        distances: dict[str, float] = {origin_node: 0.0}
        previous: dict[str, tuple[str, int]] = {}
        queue: list[tuple[float, str]] = [(0.0, origin_node)]
        visited: set[str] = set()
        while queue:
            cost, node = heappop(queue)
            if node in visited:
                continue
            visited.add(node)
            for next_node, edge_cost, edge_idx in adjacency.get(node, []):
                new_cost = cost + edge_cost
                if new_cost < distances.get(next_node, float("inf")):
                    distances[next_node] = new_cost
                    previous[next_node] = (node, edge_idx)
                    heappush(queue, (new_cost, next_node))
        return distances, previous

    # ------------------------------------------------------------------
    # Tool 37: trajectory_staypoint_detection
    # ------------------------------------------------------------------
    def trajectory_staypoint_detection(
        self,
        time_column: str,
        id_column: str = "site_id",
        min_duration: float = 0.0,
        max_radius: float = 0.01,
        distance_method: str = "euclidean",
        staypoint_suffix: str = "staypoint",
    ) -> "GeoPromptFrame":
        self._require_column(time_column)
        self._require_column(id_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        ordered_indexes = sorted(
            range(len(self._rows)),
            key=lambda index: (float(self._rows[index].get(time_column, 0) or 0), index),
        )
        ordered_rows = [self._rows[index] for index in ordered_indexes]
        centroids = self._centroids(ordered_rows, self.geometry_column)
        rows: list[Record] = []
        staypoint_id = 0
        i = 0
        while i < len(ordered_rows):
            anchor = centroids[i]
            j = i + 1
            while j < len(ordered_rows):
                d = coordinate_distance(anchor, centroids[j], method=distance_method)
                if d > max_radius:
                    break
                j += 1
            t_start = float(ordered_rows[i].get(time_column, 0) or 0)
            t_end = float(ordered_rows[j - 1].get(time_column, 0) or 0)
            duration = t_end - t_start
            is_staypoint = (j - i) >= 2 and duration >= min_duration
            if is_staypoint:
                staypoint_id += 1
                mean_x = sum(centroids[k][0] for k in range(i, j)) / (j - i)
                mean_y = sum(centroids[k][1] for k in range(i, j)) / (j - i)
                for k in range(i, j):
                    resolved = dict(ordered_rows[k])
                    resolved[f"staypoint_id_{staypoint_suffix}"] = staypoint_id
                    resolved[f"is_staypoint_{staypoint_suffix}"] = True
                    resolved[f"duration_{staypoint_suffix}"] = duration
                    resolved[f"point_count_{staypoint_suffix}"] = j - i
                    resolved[f"center_x_{staypoint_suffix}"] = mean_x
                    resolved[f"center_y_{staypoint_suffix}"] = mean_y
                    rows.append(resolved)
                i = j
            else:
                resolved = dict(ordered_rows[i])
                resolved[f"staypoint_id_{staypoint_suffix}"] = None
                resolved[f"is_staypoint_{staypoint_suffix}"] = False
                resolved[f"duration_{staypoint_suffix}"] = 0.0
                resolved[f"point_count_{staypoint_suffix}"] = 1
                rows.append(resolved)
                i += 1
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 38: trajectory_simplify (Ramer-Douglas-Peucker on trajectory)
    # ------------------------------------------------------------------
    def trajectory_simplify(
        self,
        tolerance: float = 0.001,
        time_column: str | None = None,
        simplify_suffix: str = "traj_simplified",
    ) -> "GeoPromptFrame":
        if tolerance < 0:
            raise ValueError("tolerance must be zero or greater")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        ordered_indexes = list(range(len(self._rows)))
        if time_column is not None:
            self._require_column(time_column)
            ordered_indexes.sort(key=lambda index: (float(self._rows[index].get(time_column, 0) or 0), index))
        ordered_rows = [self._rows[index] for index in ordered_indexes]
        centroids = self._centroids(ordered_rows, self.geometry_column)
        keep_indices = set(_douglas_peucker_indices(centroids, tolerance))
        rows: list[Record] = []
        for i in keep_indices:
            resolved = dict(ordered_rows[i])
            resolved[f"kept_{simplify_suffix}"] = True
            resolved[f"original_index_{simplify_suffix}"] = ordered_indexes[i]
            rows.append(resolved)
        if time_column is not None:
            rows.sort(key=lambda row: (float(row.get(time_column, 0) or 0), int(row[f"original_index_{simplify_suffix}"])))
        else:
            rows.sort(key=lambda row: int(row[f"original_index_{simplify_suffix}"]))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 39: spatiotemporal_cube
    # ------------------------------------------------------------------
    def spatiotemporal_cube(
        self,
        time_column: str,
        value_column: str,
        time_intervals: int = 5,
        grid_resolution: int = 10,
        aggregation: AggregationName = "count",
        distance_method: str = "euclidean",
        cube_suffix: str = "cube",
    ) -> "GeoPromptFrame":
        self._require_column(time_column)
        if time_intervals <= 0:
            raise ValueError("time_intervals must be greater than zero")
        if grid_resolution <= 0:
            raise ValueError("grid_resolution must be greater than zero")
        if aggregation != "count":
            self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        times = [float(row.get(time_column, 0) or 0) for row in self._rows]
        t_min, t_max = min(times), max(times)
        t_step = (t_max - t_min) / time_intervals if t_max > t_min else 1.0
        b = self.bounds()
        dx = (b.max_x - b.min_x) / grid_resolution
        dy = (b.max_y - b.min_y) / grid_resolution
        centroids = self._centroids()
        buckets: dict[tuple[int, int, int], list[float]] = {}
        for row_index, centroid in enumerate(centroids):
            time_value = times[row_index]
            if t_max > t_min:
                time_bin = min(int((time_value - t_min) / t_step), time_intervals - 1)
            else:
                time_bin = 0
            if dx > 0.0:
                grid_col = min(int((centroid[0] - b.min_x) / dx), grid_resolution - 1)
            else:
                grid_col = 0
            if dy > 0.0:
                grid_row = min(int((centroid[1] - b.min_y) / dy), grid_resolution - 1)
            else:
                grid_row = 0
            value = self._rows[row_index].get(value_column)
            buckets.setdefault((time_bin, grid_row, grid_col), []).append(float(value) if value is not None else 0.0)
        rows: list[Record] = []
        cell_id = 0
        for ti, gy, gx in sorted(buckets):
            t_lo = t_min + ti * t_step
            t_hi = t_lo + t_step
            matched_vals = buckets[(ti, gy, gx)]
            if not matched_vals and aggregation != "count":
                continue
            px = b.min_x + gx * dx + dx / 2.0 if dx > 0.0 else b.min_x
            py = b.min_y + gy * dy + dy / 2.0 if dy > 0.0 else b.min_y
            agg_val: float | None
            if aggregation == "count":
                agg_val = float(len(matched_vals))
            elif aggregation == "sum":
                agg_val = sum(matched_vals) if matched_vals else None
            elif aggregation == "mean":
                agg_val = (sum(matched_vals) / len(matched_vals)) if matched_vals else None
            elif aggregation == "min":
                agg_val = min(matched_vals) if matched_vals else None
            elif aggregation == "max":
                agg_val = max(matched_vals) if matched_vals else None
            else:
                agg_val = float(len(matched_vals))
            if aggregation == "count" and agg_val == 0.0:
                continue
            cell_id += 1
            rows.append({
                f"cell_id_{cube_suffix}": f"cube-{cell_id:04d}",
                f"time_bin_{cube_suffix}": ti,
                f"time_start_{cube_suffix}": t_lo,
                f"time_end_{cube_suffix}": t_hi,
                f"grid_row_{cube_suffix}": gy,
                f"grid_col_{cube_suffix}": gx,
                f"value_{cube_suffix}": agg_val,
                f"point_count_{cube_suffix}": len(matched_vals),
                self.geometry_column: {"type": "Point", "coordinates": (px, py)},
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 40: geohash_encode
    # ------------------------------------------------------------------
    def geohash_encode(
        self,
        precision: int = 6,
        geohash_suffix: str = "geohash",
    ) -> "GeoPromptFrame":
        if precision < 1 or precision > 12:
            raise ValueError("precision must be between 1 and 12")
        centroids = self._centroids()
        rows: list[Record] = []
        for i, row in enumerate(self._rows):
            lon, lat = centroids[i]
            hash_val = _geohash_encode(lat, lon, precision)
            resolved = dict(row)
            resolved[f"hash_{geohash_suffix}"] = hash_val
            resolved[f"precision_{geohash_suffix}"] = precision
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 41: nearest_neighbor_distance
    # ------------------------------------------------------------------
    def nearest_neighbor_distance(
        self,
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        nn_suffix: str = "nn",
    ) -> "GeoPromptFrame":
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        rows: list[Record] = []
        # Use KDTree for O(n log n) when available and euclidean
        if distance_method == "euclidean" and n >= 5:
            try:
                scipy_spatial = importlib.import_module("scipy.spatial")
                np = importlib.import_module("numpy")
                pts = np.array(centroids, dtype=float)
                tree = scipy_spatial.KDTree(pts)
                dists, idxs = tree.query(pts, k=2)
                for i in range(n):
                    resolved = dict(self._rows[i])
                    nn_idx = int(idxs[i][1])
                    resolved[f"distance_{nn_suffix}"] = float(dists[i][1])
                    resolved[f"neighbor_id_{nn_suffix}"] = str(self._rows[nn_idx].get(id_column, nn_idx))
                    rows.append(resolved)
                return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
            except ImportError:
                pass
        for i in range(n):
            min_dist = float("inf")
            nn_id: str | None = None
            for j in range(n):
                if i == j:
                    continue
                d = coordinate_distance(centroids[i], centroids[j], method=distance_method)
                if d < min_dist:
                    min_dist = d
                    nn_id = str(self._rows[j].get(id_column, j))
            resolved = dict(self._rows[i])
            resolved[f"distance_{nn_suffix}"] = min_dist if min_dist < float("inf") else None
            resolved[f"neighbor_id_{nn_suffix}"] = nn_id
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 42: pairwise_distances
    # ------------------------------------------------------------------
    def pairwise_distances(
        self,
        other: "GeoPromptFrame | None" = None,
        id_column: str = "site_id",
        max_distance: float | None = None,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        target = other if other is not None else self
        centroids_a = self._centroids()
        centroids_b = target._centroids()
        results: list[Record] = []
        for i, ca in enumerate(centroids_a):
            for j, cb in enumerate(centroids_b):
                if other is None and j <= i:
                    continue
                d = coordinate_distance(ca, cb, method=distance_method)
                if max_distance is not None and d > max_distance:
                    continue
                results.append({
                    "origin_id": self._rows[i].get(id_column, i),
                    "destination_id": target._rows[j].get(id_column, j),
                    "distance": d,
                })
        return results

    # ------------------------------------------------------------------
    # Tool 43: point_density
    # ------------------------------------------------------------------
    def point_density(
        self,
        search_radius: float,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        density_suffix: str = "ptdensity",
    ) -> "GeoPromptFrame":
        if search_radius <= 0:
            raise ValueError("search_radius must be greater than zero")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        area = math.pi * search_radius * search_radius
        use_euclidean = distance_method == "euclidean"
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                count = 0
                for c in centroids:
                    d = math.hypot(px - c[0], py - c[1]) if use_euclidean else coordinate_distance((px, py), c, method=distance_method)
                    if d <= search_radius:
                        count += 1
                cell_id += 1
                rows.append({
                    f"cell_id_{density_suffix}": f"pd-{cell_id:04d}",
                    f"count_{density_suffix}": count,
                    f"density_{density_suffix}": count / area,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 44: line_length
    # ------------------------------------------------------------------
    def line_length(
        self,
        distance_method: str = "euclidean",
        length_suffix: str = "length",
    ) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            geom = row[self.geometry_column]
            gt = geometry_type(geom)
            if gt == "LineString":
                coords = [_as_coordinate(c) for c in geom["coordinates"]]
                resolved[f"value_{length_suffix}"] = _polyline_length(coords, method=distance_method)
            elif gt == "MultiLineString":
                total = 0.0
                for part in geom["coordinates"]:
                    coords = [_as_coordinate(c) for c in part]
                    total += _polyline_length(coords, method=distance_method)
                resolved[f"value_{length_suffix}"] = total
            else:
                resolved[f"value_{length_suffix}"] = 0.0
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 45: polygon_area
    # ------------------------------------------------------------------
    def polygon_area(
        self,
        area_suffix: str = "area",
    ) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            resolved[f"value_{area_suffix}"] = geometry_area(row[self.geometry_column])
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 46: polygon_perimeter
    # ------------------------------------------------------------------
    def polygon_perimeter(
        self,
        distance_method: str = "euclidean",
        perimeter_suffix: str = "perimeter",
    ) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            geom = row[self.geometry_column]
            gt = geometry_type(geom)
            if gt == "Polygon":
                ring = [_as_coordinate(c) for c in geom["coordinates"]]
                resolved[f"value_{perimeter_suffix}"] = _polyline_length(ring, method=distance_method)
            elif gt == "MultiPolygon":
                total = 0.0
                for poly in geom["coordinates"]:
                    ring = [_as_coordinate(c) for c in poly]
                    total += _polyline_length(ring, method=distance_method)
                resolved[f"value_{perimeter_suffix}"] = total
            else:
                resolved[f"value_{perimeter_suffix}"] = 0.0
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 47: average_nearest_neighbor (Clark-Evans R)
    # ------------------------------------------------------------------
    def average_nearest_neighbor(
        self,
        distance_method: str = "euclidean",
    ) -> Record:
        n = len(self._rows)
        if n < 2:
            return {"observed_mean_distance": None, "expected_mean_distance": None, "r_ratio": None, "z_score": None, "p_value": None, "point_count": n}
        centroids = self._centroids()
        nn_dists: list[float] = []
        for i in range(n):
            min_d = float("inf")
            for j in range(n):
                if i == j:
                    continue
                d = coordinate_distance(centroids[i], centroids[j], method=distance_method)
                if d < min_d:
                    min_d = d
            nn_dists.append(min_d)
        observed = sum(nn_dists) / n
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        expected = 0.5 / math.sqrt(n / area)
        r_ratio = observed / expected if expected > 0 else None
        se = 0.26136 / math.sqrt(n * n / area)
        z_score = (observed - expected) / se if se > 0 else 0.0
        p_value = 2.0 * (1.0 - _normal_cdf(abs(z_score)))
        return {"observed_mean_distance": observed, "expected_mean_distance": expected, "r_ratio": r_ratio, "z_score": z_score, "p_value": p_value, "point_count": n}

    # ------------------------------------------------------------------
    # Tool 48: mean_center
    # ------------------------------------------------------------------
    def mean_center(
        self,
        weight_column: str | None = None,
    ) -> Coordinate:
        if not self._rows:
            return (0.0, 0.0)
        centroids = self._centroids()
        if weight_column is not None:
            weights = [float(row.get(weight_column, 1.0) or 1.0) for row in self._rows]
            w_sum = sum(weights)
            if w_sum <= 0:
                return (sum(c[0] for c in centroids) / len(centroids), sum(c[1] for c in centroids) / len(centroids))
            return (sum(w * c[0] for w, c in zip(weights, centroids)) / w_sum, sum(w * c[1] for w, c in zip(weights, centroids)) / w_sum)
        return (sum(c[0] for c in centroids) / len(centroids), sum(c[1] for c in centroids) / len(centroids))

    # ------------------------------------------------------------------
    # Tool 49: median_center (Weiszfeld iterative spatial median)
    # ------------------------------------------------------------------
    def median_center(
        self,
        weight_column: str | None = None,
        max_iterations: int = 200,
        tolerance: float = 1e-8,
        distance_method: str = "euclidean",
    ) -> Coordinate:
        if not self._rows:
            return (0.0, 0.0)
        centroids = self._centroids()
        weights = [float(row.get(weight_column, 1.0) or 1.0) for row in self._rows] if weight_column else [1.0] * len(centroids)
        cx = sum(w * c[0] for w, c in zip(weights, centroids)) / max(sum(weights), 1e-12)
        cy = sum(w * c[1] for w, c in zip(weights, centroids)) / max(sum(weights), 1e-12)
        for _ in range(max_iterations):
            wx_sum = 0.0
            wy_sum = 0.0
            w_sum = 0.0
            for c, w in zip(centroids, weights):
                d = coordinate_distance((cx, cy), c, method=distance_method)
                if d < 1e-12:
                    continue
                inv_d = w / d
                wx_sum += inv_d * c[0]
                wy_sum += inv_d * c[1]
                w_sum += inv_d
            if w_sum < 1e-12:
                break
            nx = wx_sum / w_sum
            ny = wy_sum / w_sum
            if math.hypot(nx - cx, ny - cy) < tolerance:
                cx, cy = nx, ny
                break
            cx, cy = nx, ny
        return (cx, cy)

    # ------------------------------------------------------------------
    # Tool 50: directional_distribution (directional mean & circular variance)
    # ------------------------------------------------------------------
    def directional_distribution(
        self,
        angle_column: str | None = None,
        weight_column: str | None = None,
    ) -> Record:
        if not self._rows:
            return {"mean_direction": None, "circular_variance": None, "mean_resultant_length": None, "count": 0}
        if angle_column is not None:
            angles = [float(row.get(angle_column, 0) or 0) for row in self._rows]
        else:
            centroids = self._centroids()
            if len(centroids) < 2:
                return {"mean_direction": None, "circular_variance": None, "mean_resultant_length": None, "count": len(centroids)}
            mc = (sum(c[0] for c in centroids) / len(centroids), sum(c[1] for c in centroids) / len(centroids))
            angles = [math.degrees(math.atan2(c[0] - mc[0], c[1] - mc[1])) % 360.0 for c in centroids]
        weights = [float(row.get(weight_column, 1.0) or 1.0) for row in self._rows] if weight_column else [1.0] * len(angles)
        w_sum = sum(weights)
        if w_sum <= 0:
            return {"mean_direction": None, "circular_variance": None, "mean_resultant_length": None, "count": len(angles)}
        sin_sum = sum(w * math.sin(math.radians(a)) for w, a in zip(weights, angles))
        cos_sum = sum(w * math.cos(math.radians(a)) for w, a in zip(weights, angles))
        mean_direction = math.degrees(math.atan2(sin_sum, cos_sum)) % 360.0
        mean_resultant_length = math.sqrt(sin_sum * sin_sum + cos_sum * cos_sum) / w_sum
        circular_variance = 1.0 - mean_resultant_length
        return {"mean_direction": mean_direction, "circular_variance": circular_variance, "mean_resultant_length": mean_resultant_length, "count": len(angles)}

    # ------------------------------------------------------------------
    # Tool 51: quadrat_analysis (chi-square spatial randomness test)
    # ------------------------------------------------------------------
    def quadrat_analysis(
        self,
        rows_count: int = 4,
        cols_count: int = 4,
    ) -> Record:
        n = len(self._rows)
        if n == 0:
            return {"chi_square": None, "p_value": None, "degrees_of_freedom": None, "expected_count": None, "quadrat_count": 0, "point_count": 0}
        centroids = self._centroids()
        b = self.bounds()
        total_cells = rows_count * cols_count
        dx = (b.max_x - b.min_x) / cols_count if cols_count > 0 else 1.0
        dy = (b.max_y - b.min_y) / rows_count if rows_count > 0 else 1.0
        counts: dict[tuple[int, int], int] = {}
        for c in centroids:
            col = min(int((c[0] - b.min_x) / dx), cols_count - 1) if dx > 0 else 0
            row = min(int((c[1] - b.min_y) / dy), rows_count - 1) if dy > 0 else 0
            counts[(row, col)] = counts.get((row, col), 0) + 1
        expected = n / total_cells
        chi_sq = sum((counts.get((r, c), 0) - expected) ** 2 / expected for r in range(rows_count) for c in range(cols_count)) if expected > 0 else 0.0
        df = total_cells - 1
        p_value = _chi_square_p_value(chi_sq, df) if df > 0 else 1.0
        return {"chi_square": chi_sq, "p_value": p_value, "degrees_of_freedom": df, "expected_count": expected, "quadrat_count": total_cells, "point_count": n}

    # ------------------------------------------------------------------
    # Tool 52: ripleys_k (Ripley's K function)
    # ------------------------------------------------------------------
    def ripleys_k(
        self,
        distances: list[float] | None = None,
        steps: int = 10,
        distance_method: str = "euclidean",
        edge_correction: bool = True,
    ) -> list[Record]:
        n = len(self._rows)
        if n < 2:
            return []
        centroids = self._centroids()
        b = self.bounds()
        width = b.max_x - b.min_x
        height = b.max_y - b.min_y
        area = max(width * height, 1e-12)
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        max_d = max(max(row) for row in dist_matrix)
        if distances is None:
            step_size = max_d / steps if steps > 0 else max_d
            distances = [step_size * (i + 1) for i in range(steps)]
        results: list[Record] = []
        for d in distances:
            weighted_count = 0.0
            for i in range(n):
                xi, yi = centroids[i]
                for j in range(n):
                    if i == j:
                        continue
                    if dist_matrix[i][j] <= d:
                        if edge_correction:
                            # Ripley isotropic edge correction weight
                            dist_to_left = xi - b.min_x
                            dist_to_right = b.max_x - xi
                            dist_to_bottom = yi - b.min_y
                            dist_to_top = b.max_y - yi
                            dij = dist_matrix[i][j]
                            # Proportion of circle inside study area (simplified rectangular correction)
                            correction = 1.0
                            for edge_dist in [dist_to_left, dist_to_right, dist_to_bottom, dist_to_top]:
                                if dij > edge_dist > 0:
                                    correction -= math.acos(min(edge_dist / dij, 1.0)) / (2.0 * math.pi)
                            weighted_count += 1.0 / max(correction, 0.01)
                        else:
                            weighted_count += 1.0
            k_val = area * weighted_count / (n * n)
            l_val = math.sqrt(k_val / math.pi)
            results.append({"distance": d, "k_value": k_val, "l_value": l_val, "l_minus_d": l_val - d, "point_count": n, "pair_count": int(weighted_count), "edge_corrected": edge_correction})
        return results

    # ------------------------------------------------------------------
    # Tool 53: natural_neighbor_interpolation (Sibson weights via area stealing)
    # ------------------------------------------------------------------
    def natural_neighbor_interpolation(
        self,
        value_column: str,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        nn_interp_suffix: str = "nni",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        n = len(centroids)
        use_euclidean = distance_method == "euclidean"
        # Try true Voronoi area-stealing via scipy + shapely
        voronoi_nni = _natural_neighbor_voronoi(centroids, values, b, grid_resolution, dx, dy, self.geometry_column, nn_interp_suffix)
        if voronoi_nni is not None:
            return GeoPromptFrame._from_internal_rows(voronoi_nni, geometry_column=self.geometry_column, crs=self.crs)
        # Fallback: 1/d^2 Sibson-style approximation
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                dists = []
                for ci, c in enumerate(centroids):
                    d = math.hypot(px - c[0], py - c[1]) if use_euclidean else coordinate_distance((px, py), c, method=distance_method)
                    dists.append((ci, d))
                dists.sort(key=lambda x: x[1])
                if dists[0][1] < 1e-12:
                    predicted = values[dists[0][0]]
                else:
                    nearest_idx = dists[0][0]
                    d_nearest = dists[0][1]
                    cutoff = d_nearest * 3.0
                    candidates = [(ci, d) for ci, d in dists if d <= cutoff and d > 1e-12]
                    if not candidates:
                        candidates = dists[:min(n, 4)]
                        candidates = [(ci, d) for ci, d in candidates if d > 1e-12]
                    inv_d2 = [(ci, 1.0 / (d * d)) for ci, d in candidates]
                    w_sum = sum(w for _, w in inv_d2)
                    predicted = sum(values[ci] * w for ci, w in inv_d2) / w_sum if w_sum > 0 else 0.0
                cell_id += 1
                rows.append({
                    f"cell_id_{nn_interp_suffix}": f"nni-{cell_id:04d}",
                    f"value_{nn_interp_suffix}": predicted,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 54: spline_interpolation (thin-plate spline)
    # ------------------------------------------------------------------
    def spline_interpolation(
        self,
        value_column: str,
        grid_resolution: int = 20,
        regularization: float = 0.0,
        distance_method: str = "euclidean",
        spline_suffix: str = "spline",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        n = len(centroids)
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        K = [[thin_plate_spline_basis(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])) for j in range(n)] for i in range(n)]
        P = [[1.0, centroids[i][0], centroids[i][1]] for i in range(n)]
        size = n + 3
        A = [[0.0] * size for _ in range(size)]
        for i in range(n):
            for j in range(n):
                A[i][j] = K[i][j] + (regularization if i == j else 0.0)
            for j in range(3):
                A[i][n + j] = P[i][j]
                A[n + j][i] = P[i][j]
        rhs = values + [0.0, 0.0, 0.0]
        solution = _solve_linear_system(A, rhs)
        if solution is None:
            return self.idw_interpolation(value_column=value_column, grid_resolution=grid_resolution, distance_method=distance_method)
        w = solution[:n]
        a0, a1, a2 = solution[n], solution[n + 1], solution[n + 2]

        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                val = a0 + a1 * px + a2 * py
                for i in range(n):
                    r = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    val += w[i] * thin_plate_spline_basis(r)
                cell_id += 1
                rows.append({
                    f"cell_id_{spline_suffix}": f"spl-{cell_id:04d}",
                    f"value_{spline_suffix}": val,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 55: trend_surface (polynomial trend surface)
    # ------------------------------------------------------------------
    def trend_surface(
        self,
        value_column: str,
        order: int = 1,
        grid_resolution: int = 20,
        trend_suffix: str = "trend",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if order < 1 or order > 3:
            raise ValueError("order must be 1, 2, or 3")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        n = len(centroids)

        def _design_row(x: float, y: float) -> list[float]:
            row = [1.0, x, y]
            if order >= 2:
                row.extend([x * x, x * y, y * y])
            if order >= 3:
                row.extend([x * x * x, x * x * y, x * y * y, y * y * y])
            return row

        design = [_design_row(c[0], c[1]) for c in centroids]
        p = len(design[0])
        AtA = [[sum(design[k][i] * design[k][j] for k in range(n)) for j in range(p)] for i in range(p)]
        Aty = [sum(design[k][i] * values[k] for k in range(n)) for i in range(p)]
        coeffs = _solve_linear_system(AtA, Aty)
        if coeffs is None:
            return self.idw_interpolation(value_column=value_column, grid_resolution=grid_resolution)

        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                dr = _design_row(px, py)
                val = sum(c * d for c, d in zip(coeffs, dr))
                cell_id += 1
                rows.append({
                    f"cell_id_{trend_suffix}": f"trd-{cell_id:04d}",
                    f"value_{trend_suffix}": val,
                    f"order_{trend_suffix}": order,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 56: viewshed (binary viewshed from observer point)
    # ------------------------------------------------------------------
    def viewshed(
        self,
        elevation_column: str,
        observer: Coordinate,
        observer_height: float = 1.7,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        viewshed_suffix: str = "viewshed",
    ) -> "GeoPromptFrame":
        self._require_column(elevation_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx, dy = self._idw_grid(elevation_column, grid_resolution, distance_method)
        obs_col = max(0, min(grid_resolution - 1, int((observer[0] - b.min_x) / dx) if dx > 0 else 0))
        obs_row = max(0, min(grid_resolution - 1, int((observer[1] - b.min_y) / dy) if dy > 0 else 0))
        obs_elev = grid[obs_row][obs_col] + observer_height
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                cell_id += 1
                if gx == obs_col and gy == obs_row:
                    rows.append({f"cell_id_{viewshed_suffix}": f"vs-{cell_id:04d}", f"visible_{viewshed_suffix}": True, f"elevation_{viewshed_suffix}": grid[gy][gx], self.geometry_column: {"type": "Point", "coordinates": (px, py)}})
                    continue
                visible = True
                steps = max(abs(gx - obs_col), abs(gy - obs_row))
                for s in range(1, steps):
                    t = s / steps
                    ix = obs_col + t * (gx - obs_col)
                    iy = obs_row + t * (gy - obs_row)
                    ix0 = int(ix)
                    iy0 = int(iy)
                    ix0 = max(0, min(grid_resolution - 1, ix0))
                    iy0 = max(0, min(grid_resolution - 1, iy0))
                    intermediate_elev = grid[iy0][ix0]
                    target_elev = grid[gy][gx]
                    line_of_sight_elev = obs_elev + t * (target_elev - obs_elev)
                    if intermediate_elev > line_of_sight_elev:
                        visible = False
                        break
                rows.append({f"cell_id_{viewshed_suffix}": f"vs-{cell_id:04d}", f"visible_{viewshed_suffix}": visible, f"elevation_{viewshed_suffix}": grid[gy][gx], self.geometry_column: {"type": "Point", "coordinates": (px, py)}})
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 57: aspect_reclassify (classify aspect into compass directions)
    # ------------------------------------------------------------------
    def aspect_reclassify(
        self,
        aspect_column: str,
        num_classes: int = 8,
        aspect_reclass_suffix: str = "aspect_class",
    ) -> "GeoPromptFrame":
        self._require_column(aspect_column)
        labels_8 = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
        labels_4 = ["N", "E", "S", "W"]
        if num_classes == 4:
            labels = labels_4
        elif num_classes == 8:
            labels = labels_8
        else:
            raise ValueError("num_classes must be 4 or 8")
        bin_size = 360.0 / num_classes
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            aspect = float(row.get(aspect_column, 0) or 0) % 360.0
            idx = int((aspect + bin_size / 2) % 360 / bin_size)
            idx = min(idx, num_classes - 1)
            resolved[f"label_{aspect_reclass_suffix}"] = labels[idx]
            resolved[f"index_{aspect_reclass_suffix}"] = idx
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 58: euclidean_allocation
    # ------------------------------------------------------------------
    def euclidean_allocation(
        self,
        id_column: str = "site_id",
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        alloc_suffix: str = "ealloc",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        use_euclidean = distance_method == "euclidean"
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                best_i = 0
                best_d = float("inf")
                for ci, c in enumerate(centroids):
                    d = math.hypot(px - c[0], py - c[1]) if use_euclidean else coordinate_distance((px, py), c, method=distance_method)
                    if d < best_d:
                        best_d = d
                        best_i = ci
                cell_id += 1
                rows.append({
                    f"cell_id_{alloc_suffix}": f"ea-{cell_id:04d}",
                    f"source_id_{alloc_suffix}": self._rows[best_i].get(id_column, best_i),
                    f"distance_{alloc_suffix}": best_d,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 59: cost_distance (accumulated cost distance)
    # ------------------------------------------------------------------
    def cost_distance(
        self,
        cost_column: str,
        sources: "GeoPromptFrame",
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        cost_suffix: str = "costdist",
    ) -> "GeoPromptFrame":
        self._require_column(cost_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx_step, dy_step = self._idw_grid(cost_column, grid_resolution, distance_method)
        source_centroids = sources._centroids()
        INF = float("inf")
        cost_grid = [[INF] * grid_resolution for _ in range(grid_resolution)]
        heap: list[tuple[float, int, int]] = []
        for sc in source_centroids:
            col = max(0, min(grid_resolution - 1, int((sc[0] - b.min_x) / dx_step) if dx_step > 0 else 0))
            row = max(0, min(grid_resolution - 1, int((sc[1] - b.min_y) / dy_step) if dy_step > 0 else 0))
            if cost_grid[row][col] > 0.0:
                cost_grid[row][col] = 0.0
                heapq.heappush(heap, (0.0, row, col))
        cell_size = math.hypot(dx_step, dy_step) if dx_step > 0 or dy_step > 0 else 1.0
        neighbors_offsets = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
        diag = math.sqrt(2.0)
        while heap:
            cur_cost, r, c = heapq.heappop(heap)
            if cur_cost > cost_grid[r][c]:
                continue
            for dr, dc in neighbors_offsets:
                nr, nc = r + dr, c + dc
                if 0 <= nr < grid_resolution and 0 <= nc < grid_resolution:
                    step_dist = cell_size * (diag if (dr != 0 and dc != 0) else 1.0)
                    travel_cost = step_dist * 0.5 * (grid[r][c] + grid[nr][nc])
                    new_cost = cur_cost + travel_cost
                    if new_cost < cost_grid[nr][nc]:
                        cost_grid[nr][nc] = new_cost
                        heapq.heappush(heap, (new_cost, nr, nc))
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy_step
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx_step
                cell_id += 1
                rows.append({
                    f"cell_id_{cost_suffix}": f"cd-{cell_id:04d}",
                    f"cost_{cost_suffix}": cost_grid[gy][gx] if cost_grid[gy][gx] < INF else None,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 60: cost_allocation
    # ------------------------------------------------------------------
    def cost_allocation(
        self,
        cost_column: str,
        sources: "GeoPromptFrame",
        source_id_column: str = "site_id",
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        calloc_suffix: str = "calloc",
    ) -> "GeoPromptFrame":
        self._require_column(cost_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx_step, dy_step = self._idw_grid(cost_column, grid_resolution, distance_method)
        source_centroids = sources._centroids()
        source_ids = [str(row.get(source_id_column, i)) for i, row in enumerate(sources._rows)]
        INF = float("inf")
        cost_grid = [[INF] * grid_resolution for _ in range(grid_resolution)]
        alloc_grid = [[""] * grid_resolution for _ in range(grid_resolution)]
        heap: list[tuple[float, int, int]] = []
        for si, sc in enumerate(source_centroids):
            col = max(0, min(grid_resolution - 1, int((sc[0] - b.min_x) / dx_step) if dx_step > 0 else 0))
            row = max(0, min(grid_resolution - 1, int((sc[1] - b.min_y) / dy_step) if dy_step > 0 else 0))
            if cost_grid[row][col] > 0.0:
                cost_grid[row][col] = 0.0
                alloc_grid[row][col] = source_ids[si]
                heapq.heappush(heap, (0.0, row, col))
        cell_size = math.hypot(dx_step, dy_step) if dx_step > 0 or dy_step > 0 else 1.0
        neighbors_offsets = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
        diag = math.sqrt(2.0)
        while heap:
            cur_cost, r, c = heapq.heappop(heap)
            if cur_cost > cost_grid[r][c]:
                continue
            for dr, dc in neighbors_offsets:
                nr, nc = r + dr, c + dc
                if 0 <= nr < grid_resolution and 0 <= nc < grid_resolution:
                    step_dist = cell_size * (diag if (dr != 0 and dc != 0) else 1.0)
                    travel_cost = step_dist * 0.5 * (grid[r][c] + grid[nr][nc])
                    new_cost = cur_cost + travel_cost
                    if new_cost < cost_grid[nr][nc]:
                        cost_grid[nr][nc] = new_cost
                        alloc_grid[nr][nc] = alloc_grid[r][c]
                        heapq.heappush(heap, (new_cost, nr, nc))
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy_step
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx_step
                cell_id += 1
                rows.append({
                    f"cell_id_{calloc_suffix}": f"ca-{cell_id:04d}",
                    f"source_id_{calloc_suffix}": alloc_grid[gy][gx] or None,
                    f"cost_{calloc_suffix}": cost_grid[gy][gx] if cost_grid[gy][gx] < INF else None,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 61: near_table
    # ------------------------------------------------------------------
    def near_table(
        self,
        targets: "GeoPromptFrame",
        k: int = 1,
        max_distance: float | None = None,
        id_column: str = "site_id",
        target_id_column: str = "site_id",
        distance_method: str = "euclidean",
    ) -> list[Record]:
        origin_centroids = self._centroids()
        target_centroids = targets._centroids()
        results: list[Record] = []
        for i, oc in enumerate(origin_centroids):
            dists: list[tuple[int, float]] = []
            for j, tc in enumerate(target_centroids):
                d = coordinate_distance(oc, tc, method=distance_method)
                if max_distance is not None and d > max_distance:
                    continue
                dists.append((j, d))
            nearest = nsmallest(k, dists, key=lambda x: x[1])
            for rank, (j, d) in enumerate(nearest):
                angle = math.degrees(math.atan2(target_centroids[j][0] - oc[0], target_centroids[j][1] - oc[1])) % 360.0
                results.append({
                    "origin_id": self._rows[i].get(id_column, i),
                    "target_id": targets._rows[j].get(target_id_column, j),
                    "distance": d,
                    "angle": angle,
                    "rank": rank + 1,
                })
        return results

    # ------------------------------------------------------------------
    # Tool 62: point_distance_matrix
    # ------------------------------------------------------------------
    def point_distance_matrix(
        self,
        targets: "GeoPromptFrame",
        id_column: str = "site_id",
        target_id_column: str = "site_id",
        distance_method: str = "euclidean",
    ) -> list[Record]:
        origin_centroids = self._centroids()
        target_centroids = targets._centroids()
        results: list[Record] = []
        for i, oc in enumerate(origin_centroids):
            for j, tc in enumerate(target_centroids):
                results.append({
                    "origin_id": self._rows[i].get(id_column, i),
                    "target_id": targets._rows[j].get(target_id_column, j),
                    "distance": coordinate_distance(oc, tc, method=distance_method),
                })
        return results

    # ------------------------------------------------------------------
    # Tool 63: union_overlay
    # ------------------------------------------------------------------
    def union_overlay(
        self,
        other: "GeoPromptFrame",
        id_column: str = "site_id",
        other_id_column: str = "site_id",
        union_suffix: str = "union",
    ) -> "GeoPromptFrame":
        try:
            shapely = importlib.import_module("shapely")
            shapely_geometry = importlib.import_module("shapely.geometry")
        except ImportError:
            raise ImportError("union_overlay requires shapely")
        rows: list[Record] = []
        for row_a in self._rows:
            shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
            intersected = False
            for row_b in other._rows:
                shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
                if shape_a.intersects(shape_b):
                    intersected = True
                    inter = shape_a.intersection(shape_b)
                    if not inter.is_empty:
                        resolved = dict(row_a)
                        resolved[f"overlay_type_{union_suffix}"] = "intersection"
                        resolved[f"other_id_{union_suffix}"] = row_b.get(other_id_column)
                        resolved[self.geometry_column] = shapely_geometry.mapping(inter)
                        rows.append(resolved)
            diff = shape_a
            for row_b in other._rows:
                shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
                diff = diff.difference(shape_b)
            if not diff.is_empty:
                resolved = dict(row_a)
                resolved[f"overlay_type_{union_suffix}"] = "self_only"
                resolved[f"other_id_{union_suffix}"] = None
                resolved[self.geometry_column] = shapely_geometry.mapping(diff)
                rows.append(resolved)
        for row_b in other._rows:
            shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
            diff_b = shape_b
            for row_a in self._rows:
                shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
                diff_b = diff_b.difference(shape_a)
            if not diff_b.is_empty:
                resolved = dict(row_b)
                resolved[f"overlay_type_{union_suffix}"] = "other_only"
                resolved[f"other_id_{union_suffix}"] = row_b.get(other_id_column)
                resolved[self.geometry_column] = shapely_geometry.mapping(diff_b)
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    # ------------------------------------------------------------------
    # Tool 64: update_overlay
    # ------------------------------------------------------------------
    def update_overlay(
        self,
        update_layer: "GeoPromptFrame",
        update_suffix: str = "update",
    ) -> "GeoPromptFrame":
        try:
            shapely_geometry = importlib.import_module("shapely.geometry")
        except ImportError:
            raise ImportError("update_overlay requires shapely")
        update_union = None
        for row_b in update_layer._rows:
            shape_b = shapely_geometry.shape(_to_geojson(row_b[update_layer.geometry_column]))
            update_union = shape_b if update_union is None else update_union.union(shape_b)
        rows: list[Record] = []
        for row_a in self._rows:
            shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
            if update_union is not None:
                remaining = shape_a.difference(update_union)
            else:
                remaining = shape_a
            if not remaining.is_empty:
                resolved = dict(row_a)
                resolved[f"source_{update_suffix}"] = "original"
                resolved[self.geometry_column] = shapely_geometry.mapping(remaining)
                rows.append(resolved)
        for row_b in update_layer._rows:
            resolved = dict(row_b)
            resolved[f"source_{update_suffix}"] = "update"
            resolved[self.geometry_column] = row_b[update_layer.geometry_column]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or update_layer.crs)

    # ------------------------------------------------------------------
    # Tool 65: symmetrical_difference_overlay
    # ------------------------------------------------------------------
    def symmetrical_difference_overlay(
        self,
        other: "GeoPromptFrame",
        symdiff_suffix: str = "symdiff",
    ) -> "GeoPromptFrame":
        try:
            shapely_geometry = importlib.import_module("shapely.geometry")
        except ImportError:
            raise ImportError("symmetrical_difference_overlay requires shapely")
        rows: list[Record] = []
        for row_a in self._rows:
            shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
            diff = shape_a
            for row_b in other._rows:
                shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
                diff = diff.difference(shape_b)
            if not diff.is_empty:
                resolved = dict(row_a)
                resolved[f"source_{symdiff_suffix}"] = "self"
                resolved[self.geometry_column] = shapely_geometry.mapping(diff)
                rows.append(resolved)
        for row_b in other._rows:
            shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
            diff = shape_b
            for row_a in self._rows:
                shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
                diff = diff.difference(shape_a)
            if not diff.is_empty:
                resolved = dict(row_b)
                resolved[f"source_{symdiff_suffix}"] = "other"
                resolved[self.geometry_column] = shapely_geometry.mapping(diff)
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs or other.crs)

    # ------------------------------------------------------------------
    # Tool 66: spatial_selection
    # ------------------------------------------------------------------
    def spatial_selection(
        self,
        selector: "GeoPromptFrame",
        predicate: str = "intersects",
    ) -> "GeoPromptFrame":
        try:
            shapely_geometry = importlib.import_module("shapely.geometry")
        except ImportError:
            raise ImportError("spatial_selection requires shapely")
        if predicate not in {"intersects", "within", "contains"}:
            raise ValueError("predicate must be 'intersects', 'within', or 'contains'")
        rows: list[Record] = []
        for row_a in self._rows:
            shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
            for row_b in selector._rows:
                shape_b = shapely_geometry.shape(_to_geojson(row_b[selector.geometry_column]))
                if predicate == "intersects" and shape_a.intersects(shape_b):
                    rows.append(dict(row_a))
                    break
                elif predicate == "within" and shape_a.within(shape_b):
                    rows.append(dict(row_a))
                    break
                elif predicate == "contains" and shape_a.contains(shape_b):
                    rows.append(dict(row_a))
                    break
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 67: tabulate_intersection
    # ------------------------------------------------------------------
    def tabulate_intersection(
        self,
        other: "GeoPromptFrame",
        id_column: str = "site_id",
        other_id_column: str = "site_id",
    ) -> list[Record]:
        try:
            shapely_geometry = importlib.import_module("shapely.geometry")
        except ImportError:
            raise ImportError("tabulate_intersection requires shapely")
        results: list[Record] = []
        for row_a in self._rows:
            shape_a = shapely_geometry.shape(_to_geojson(row_a[self.geometry_column]))
            for row_b in other._rows:
                shape_b = shapely_geometry.shape(_to_geojson(row_b[other.geometry_column]))
                if shape_a.intersects(shape_b):
                    inter = shape_a.intersection(shape_b)
                    if not inter.is_empty:
                        results.append({
                            "self_id": row_a.get(id_column),
                            "other_id": row_b.get(other_id_column),
                            "intersection_area": inter.area,
                            "self_area": shape_a.area,
                            "other_area": shape_b.area,
                            "pct_of_self": (inter.area / shape_a.area * 100) if shape_a.area > 0 else 0.0,
                            "pct_of_other": (inter.area / shape_b.area * 100) if shape_b.area > 0 else 0.0,
                        })
        return results

    # ------------------------------------------------------------------
    # Tool 68: feature_envelope_to_polygon
    # ------------------------------------------------------------------
    def feature_envelope_to_polygon(
        self,
        envelope_suffix: str = "envelope",
    ) -> "GeoPromptFrame":
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            b = geometry_bounds(row[self.geometry_column])
            min_x, min_y, max_x, max_y = b
            resolved[self.geometry_column] = _rectangle_polygon(min_x, min_y, max_x, max_y)
            resolved[f"width_{envelope_suffix}"] = max_x - min_x
            resolved[f"height_{envelope_suffix}"] = max_y - min_y
            resolved[f"area_{envelope_suffix}"] = (max_x - min_x) * (max_y - min_y)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 69: network_partition (connected components)
    # ------------------------------------------------------------------
    def network_partition(
        self,
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        partition_suffix: str = "component",
    ) -> "GeoPromptFrame":
        self._require_column(from_node_id_column)
        self._require_column(to_node_id_column)
        adjacency: dict[str, set[str]] = {}
        for row in self._rows:
            fn = str(row[from_node_id_column])
            tn = str(row[to_node_id_column])
            adjacency.setdefault(fn, set()).add(tn)
            adjacency.setdefault(tn, set()).add(fn)
        all_nodes = set(adjacency.keys())
        visited: dict[str, int] = {}
        component_id = 0
        for start in sorted(all_nodes):
            if start in visited:
                continue
            component_id += 1
            stack = [start]
            while stack:
                node = stack.pop()
                if node in visited:
                    continue
                visited[node] = component_id
                for neighbor in adjacency.get(node, set()):
                    if neighbor not in visited:
                        stack.append(neighbor)
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            fn = str(row[from_node_id_column])
            resolved[f"id_{partition_suffix}"] = visited.get(fn, 0)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 70: network_centrality (betweenness and closeness)
    # ------------------------------------------------------------------
    def network_centrality(
        self,
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        cost_column: str = "edge_length",
        directed: bool = False,
        centrality_suffix: str = "centrality",
    ) -> list[Record]:
        self._require_column(from_node_id_column)
        self._require_column(to_node_id_column)
        self._require_column(cost_column)
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        nodes = sorted(adjacency.keys())
        n = len(nodes)
        if n == 0:
            return []
        betweenness: dict[str, float] = {nd: 0.0 for nd in nodes}
        closeness: dict[str, float] = {nd: 0.0 for nd in nodes}
        for source in nodes:
            dist: dict[str, float] = {source: 0.0}
            predecessors: dict[str, list[str]] = {source: []}
            sigma: dict[str, float] = {nd: 0.0 for nd in nodes}
            sigma[source] = 1.0
            stack: list[str] = []
            heap: list[tuple[float, str]] = [(0.0, source)]
            while heap:
                d, u = heapq.heappop(heap)
                if d > dist.get(u, float("inf")):
                    continue
                stack.append(u)
                for v, cost, _eidx in adjacency.get(u, []):
                    new_d = d + cost
                    if v not in dist or new_d < dist[v]:
                        dist[v] = new_d
                        sigma[v] = sigma[u]
                        predecessors[v] = [u]
                        heapq.heappush(heap, (new_d, v))
                    elif abs(new_d - dist.get(v, float("inf"))) < 1e-12:
                        sigma[v] += sigma[u]
                        predecessors.setdefault(v, []).append(u)
            total_dist = sum(dist.get(nd, float("inf")) for nd in nodes if nd != source)
            reachable = sum(1 for nd in nodes if nd != source and nd in dist)
            closeness[source] = reachable / total_dist if total_dist > 0 and reachable > 0 else 0.0
            delta: dict[str, float] = {nd: 0.0 for nd in nodes}
            while stack:
                w = stack.pop()
                for v in predecessors.get(w, []):
                    if sigma[w] > 0:
                        delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
                if w != source:
                    betweenness[w] += delta[w]
        if not directed and n > 1:
            for nd in nodes:
                betweenness[nd] /= 2.0
        norm = (n - 1) * (n - 2) if n > 2 else 1.0
        return [
            {"node_id": nd, f"betweenness_{centrality_suffix}": betweenness[nd] / norm if norm > 0 else 0.0, f"closeness_{centrality_suffix}": closeness[nd]}
            for nd in nodes
        ]

    # ------------------------------------------------------------------
    # Tool 71: network_isochrone (travel-time isochrone polygons)
    # ------------------------------------------------------------------
    def network_isochrone(
        self,
        origins: "GeoPromptFrame",
        cost_thresholds: list[float],
        from_node_id_column: str = "from_node_id",
        to_node_id_column: str = "to_node_id",
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "edge_length",
        origin_node_column: str | None = None,
        directed: bool = False,
        iso_suffix: str = "isochrone",
    ) -> "GeoPromptFrame":
        for col in (from_node_id_column, to_node_id_column, from_node_column, to_node_column, cost_column):
            self._require_column(col)
        adjacency = self._network_graph(from_node_id_column, to_node_id_column, cost_column, directed=directed)
        node_coords: dict[str, Coordinate] = {}
        for row in self._rows:
            fn = str(row[from_node_id_column])
            tn = str(row[to_node_id_column])
            fc = row[from_node_column]
            tc = row[to_node_column]
            if isinstance(fc, (list, tuple)):
                node_coords[fn] = _as_coordinate(fc)
            elif isinstance(fc, dict):
                node_coords[fn] = _as_coordinate(fc.get("coordinates", (0, 0)))
            if isinstance(tc, (list, tuple)):
                node_coords[tn] = _as_coordinate(tc)
            elif isinstance(tc, dict):
                node_coords[tn] = _as_coordinate(tc.get("coordinates", (0, 0)))
        sorted_thresholds = sorted(cost_thresholds)
        rows: list[Record] = []
        for origin_row in origins._rows:
            origin_node = self._resolve_network_node(
                origin_row.get(origin_node_column) if origin_node_column else geometry_centroid(origin_row[origins.geometry_column]),
                from_node_id_column, to_node_id_column, from_node_column, to_node_column,
            )
            dist: dict[str, float] = {origin_node: 0.0}
            heap: list[tuple[float, str]] = [(0.0, origin_node)]
            while heap:
                d, u = heapq.heappop(heap)
                if d > dist.get(u, float("inf")):
                    continue
                for v, cost, _eidx in adjacency.get(u, []):
                    new_d = d + cost
                    if new_d <= sorted_thresholds[-1] and (v not in dist or new_d < dist[v]):
                        dist[v] = new_d
                        heapq.heappush(heap, (new_d, v))
            for threshold in sorted_thresholds:
                reachable = [node_coords[nd] for nd in dist if dist[nd] <= threshold and nd in node_coords]
                if len(reachable) < 3:
                    polygon: Geometry = {"type": "Point", "coordinates": node_coords.get(origin_node, (0.0, 0.0))}
                else:
                    try:
                        shapely_geometry = importlib.import_module("shapely.geometry")
                        hull = shapely_geometry.MultiPoint(reachable).convex_hull
                        polygon = shapely_geometry.mapping(hull)
                    except ImportError:
                        xs = [c[0] for c in reachable]
                        ys = [c[1] for c in reachable]
                        polygon = _rectangle_polygon(min(xs), min(ys), max(xs), max(ys))
                rows.append({
                    f"origin_{iso_suffix}": origin_node,
                    f"threshold_{iso_suffix}": threshold,
                    f"reachable_count_{iso_suffix}": len(reachable),
                    self.geometry_column: polygon,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 72: minimum_spanning_tree
    # ------------------------------------------------------------------
    def minimum_spanning_tree(
        self,
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        mst_suffix: str = "mst",
    ) -> "GeoPromptFrame":
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        parent = list(range(n))
        rank = [0] * n

        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a: int, b: int) -> bool:
            ra, rb = find(a), find(b)
            if ra == rb:
                return False
            if rank[ra] < rank[rb]:
                ra, rb = rb, ra
            parent[rb] = ra
            if rank[ra] == rank[rb]:
                rank[ra] += 1
            return True

        edges: list[tuple[float, int, int]] = []
        for i in range(n):
            for j in range(i + 1, n):
                d = coordinate_distance(centroids[i], centroids[j], method=distance_method)
                edges.append((d, i, j))
        edges.sort()
        rows: list[Record] = []
        total_cost = 0.0
        for d, i, j in edges:
            if union(i, j):
                total_cost += d
                rows.append({
                    f"from_id_{mst_suffix}": self._rows[i].get(id_column, i),
                    f"to_id_{mst_suffix}": self._rows[j].get(id_column, j),
                    f"cost_{mst_suffix}": d,
                    f"total_cost_{mst_suffix}": total_cost,
                    self.geometry_column: {"type": "LineString", "coordinates": [centroids[i], centroids[j]]},
                })
                if len(rows) == n - 1:
                    break
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 73: traveling_salesman_nn (nearest-neighbor TSP)
    # ------------------------------------------------------------------
    def traveling_salesman_nn(
        self,
        id_column: str = "site_id",
        return_to_start: bool = True,
        distance_method: str = "euclidean",
        tsp_suffix: str = "tsp",
    ) -> "GeoPromptFrame":
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        visited = [False] * n
        order: list[int] = [0]
        visited[0] = True
        total = 0.0
        for _ in range(n - 1):
            cur = order[-1]
            best_j = -1
            best_d = float("inf")
            for j in range(n):
                if not visited[j] and dist_matrix[cur][j] < best_d:
                    best_d = dist_matrix[cur][j]
                    best_j = j
            order.append(best_j)
            visited[best_j] = True
            total += best_d
        if return_to_start:
            total += dist_matrix[order[-1]][order[0]]
        rows: list[Record] = []
        for visit_idx, i in enumerate(order):
            resolved = dict(self._rows[i])
            resolved[f"visit_order_{tsp_suffix}"] = visit_idx + 1
            resolved[f"total_cost_{tsp_suffix}"] = total
            resolved[f"return_to_start_{tsp_suffix}"] = return_to_start
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 74: dbscan_cluster
    # ------------------------------------------------------------------
    def dbscan_cluster(
        self,
        eps: float,
        min_samples: int = 5,
        distance_method: str = "euclidean",
        dbscan_suffix: str = "dbscan",
    ) -> "GeoPromptFrame":
        if eps <= 0:
            raise ValueError("eps must be greater than zero")
        if min_samples < 1:
            raise ValueError("min_samples must be at least 1")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        neighbors: list[list[int]] = []
        for i in range(n):
            neighbors.append([j for j in range(n) if j != i and dist_matrix[i][j] <= eps])
        labels = [-1] * n
        cluster_id = 0
        for i in range(n):
            if labels[i] != -1:
                continue
            if len(neighbors[i]) + 1 < min_samples:
                continue
            cluster_id += 1
            labels[i] = cluster_id
            seed_set = list(neighbors[i])
            si = 0
            while si < len(seed_set):
                q = seed_set[si]
                si += 1
                if labels[q] == -1:
                    labels[q] = cluster_id
                elif labels[q] != -1 and labels[q] != cluster_id:
                    continue
                else:
                    labels[q] = cluster_id
                if len(neighbors[q]) + 1 >= min_samples:
                    for nb in neighbors[q]:
                        if labels[nb] == -1:
                            seed_set.append(nb)
                            labels[nb] = cluster_id
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{dbscan_suffix}"] = labels[i] if labels[i] > 0 else None
            resolved[f"noise_{dbscan_suffix}"] = labels[i] <= 0
            resolved[f"neighbor_count_{dbscan_suffix}"] = len(neighbors[i])
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 75: hierarchical_cluster (agglomerative single-linkage)
    # ------------------------------------------------------------------
    def hierarchical_cluster(
        self,
        k: int = 3,
        distance_method: str = "euclidean",
        hclust_suffix: str = "hclust",
    ) -> "GeoPromptFrame":
        if k < 1:
            raise ValueError("k must be at least 1")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        cluster_assignment = list(range(n))
        num_clusters = n
        while num_clusters > k:
            best_d = float("inf")
            best_i, best_j = 0, 1
            for i in range(n):
                for j in range(i + 1, n):
                    if cluster_assignment[i] != cluster_assignment[j] and dist_matrix[i][j] < best_d:
                        best_d = dist_matrix[i][j]
                        best_i, best_j = i, j
            merge_from = cluster_assignment[best_j]
            merge_to = cluster_assignment[best_i]
            for idx in range(n):
                if cluster_assignment[idx] == merge_from:
                    cluster_assignment[idx] = merge_to
            num_clusters -= 1
        unique_labels = sorted(set(cluster_assignment))
        label_map = {old: new + 1 for new, old in enumerate(unique_labels)}
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{hclust_suffix}"] = label_map[cluster_assignment[i]]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 76: spatial_outlier_zscore
    # ------------------------------------------------------------------
    def spatial_outlier_zscore(
        self,
        value_column: str,
        threshold: float = 2.0,
        k: int = 8,
        distance_method: str = "euclidean",
        zscore_suffix: str = "zscore",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        global_mean = sum(values) / n
        global_std = math.sqrt(sum((v - global_mean) ** 2 for v in values) / n) if n > 1 else 0.0
        # Compute local spatial z-scores using k nearest neighbors
        centroids = self._centroids()
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        rows: list[Record] = []
        for i in range(n):
            if k_actual > 0:
                dists = [(j, distance_matrix[i][j]) for j in range(n) if i != j]
                neighbors = nsmallest(k_actual, dists, key=lambda x: x[1])
                neighbor_vals = [values[j] for j, _ in neighbors]
                local_mean = sum(neighbor_vals) / len(neighbor_vals)
                local_std = math.sqrt(sum((v - local_mean) ** 2 for v in neighbor_vals) / len(neighbor_vals)) if len(neighbor_vals) > 1 else 0.0
                local_z = (values[i] - local_mean) / local_std if local_std > 0 else ((values[i] - local_mean) / global_std if global_std > 0 else 0.0)
            else:
                local_mean = global_mean
                local_std = global_std
                local_z = (values[i] - global_mean) / global_std if global_std > 0 else 0.0
            global_z = (values[i] - global_mean) / global_std if global_std > 0 else 0.0
            resolved = dict(self._rows[i])
            resolved[f"z_score_{zscore_suffix}"] = local_z
            resolved[f"global_z_{zscore_suffix}"] = global_z
            resolved[f"outlier_{zscore_suffix}"] = abs(local_z) > threshold
            resolved[f"local_mean_{zscore_suffix}"] = local_mean
            resolved[f"local_std_{zscore_suffix}"] = local_std
            resolved[f"mean_{zscore_suffix}"] = global_mean
            resolved[f"std_{zscore_suffix}"] = global_std
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 77: jenks_natural_breaks
    # ------------------------------------------------------------------
    def jenks_natural_breaks(
        self,
        value_column: str,
        k: int = 5,
        jenks_suffix: str = "jenks",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if k < 2:
            raise ValueError("k must be at least 2")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        values = sorted(float(row.get(value_column, 0) or 0) for row in self._rows)
        k = min(k, n)
        mat1 = [[0] * (k + 1) for _ in range(n + 1)]
        mat2 = [[float("inf")] * (k + 1) for _ in range(n + 1)]
        for i in range(1, k + 1):
            mat1[1][i] = 1
            mat2[1][i] = 0.0
        v = 0.0
        for l in range(2, n + 1):
            s2 = 0.0
            s1 = 0.0
            for m in range(1, l + 1):
                i3 = l - m + 1
                val = values[i3 - 1]
                s2 += val * val
                s1 += val
                w = m
                v = s2 - (s1 * s1) / w
                if i3 > 1:
                    for j in range(2, k + 1):
                        if mat2[l][j] >= v + mat2[i3 - 1][j - 1]:
                            mat1[l][j] = i3
                            mat2[l][j] = v + mat2[i3 - 1][j - 1]
            mat1[l][1] = 1
            mat2[l][1] = v
        breaks = [values[0]]
        kk = k
        ll = n
        while kk >= 2:
            idx = int(mat1[ll][kk]) - 1
            breaks.insert(1, values[idx])
            ll = idx
            kk -= 1
        breaks.append(values[-1])
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            val = float(row.get(value_column, 0) or 0)
            cls = k
            for bi in range(1, len(breaks) - 1):
                if val < breaks[bi]:
                    cls = bi
                    break
            resolved[f"class_{jenks_suffix}"] = cls
            resolved[f"break_count_{jenks_suffix}"] = k
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 78: equal_interval_classify
    # ------------------------------------------------------------------
    def equal_interval_classify(
        self,
        value_column: str,
        k: int = 5,
        eqint_suffix: str = "eqint",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if k < 1:
            raise ValueError("k must be at least 1")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        v_min = min(values)
        v_max = max(values)
        interval = (v_max - v_min) / k if k > 0 and v_max > v_min else 1.0
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            val = float(row.get(value_column, 0) or 0)
            cls = min(int((val - v_min) / interval), k - 1) if interval > 0 else 0
            resolved[f"class_{eqint_suffix}"] = cls + 1
            resolved[f"break_count_{eqint_suffix}"] = k
            resolved[f"interval_{eqint_suffix}"] = interval
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 79: quantile_classify
    # ------------------------------------------------------------------
    def quantile_classify(
        self,
        value_column: str,
        k: int = 5,
        quantile_suffix: str = "quantile",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if k < 1:
            raise ValueError("k must be at least 1")
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        indexed_values = sorted(range(n), key=lambda i: float(self._rows[i].get(value_column, 0) or 0))
        class_map: dict[int, int] = {}
        per_class = n / k
        for rank, idx in enumerate(indexed_values):
            class_map[idx] = min(int(rank / per_class), k - 1) + 1
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"class_{quantile_suffix}"] = class_map[i]
            resolved[f"break_count_{quantile_suffix}"] = k
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 80: focal_statistics (moving window on grid)
    # ------------------------------------------------------------------
    def focal_statistics(
        self,
        value_column: str,
        grid_resolution: int = 20,
        window_size: int = 3,
        statistic: str = "mean",
        distance_method: str = "euclidean",
        focal_suffix: str = "focal",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if window_size < 1 or window_size % 2 == 0:
            raise ValueError("window_size must be a positive odd number")
        if statistic not in {"mean", "sum", "min", "max", "std"}:
            raise ValueError("statistic must be 'mean', 'sum', 'min', 'max', or 'std'")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid, b, dx, dy = self._idw_grid(value_column, grid_resolution, distance_method)
        half = window_size // 2
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                vals: list[float] = []
                for wy in range(max(0, gy - half), min(grid_resolution, gy + half + 1)):
                    for wx in range(max(0, gx - half), min(grid_resolution, gx + half + 1)):
                        vals.append(grid[wy][wx])
                if statistic == "mean":
                    result = sum(vals) / len(vals)
                elif statistic == "sum":
                    result = sum(vals)
                elif statistic == "min":
                    result = min(vals)
                elif statistic == "max":
                    result = max(vals)
                else:
                    m = sum(vals) / len(vals)
                    result = math.sqrt(sum((v - m) ** 2 for v in vals) / len(vals))
                cell_id += 1
                rows.append({
                    f"cell_id_{focal_suffix}": f"foc-{cell_id:04d}",
                    f"value_{focal_suffix}": result,
                    f"statistic_{focal_suffix}": statistic,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 81: zonal_histogram
    # ------------------------------------------------------------------
    def zonal_histogram(
        self,
        value_column: str,
        zones: "GeoPromptFrame",
        zone_id_column: str = "site_id",
        bins: int = 10,
    ) -> list[Record]:
        self._require_column(value_column)
        zones._require_column(zone_id_column)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        if not values:
            return []
        v_min = min(values)
        v_max = max(values)
        bin_width = (v_max - v_min) / bins if bins > 0 and v_max > v_min else 1.0
        centroids = self._centroids()
        results: list[Record] = []
        for zone_row in zones._rows:
            zone_id = zone_row.get(zone_id_column)
            zone_geom = zone_row[zones.geometry_column]
            zone_bounds = geometry_bounds(zone_geom)
            histogram = [0] * bins
            for ci, c in enumerate(centroids):
                if zone_bounds[0] <= c[0] <= zone_bounds[2] and zone_bounds[1] <= c[1] <= zone_bounds[3]:
                    bi = min(int((values[ci] - v_min) / bin_width), bins - 1) if bin_width > 0 else 0
                    histogram[bi] += 1
            for bi in range(bins):
                results.append({
                    "zone_id": zone_id, "bin_index": bi,
                    "bin_min": v_min + bi * bin_width, "bin_max": v_min + (bi + 1) * bin_width,
                    "count": histogram[bi],
                })
        return results

    # ------------------------------------------------------------------
    # Tool 82: raster_calculator
    # ------------------------------------------------------------------
    def raster_calculator(
        self,
        expression: str,
        columns: list[str],
        result_column: str = "calculated",
    ) -> "GeoPromptFrame":
        for col in columns:
            self._require_column(col)
        allowed_names = set(columns) | {"math", "abs", "min", "max", "pow", "sqrt", "log", "exp", "sin", "cos", "tan", "pi"}
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            local_vars: dict[str, Any] = {}
            for col in columns:
                local_vars[col] = float(row.get(col, 0) or 0)
            local_vars["math"] = math
            local_vars["abs"] = abs
            local_vars["min"] = min
            local_vars["max"] = max
            local_vars["pow"] = pow
            local_vars["sqrt"] = math.sqrt
            local_vars["log"] = math.log
            local_vars["exp"] = math.exp
            local_vars["sin"] = math.sin
            local_vars["cos"] = math.cos
            local_vars["tan"] = math.tan
            local_vars["pi"] = math.pi
            try:
                import ast
                tree = ast.parse(expression, mode="eval")
                for node in ast.walk(tree):
                    if isinstance(node, ast.Name) and node.id not in allowed_names:
                        raise ValueError(f"Disallowed name in expression: {node.id}")
                    if isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                        raise ValueError("Disallowed construct in expression")
                    if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
                        if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "math"):
                            raise ValueError("Only math.* calls are allowed")
                result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}}, local_vars)  # noqa: S307
                resolved[result_column] = float(result)
            except Exception:
                resolved[result_column] = None
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 83: aggregate_grid
    # ------------------------------------------------------------------
    def aggregate_grid(
        self,
        value_column: str,
        grid_resolution: int = 10,
        aggregation: str = "mean",
        distance_method: str = "euclidean",
        agg_suffix: str = "aggrid",
    ) -> "GeoPromptFrame":
        self._require_column(value_column)
        if aggregation not in {"mean", "sum", "min", "max", "count"}:
            raise ValueError("aggregation must be 'mean', 'sum', 'min', 'max', or 'count'")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / grid_resolution if grid_resolution > 0 else 1.0
        dy = (b.max_y - b.min_y) / grid_resolution if grid_resolution > 0 else 1.0
        bins: dict[tuple[int, int], list[float]] = {}
        for ci, c in enumerate(centroids):
            col = min(int((c[0] - b.min_x) / dx), grid_resolution - 1) if dx > 0 else 0
            row_idx = min(int((c[1] - b.min_y) / dy), grid_resolution - 1) if dy > 0 else 0
            bins.setdefault((row_idx, col), []).append(values[ci])
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + (gy + 0.5) * dy
            for gx in range(grid_resolution):
                px = b.min_x + (gx + 0.5) * dx
                cell_id += 1
                cell_vals = bins.get((gy, gx), [])
                if not cell_vals:
                    val = None
                elif aggregation == "mean":
                    val = sum(cell_vals) / len(cell_vals)
                elif aggregation == "sum":
                    val = sum(cell_vals)
                elif aggregation == "min":
                    val = min(cell_vals)
                elif aggregation == "max":
                    val = max(cell_vals)
                else:
                    val = len(cell_vals)
                rows.append({
                    f"cell_id_{agg_suffix}": f"ag-{cell_id:04d}",
                    f"value_{agg_suffix}": val,
                    f"count_{agg_suffix}": len(cell_vals),
                    f"aggregation_{agg_suffix}": aggregation,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 84: grid_to_polygons
    # ------------------------------------------------------------------
    def grid_to_polygons(
        self,
        grid_resolution: int = 20,
        value_column: str | None = None,
        distance_method: str = "euclidean",
        grid_poly_suffix: str = "gridpoly",
    ) -> "GeoPromptFrame":
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        b = self.bounds()
        dx = (b.max_x - b.min_x) / grid_resolution if grid_resolution > 0 else 1.0
        dy = (b.max_y - b.min_y) / grid_resolution if grid_resolution > 0 else 1.0
        if value_column:
            grid, _, _, _ = self._idw_grid(value_column, grid_resolution, distance_method)
        else:
            grid = None
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            for gx in range(grid_resolution):
                cell_id += 1
                min_x = b.min_x + gx * dx
                min_y = b.min_y + gy * dy
                max_x = min_x + dx
                max_y = min_y + dy
                rec: Record = {
                    f"cell_id_{grid_poly_suffix}": f"gp-{cell_id:04d}",
                    f"row_{grid_poly_suffix}": gy,
                    f"col_{grid_poly_suffix}": gx,
                    self.geometry_column: _rectangle_polygon(min_x, min_y, max_x, max_y),
                }
                if grid is not None:
                    rec[f"value_{grid_poly_suffix}"] = grid[gy][gx]
                rows.append(rec)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 85: random_points
    # ------------------------------------------------------------------
    @classmethod
    def random_points(
        cls,
        count: int,
        min_x: float = 0.0,
        min_y: float = 0.0,
        max_x: float = 1.0,
        max_y: float = 1.0,
        seed: int | None = None,
        crs: str | None = "EPSG:4326",
    ) -> "GeoPromptFrame":
        if count < 0:
            raise ValueError("count must be zero or greater")
        rng = random.Random(seed)
        rows: list[Record] = []
        for i in range(count):
            x = min_x + rng.random() * (max_x - min_x)
            y = min_y + rng.random() * (max_y - min_y)
            rows.append({
                "site_id": f"rp-{i + 1:04d}",
                "geometry": {"type": "Point", "coordinates": (x, y)},
            })
        return cls.from_records(rows, crs=crs)

    # ------------------------------------------------------------------
    # Tool 86: bivariate_morans_i
    # ------------------------------------------------------------------
    def bivariate_morans_i(
        self,
        x_column: str,
        y_column: str,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
    ) -> Record:
        """Bivariate Moran's I — spatial correlation between two variables."""
        self._require_column(x_column)
        self._require_column(y_column)
        n = len(self._rows)
        if n < 3:
            return {"I": 0.0, "expected_I": 0.0, "z_score": 0.0, "p_value": 1.0, "n": n}
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        x_vals = [float(row.get(x_column, 0) or 0) for row in self._rows]
        y_vals = [float(row.get(y_column, 0) or 0) for row in self._rows]
        x_mean = sum(x_vals) / n
        y_mean = sum(y_vals) / n
        x_dev = [v - x_mean for v in x_vals]
        y_dev = [v - y_mean for v in y_vals]
        sx2 = sum(d * d for d in x_dev)
        # Build spatial weights
        w_total = 0.0
        numerator = 0.0
        for i in range(n):
            if mode == "k_nearest":
                dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors = dists_i[:k]
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                neighbors = [(j, dist_matrix[i][j]) for j in range(n) if j != i and dist_matrix[i][j] <= md]
            else:
                neighbors = [(j, dist_matrix[i][j]) for j in range(n) if j != i]
            for j, _ in neighbors:
                w = 1.0
                w_total += w
                numerator += w * x_dev[i] * y_dev[j]
        I = (n / max(w_total, 1e-12)) * (numerator / max(sx2, 1e-12))
        expected = -1.0 / (n - 1)
        # Approximate z-score
        z_score = (I - expected) / max(abs(expected) * 0.5, 0.01)
        p_value = 2.0 * (1.0 - _normal_cdf(abs(z_score)))
        return {"I": I, "expected_I": expected, "z_score": z_score, "p_value": p_value, "n": n}

    # ------------------------------------------------------------------
    # Tool 87: local_gearys_c
    # ------------------------------------------------------------------
    def local_gearys_c(
        self,
        value_column: str,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        geary_suffix: str = "geary",
    ) -> "GeoPromptFrame":
        """Local Geary's C for each feature."""
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        mean_val = sum(values) / n
        variance = sum((v - mean_val) ** 2 for v in values) / n if n > 1 else 1.0
        rows: list[Record] = []
        for i in range(n):
            if mode == "k_nearest":
                dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors = dists_i[:k]
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                neighbors = [(j, dist_matrix[i][j]) for j in range(n) if j != i and dist_matrix[i][j] <= md]
            else:
                neighbors = [(j, dist_matrix[i][j]) for j in range(n) if j != i]
            w_sum = 0.0
            c_sum = 0.0
            for j, _ in neighbors:
                w = 1.0
                w_sum += w
                c_sum += w * (values[i] - values[j]) ** 2
            c_local = c_sum / max(w_sum * variance, 1e-12) if w_sum > 0 else 0.0
            resolved = dict(self._rows[i])
            resolved[f"c_local_{geary_suffix}"] = c_local
            resolved[f"neighbor_count_{geary_suffix}"] = len(neighbors)
            # c < 1 means positive spatial autocorrelation, c > 1 means negative
            if c_local < 0.5:
                resolved[f"cluster_type_{geary_suffix}"] = "positive_cluster"
            elif c_local > 1.5:
                resolved[f"cluster_type_{geary_suffix}"] = "spatial_outlier"
            else:
                resolved[f"cluster_type_{geary_suffix}"] = "random"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 88: loess_regression (locally weighted scatterplot smoothing)
    # ------------------------------------------------------------------
    def loess_regression(
        self,
        dependent_column: str,
        independent_column: str,
        fraction: float = 0.3,
        degree: int = 1,
        distance_method: str = "euclidean",
        loess_suffix: str = "loess",
    ) -> "GeoPromptFrame":
        """Spatially-weighted LOESS regression."""
        self._require_column(dependent_column)
        self._require_column(independent_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        y_vals = [float(row[dependent_column]) for row in self._rows]
        x_vals = [float(row[independent_column]) for row in self._rows]
        k = max(2, int(fraction * n))
        rows: list[Record] = []
        for i in range(n):
            dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n)), key=lambda t: t[1])
            neighbors = dists_i[:k]
            max_d = max(d for _, d in neighbors) if neighbors else 1.0
            max_d = max(max_d, 1e-12)
            # Tricube kernel weights
            weights = []
            for j, d in neighbors:
                u = d / max_d
                w = (1.0 - u ** 3) ** 3 if u < 1.0 else 0.0
                weights.append((j, max(w, 1e-12)))
            if degree == 0:
                w_sum = sum(w for _, w in weights)
                predicted = sum(w * y_vals[j] for j, w in weights) / w_sum if w_sum > 0 else 0.0
            else:
                # Weighted least squares: y = a + b*x
                sw = sum(w for _, w in weights)
                swx = sum(w * x_vals[j] for j, w in weights)
                swx2 = sum(w * x_vals[j] ** 2 for j, w in weights)
                swy = sum(w * y_vals[j] for j, w in weights)
                swxy = sum(w * x_vals[j] * y_vals[j] for j, w in weights)
                det = sw * swx2 - swx * swx
                if abs(det) > 1e-12:
                    a = (swx2 * swy - swx * swxy) / det
                    b = (sw * swxy - swx * swy) / det
                    predicted = a + b * x_vals[i]
                else:
                    predicted = swy / sw if sw > 0 else 0.0
            residual = y_vals[i] - predicted
            resolved = dict(self._rows[i])
            resolved[f"predicted_{loess_suffix}"] = predicted
            resolved[f"residual_{loess_suffix}"] = residual
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 89: spatial_scan_statistic (Kulldorff)
    # ------------------------------------------------------------------
    def spatial_scan_statistic(
        self,
        case_column: str,
        population_column: str | None = None,
        max_radius_fraction: float = 0.5,
        distance_method: str = "euclidean",
        n_simulations: int = 99,
        seed: int | None = None,
        scan_suffix: str = "scan",
    ) -> "GeoPromptFrame":
        """Kulldorff's spatial scan statistic for cluster detection."""
        self._require_column(case_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        cases = [float(row.get(case_column, 0) or 0) for row in self._rows]
        pops = [float(row.get(population_column, 1) or 1) if population_column else 1.0 for row in self._rows]
        total_cases = sum(cases)
        total_pop = sum(pops)
        if total_cases <= 0 or total_pop <= 0:
            rows = [dict(r) | {f"in_cluster_{scan_suffix}": False, f"llr_{scan_suffix}": 0.0, f"p_value_{scan_suffix}": 1.0} for r in self._rows]
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        max_radius = extent * max_radius_fraction

        def _log_likelihood_ratio(c_in: float, n_in: float, c_total: float, n_total: float) -> float:
            c_out = c_total - c_in
            n_out = n_total - n_in
            if c_in <= 0 or n_in <= 0 or c_out < 0 or n_out <= 0:
                return 0.0
            rate_in = c_in / n_in
            rate_out = c_out / n_out if n_out > 0 else 0.0
            if rate_in <= rate_out:
                return 0.0
            llr = 0.0
            if c_in > 0:
                llr += c_in * math.log(max(c_in / max(n_in * c_total / n_total, 1e-12), 1e-12))
            if c_out > 0 and n_out > 0:
                llr += c_out * math.log(max(c_out / max(n_out * c_total / n_total, 1e-12), 1e-12))
            return max(llr, 0.0)

        # Find most likely cluster
        best_llr = 0.0
        best_center = 0
        best_radius = 0.0
        best_members: set[int] = set()
        for i in range(n):
            sorted_neighbors = sorted(range(n), key=lambda j: dist_matrix[i][j])
            c_in = 0.0
            n_in = 0.0
            for j in sorted_neighbors:
                if dist_matrix[i][j] > max_radius and j != i:
                    break
                c_in += cases[j]
                n_in += pops[j]
                llr = _log_likelihood_ratio(c_in, n_in, total_cases, total_pop)
                if llr > best_llr:
                    best_llr = llr
                    best_center = i
                    best_radius = dist_matrix[i][j]
                    best_members = {sorted_neighbors[k_idx] for k_idx in range(sorted_neighbors.index(j) + 1)}
        # Monte Carlo significance
        rng = random.Random(seed)
        exceed_count = 0
        for _ in range(n_simulations):
            sim_cases = list(cases)
            rng.shuffle(sim_cases)
            sim_best = 0.0
            for i in range(n):
                sorted_neighbors = sorted(range(n), key=lambda j: dist_matrix[i][j])
                c_in = 0.0
                n_in = 0.0
                for j in sorted_neighbors:
                    if dist_matrix[i][j] > max_radius and j != i:
                        break
                    c_in += sim_cases[j]
                    n_in += pops[j]
                    llr = _log_likelihood_ratio(c_in, n_in, total_cases, total_pop)
                    if llr > sim_best:
                        sim_best = llr
            if sim_best >= best_llr:
                exceed_count += 1
        p_value = (exceed_count + 1) / (n_simulations + 1)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"in_cluster_{scan_suffix}"] = i in best_members
            resolved[f"llr_{scan_suffix}"] = best_llr if i in best_members else 0.0
            resolved[f"p_value_{scan_suffix}"] = p_value
            resolved[f"cluster_center_{scan_suffix}"] = i == best_center
            resolved[f"cluster_radius_{scan_suffix}"] = best_radius
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 90: optics_clustering
    # ------------------------------------------------------------------
    def optics_clustering(
        self,
        min_samples: int = 5,
        max_eps: float | None = None,
        xi: float = 0.05,
        distance_method: str = "euclidean",
        optics_suffix: str = "optics",
    ) -> "GeoPromptFrame":
        """OPTICS density-based clustering with variable epsilon."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        inf = float("inf")
        max_epsilon = max_eps if max_eps is not None else max(max(row) for row in dist_matrix)
        min_s = min(min_samples, n)
        # Core distance for each point
        core_dists: list[float] = []
        for i in range(n):
            dists_i = sorted(dist_matrix[i][j] for j in range(n) if j != i)
            if len(dists_i) >= min_s:
                core_dists.append(dists_i[min_s - 1])
            else:
                core_dists.append(inf)
        # OPTICS ordering
        reachability: list[float] = [inf] * n
        processed: list[bool] = [False] * n
        ordering: list[int] = []
        reach_values: list[float] = []
        # Start from point with smallest core distance
        start = min(range(n), key=lambda i: core_dists[i])
        seeds: list[tuple[float, int]] = [(0.0, start)]
        while seeds or len(ordering) < n:
            if seeds:
                seeds.sort(key=lambda x: x[0])
                rd, current = seeds.pop(0)
                while processed[current] and seeds:
                    rd, current = seeds.pop(0)
                if processed[current]:
                    # Pick any unprocessed point
                    unproc = [i for i in range(n) if not processed[i]]
                    if not unproc:
                        break
                    current = unproc[0]
                    rd = inf
            else:
                unproc = [i for i in range(n) if not processed[i]]
                if not unproc:
                    break
                current = unproc[0]
                rd = inf
            processed[current] = True
            ordering.append(current)
            reach_values.append(rd)
            if core_dists[current] < inf:
                for j in range(n):
                    if processed[j] or current == j:
                        continue
                    new_reach = max(core_dists[current], dist_matrix[current][j])
                    if new_reach < reachability[j]:
                        reachability[j] = new_reach
                        seeds.append((new_reach, j))
        # Extract clusters using xi steep areas
        cluster_labels = [-1] * n
        cluster_id = 0
        in_cluster = False
        for idx in range(1, len(ordering)):
            prev_reach = reach_values[idx - 1] if reach_values[idx - 1] < inf else max_epsilon
            curr_reach = reach_values[idx] if reach_values[idx] < inf else max_epsilon
            if prev_reach > 0 and (prev_reach - curr_reach) / max(prev_reach, 1e-12) > xi:
                in_cluster = True
                cluster_id += 1
            elif curr_reach > 0 and (curr_reach - prev_reach) / max(curr_reach, 1e-12) > xi:
                in_cluster = False
            if in_cluster:
                cluster_labels[ordering[idx]] = cluster_id
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{optics_suffix}"] = cluster_labels[i]
            resolved[f"reachability_{optics_suffix}"] = reachability[i] if reachability[i] < inf else None
            resolved[f"core_distance_{optics_suffix}"] = core_dists[i] if core_dists[i] < inf else None
            resolved[f"noise_{optics_suffix}"] = cluster_labels[i] == -1
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 91: geographic_detector (factor detector)
    # ------------------------------------------------------------------
    def geographic_detector(
        self,
        dependent_column: str,
        factor_column: str,
        detector_suffix: str = "geodetector",
    ) -> Record:
        """Wang & Xu Geographic Detector q-statistic (factor detector)."""
        self._require_column(dependent_column)
        self._require_column(factor_column)
        n = len(self._rows)
        if n == 0:
            return {"q_statistic": 0.0, "p_value": 1.0, "n": 0}
        y_vals = [float(row.get(dependent_column, 0) or 0) for row in self._rows]
        factors = [row.get(factor_column) for row in self._rows]
        y_mean = sum(y_vals) / n
        sst = sum((v - y_mean) ** 2 for v in y_vals)
        if sst < 1e-12:
            return {"q_statistic": 0.0, "p_value": 1.0, "n": n}
        # Group by factor
        strata: dict[str, list[float]] = {}
        for i in range(n):
            key = str(factors[i])
            strata.setdefault(key, []).append(y_vals[i])
        ssw = 0.0
        for vals in strata.values():
            m = sum(vals) / len(vals)
            ssw += sum((v - m) ** 2 for v in vals)
        q = 1.0 - ssw / sst
        # F-test approximation
        L = len(strata)
        if L <= 1 or n <= L:
            return {"q_statistic": q, "p_value": 1.0, "n": n, "strata_count": L}
        if ssw < 1e-12:
            return {"q_statistic": q, "f_statistic": float("inf"), "p_value": 0.0, "n": n, "strata_count": L}
        f_stat = ((sst - ssw) / max(L - 1, 1)) / (ssw / max(n - L, 1))
        # Approximate p-value using chi-square surrogate
        p_value = 1.0 - _normal_cdf(math.sqrt(max(f_stat, 0.0)) - 1.0) if f_stat > 0 else 1.0
        return {"q_statistic": q, "f_statistic": f_stat, "p_value": p_value, "n": n, "strata_count": L}

    # ------------------------------------------------------------------
    # Tool 92: terrain_ruggedness_index
    # ------------------------------------------------------------------
    def terrain_ruggedness_index(
        self,
        elevation_column: str,
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        tri_suffix: str = "tri",
    ) -> "GeoPromptFrame":
        """Terrain Ruggedness Index (TRI) — RMS elevation change to neighbors."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        # Use 8 nearest neighbors (3x3 window analog)
        k = min(8, n - 1)
        rows: list[Record] = []
        for i in range(n):
            if k <= 0:
                tri_val = 0.0
            else:
                dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors = dists_i[:k]
                sq_sum = sum((elevations[i] - elevations[j]) ** 2 for j, _ in neighbors)
                tri_val = math.sqrt(sq_sum / len(neighbors))
            resolved = dict(self._rows[i])
            resolved[f"tri_{tri_suffix}"] = tri_val
            resolved[f"elevation_{tri_suffix}"] = elevations[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 93: topographic_position_index
    # ------------------------------------------------------------------
    def topographic_position_index(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        tpi_suffix: str = "tpi",
    ) -> "GeoPromptFrame":
        """Topographic Position Index — elevation relative to neighbors."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        rows: list[Record] = []
        for i in range(n):
            if k_actual <= 0:
                tpi_val = 0.0
                landform = "flat"
            else:
                dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors = dists_i[:k_actual]
                neighbor_mean = sum(elevations[j] for j, _ in neighbors) / len(neighbors)
                tpi_val = elevations[i] - neighbor_mean
                neighbor_std = math.sqrt(sum((elevations[j] - neighbor_mean) ** 2 for j, _ in neighbors) / len(neighbors)) if len(neighbors) > 1 else 1.0
                norm_tpi = tpi_val / neighbor_std if neighbor_std > 0 else 0.0
                if norm_tpi > 1.0:
                    landform = "ridge"
                elif norm_tpi < -1.0:
                    landform = "valley"
                elif abs(norm_tpi) <= 0.5:
                    landform = "flat"
                else:
                    landform = "slope"
            resolved = dict(self._rows[i])
            resolved[f"tpi_{tpi_suffix}"] = tpi_val
            resolved[f"landform_{tpi_suffix}"] = landform
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 94: flow_direction (D8 algorithm)
    # ------------------------------------------------------------------
    def flow_direction(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        flow_suffix: str = "flow",
    ) -> "GeoPromptFrame":
        """D8 flow direction — steepest-descent neighbor for each point."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        rows: list[Record] = []
        for i in range(n):
            dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
            neighbors = dists_i[:k_actual]
            best_j = -1
            best_slope = 0.0
            for j, d in neighbors:
                if d < 1e-12:
                    continue
                slope = (elevations[i] - elevations[j]) / d
                if slope > best_slope:
                    best_slope = slope
                    best_j = j
            resolved = dict(self._rows[i])
            resolved[f"flow_to_{flow_suffix}"] = best_j if best_j >= 0 else None
            resolved[f"slope_{flow_suffix}"] = best_slope
            if best_j >= 0:
                dx = centroids[best_j][0] - centroids[i][0]
                dy = centroids[best_j][1] - centroids[i][1]
                resolved[f"direction_deg_{flow_suffix}"] = (math.degrees(math.atan2(dy, dx)) + 360) % 360
            else:
                resolved[f"direction_deg_{flow_suffix}"] = None
                resolved[f"is_sink_{flow_suffix}"] = True
            if best_j >= 0:
                resolved[f"is_sink_{flow_suffix}"] = False
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 95: flow_accumulation
    # ------------------------------------------------------------------
    def flow_accumulation(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        accum_suffix: str = "accum",
    ) -> "GeoPromptFrame":
        """Flow accumulation — count of upslope cells draining through each point."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        # Compute flow_to for each cell
        flow_to: list[int | None] = []
        for i in range(n):
            dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
            neighbors = dists_i[:k_actual]
            best_j: int | None = None
            best_slope = 0.0
            for j, d in neighbors:
                if d < 1e-12:
                    continue
                slope = (elevations[i] - elevations[j]) / d
                if slope > best_slope:
                    best_slope = slope
                    best_j = j
            flow_to.append(best_j)
        # Accumulate by sorting by elevation descending
        accumulation = [1.0] * n  # each cell contributes 1
        sorted_by_elev = sorted(range(n), key=lambda i: -elevations[i])
        for i in sorted_by_elev:
            target = flow_to[i]
            if target is not None:
                accumulation[target] += accumulation[i]
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"accumulation_{accum_suffix}"] = accumulation[i]
            resolved[f"flow_to_{accum_suffix}"] = flow_to[i]
            resolved[f"is_channel_{accum_suffix}"] = accumulation[i] > math.sqrt(n)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 96: mark_correlation_function
    # ------------------------------------------------------------------
    def mark_correlation_function(
        self,
        mark_column: str,
        distances: list[float] | None = None,
        steps: int = 10,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        """Mark correlation function — how spatial correlation of marks varies with distance."""
        self._require_column(mark_column)
        n = len(self._rows)
        if n < 2:
            return []
        centroids = self._centroids()
        marks = [float(row.get(mark_column, 0) or 0) for row in self._rows]
        mean_mark = sum(marks) / n
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        max_d = max(max(row) for row in dist_matrix)
        if distances is None:
            step_size = max_d / steps if steps > 0 else max_d
            distances = [step_size * (i + 1) for i in range(steps)]
        lag_width = distances[0] if len(distances) == 1 else (distances[1] - distances[0]) if len(distances) > 1 else max_d / steps
        results: list[Record] = []
        for d in distances:
            lo = d - lag_width / 2
            hi = d + lag_width / 2
            pairs = [(i, j) for i in range(n) for j in range(i + 1, n) if lo <= dist_matrix[i][j] < hi]
            if pairs:
                mcf = sum(marks[i] * marks[j] for i, j in pairs) / (len(pairs) * mean_mark * mean_mark) if mean_mark != 0 else 0.0
            else:
                mcf = 1.0
            results.append({"distance": d, "mark_correlation": mcf, "pair_count": len(pairs)})
        return results

    # ------------------------------------------------------------------
    # Tool 97: point_pattern_intensity
    # ------------------------------------------------------------------
    def point_pattern_intensity(
        self,
        grid_resolution: int = 10,
        kernel_bandwidth: float | None = None,
        intensity_suffix: str = "intensity",
    ) -> "GeoPromptFrame":
        """First-order intensity function lambda(s) of point pattern."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        width = b.max_x - b.min_x
        height = b.max_y - b.min_y
        area = max(width * height, 1e-12)
        h = kernel_bandwidth if kernel_bandwidth is not None else _silverman_bandwidth(centroids)
        dx = width / max(grid_resolution - 1, 1)
        dy = height / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        global_intensity = n / area
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                local_intensity = 0.0
                for c in centroids:
                    d = math.hypot(px - c[0], py - c[1])
                    u = d / h
                    if u < 1.0:
                        local_intensity += (3.0 / math.pi) * (1.0 - u * u) ** 2 / (h * h)
                cell_id += 1
                rows.append({
                    f"cell_id_{intensity_suffix}": f"int-{cell_id:04d}",
                    f"intensity_{intensity_suffix}": local_intensity,
                    f"global_intensity_{intensity_suffix}": global_intensity,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 98: location_allocation (p-median)
    # ------------------------------------------------------------------
    def location_allocation(
        self,
        demand_column: str | None = None,
        p: int = 3,
        distance_method: str = "euclidean",
        max_iterations: int = 100,
        seed: int | None = None,
        allocation_suffix: str = "pmedian",
    ) -> "GeoPromptFrame":
        """P-median location-allocation — find p optimal facility locations."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        p = min(p, n)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        demands = [float(row.get(demand_column, 1) or 1) if demand_column else 1.0 for row in self._rows]
        rng = random.Random(seed)
        # Initialize with random facility locations
        facilities = rng.sample(range(n), p)

        def _total_cost(facs: list[int]) -> float:
            return sum(demands[i] * min(dist_matrix[i][f] for f in facs) for i in range(n))

        def _assignments(facs: list[int]) -> list[int]:
            return [min(facs, key=lambda f: dist_matrix[i][f]) for i in range(n)]

        best_cost = _total_cost(facilities)
        for _ in range(max_iterations):
            improved = False
            for fi in range(p):
                for candidate in range(n):
                    if candidate in facilities:
                        continue
                    trial = list(facilities)
                    trial[fi] = candidate
                    cost = _total_cost(trial)
                    if cost < best_cost - 1e-12:
                        facilities = trial
                        best_cost = cost
                        improved = True
            if not improved:
                break
        assignment = _assignments(facilities)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"facility_{allocation_suffix}"] = i in facilities
            resolved[f"assigned_to_{allocation_suffix}"] = assignment[i]
            resolved[f"distance_to_facility_{allocation_suffix}"] = dist_matrix[i][assignment[i]]
            resolved[f"demand_{allocation_suffix}"] = demands[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 99: spatial_durbin_model
    # ------------------------------------------------------------------
    def spatial_durbin_model(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        sdm_suffix: str = "sdm",
    ) -> "GeoPromptFrame":
        """Spatial Durbin Model — regression with spatial lags of X and Y."""
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        y_vals = [float(row[dependent_column]) for row in self._rows]
        x_vals = [[float(row.get(col, 0) or 0) for col in independent_columns] for row in self._rows]
        # Build neighbor structure
        neighbors: list[list[int]] = []
        for i in range(n):
            if mode == "k_nearest":
                dists_i = sorted(((j, dist_matrix[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors.append([j for j, _ in dists_i[:k]])
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                neighbors.append([j for j in range(n) if j != i and dist_matrix[i][j] <= md])
            else:
                neighbors.append([j for j in range(n) if j != i])
        # Compute spatial lags
        lag_y = [sum(y_vals[j] for j in neighbors[i]) / max(len(neighbors[i]), 1) for i in range(n)]
        lag_x = [[sum(x_vals[j][c] for j in neighbors[i]) / max(len(neighbors[i]), 1) for c in range(len(independent_columns))] for i in range(n)]
        # Build design matrix: [1, X, WY, WX]
        p = 1 + len(independent_columns) + 1 + len(independent_columns)
        x_matrix = []
        for i in range(n):
            row = [1.0] + x_vals[i] + [lag_y[i]] + lag_x[i]
            x_matrix.append(row)
        # OLS
        xtx = [[sum(x_matrix[i][a] * x_matrix[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
        xty = [sum(x_matrix[i][a] * y_vals[i] for i in range(n)) for a in range(p)]
        coefficients = _solve_linear_system(xtx, xty)
        if coefficients is None:
            coefficients = [0.0] * p
        rows: list[Record] = []
        for i in range(n):
            predicted = sum(coefficients[j] * x_matrix[i][j] for j in range(p))
            residual = y_vals[i] - predicted
            resolved = dict(self._rows[i])
            resolved[f"predicted_{sdm_suffix}"] = predicted
            resolved[f"residual_{sdm_suffix}"] = residual
            resolved[f"spatial_lag_y_{sdm_suffix}"] = lag_y[i]
            rows.append(resolved)
        # Global R-squared
        y_mean = sum(y_vals) / n
        ss_tot = sum((v - y_mean) ** 2 for v in y_vals)
        ss_res = sum((y_vals[i] - sum(coefficients[j] * x_matrix[i][j] for j in range(p))) ** 2 for i in range(n))
        r_squared = 1.0 - ss_res / ss_tot if ss_tot > 1e-12 else 0.0
        for row in rows:
            row[f"r_squared_{sdm_suffix}"] = r_squared
            row[f"rho_{sdm_suffix}"] = coefficients[1 + len(independent_columns)]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 100: kriging_cross_validation (LOOCV)
    # ------------------------------------------------------------------
    def kriging_cross_validation(
        self,
        value_column: str,
        variogram_range: float | None = None,
        variogram_sill: float | None = None,
        variogram_nugget: float | None = None,
        variogram_model: str | None = None,
        distance_method: str = "euclidean",
    ) -> Record:
        """Leave-one-out cross-validation for kriging — returns RMSE and MAE."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return {"rmse": 0.0, "mae": 0.0, "n": n}
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        # Auto-fit variogram if not specified
        if variogram_range is None and variogram_sill is None and variogram_model is None:
            model, vr, vs, vn = _fit_empirical_variogram(centroids, values, distance_method)
        else:
            model = variogram_model or "spherical"
            b = self.bounds()
            extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
            vr = variogram_range if variogram_range is not None else extent / 3.0
            vs = variogram_sill if variogram_sill is not None else 1.0
            vn = variogram_nugget if variogram_nugget is not None else 0.0
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        errors: list[float] = []
        for leave_out in range(n):
            idx = [j for j in range(n) if j != leave_out]
            m = len(idx)
            krig_mat = [
                [_variogram_value(model, dist_matrix[idx[a]][idx[b]], vr, vs, vn) for b in range(m)] + [1.0]
                for a in range(m)
            ]
            krig_mat.append([1.0] * m + [0.0])
            rhs = [_variogram_value(model, dist_matrix[leave_out][idx[a]], vr, vs, vn) for a in range(m)] + [1.0]
            solution = _solve_linear_system(krig_mat, rhs)
            if solution is not None:
                predicted = sum(solution[a] * values[idx[a]] for a in range(m))
            else:
                predicted = sum(values[j] for j in idx) / m
            errors.append(values[leave_out] - predicted)
        rmse = math.sqrt(sum(e * e for e in errors) / n)
        mae = sum(abs(e) for e in errors) / n
        return {"rmse": rmse, "mae": mae, "n": n, "variogram_model": model, "variogram_range": vr, "variogram_sill": vs, "variogram_nugget": vn}

    # ==================================================================
    # P3 — NEW TOOLS
    # ==================================================================

    # ------------------------------------------------------------------
    # Tool 101: getis_ord_g_global — Global G statistic
    # ------------------------------------------------------------------
    def getis_ord_g_global(
        self,
        value_column: str,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        g_suffix: str = "g_global",
    ) -> dict[str, Any]:
        """Compute global Getis-Ord G statistic for overall clustering."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return {"g_statistic": None, "z_score": None, "p_value": None, "n": n}
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        x_bar = sum(values) / n
        s2 = sum(v * v for v in values) / n - x_bar * x_bar
        s = math.sqrt(max(s2, 1e-12))
        w_sum = 0.0
        numerator = 0.0
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors = dists[:k]
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                neighbors = [(j, dm[i][j]) for j in range(n) if j != i and dm[i][j] <= md]
            else:
                neighbors = [(j, dm[i][j]) for j in range(n) if j != i]
            for j, _ in neighbors:
                numerator += values[i] * values[j]
                w_sum += 1.0
        total_sq = sum(v * v for v in values)
        total_sum = sum(values)
        g = numerator / max(total_sum * total_sum - total_sq, 1e-12) if total_sum > 0 else 0.0
        e_g = w_sum / (n * (n - 1)) if n > 1 else 0.0
        z = (g - e_g) / max(s * e_g, 1e-12) if s > 0 and e_g > 0 else 0.0
        p = 2.0 * (1.0 - _normal_cdf(abs(z)))
        return {"g_statistic": g, "expected_g": e_g, "z_score": z, "p_value": p, "n": n, "total_weight": w_sum}

    # ------------------------------------------------------------------
    # Tool 102: lees_l — Lee's L bivariate spatial association
    # ------------------------------------------------------------------
    def lees_l(
        self,
        x_column: str,
        y_column: str,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        lee_suffix: str = "lee",
    ) -> "GeoPromptFrame":
        """Compute Lee's L bivariate spatial association statistic."""
        self._require_column(x_column)
        self._require_column(y_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        x_vals = [float(row.get(x_column, 0) or 0) for row in self._rows]
        y_vals = [float(row.get(y_column, 0) or 0) for row in self._rows]
        x_bar = sum(x_vals) / n
        y_bar = sum(y_vals) / n
        sx = math.sqrt(sum((v - x_bar) ** 2 for v in x_vals) / n) or 1e-12
        sy = math.sqrt(sum((v - y_bar) ** 2 for v in y_vals) / n) or 1e-12
        zx = [(v - x_bar) / sx for v in x_vals]
        zy = [(v - y_bar) / sy for v in y_vals]
        # Build neighbour lists
        neighbors_list: list[list[int]] = []
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                neighbors_list.append([j for j, _ in dists[:k]])
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                neighbors_list.append([j for j in range(n) if j != i and dm[i][j] <= md])
            else:
                neighbors_list.append([j for j in range(n) if j != i])
        global_l = 0.0
        rows: list[Record] = []
        for i in range(n):
            nbs = neighbors_list[i]
            nk = len(nbs) or 1
            lag_x = sum(zx[j] for j in nbs) / nk
            lag_y = sum(zy[j] for j in nbs) / nk
            local_l = lag_x * lag_y
            global_l += zx[i] * lag_y
            resolved = dict(self._rows[i])
            resolved[f"local_l_{lee_suffix}"] = local_l
            resolved[f"lag_x_{lee_suffix}"] = lag_x
            resolved[f"lag_y_{lee_suffix}"] = lag_y
            rows.append(resolved)
        global_l /= n
        for r in rows:
            r[f"global_l_{lee_suffix}"] = global_l
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 103: spatial_correlogram — Moran's I at multiple distance lags
    # ------------------------------------------------------------------
    def spatial_correlogram(
        self,
        value_column: str,
        n_lags: int = 10,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        """Compute Moran's I at multiple distance lags (correlogram)."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return []
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        # Determine max distance
        all_dists = [dm[i][j] for i in range(n) for j in range(i + 1, n)]
        if not all_dists:
            return []
        md = max_distance if max_distance is not None else max(all_dists)
        lag_width = md / n_lags
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        mean_v = sum(values) / n
        centered = [v - mean_v for v in values]
        var_sum = sum(c * c for c in centered)
        results: list[Record] = []
        for lag_idx in range(n_lags):
            lo = lag_idx * lag_width
            hi = (lag_idx + 1) * lag_width
            cross_sum = 0.0
            w_count = 0
            for i in range(n):
                for j in range(n):
                    if i == j:
                        continue
                    if lo <= dm[i][j] < hi:
                        cross_sum += centered[i] * centered[j]
                        w_count += 1
            if w_count > 0 and var_sum > 0:
                morans_i = (n / w_count) * (cross_sum / var_sum)
            else:
                morans_i = 0.0
            results.append({
                "lag": lag_idx + 1,
                "distance_min": lo,
                "distance_max": hi,
                "distance_mid": (lo + hi) / 2.0,
                "morans_i": morans_i,
                "pair_count": w_count,
            })
        return results

    # ------------------------------------------------------------------
    # Tool 104: variogram_cloud — Raw semivariance scatter
    # ------------------------------------------------------------------
    def variogram_cloud(
        self,
        value_column: str,
        distance_method: str = "euclidean",
        max_distance: float | None = None,
    ) -> list[Record]:
        """Return raw semivariance point cloud from all pairs."""
        self._require_column(value_column)
        n = len(self._rows)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        results: list[Record] = []
        for i in range(n):
            for j in range(i + 1, n):
                d = coordinate_distance(centroids[i], centroids[j], method=distance_method)
                if max_distance is not None and d > max_distance:
                    continue
                sv = semivariance(values[i], values[j])
                results.append({"distance": d, "semivariance": sv, "point_i": i, "point_j": j})
        return results

    # ------------------------------------------------------------------
    # Tool 105: universal_kriging — Kriging with polynomial drift
    # ------------------------------------------------------------------
    def universal_kriging(
        self,
        value_column: str,
        grid_resolution: int = 20,
        drift_order: int = 1,
        variogram_model: str = "spherical",
        distance_method: str = "euclidean",
        uk_suffix: str = "uk",
    ) -> "GeoPromptFrame":
        """Universal Kriging with polynomial drift (trend + residual)."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        n = len(centroids)
        b = self.bounds()
        # Fit trend surface
        trend_terms = _polynomial_terms(centroids, drift_order)
        m = len(trend_terms[0]) if trend_terms else 1
        # Solve trend via least squares
        ata = [[0.0] * m for _ in range(m)]
        atb_vec = [0.0] * m
        for i in range(n):
            for p in range(m):
                for q in range(m):
                    ata[p][q] += trend_terms[i][p] * trend_terms[i][q]
                atb_vec[p] += trend_terms[i][p] * values[i]
        trend_coeffs = _solve_linear_system_with_regularization(ata, atb_vec) or [0.0] * m
        # Compute residuals
        residuals = [values[i] - sum(trend_coeffs[p] * trend_terms[i][p] for p in range(m)) for i in range(n)]
        # Fit variogram on residuals
        fitted_model, fitted_range, fitted_sill, fitted_nugget = _fit_empirical_variogram(centroids, residuals, distance_method)
        if variogram_model != "auto":
            fitted_model = variogram_model
        # Build kriging matrix on residuals
        sample_distances = _pairwise_distance_matrix(centroids, distance_method)
        krig_matrix = [
            [_variogram_value(fitted_model, sample_distances[ri][ci], fitted_range, fitted_sill, fitted_nugget)
             for ci in range(n)] + [1.0]
            for ri in range(n)
        ]
        krig_matrix.append([1.0] * n + [0.0])
        inv_matrix = _invert_matrix_with_regularization(krig_matrix)
        # Generate grid
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                # Trend at query point
                q_terms = _polynomial_terms_single((px, py), drift_order)
                trend_val = sum(trend_coeffs[p] * q_terms[p] for p in range(m))
                # Kriging residual
                query_dists = [coordinate_distance((px, py), c, method=distance_method) for c in centroids]
                rhs = [_variogram_value(fitted_model, d, fitted_range, fitted_sill, fitted_nugget) for d in query_dists] + [1.0]
                solution = _matrix_vector_product(inv_matrix, rhs) if inv_matrix else None
                if solution:
                    krig_residual = sum(solution[i] * residuals[i] for i in range(n))
                    variance = sum(solution[i] * rhs[i] for i in range(n)) + float(solution[-1])
                else:
                    krig_residual = 0.0
                    variance = fitted_sill
                cell_id += 1
                rows.append({
                    f"cell_id_{uk_suffix}": f"uk-{cell_id:04d}",
                    f"value_{uk_suffix}": trend_val + krig_residual,
                    f"trend_{uk_suffix}": trend_val,
                    f"residual_{uk_suffix}": krig_residual,
                    f"variance_{uk_suffix}": max(0.0, variance),
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 106: rbf_interpolation — Radial Basis Function
    # ------------------------------------------------------------------
    def rbf_interpolation(
        self,
        value_column: str,
        grid_resolution: int = 20,
        function: str = "multiquadric",
        epsilon: float | None = None,
        distance_method: str = "euclidean",
        rbf_suffix: str = "rbf",
    ) -> "GeoPromptFrame":
        """Radial Basis Function interpolation (multiquadric, thin-plate, inverse, gaussian)."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        n = len(centroids)
        b = self.bounds()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        eps = epsilon if epsilon is not None else max(b.max_x - b.min_x, b.max_y - b.min_y) / n
        if eps <= 0:
            eps = 1.0
        # Build RBF matrix
        func = function.lower()
        rbf_matrix = [[_rbf_value(func, dm[i][j], eps) for j in range(n)] for i in range(n)]
        weights = _solve_linear_system_with_regularization(rbf_matrix, values)
        if weights is None:
            weights = [1.0 / n] * n
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                val = 0.0
                for ci, c in enumerate(centroids):
                    d = coordinate_distance((px, py), c, method=distance_method)
                    val += weights[ci] * _rbf_value(func, d, eps)
                cell_id += 1
                rows.append({
                    f"cell_id_{rbf_suffix}": f"rbf-{cell_id:04d}",
                    f"value_{rbf_suffix}": val,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 107: fuzzy_c_means — Soft clustering with membership degrees
    # ------------------------------------------------------------------
    def fuzzy_c_means(
        self,
        n_clusters: int = 3,
        fuzziness: float = 2.0,
        max_iter: int = 100,
        tolerance: float = 1e-6,
        random_seed: int = 42,
        fcm_suffix: str = "fcm",
    ) -> "GeoPromptFrame":
        """Fuzzy C-Means clustering with membership degrees."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        rng = random.Random(random_seed)
        # Initialize random membership matrix
        membership = [[rng.random() for _ in range(n_clusters)] for _ in range(n)]
        for i in range(n):
            s = sum(membership[i])
            membership[i] = [m / s for m in membership[i]]
        m = fuzziness
        for _ in range(max_iter):
            # Update cluster centers
            centers = []
            for c in range(n_clusters):
                wx = sum(membership[i][c] ** m * centroids[i][0] for i in range(n))
                wy = sum(membership[i][c] ** m * centroids[i][1] for i in range(n))
                ws = sum(membership[i][c] ** m for i in range(n))
                centers.append((wx / max(ws, 1e-12), wy / max(ws, 1e-12)))
            # Update membership
            new_membership = [[0.0] * n_clusters for _ in range(n)]
            for i in range(n):
                dists = [math.hypot(centroids[i][0] - centers[c][0], centroids[i][1] - centers[c][1]) for c in range(n_clusters)]
                for c in range(n_clusters):
                    if dists[c] < 1e-12:
                        new_membership[i] = [0.0] * n_clusters
                        new_membership[i][c] = 1.0
                        break
                    total = sum((dists[c] / max(dists[k], 1e-12)) ** (2.0 / (m - 1)) for k in range(n_clusters))
                    new_membership[i][c] = 1.0 / max(total, 1e-12)
            # Check convergence
            diff = max(abs(new_membership[i][c] - membership[i][c]) for i in range(n) for c in range(n_clusters))
            membership = new_membership
            if diff < tolerance:
                break
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            hard_cluster = max(range(n_clusters), key=lambda c: membership[i][c])
            resolved[f"cluster_{fcm_suffix}"] = hard_cluster
            for c in range(n_clusters):
                resolved[f"membership_{c}_{fcm_suffix}"] = membership[i][c]
            resolved[f"max_membership_{fcm_suffix}"] = max(membership[i])
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 108: gaussian_mixture_spatial — GMM clustering
    # ------------------------------------------------------------------
    def gaussian_mixture_spatial(
        self,
        n_components: int = 3,
        max_iter: int = 100,
        random_seed: int = 42,
        gmm_suffix: str = "gmm",
    ) -> "GeoPromptFrame":
        """Spatial Gaussian Mixture Model clustering."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        try:
            sklearn_mixture = importlib.import_module("sklearn.mixture")
            np = importlib.import_module("numpy")
            pts = np.array(centroids, dtype=float)
            gmm = sklearn_mixture.GaussianMixture(n_components=min(n_components, n), max_iter=max_iter, random_state=random_seed)
            labels = gmm.fit_predict(pts)
            probs = gmm.predict_proba(pts)
            rows: list[Record] = []
            for i in range(n):
                resolved = dict(self._rows[i])
                resolved[f"cluster_{gmm_suffix}"] = int(labels[i])
                resolved[f"probability_{gmm_suffix}"] = float(probs[i][labels[i]])
                for c in range(min(n_components, n)):
                    resolved[f"prob_{c}_{gmm_suffix}"] = float(probs[i][c])
                rows.append(resolved)
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        except ImportError:
            # Pure-python fallback: K-means approximation
            fallback = self.centroid_cluster(k=min(n_components, n), max_iterations=max_iter)
            rows: list[Record] = []
            for row in fallback.to_records():
                resolved = dict(row)
                cluster_id = int(resolved.pop("cluster_id", 0))
                resolved[f"cluster_{gmm_suffix}"] = cluster_id
                resolved[f"probability_{gmm_suffix}"] = 1.0
                for c in range(min(n_components, n)):
                    resolved[f"prob_{c}_{gmm_suffix}"] = 1.0 if c == cluster_id else 0.0
                rows.append(resolved)
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 109: spatial_error_model — SEM via iterative GLS
    # ------------------------------------------------------------------
    def spatial_error_model(
        self,
        dependent: str,
        independents: list[str],
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        sem_suffix: str = "sem",
    ) -> "GeoPromptFrame":
        """Spatial Error Model (SEM) via iterative GLS estimation."""
        self._require_column(dependent)
        for col in independents:
            self._require_column(col)
        n = len(self._rows)
        if n < len(independents) + 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        y = [float(row.get(dependent, 0) or 0) for row in self._rows]
        p = len(independents) + 1
        X = [[1.0] + [float(row.get(col, 0) or 0) for col in independents] for row in self._rows]
        # Build weight matrix (row-standardized)
        W: list[list[float]] = [[0.0] * n for _ in range(n)]
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                nbs = [j for j, _ in dists[:k]]
            elif mode == "distance_band":
                md = max_distance if max_distance is not None else float("inf")
                nbs = [j for j in range(n) if j != i and dm[i][j] <= md]
            else:
                nbs = [j for j in range(n) if j != i]
            for j in nbs:
                W[i][j] = 1.0 / len(nbs) if nbs else 0.0
        # OLS first
        XtX = [[sum(X[i][a] * X[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
        Xty = [sum(X[i][a] * y[i] for i in range(n)) for a in range(p)]
        beta = _solve_linear_system_with_regularization(XtX, Xty) or [0.0] * p
        residuals = [y[i] - sum(beta[a] * X[i][a] for a in range(p)) for i in range(n)]
        # Estimate lambda (spatial autocorrelation of errors)
        Wr = [sum(W[i][j] * residuals[j] for j in range(n)) for i in range(n)]
        num_lam = sum(residuals[i] * Wr[i] for i in range(n))
        den_lam = sum(Wr[i] * Wr[i] for i in range(n))
        lam = num_lam / max(den_lam, 1e-12)
        lam = clamp(lam, -0.99, 0.99)
        # GLS: y* = y - λWy, X* = X - λWX
        y_star = [y[i] - lam * sum(W[i][j] * y[j] for j in range(n)) for i in range(n)]
        X_star = [[X[i][a] - lam * sum(W[i][j] * X[j][a] for j in range(n)) for a in range(p)] for i in range(n)]
        XsXs = [[sum(X_star[i][a] * X_star[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
        Xsys = [sum(X_star[i][a] * y_star[i] for i in range(n)) for a in range(p)]
        beta_gls = _solve_linear_system_with_regularization(XsXs, Xsys) or beta
        predicted = [sum(beta_gls[a] * X[i][a] for a in range(p)) for i in range(n)]
        residuals_gls = [y[i] - predicted[i] for i in range(n)]
        ss_res = sum(r * r for r in residuals_gls)
        ss_tot = sum((y[i] - sum(y) / n) ** 2 for i in range(n))
        r_squared = 1.0 - ss_res / max(ss_tot, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{sem_suffix}"] = predicted[i]
            resolved[f"residual_{sem_suffix}"] = residuals_gls[i]
            resolved[f"r_squared_{sem_suffix}"] = r_squared
            resolved[f"lambda_{sem_suffix}"] = lam
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 110: spatial_lag_model — Spatial Lag Model via 2SLS
    # ------------------------------------------------------------------
    def spatial_lag_model(
        self,
        dependent: str,
        independents: list[str],
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        slm_suffix: str = "slm",
    ) -> "GeoPromptFrame":
        """Spatial Lag Model (SLM) via two-stage least squares."""
        self._require_column(dependent)
        for col in independents:
            self._require_column(col)
        n = len(self._rows)
        p = len(independents) + 1
        if n < p + 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        y = [float(row.get(dependent, 0) or 0) for row in self._rows]
        X = [[1.0] + [float(row.get(col, 0) or 0) for col in independents] for row in self._rows]
        # Build row-standardized W
        W: list[list[float]] = [[0.0] * n for _ in range(n)]
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                nbs = [j for j, _ in dists[:k]]
            else:
                md = max_distance if max_distance is not None else float("inf")
                nbs = [j for j in range(n) if j != i and dm[i][j] <= md]
            for j in nbs:
                W[i][j] = 1.0 / len(nbs) if nbs else 0.0
        Wy = [sum(W[i][j] * y[j] for j in range(n)) for i in range(n)]
        # 2SLS: instrument Wy with WX
        WX = [[sum(W[i][j] * X[j][a] for j in range(n)) for a in range(p)] for i in range(n)]
        Z = [X[i] + WX[i] for i in range(n)]  # instruments
        q = len(Z[0])
        # Stage 1: regress Wy on Z
        ZtZ = [[sum(Z[i][a] * Z[i][b] for i in range(n)) for b in range(q)] for a in range(q)]
        ZtWy = [sum(Z[i][a] * Wy[i] for i in range(n)) for a in range(q)]
        gamma = _solve_linear_system_with_regularization(ZtZ, ZtWy) or [0.0] * q
        Wy_hat = [sum(gamma[a] * Z[i][a] for a in range(q)) for i in range(n)]
        # Stage 2: regress y on [X, Wy_hat]
        X2 = [X[i] + [Wy_hat[i]] for i in range(n)]
        p2 = p + 1
        X2tX2 = [[sum(X2[i][a] * X2[i][b] for i in range(n)) for b in range(p2)] for a in range(p2)]
        X2ty = [sum(X2[i][a] * y[i] for i in range(n)) for a in range(p2)]
        beta2 = _solve_linear_system_with_regularization(X2tX2, X2ty) or [0.0] * p2
        rho = beta2[-1]
        predicted = [sum(beta2[a] * X2[i][a] for a in range(p2)) for i in range(n)]
        residuals = [y[i] - predicted[i] for i in range(n)]
        ss_res = sum(r * r for r in residuals)
        ss_tot = sum((v - sum(y) / n) ** 2 for v in y)
        r_squared = 1.0 - ss_res / max(ss_tot, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{slm_suffix}"] = predicted[i]
            resolved[f"residual_{slm_suffix}"] = residuals[i]
            resolved[f"r_squared_{slm_suffix}"] = r_squared
            resolved[f"rho_{slm_suffix}"] = rho
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 111: curvature — Plan/profile curvature from grid
    # ------------------------------------------------------------------
    def curvature(
        self,
        value_column: str,
        grid_resolution: int | None = None,
        curv_suffix: str = "curv",
    ) -> "GeoPromptFrame":
        """Compute plan and profile curvature from elevation grid."""
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        if grid_resolution is None:
            grid_resolution = int(math.sqrt(n))
        if grid_resolution < 3:
            grid_resolution = 3
        # Build grid via IDW
        grid_data, b, dx, dy = self._idw_grid(value_column, grid_resolution, "euclidean")
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            for gx in range(grid_resolution):
                z = grid_data[gy][gx]
                # 3x3 window
                z_n = grid_data[gy - 1][gx] if gy > 0 else z
                z_s = grid_data[gy + 1][gx] if gy < grid_resolution - 1 else z
                z_e = grid_data[gy][gx + 1] if gx < grid_resolution - 1 else z
                z_w = grid_data[gy][gx - 1] if gx > 0 else z
                z_ne = grid_data[gy - 1][gx + 1] if gy > 0 and gx < grid_resolution - 1 else z
                z_nw = grid_data[gy - 1][gx - 1] if gy > 0 and gx > 0 else z
                z_se = grid_data[gy + 1][gx + 1] if gy < grid_resolution - 1 and gx < grid_resolution - 1 else z
                z_sw = grid_data[gy + 1][gx - 1] if gy < grid_resolution - 1 and gx > 0 else z
                dz_dx = (z_e - z_w) / (2.0 * max(dx, 1e-12))
                dz_dy = (z_n - z_s) / (2.0 * max(dy, 1e-12))
                d2z_dx2 = (z_e - 2 * z + z_w) / max(dx * dx, 1e-24)
                d2z_dy2 = (z_n - 2 * z + z_s) / max(dy * dy, 1e-24)
                d2z_dxdy = (z_ne - z_nw - z_se + z_sw) / (4.0 * max(dx * dy, 1e-24))
                p = dz_dx
                q = dz_dy
                denom = (p * p + q * q)
                denom_32 = max((1 + denom) ** 1.5, 1e-12)
                profile = -((p * p * d2z_dx2 + 2.0 * p * q * d2z_dxdy + q * q * d2z_dy2) / (max(denom, 1e-12) * denom_32)) if denom > 1e-12 else 0.0
                plan = -((q * q * d2z_dx2 - 2.0 * p * q * d2z_dxdy + p * p * d2z_dy2) / (max(denom, 1e-12) ** 1.5)) if denom > 1e-12 else 0.0
                total_curv = d2z_dx2 + d2z_dy2
                px = b.min_x + gx * dx
                py = b.min_y + gy * dy
                cell_id += 1
                rows.append({
                    f"cell_id_{curv_suffix}": f"curv-{cell_id:04d}",
                    f"profile_{curv_suffix}": profile,
                    f"plan_{curv_suffix}": plan,
                    f"total_{curv_suffix}": total_curv,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 112: topographic_wetness_index — ln(a / tan(β))
    # ------------------------------------------------------------------
    def topographic_wetness_index(
        self,
        value_column: str,
        grid_resolution: int | None = None,
        twi_suffix: str = "twi",
    ) -> "GeoPromptFrame":
        """Compute Topographic Wetness Index from elevation."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        n = len(self._rows)
        if grid_resolution is None:
            grid_resolution = max(int(math.sqrt(n)), 3)
        g = grid_resolution
        grid_data, b, dx, dy = self._idw_grid(value_column, g, "euclidean")
        # Compute slope on grid
        slope_grid = [[0.0] * g for _ in range(g)]
        cell_size = max(dx, dy, 1e-12)
        for gy in range(1, g - 1):
            for gx in range(1, g - 1):
                dzdx = (grid_data[gy][gx + 1] - grid_data[gy][gx - 1]) / (2.0 * cell_size)
                dzdy = (grid_data[gy + 1][gx] - grid_data[gy - 1][gx]) / (2.0 * cell_size)
                slope_grid[gy][gx] = math.atan(math.sqrt(dzdx * dzdx + dzdy * dzdy))
        # D8 flow accumulation on grid
        flow_to: list[tuple[int, int] | None] = [None] * (g * g)
        for gy in range(g):
            for gx in range(g):
                best_drop = 0.0
                best_target: tuple[int, int] | None = None
                for ddy in (-1, 0, 1):
                    for ddx in (-1, 0, 1):
                        if ddy == 0 and ddx == 0:
                            continue
                        ny, nx = gy + ddy, gx + ddx
                        if 0 <= ny < g and 0 <= nx < g:
                            drop = grid_data[gy][gx] - grid_data[ny][nx]
                            dist = cell_size * (1.414 if ddy != 0 and ddx != 0 else 1.0)
                            slope_drop = drop / dist
                            if slope_drop > best_drop:
                                best_drop = slope_drop
                                best_target = (ny, nx)
                flow_to[gy * g + gx] = best_target
        accum = [1.0] * (g * g)
        order = sorted(range(g * g), key=lambda idx: -grid_data[idx // g][idx % g])
        for idx in order:
            t = flow_to[idx]
            if t is not None:
                accum[t[0] * g + t[1]] += accum[idx]
        # Build output rows for interior cells
        rows: list[Record] = []
        for gy in range(1, g - 1):
            for gx in range(1, g - 1):
                idx = gy * g + gx
                acc = accum[idx]
                slp_rad = max(slope_grid[gy][gx], 0.001)
                twi = math.log(max(acc * cell_size, 1e-6) / math.tan(slp_rad))
                cx = b.min_x + (gx + 0.5) * dx
                cy = b.min_y + (gy + 0.5) * dy
                rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                    f"twi_{twi_suffix}": twi,
                    f"slope_rad_{twi_suffix}": slp_rad,
                    f"accumulation_{twi_suffix}": acc,
                    f"elevation_{twi_suffix}": grid_data[gy][gx],
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 113: depression_fill — Fill sinks in DEM
    # ------------------------------------------------------------------
    def depression_fill(
        self,
        value_column: str,
        grid_resolution: int | None = None,
        fill_suffix: str = "fill",
    ) -> "GeoPromptFrame":
        """Fill depressions/sinks in elevation grid."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        n = len(self._rows)
        if grid_resolution is None:
            grid_resolution = max(int(math.sqrt(n)), 3)
        grid_data, b, dx, dy = self._idw_grid(value_column, grid_resolution, "euclidean")
        g = grid_resolution
        # Priority-flood algorithm (simplified)
        filled = [list(row) for row in grid_data]
        # Set interior cells to infinity, keep border cells
        INF = float("inf")
        for gy in range(1, g - 1):
            for gx in range(1, g - 1):
                filled[gy][gx] = INF
        # Priority queue: (elevation, gy, gx)
        pq: list[tuple[float, int, int]] = []
        visited = [[False] * g for _ in range(g)]
        for gy in range(g):
            for gx in range(g):
                if gy == 0 or gy == g - 1 or gx == 0 or gx == g - 1:
                    heappush(pq, (grid_data[gy][gx], gy, gx))
                    visited[gy][gx] = True
                    filled[gy][gx] = grid_data[gy][gx]
        neighbors_offsets = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
        while pq:
            elev, cy, cx = heappop(pq)
            for ddy, ddx in neighbors_offsets:
                ny, nx = cy + ddy, cx + ddx
                if 0 <= ny < g and 0 <= nx < g and not visited[ny][nx]:
                    visited[ny][nx] = True
                    filled[ny][nx] = max(grid_data[ny][nx], elev)
                    heappush(pq, (filled[ny][nx], ny, nx))
        rows: list[Record] = []
        cell_id = 0
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                cell_id += 1
                rows.append({
                    f"cell_id_{fill_suffix}": f"fill-{cell_id:04d}",
                    f"original_{fill_suffix}": grid_data[gy][gx],
                    f"filled_{fill_suffix}": filled[gy][gx],
                    f"depth_{fill_suffix}": filled[gy][gx] - grid_data[gy][gx],
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 114: solar_radiation — Hillshade-based annual insolation
    # ------------------------------------------------------------------
    def solar_radiation(
        self,
        value_column: str,
        latitude: float = 40.0,
        grid_resolution: int | None = None,
        solar_suffix: str = "solar",
    ) -> "GeoPromptFrame":
        """Estimate relative solar radiation from hillshade at multiple sun angles."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        n = len(self._rows)
        if grid_resolution is None:
            grid_resolution = max(int(math.sqrt(n)), 3)
        # Sum hillshade from multiple sun positions (seasonal approximation)
        azimuths = [90, 135, 180, 225, 270]
        altitudes = [25, 45, 65]
        total_hs: dict[int, float] = {}
        count = 0
        for az in azimuths:
            for alt in altitudes:
                hs_frame = self.hillshade(value_column, grid_resolution=grid_resolution, azimuth=float(az), altitude=float(alt))
                for i, rec in enumerate(hs_frame.to_records()):
                    total_hs[i] = total_hs.get(i, 0.0) + float(rec.get("hillshade_hs", 0) or 0)
                count += 1
        rows: list[Record] = []
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                idx = gy * grid_resolution + gx
                cell_id += 1
                rows.append({
                    f"cell_id_{solar_suffix}": f"solar-{cell_id:04d}",
                    f"radiation_{solar_suffix}": total_hs.get(idx, 0.0) / max(count, 1),
                    f"total_exposure_{solar_suffix}": total_hs.get(idx, 0.0),
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 115: network_voronoi — Service area partitioning on network
    # ------------------------------------------------------------------
    def network_voronoi(
        self,
        facilities: "GeoPromptFrame",
        network: "GeoPromptFrame | None" = None,
        id_column: str = "site_id",
        nv_suffix: str = "nv",
    ) -> "GeoPromptFrame":
        """Assign each point to nearest facility via network or Euclidean distance."""
        fac_recs = facilities.to_records()
        if not fac_recs or not self._rows:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        fac_centroids = facilities._centroids()
        centroids = self._centroids()
        rows: list[Record] = []
        for i, c in enumerate(centroids):
            min_d = float("inf")
            nearest_fac = None
            for fi, fc in enumerate(fac_centroids):
                d = math.hypot(c[0] - fc[0], c[1] - fc[1])
                if d < min_d:
                    min_d = d
                    nearest_fac = str(fac_recs[fi].get(id_column, fi))
            resolved = dict(self._rows[i])
            resolved[f"facility_{nv_suffix}"] = nearest_fac
            resolved[f"distance_{nv_suffix}"] = min_d
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 116: max_flow — Maximum flow on spatial network
    # ------------------------------------------------------------------
    def max_flow(
        self,
        source_id: str,
        sink_id: str,
        capacity_column: str = "capacity",
        id_column: str = "site_id",
        flow_suffix: str = "flow",
    ) -> dict[str, Any]:
        """Compute max flow / min cut on a spatial network using BFS augmentation."""
        self._require_column(id_column)
        self._require_column(capacity_column)
        # Build capacity graph from edge_to / id columns
        cap: dict[str, dict[str, float]] = {}
        for row in self._rows:
            sid = str(row.get(id_column, ""))
            cap.setdefault(sid, {})
            to_id = str(row.get("edge_to", "") or "")
            if to_id:
                c = float(row.get(capacity_column, 1.0) or 1.0)
                cap[sid][to_id] = c
                cap.setdefault(to_id, {}).setdefault(sid, 0.0)
        # Edmonds-Karp (BFS-based Ford-Fulkerson)
        total_flow = 0.0
        flow_dict: dict[str, dict[str, float]] = {u: {v: 0.0 for v in cap.get(u, {})} for u in cap}
        while True:
            # BFS to find augmenting path
            from collections import deque
            parent: dict[str, str | None] = {source_id: None}
            visited_set = {source_id}
            queue: deque[str] = deque([source_id])
            found = False
            while queue:
                u = queue.popleft()
                for v in cap.get(u, {}):
                    residual = cap[u][v] - flow_dict.get(u, {}).get(v, 0.0)
                    if v not in visited_set and residual > 1e-12:
                        parent[v] = u
                        visited_set.add(v)
                        if v == sink_id:
                            found = True
                            break
                        queue.append(v)
                if found:
                    break
            if not found:
                break
            # Find bottleneck
            path_flow = float("inf")
            v = sink_id
            while True:
                parent_node = parent.get(v)
                if parent_node is None:
                    break
                path_flow = min(path_flow, cap[parent_node][v] - flow_dict.get(parent_node, {}).get(v, 0.0))
                v = parent_node
            # Update flow
            v = sink_id
            while True:
                parent_node = parent.get(v)
                if parent_node is None:
                    break
                flow_dict.setdefault(parent_node, {})[v] = flow_dict.get(parent_node, {}).get(v, 0.0) + path_flow
                flow_dict.setdefault(v, {})[parent_node] = flow_dict.get(v, {}).get(parent_node, 0.0) - path_flow
                v = parent_node
            total_flow += path_flow
        return {"max_flow": total_flow, "source": source_id, "sink": sink_id}

    # ------------------------------------------------------------------
    # Tool 117: k_cross_function — Bivariate Ripley's K
    # ------------------------------------------------------------------
    def k_cross_function(
        self,
        other: "GeoPromptFrame",
        n_distances: int = 20,
        max_distance: float | None = None,
        edge_correction: bool = True,
    ) -> list[Record]:
        """K-cross function between two point patterns."""
        c1 = self._centroids()
        c2 = other._centroids()
        n1, n2 = len(c1), len(c2)
        if n1 == 0 or n2 == 0:
            return []
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        all_dists = [math.hypot(c1[i][0] - c2[j][0], c1[i][1] - c2[j][1]) for i in range(n1) for j in range(n2)]
        md = max_distance if max_distance is not None else max(all_dists) * 0.5
        step = md / n_distances
        results: list[Record] = []
        for di in range(n_distances):
            d = (di + 1) * step
            count = sum(1 for dist in all_dists if dist <= d)
            k_val = area * count / (n1 * n2)
            l_val = math.sqrt(k_val / math.pi)
            results.append({"distance": d, "k_cross": k_val, "l_cross": l_val, "l_minus_d": l_val - d})
        return results

    # ------------------------------------------------------------------
    # Tool 118: pair_correlation_function — g(r) unnormalized K derivative
    # ------------------------------------------------------------------
    def pair_correlation_function(
        self,
        n_distances: int = 20,
        max_distance: float | None = None,
        bandwidth: float | None = None,
    ) -> list[Record]:
        """Pair correlation function g(r) — derivative of K(r)."""
        centroids = self._centroids()
        n = len(centroids)
        if n < 2:
            return []
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        lambda_hat = n / area
        all_dists = [math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for i in range(n) for j in range(i + 1, n)]
        md = max_distance if max_distance is not None else max(all_dists) * 0.5
        step = md / n_distances
        h = bandwidth if bandwidth is not None else step * 1.5
        results: list[Record] = []
        for di in range(n_distances):
            d = (di + 1) * step
            if d < 1e-12:
                continue
            # Kernel density of pair distances at distance d
            kernel_sum = sum(math.exp(-0.5 * ((dist - d) / h) ** 2) for dist in all_dists)
            g_val = kernel_sum / (2.0 * math.pi * d * n * lambda_hat * h * math.sqrt(2 * math.pi))
            results.append({"distance": d, "g_r": g_val, "expected_g": 1.0})
        return results

    # ------------------------------------------------------------------
    # Tool 119: nn_g_function — Nearest-neighbor G function
    # ------------------------------------------------------------------
    def nn_g_function(
        self,
        n_distances: int = 20,
        max_distance: float | None = None,
    ) -> list[Record]:
        """Nearest-neighbor distribution G(r) — CDF of NN distances."""
        centroids = self._centroids()
        n = len(centroids)
        if n < 2:
            return []
        nn_dists = []
        for i in range(n):
            min_d = min(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for j in range(n) if j != i)
            nn_dists.append(min_d)
        nn_dists.sort()
        md = max_distance if max_distance is not None else max(nn_dists) * 1.2
        step = md / n_distances
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        lambda_hat = n / area
        results: list[Record] = []
        for di in range(n_distances):
            d = (di + 1) * step
            empirical = sum(1 for nd in nn_dists if nd <= d) / n
            theoretical = 1.0 - math.exp(-lambda_hat * math.pi * d * d)
            results.append({"distance": d, "g_empirical": empirical, "g_theoretical": theoretical, "g_diff": empirical - theoretical})
        return results

    # ------------------------------------------------------------------
    # Tool 120: empty_space_f_function — CDF of point-to-nearest-event
    # ------------------------------------------------------------------
    def empty_space_f_function(
        self,
        n_distances: int = 20,
        n_random: int = 200,
        max_distance: float | None = None,
        random_seed: int = 42,
    ) -> list[Record]:
        """Empty space F function — CDF of distances from random points to nearest event."""
        centroids = self._centroids()
        n = len(centroids)
        if n == 0:
            return []
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        lambda_hat = n / area
        rng = random.Random(random_seed)
        # Generate random test points
        random_dists = []
        for _ in range(n_random):
            rx = b.min_x + rng.random() * (b.max_x - b.min_x)
            ry = b.min_y + rng.random() * (b.max_y - b.min_y)
            min_d = min(math.hypot(rx - c[0], ry - c[1]) for c in centroids)
            random_dists.append(min_d)
        random_dists.sort()
        md = max_distance if max_distance is not None else max(random_dists) * 1.2
        step = md / n_distances
        results: list[Record] = []
        for di in range(n_distances):
            d = (di + 1) * step
            empirical = sum(1 for rd in random_dists if rd <= d) / n_random
            theoretical = 1.0 - math.exp(-lambda_hat * math.pi * d * d)
            results.append({"distance": d, "f_empirical": empirical, "f_theoretical": theoretical, "f_diff": empirical - theoretical})
        return results

    # ------------------------------------------------------------------
    # Tool 121: minimum_bounding_circle
    # ------------------------------------------------------------------
    def minimum_bounding_circle(
        self,
        mbc_suffix: str = "mbc",
    ) -> "GeoPromptFrame":
        """Compute minimum bounding circle for each geometry."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            verts = list(geometry_vertices(geom)) if geom else []
            if not verts:
                resolved = dict(row)
                resolved[f"center_x_{mbc_suffix}"] = None
                resolved[f"center_y_{mbc_suffix}"] = None
                resolved[f"radius_{mbc_suffix}"] = None
                rows.append(resolved)
                continue
            cx, cy, radius = _welzl_mbc(verts)
            resolved = dict(row)
            resolved[f"center_x_{mbc_suffix}"] = cx
            resolved[f"center_y_{mbc_suffix}"] = cy
            resolved[f"radius_{mbc_suffix}"] = radius
            resolved[f"area_{mbc_suffix}"] = math.pi * radius * radius
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 122: minimum_bounding_rectangle
    # ------------------------------------------------------------------
    def minimum_bounding_rectangle(
        self,
        mbr_suffix: str = "mbr",
    ) -> "GeoPromptFrame":
        """Compute minimum-area bounding rectangle (rotated) for each geometry."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            verts = list(geometry_vertices(geom)) if geom else []
            if len(verts) < 3:
                resolved = dict(row)
                resolved[f"width_{mbr_suffix}"] = None
                resolved[f"height_{mbr_suffix}"] = None
                resolved[f"angle_{mbr_suffix}"] = None
                resolved[f"area_{mbr_suffix}"] = None
                rows.append(resolved)
                continue
            # Rotating calipers on convex hull
            hull = _convex_hull_2d(verts)
            best_area = float("inf")
            best_dims = (0.0, 0.0, 0.0)
            for i in range(len(hull)):
                edge_x = hull[(i + 1) % len(hull)][0] - hull[i][0]
                edge_y = hull[(i + 1) % len(hull)][1] - hull[i][1]
                edge_len = math.hypot(edge_x, edge_y)
                if edge_len < 1e-12:
                    continue
                ux, uy = edge_x / edge_len, edge_y / edge_len
                vx, vy = -uy, ux
                projected_u = [p[0] * ux + p[1] * uy for p in hull]
                projected_v = [p[0] * vx + p[1] * vy for p in hull]
                w = max(projected_u) - min(projected_u)
                h = max(projected_v) - min(projected_v)
                a = w * h
                if a < best_area:
                    best_area = a
                    angle = math.degrees(math.atan2(uy, ux))
                    best_dims = (w, h, angle)
            resolved = dict(row)
            resolved[f"width_{mbr_suffix}"] = best_dims[0]
            resolved[f"height_{mbr_suffix}"] = best_dims[1]
            resolved[f"angle_{mbr_suffix}"] = best_dims[2]
            resolved[f"area_{mbr_suffix}"] = best_dims[0] * best_dims[1]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 123: hausdorff_distance — Shape similarity metric
    # ------------------------------------------------------------------
    def hausdorff_distance(
        self,
        other: "GeoPromptFrame",
    ) -> float:
        """Compute Hausdorff distance between two geometries (max of directed distances)."""
        v1 = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if geom:
                v1.extend(geometry_vertices(geom))
        v2 = []
        for row in other._rows:
            geom = row.get(other.geometry_column)
            if geom:
                v2.extend(geometry_vertices(geom))
        if not v1 or not v2:
            return float("inf")
        def directed(a: list[Coordinate], b_pts: list[Coordinate]) -> float:
            return max(min(math.hypot(p[0] - q[0], p[1] - q[1]) for q in b_pts) for p in a)
        return max(directed(v1, v2), directed(v2, v1))

    # ------------------------------------------------------------------
    # Tool 124: snap_to_grid — Coordinate rounding/snapping
    # ------------------------------------------------------------------
    def snap_to_grid(
        self,
        cell_size: float = 1.0,
    ) -> "GeoPromptFrame":
        """Snap all geometry coordinates to a regular grid."""
        if cell_size <= 0:
            raise ValueError("cell_size must be positive")
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            geom = row.get(self.geometry_column)
            if geom:
                resolved[self.geometry_column] = _snap_geometry_to_grid(geom, cell_size)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 125: polygon_skeleton — Medial axis approximation
    # ------------------------------------------------------------------
    def polygon_skeleton(
        self,
        step: float | None = None,
        skel_suffix: str = "skel",
    ) -> "GeoPromptFrame":
        """Approximate medial axis / skeleton of polygons via interior sampling."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom or geometry_type(geom) != "Polygon":
                rows.append(dict(row))
                continue
            verts = geometry_vertices(geom) or []
            if len(verts) < 3:
                rows.append(dict(row))
                continue
            bb = geometry_bounds(geom)
            s = step if step else max(bb[2] - bb[0], bb[3] - bb[1]) / 20.0
            # Sample interior points and find max-inscribed-circle-radius
            skeleton_points: list[Coordinate] = []
            for gx in range(int((bb[2] - bb[0]) / s) + 1):
                px = bb[0] + gx * s
                for gy in range(int((bb[3] - bb[1]) / s) + 1):
                    py = bb[1] + gy * s
                    if geometry_contains(geom, {"type": "Point", "coordinates": (px, py)}):
                        # Distance to nearest edge
                        min_edge_d = float("inf")
                        for vi in range(len(verts) - 1):
                            d = _perpendicular_distance((px, py), verts[vi], verts[vi + 1])
                            min_edge_d = min(min_edge_d, d)
                        skeleton_points.append((px, py))
            if skeleton_points:
                resolved = dict(row)
                resolved[self.geometry_column] = {"type": "MultiPoint", "coordinates": skeleton_points}
                resolved[f"skeleton_count_{skel_suffix}"] = len(skeleton_points)
                rows.append(resolved)
            else:
                rows.append(dict(row))
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 126: wkt_to_geometry / geometry_to_wkt — WKT/WKB conversion
    # ------------------------------------------------------------------
    def wkt_to_geometry(
        self,
        wkt_column: str,
        output_column: str | None = None,
    ) -> "GeoPromptFrame":
        """Parse WKT strings into GeoJSON geometry."""
        self._require_column(wkt_column)
        out_col = output_column or self.geometry_column
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            wkt = str(row.get(wkt_column, "") or "")
            resolved[out_col] = _parse_wkt(wkt)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=out_col, crs=self.crs)

    def geometry_to_wkt(
        self,
        wkt_column: str = "wkt",
    ) -> "GeoPromptFrame":
        """Convert geometry to WKT string."""
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            geom = row.get(self.geometry_column)
            resolved[wkt_column] = _geometry_to_wkt(geom) if geom else None
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 127: spatial_markov — Transition probabilities
    # ------------------------------------------------------------------
    def spatial_markov(
        self,
        value_column: str,
        time_column: str,
        n_classes: int = 4,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        distance_method: str = "euclidean",
    ) -> dict[str, Any]:
        """Spatial Markov chains: transition probabilities between spatial states."""
        self._require_column(value_column)
        self._require_column(time_column)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        times = sorted({time_value for row in self._rows if (time_value := row.get(time_column)) is not None})
        if len(times) < 2:
            return {"transitions": {}, "n_classes": n_classes}
        # Classify values into quantile-based classes
        sorted_vals = sorted(values)
        n = len(sorted_vals)
        breaks = [sorted_vals[int(i * n / n_classes)] for i in range(1, n_classes)]
        def classify(v: float) -> int:
            for ci, br in enumerate(breaks):
                if v <= br:
                    return ci
            return n_classes - 1
        # Build transition matrix
        transitions = [[0] * n_classes for _ in range(n_classes)]
        id_to_rows: dict[Any, list[Record]] = {}
        for row in self._rows:
            sid = row.get("site_id", id(row))
            id_to_rows.setdefault(sid, []).append(row)
        for sid, rows_list in id_to_rows.items():
            rows_sorted = sorted(rows_list, key=lambda r: r.get(time_column, 0))
            for i in range(len(rows_sorted) - 1):
                c1 = classify(float(rows_sorted[i].get(value_column, 0) or 0))
                c2 = classify(float(rows_sorted[i + 1].get(value_column, 0) or 0))
                transitions[c1][c2] += 1
        # Normalize to probabilities
        probs = []
        for row_t in transitions:
            s = sum(row_t)
            probs.append([v / s if s > 0 else 0.0 for v in row_t])
        return {"transition_matrix": probs, "transition_counts": transitions, "n_classes": n_classes, "class_breaks": breaks}

    # ------------------------------------------------------------------
    # Tool 128: spatially_constrained_clustering — SKATER-like
    # ------------------------------------------------------------------
    def spatially_constrained_clustering(
        self,
        value_columns: list[str],
        n_clusters: int = 5,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        distance_method: str = "euclidean",
        scc_suffix: str = "scc",
    ) -> "GeoPromptFrame":
        """Spatially constrained clustering (SKATER-like regionalization)."""
        for col in value_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        # Build adjacency (MST on attribute-weighted spatial graph)
        vals = [[float(row.get(c, 0) or 0) for c in value_columns] for row in self._rows]
        # Normalize attributes
        for ci in range(len(value_columns)):
            col_vals = [v[ci] for v in vals]
            mn, mx = min(col_vals), max(col_vals)
            rng_v = mx - mn if mx > mn else 1.0
            for i in range(n):
                vals[i][ci] = (vals[i][ci] - mn) / rng_v
        # Build spatial adjacency
        adj: list[list[int]] = []
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                adj.append([j for j, _ in dists[:k]])
            else:
                md = float("inf")
                adj.append([j for j in range(n) if j != i and dm[i][j] <= md])
        # Build MST using spatial adjacency with attribute distance
        mst_edges: list[tuple[float, int, int]] = []
        for i in range(n):
            for j in adj[i]:
                if j > i:
                    attr_d = math.sqrt(sum((vals[i][c] - vals[j][c]) ** 2 for c in range(len(value_columns))))
                    mst_edges.append((attr_d, i, j))
        mst_edges.sort()
        # Kruskal's MST
        parent = list(range(n))
        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        tree_edges: list[tuple[float, int, int]] = []
        for cost, u, v in mst_edges:
            ru, rv = find(u), find(v)
            if ru != rv:
                parent[ru] = rv
                tree_edges.append((cost, u, v))
                if len(tree_edges) == n - 1:
                    break
        # Remove k-1 most expensive tree edges to get k clusters
        tree_edges.sort(reverse=True)
        remove_set = set()
        for i in range(min(n_clusters - 1, len(tree_edges))):
            remove_set.add((tree_edges[i][1], tree_edges[i][2]))
        # Rebuild components
        parent2 = list(range(n))
        def find2(x: int) -> int:
            while parent2[x] != x:
                parent2[x] = parent2[parent2[x]]
                x = parent2[x]
            return x
        for cost, u, v in tree_edges:
            if (u, v) not in remove_set:
                ru, rv = find2(u), find2(v)
                if ru != rv:
                    parent2[ru] = rv
        # Assign cluster labels
        root_to_cluster: dict[int, int] = {}
        cluster_count = 0
        labels = [0] * n
        for i in range(n):
            r = find2(i)
            if r not in root_to_cluster:
                root_to_cluster[r] = cluster_count
                cluster_count += 1
            labels[i] = root_to_cluster[r]
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{scc_suffix}"] = labels[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 129: max_p_regions — Maximize regions with constraint
    # ------------------------------------------------------------------
    def max_p_regions(
        self,
        value_column: str,
        threshold: float,
        threshold_column: str | None = None,
        mode: SpatialLagMode = "k_nearest",
        k: int = 4,
        distance_method: str = "euclidean",
        maxp_suffix: str = "maxp",
    ) -> "GeoPromptFrame":
        """Max-p regionalization: maximize number of regions subject to a minimum threshold."""
        self._require_column(value_column)
        tc = threshold_column or value_column
        self._require_column(tc)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dm = _pairwise_distance_matrix(centroids, distance_method)
        thresh_vals = [float(row.get(tc, 0) or 0) for row in self._rows]
        # Build adjacency
        adj: list[set[int]] = [set() for _ in range(n)]
        for i in range(n):
            if mode == "k_nearest":
                dists = sorted(((j, dm[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
                for j, _ in dists[:k]:
                    adj[i].add(j)
                    adj[j].add(i)
            else:
                md = float("inf")
                for j in range(n):
                    if j != i and dm[i][j] <= md:
                        adj[i].add(j)
                        adj[j].add(i)
        # Greedy region growing
        assigned = [-1] * n
        region_id = 0
        unassigned = set(range(n))
        while unassigned:
            # Start from unassigned point with largest threshold value
            seed = max(unassigned, key=lambda i: thresh_vals[i])
            region = {seed}
            assigned[seed] = region_id
            unassigned.discard(seed)
            region_sum = thresh_vals[seed]
            # Grow until threshold met
            while region_sum < threshold and unassigned:
                # Find best neighbor to add
                candidates = []
                for i in region:
                    for j in adj[i]:
                        if j in unassigned:
                            candidates.append(j)
                if not candidates:
                    break
                best = max(candidates, key=lambda j: thresh_vals[j])
                region.add(best)
                assigned[best] = region_id
                unassigned.discard(best)
                region_sum += thresh_vals[best]
            region_id += 1
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"region_{maxp_suffix}"] = assigned[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 130: spatial_regime_model — Different coefficients per region
    # ------------------------------------------------------------------
    def spatial_regime_model(
        self,
        dependent: str,
        independents: list[str],
        regime_column: str,
        regime_suffix: str = "regime",
    ) -> "GeoPromptFrame":
        """Spatial regime regression: separate OLS per regime."""
        self._require_column(dependent)
        for col in independents:
            self._require_column(col)
        self._require_column(regime_column)
        n = len(self._rows)
        if n < len(independents) + 1:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        regimes: dict[str, list[int]] = {}
        for i, row in enumerate(self._rows):
            r = str(row.get(regime_column, "default"))
            regimes.setdefault(r, []).append(i)
        y_all = [float(row.get(dependent, 0) or 0) for row in self._rows]
        predicted = [0.0] * n
        residuals = [0.0] * n
        coefficients: dict[str, list[float]] = {}
        for regime, idxs in regimes.items():
            if len(idxs) < len(independents) + 1:
                for i in idxs:
                    predicted[i] = sum(y_all[i] for i in idxs) / len(idxs)
                    residuals[i] = y_all[i] - predicted[i]
                continue
            y = [y_all[i] for i in idxs]
            X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in independents] for i in idxs]
            p = len(X[0])
            XtX = [[sum(X[r][a] * X[r][b] for r in range(len(idxs))) for b in range(p)] for a in range(p)]
            Xty = [sum(X[r][a] * y[r] for r in range(len(idxs))) for a in range(p)]
            beta = _solve_linear_system_with_regularization(XtX, Xty) or [0.0] * p
            coefficients[regime] = beta
            for ri, i in enumerate(idxs):
                predicted[i] = sum(beta[a] * X[ri][a] for a in range(p))
                residuals[i] = y_all[i] - predicted[i]
        ss_res = sum(r * r for r in residuals)
        ss_tot = sum((y_all[i] - sum(y_all) / n) ** 2 for i in range(n))
        r_squared = 1.0 - ss_res / max(ss_tot, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{regime_suffix}"] = predicted[i]
            resolved[f"residual_{regime_suffix}"] = residuals[i]
            resolved[f"r_squared_{regime_suffix}"] = r_squared
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 131: co_kriging — Multivariate kriging
    # ------------------------------------------------------------------
    def co_kriging(
        self,
        primary_column: str,
        secondary_column: str,
        grid_resolution: int = 20,
        cok_suffix: str = "cok",
    ) -> "GeoPromptFrame":
        """Co-kriging using a correlated secondary variable."""
        self._require_column(primary_column)
        self._require_column(secondary_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        n = len(self._rows)
        pv = [float(row.get(primary_column, 0) or 0) for row in self._rows]
        sv = [float(row.get(secondary_column, 0) or 0) for row in self._rows]
        pm, sm = sum(pv) / n, sum(sv) / n
        pc = [v - pm for v in pv]
        sc = [v - sm for v in sv]
        var_s = sum(v * v for v in sc) / n
        cov_ps = sum(pc[i] * sc[i] for i in range(n)) / n
        beta = cov_ps / max(var_s, 1e-12)
        adjusted = [pv[i] + beta * (sm - sv[i]) for i in range(n)]
        temp_rows = [dict(r) for r in self._rows]
        for i, r in enumerate(temp_rows):
            r["__cok_adj"] = adjusted[i]
        temp = GeoPromptFrame._from_internal_rows(temp_rows, geometry_column=self.geometry_column, crs=self.crs)
        result = temp.kriging_surface("__cok_adj", grid_resolution=grid_resolution, kriging_suffix=cok_suffix)
        out: list[Record] = []
        for rec in result.to_records():
            resolved = dict(rec)
            resolved.pop("__cok_adj", None)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 132: empirical_bayesian_kriging
    # ------------------------------------------------------------------
    def empirical_bayesian_kriging(
        self,
        value_column: str,
        grid_resolution: int = 20,
        n_subsets: int = 5,
        subset_size: int | None = None,
        ebk_suffix: str = "ebk",
    ) -> "GeoPromptFrame":
        """Empirical Bayesian Kriging — average predictions from multiple local variograms."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        if subset_size is None:
            subset_size = max(n // n_subsets, 5)
        import random as _rng
        rng = _rng.Random(42)
        indices = list(range(n))
        g = grid_resolution
        grid_data, b, dx, dy = self._idw_grid(value_column, g, "euclidean")
        accum = [[0.0] * g for _ in range(g)]
        count = 0
        for _ in range(n_subsets):
            rng.shuffle(indices)
            sub_rows = [self._rows[i] for i in indices[:subset_size]]
            sub = GeoPromptFrame._from_internal_rows(sub_rows, geometry_column=self.geometry_column, crs=self.crs)
            try:
                kr = sub.kriging_surface(value_column, grid_resolution=g, kriging_suffix="_ebktmp")
                kr_recs = kr.to_records()
                if len(kr_recs) == g * g:
                    for idx_r, rec in enumerate(kr_recs):
                        gy_i, gx_i = divmod(idx_r, g)
                        accum[gy_i][gx_i] += float(rec.get("value__ebktmp", 0) or 0)
                    count += 1
            except Exception:
                continue
        if count == 0:
            count = 1
        rows: list[Record] = []
        for gy in range(g):
            for gx in range(g):
                cx = b.min_x + (gx + 0.5) * dx
                cy = b.min_y + (gy + 0.5) * dy
                rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                    f"value_{ebk_suffix}": accum[gy][gx] / count,
                    f"n_models_{ebk_suffix}": count,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 133: anisotropic_idw
    # ------------------------------------------------------------------
    def anisotropic_idw(
        self,
        value_column: str,
        grid_resolution: int = 20,
        angle: float = 0.0,
        ratio: float = 2.0,
        power: float = 2.0,
        aidw_suffix: str = "aidw",
    ) -> "GeoPromptFrame":
        """Anisotropic IDW interpolation with directional weighting."""
        self._require_column(value_column)
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g - 1, 1)
        dy = (b.max_y - b.min_y) / max(g - 1, 1)
        cos_a = math.cos(math.radians(angle))
        sin_a = math.sin(math.radians(angle))
        rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                weight_sum = 0.0
                value_sum = 0.0
                for ci, c in enumerate(centroids):
                    ddx = px - c[0]
                    ddy = py - c[1]
                    u = ddx * cos_a + ddy * sin_a
                    v = -ddx * sin_a + ddy * cos_a
                    d = math.sqrt(u * u + (v * ratio) ** 2)
                    w = 1.0 / max(d ** power, 1e-12)
                    weight_sum += w
                    value_sum += w * values[ci]
                rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{aidw_suffix}": value_sum / weight_sum if weight_sum > 0 else 0.0,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 134: som_spatial — Self-Organizing Map
    # ------------------------------------------------------------------
    def som_spatial(
        self,
        feature_columns: list[str] | None = None,
        grid_rows: int = 3,
        grid_cols: int = 3,
        n_iterations: int = 200,
        learning_rate: float = 0.5,
        random_seed: int = 42,
        som_suffix: str = "som",
    ) -> "GeoPromptFrame":
        """Self-Organizing Map (SOM) clustering."""
        if feature_columns:
            for col in feature_columns:
                self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vecs: list[list[float]] = []
        for i, c in enumerate(centroids):
            v = [c[0], c[1]]
            if feature_columns:
                for col in feature_columns:
                    v.append(float(self._rows[i].get(col, 0) or 0))
            vecs.append(v)
        dim = len(vecs[0])
        mins = [min(vecs[j][d] for j in range(n)) for d in range(dim)]
        maxs = [max(vecs[j][d] for j in range(n)) for d in range(dim)]
        rngs = [maxs[d] - mins[d] if maxs[d] != mins[d] else 1.0 for d in range(dim)]
        norm = [[(vecs[j][d] - mins[d]) / rngs[d] for d in range(dim)] for j in range(n)]
        import random as _rng
        rng = _rng.Random(random_seed)
        weights = [[[rng.random() for _ in range(dim)] for _ in range(grid_cols)] for _ in range(grid_rows)]
        for it in range(n_iterations):
            lr = learning_rate * (1.0 - it / n_iterations)
            radius = max(max(grid_rows, grid_cols) * (1.0 - it / n_iterations), 1.0)
            idx = rng.randint(0, n - 1)
            sample = norm[idx]
            best_r, best_c, best_d = 0, 0, float("inf")
            for r in range(grid_rows):
                for c in range(grid_cols):
                    d = sum((weights[r][c][dd] - sample[dd]) ** 2 for dd in range(dim))
                    if d < best_d:
                        best_r, best_c, best_d = r, c, d
            for r in range(grid_rows):
                for c in range(grid_cols):
                    dist_sq = (r - best_r) ** 2 + (c - best_c) ** 2
                    if dist_sq <= radius * radius:
                        influence = math.exp(-dist_sq / (2 * radius * radius)) * lr
                        for dd in range(dim):
                            weights[r][c][dd] += influence * (sample[dd] - weights[r][c][dd])
        rows: list[Record] = []
        for i in range(n):
            best_r, best_c, best_d = 0, 0, float("inf")
            for r in range(grid_rows):
                for c in range(grid_cols):
                    d = sum((weights[r][c][dd] - norm[i][dd]) ** 2 for dd in range(dim))
                    if d < best_d:
                        best_r, best_c, best_d = r, c, d
            resolved = dict(self._rows[i])
            resolved[f"cluster_{som_suffix}"] = best_r * grid_cols + best_c
            resolved[f"bmu_row_{som_suffix}"] = best_r
            resolved[f"bmu_col_{som_suffix}"] = best_c
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 135: mgwr — Multiscale GWR
    # ------------------------------------------------------------------
    def mgwr(
        self,
        dependent_column: str,
        independent_columns: list[str],
        bandwidths: list[float] | None = None,
        distance_method: str = "euclidean",
        mgwr_suffix: str = "mgwr",
    ) -> "GeoPromptFrame":
        """Multiscale GWR — per-variable bandwidths."""
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        p = len(independent_columns) + 1
        y = [float(self._rows[i].get(dependent_column, 0) or 0) for i in range(n)]
        X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in independent_columns] for i in range(n)]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        if bandwidths is None:
            dists_flat = sorted(set(dist_mat[i][j] for i in range(n) for j in range(n) if i != j))
            median_d = dists_flat[len(dists_flat) // 2] if dists_flat else 1.0
            bandwidths = [median_d * (0.5 + 0.5 * k) for k in range(p)]
        if len(bandwidths) < p:
            bandwidths = bandwidths + [bandwidths[-1]] * (p - len(bandwidths))
        predictions = [0.0] * n
        coefficients = [[0.0] * p for _ in range(n)]
        for i in range(n):
            W = [0.0] * n
            for j in range(n):
                w = 1.0
                for k_idx in range(p):
                    bw = max(bandwidths[k_idx], 1e-12)
                    w *= gaussian_kernel(dist_mat[i][j], bw)
                W[j] = w
            XtWX = [[0.0] * p for _ in range(p)]
            XtWy = [0.0] * p
            for j in range(n):
                for a in range(p):
                    XtWy[a] += X[j][a] * W[j] * y[j]
                    for bb in range(p):
                        XtWX[a][bb] += X[j][a] * W[j] * X[j][bb]
            for a in range(p):
                XtWX[a][a] += 1e-10
            beta = _solve_linear_system(XtWX, XtWy)
            if beta is None:
                beta = [0.0] * p
            predictions[i] = sum(beta[a] * X[i][a] for a in range(p))
            coefficients[i] = beta
        ss_res = sum((y[i] - predictions[i]) ** 2 for i in range(n))
        y_mean = sum(y) / n
        ss_tot = sum((y[i] - y_mean) ** 2 for i in range(n))
        r2 = 1.0 - ss_res / max(ss_tot, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{mgwr_suffix}"] = predictions[i]
            resolved[f"residual_{mgwr_suffix}"] = y[i] - predictions[i]
            resolved[f"r_squared_{mgwr_suffix}"] = r2
            for k_idx, col in enumerate(["intercept"] + independent_columns):
                resolved[f"coeff_{col}_{mgwr_suffix}"] = coefficients[i][k_idx]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 136: gwr_poisson — GWR for count outcomes
    # ------------------------------------------------------------------
    def gwr_poisson(
        self,
        dependent_column: str,
        independent_columns: list[str],
        bandwidth: float | None = None,
        distance_method: str = "euclidean",
        max_iter: int = 20,
        gwrp_suffix: str = "gwrp",
    ) -> "GeoPromptFrame":
        """GWR with Poisson link function for count data."""
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        p = len(independent_columns) + 1
        y = [float(self._rows[i].get(dependent_column, 0) or 0) for i in range(n)]
        X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in independent_columns] for i in range(n)]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        if bandwidth is None:
            dists_flat = sorted(set(dist_mat[i][j] for i in range(n) for j in range(n) if i != j))
            bandwidth = dists_flat[len(dists_flat) // 2] if dists_flat else 1.0
        predictions = [0.0] * n
        coefficients = [[0.0] * p for _ in range(n)]
        for i in range(n):
            W = [gaussian_kernel(dist_mat[i][j], max(bandwidth, 1e-12)) for j in range(n)]
            beta = [0.0] * p
            for _it in range(max_iter):
                mu = [max(math.exp(min(sum(beta[a] * X[j][a] for a in range(p)), 20)), 1e-12) for j in range(n)]
                z = [sum(beta[a] * X[j][a] for a in range(p)) + (y[j] - mu[j]) / mu[j] for j in range(n)]
                WM = [W[j] * mu[j] for j in range(n)]
                XtWX = [[0.0] * p for _ in range(p)]
                XtWz = [0.0] * p
                for j in range(n):
                    for a in range(p):
                        XtWz[a] += X[j][a] * WM[j] * z[j]
                        for bb in range(p):
                            XtWX[a][bb] += X[j][a] * WM[j] * X[j][bb]
                for a in range(p):
                    XtWX[a][a] += 1e-10
                new_beta = _solve_linear_system(XtWX, XtWz)
                if new_beta is None:
                    break
                beta = new_beta
            predictions[i] = max(math.exp(min(sum(beta[a] * X[i][a] for a in range(p)), 20)), 1e-12)
            coefficients[i] = beta
        ss_res = sum((y[i] - predictions[i]) ** 2 for i in range(n))
        y_mean = sum(y) / n
        ss_tot = sum((y[i] - y_mean) ** 2 for i in range(n))
        r2 = 1.0 - ss_res / max(ss_tot, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{gwrp_suffix}"] = predictions[i]
            resolved[f"residual_{gwrp_suffix}"] = y[i] - predictions[i]
            resolved[f"r_squared_{gwrp_suffix}"] = r2
            for k_idx, col in enumerate(["intercept"] + independent_columns):
                resolved[f"coeff_{col}_{gwrp_suffix}"] = coefficients[i][k_idx]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 137: spacetime_morans_i
    # ------------------------------------------------------------------
    def spacetime_morans_i(
        self,
        value_column: str,
        time_column: str,
        k: int = 4,
        distance_method: str = "euclidean",
    ) -> dict[str, Any]:
        """Space-time Moran's I across temporal slices."""
        self._require_column(value_column)
        self._require_column(time_column)
        if not self._rows:
            return {"global_morans_i": None, "time_slices": []}
        times = sorted({time_value for row in self._rows if (time_value := row.get(time_column)) is not None}, key=str)
        slices: list[dict[str, Any]] = []
        for t in times:
            sub_rows = [r for r in self._rows if r.get(time_column) == t]
            if len(sub_rows) < 3:
                slices.append({"time": t, "morans_i": None, "n": len(sub_rows)})
                continue
            sub = GeoPromptFrame._from_internal_rows(sub_rows, geometry_column=self.geometry_column, crs=self.crs)
            auto = sub.spatial_autocorrelation(value_column, k=k, distance_method=distance_method)
            recs = auto.to_records()
            mi = recs[0].get("global_moran_autocorr") if recs else None
            slices.append({"time": t, "morans_i": mi, "n": len(sub_rows)})
        valid = [s["morans_i"] for s in slices if s["morans_i"] is not None]
        global_avg = sum(valid) / len(valid) if valid else None
        return {"global_morans_i": global_avg, "time_slices": slices}

    # ------------------------------------------------------------------
    # Tool 138: watershed_delineation
    # ------------------------------------------------------------------
    def watershed_delineation(
        self,
        elevation_column: str,
        pour_points: "GeoPromptFrame | None" = None,
        k: int = 8,
        ws_suffix: str = "ws",
    ) -> "GeoPromptFrame":
        """Delineate watersheds from pour points."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_mat = _pairwise_distance_matrix(centroids, "euclidean")
        k_actual = min(k, n - 1)
        flow_to: list[int | None] = [None] * n
        for i in range(n):
            dists = sorted(((j, dist_mat[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
            best_j: int | None = None
            best_slope = 0.0
            for j, d in dists[:k_actual]:
                if d < 1e-12:
                    continue
                slope = (elevations[i] - elevations[j]) / d
                if slope > best_slope:
                    best_slope = slope
                    best_j = j
            flow_to[i] = best_j
        pour_ids: list[int] = list(range(n))
        for i in range(n):
            current = i
            visited: set[int] = set()
            while flow_to[current] is not None and current not in visited:
                visited.add(current)
                next_node = flow_to[current]
                if next_node is None:
                    break
                current = next_node
            pour_ids[i] = current
        if pour_points is not None and len(pour_points) > 0:
            pp_centroids = pour_points._centroids()
            pour_mapping: dict[int, int] = {}
            for pi in set(pour_ids):
                pc = centroids[pi]
                best_pp, best_d = 0, float("inf")
                for ppi, ppc in enumerate(pp_centroids):
                    d = math.hypot(pc[0] - ppc[0], pc[1] - ppc[1])
                    if d < best_d:
                        best_d = d
                        best_pp = ppi
                pour_mapping[pi] = best_pp
            pour_ids = [pour_mapping.get(p, p) for p in pour_ids]
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"watershed_{ws_suffix}"] = pour_ids[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 139: stream_ordering — Strahler and Shreve
    # ------------------------------------------------------------------
    def stream_ordering(
        self,
        elevation_column: str,
        k: int = 8,
        stream_suffix: str = "stream",
    ) -> "GeoPromptFrame":
        """Strahler and Shreve stream ordering from elevation."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        dist_mat = _pairwise_distance_matrix(centroids, "euclidean")
        k_actual = min(k, n - 1)
        flow_to: list[int | None] = [None] * n
        for i in range(n):
            dists = sorted(((j, dist_mat[i][j]) for j in range(n) if j != i), key=lambda t: t[1])
            best_j: int | None = None
            best_slope = 0.0
            for j, d in dists[:k_actual]:
                if d < 1e-12:
                    continue
                slope = (elevations[i] - elevations[j]) / d
                if slope > best_slope:
                    best_slope = slope
                    best_j = j
            flow_to[i] = best_j
        children: dict[int, list[int]] = {i: [] for i in range(n)}
        for i in range(n):
            if flow_to[i] is not None:
                children[flow_to[i]].append(i)  # type: ignore[index]
        strahler = [1] * n
        shreve = [1] * n
        processed = [False] * n
        for i in sorted(range(n), key=lambda i: -elevations[i]):
            child_orders = [strahler[c] for c in children[i] if processed[c]]
            if child_orders:
                max_order = max(child_orders)
                strahler[i] = max_order + 1 if child_orders.count(max_order) >= 2 else max_order
                shreve[i] = sum(shreve[c] for c in children[i] if processed[c])
            processed[i] = True
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"strahler_{stream_suffix}"] = strahler[i]
            resolved[f"shreve_{stream_suffix}"] = shreve[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 140: time_dependent_routing
    # ------------------------------------------------------------------
    def time_dependent_routing(
        self,
        origin_id: str,
        destination_id: str,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        base_cost_column: str = "cost",
        time_factor_column: str = "time_factor",
        departure_time: float = 0.0,
    ) -> dict[str, Any]:
        """Shortest path with time-dependent edge costs."""
        if not self._rows:
            return {"path": [], "total_cost": float("inf")}
        for _col in (from_node_column, to_node_column, base_cost_column):
            self._require_column(_col)
        graph: dict[str, list[tuple[str, float, float]]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            base_cost = float(row.get(base_cost_column, 1.0) or 1.0)
            tf = float(row.get(time_factor_column, 1.0) or 1.0)
            graph.setdefault(fn, []).append((tn, base_cost, tf))
        import heapq
        dist: dict[str, float] = {origin_id: departure_time}
        prev: dict[str, str | None] = {origin_id: None}
        pq = [(departure_time, origin_id)]
        visited: set[str] = set()
        while pq:
            d_u, u = heapq.heappop(pq)
            if u in visited:
                continue
            visited.add(u)
            if u == destination_id:
                break
            for v, base, tf in graph.get(u, []):
                cost = base * (1.0 + tf * math.sin(d_u * 0.01))
                d_v = d_u + max(cost, 1e-12)
                if d_v < dist.get(v, float("inf")):
                    dist[v] = d_v
                    prev[v] = u
                    heapq.heappush(pq, (d_v, v))
        if destination_id not in dist:
            return {"path": [], "total_cost": float("inf")}
        path: list[str] = []
        cur: str | None = destination_id
        while cur is not None:
            path.append(cur)
            cur = prev.get(cur)
        path.reverse()
        return {"path": path, "total_cost": dist[destination_id] - departure_time}

    # ------------------------------------------------------------------
    # Tool 141: cvrp — Capacitated Vehicle Routing
    # ------------------------------------------------------------------
    def cvrp(
        self,
        depot_id: str,
        demand_column: str = "demand",
        capacity: float = 100.0,
        id_column: str = "site_id",
        distance_method: str = "euclidean",
        cvrp_suffix: str = "cvrp",
    ) -> "GeoPromptFrame":
        """Greedy nearest-neighbor CVRP heuristic."""
        self._require_column(demand_column)
        self._require_column(id_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        ids = [str(row.get(id_column, f"p{i}")) for i, row in enumerate(self._rows)]
        demands = [float(row.get(demand_column, 0) or 0) for row in self._rows]
        depot_idx = next((i for i, sid in enumerate(ids) if sid == depot_id), 0)
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        assigned = [False] * n
        assigned[depot_idx] = True
        routes: list[list[int]] = []
        route_map: dict[int, int] = {depot_idx: 0}
        unvisited = [i for i in range(n) if i != depot_idx]
        route_id = 0
        while unvisited:
            route: list[int] = []
            remaining_cap = capacity
            current = depot_idx
            while True:
                best_j = -1
                best_d = float("inf")
                for j in unvisited:
                    if demands[j] <= remaining_cap and dist_mat[current][j] < best_d:
                        best_d = dist_mat[current][j]
                        best_j = j
                if best_j == -1:
                    break
                route.append(best_j)
                route_map[best_j] = route_id
                remaining_cap -= demands[best_j]
                unvisited.remove(best_j)
                current = best_j
            if not route:
                for j in list(unvisited):
                    route.append(j)
                    route_map[j] = route_id
                    unvisited.remove(j)
            routes.append(route)
            route_id += 1
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"route_{cvrp_suffix}"] = route_map.get(i, 0)
            ri = route_map.get(i, 0)
            resolved[f"sequence_{cvrp_suffix}"] = routes[ri].index(i) if i != depot_idx and i in routes[ri] else 0
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 142: inhomogeneous_k
    # ------------------------------------------------------------------
    def inhomogeneous_k(
        self,
        n_distances: int = 10,
        bandwidth: float | None = None,
    ) -> list[Record]:
        """Inhomogeneous K function adjusting for spatially varying intensity."""
        n = len(self._rows)
        if n < 2:
            return []
        centroids = self._centroids()
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        dist_mat = _pairwise_distance_matrix(centroids, "euclidean")
        if bandwidth is None:
            dists_all = sorted(dist_mat[0][j] for j in range(1, n))
            bandwidth = dists_all[min(4, len(dists_all) - 1)] if dists_all else 1.0
        intensity = [0.0] * n
        for i in range(n):
            s = sum(gaussian_kernel(dist_mat[i][j], max(bandwidth, 1e-12)) for j in range(n))
            intensity[i] = s / (2 * math.pi * bandwidth * bandwidth * n)
        max_d = max(dist_mat[i][j] for i in range(n) for j in range(i + 1, n))
        distances = [max_d * (k + 1) / n_distances for k in range(n_distances)]
        results: list[Record] = []
        for d_thresh in distances:
            k_val = 0.0
            for i in range(n):
                for j in range(n):
                    if i != j and dist_mat[i][j] <= d_thresh:
                        k_val += 1.0 / max(intensity[i] * intensity[j], 1e-24)
            k_val /= area
            l_val = math.sqrt(k_val / math.pi) - d_thresh
            results.append({"distance": d_thresh, "k_inhom": k_val, "l_inhom": l_val})
        return results

    # ------------------------------------------------------------------
    # Tool 143: polygon_subdivision
    # ------------------------------------------------------------------
    def polygon_subdivision(
        self,
        n_divisions: int = 4,
        sub_suffix: str = "sub",
    ) -> "GeoPromptFrame":
        """Subdivide polygons into roughly equal sub-areas."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom or geometry_type(geom) != "Polygon":
                resolved = dict(row)
                resolved[f"subdivision_{sub_suffix}"] = 0
                rows.append(resolved)
                continue
            bb = geometry_bounds(geom)
            cells = [(bb[0], bb[1], bb[2], bb[3])]
            while len(cells) < n_divisions:
                new_cells = []
                for x0, y0, x1, y1 in cells:
                    mx, my = (x0 + x1) / 2, (y0 + y1) / 2
                    new_cells.extend([(x0, y0, mx, my), (mx, y0, x1, my), (x0, my, mx, y1), (mx, my, x1, y1)])
                cells = new_cells
            for ci, (x0, y0, x1, y1) in enumerate(cells[:n_divisions]):
                resolved = dict(row)
                resolved[self.geometry_column] = {
                    "type": "Polygon",
                    "coordinates": [[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]],
                }
                resolved[f"subdivision_{sub_suffix}"] = ci
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 144: line_planarize
    # ------------------------------------------------------------------
    def line_planarize(
        self,
        lp_suffix: str = "lp",
    ) -> "GeoPromptFrame":
        """Planarize a line network by splitting at intersection points."""
        segments: list[tuple[tuple[float, float], tuple[float, float], int]] = []
        for ri, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom:
                continue
            gt = geometry_type(geom)
            if gt == "LineString":
                coords = geom.get("coordinates", [])
                for k in range(len(coords) - 1):
                    segments.append((tuple(coords[k][:2]), tuple(coords[k + 1][:2]), ri))
            elif gt == "MultiLineString":
                for line in geom.get("coordinates", []):
                    for k in range(len(line) - 1):
                        segments.append((tuple(line[k][:2]), tuple(line[k + 1][:2]), ri))
        split_points: dict[int, list[tuple[float, float]]] = {}
        for i in range(len(segments)):
            for j in range(i + 1, len(segments)):
                pts = _segment_intersection_points(segments[i][0], segments[i][1], segments[j][0], segments[j][1])
                for p in pts:
                    if p != segments[i][0] and p != segments[i][1]:
                        split_points.setdefault(i, []).append(p)
                    if p != segments[j][0] and p != segments[j][1]:
                        split_points.setdefault(j, []).append(p)
        rows: list[Record] = []
        seg_id = 0
        for si, (p1, p2, ri) in enumerate(segments):
            pts_on_seg = split_points.get(si, [])
            if not pts_on_seg:
                resolved = dict(self._rows[ri])
                resolved[self.geometry_column] = {"type": "LineString", "coordinates": [list(p1), list(p2)]}
                resolved[f"segment_{lp_suffix}"] = seg_id
                resolved[f"source_row_{lp_suffix}"] = ri
                rows.append(resolved)
                seg_id += 1
            else:
                all_pts = [p1] + sorted(pts_on_seg, key=lambda pt: math.hypot(pt[0] - p1[0], pt[1] - p1[1])) + [p2]
                for k in range(len(all_pts) - 1):
                    resolved = dict(self._rows[ri])
                    resolved[self.geometry_column] = {"type": "LineString", "coordinates": [list(all_pts[k]), list(all_pts[k + 1])]}
                    resolved[f"segment_{lp_suffix}"] = seg_id
                    resolved[f"source_row_{lp_suffix}"] = ri
                    rows.append(resolved)
                    seg_id += 1
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 145: read_shapefile / to_shapefile
    # ------------------------------------------------------------------
    @classmethod
    def read_shapefile(cls, path: str, geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read an ESRI Shapefile. Requires ``pyshp`` or ``fiona``."""
        try:
            shp = importlib.import_module("shapefile")
        except ImportError:
            try:
                fiona = importlib.import_module("fiona")
                with fiona.open(path) as src:
                    crs = crs or str(src.crs)
                    records = [{"geometry": dict(feat["geometry"]), **dict(feat["properties"])} for feat in src]
                    return cls.from_records(records, geometry=geometry, crs=crs)
            except ImportError:
                raise ImportError("Install pyshp or fiona to read shapefiles")
        sf = shp.Reader(path)
        fields = [f[0] for f in sf.fields[1:]]
        records: list[Record] = []
        for sr in sf.shapeRecords():
            rec: Record = dict(zip(fields, sr.record))
            rec[geometry] = sr.shape.__geo_interface__
            records.append(rec)
        return cls.from_records(records, geometry=geometry, crs=crs)

    def to_shapefile(self, path: str, id_column: str = "site_id") -> None:
        """Write to ESRI Shapefile. Requires ``pyshp``."""
        try:
            shp = importlib.import_module("shapefile")
        except ImportError:
            raise ImportError("Install pyshp to write shapefiles")
        w = shp.Writer(path)
        cols = sorted({k for row in self._rows for k in row if k != self.geometry_column})
        for c in cols:
            w.field(c, "C", 254)
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if geom:
                w.shape(geom)
            else:
                w.null()
            w.record(*[str(row.get(c, "")) for c in cols])
        w.close()

    # ------------------------------------------------------------------
    # Tool 146: read_geopackage
    # ------------------------------------------------------------------
    @classmethod
    def read_geopackage(cls, path: str, layer: str | None = None, geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read an OGC GeoPackage. Requires ``fiona`` or falls back to ``sqlite3``."""
        try:
            fiona = importlib.import_module("fiona")
            with fiona.open(path, layer=layer) as src:
                crs = crs or str(src.crs)
                records = [{"geometry": dict(feat["geometry"]), **dict(feat["properties"])} for feat in src]
                return cls.from_records(records, geometry=geometry, crs=crs)
        except ImportError:
            pass
        import sqlite3
        conn = sqlite3.connect(path)
        cursor = conn.cursor()
        cursor.execute("SELECT table_name FROM gpkg_contents WHERE data_type='features' LIMIT 1")
        table_row = cursor.fetchone()
        if not table_row:
            conn.close()
            return cls.from_records([], geometry=geometry, crs=crs)
        table = layer or table_row[0]
        cursor.execute(f'SELECT * FROM "{table}"')  # noqa: S608
        col_names = [desc[0] for desc in cursor.description]
        records_list: list[Record] = []
        for db_row in cursor.fetchall():
            rec = dict(zip(col_names, db_row))
            geom_val = rec.pop("geom", None) or rec.pop(geometry, None)
            if isinstance(geom_val, str) and geom_val.startswith(("{", "[")):
                import json
                rec[geometry] = json.loads(geom_val)
            elif isinstance(geom_val, str):
                rec[geometry] = _parse_wkt(geom_val)
            else:
                rec[geometry] = {"type": "Point", "coordinates": (0, 0)}
            records_list.append(rec)
        conn.close()
        return cls.from_records(records_list, geometry=geometry, crs=crs)

    # ------------------------------------------------------------------
    # Tool 147: read_kml
    # ------------------------------------------------------------------
    @classmethod
    def read_kml(cls, path: str, geometry: str = "geometry", crs: str | None = "EPSG:4326") -> "GeoPromptFrame":
        """Read a KML file (simple Placemark extraction)."""
        import xml.etree.ElementTree as ET
        tree = ET.parse(path)  # noqa: S314
        root = tree.getroot()
        ns = {"kml": "http://www.opengis.net/kml/2.2"}
        records: list[Record] = []
        for pm in root.iter("{http://www.opengis.net/kml/2.2}Placemark"):
            rec: Record = {}
            name_el = pm.find("kml:name", ns)
            if name_el is not None and name_el.text:
                rec["name"] = name_el.text
            point_el = pm.find("kml:Point/kml:coordinates", ns)
            if point_el is not None and point_el.text:
                parts = point_el.text.strip().split(",")
                rec[geometry] = {"type": "Point", "coordinates": (float(parts[0]), float(parts[1]))}
            else:
                ls_el = pm.find("kml:LineString/kml:coordinates", ns)
                if ls_el is not None and ls_el.text:
                    coords = [tuple(float(x) for x in p.split(",")[:2]) for p in ls_el.text.strip().split()]
                    rec[geometry] = {"type": "LineString", "coordinates": coords}
                else:
                    poly_el = pm.find("kml:Polygon/kml:outerBoundaryIs/kml:LinearRing/kml:coordinates", ns)
                    if poly_el is not None and poly_el.text:
                        coords = [tuple(float(x) for x in p.split(",")[:2]) for p in poly_el.text.strip().split()]
                        rec[geometry] = {"type": "Polygon", "coordinates": [coords]}
                    else:
                        continue
            records.append(rec)
        return cls.from_records(records, geometry=geometry, crs=crs)

    # ------------------------------------------------------------------
    # Tool 200: read_geoparquet / to_geoparquet
    # ------------------------------------------------------------------
    @classmethod
    def read_geoparquet(cls, path: str, geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read a GeoParquet file.

        Requires ``geopandas`` with a parquet backend such as ``pyarrow``.
        """
        try:
            import geopandas as gpd
        except ImportError as exc:
            raise ImportError("Install geopandas and pyarrow to read GeoParquet files") from exc
        gdf = gpd.read_parquet(path)
        active_geometry = getattr(gdf, "geometry", None)
        geometry_name = getattr(active_geometry, "name", geometry)
        records: list[Record] = []
        for _, source_row in gdf.iterrows():
            rec = dict(source_row)
            geom = rec.pop(geometry_name, None)
            rec[geometry] = geometry_from_shapely(geom) if geom is not None else None
            records.append(rec)
        frame_crs = crs or (str(getattr(gdf, "crs", "")) if getattr(gdf, "crs", None) else None)
        return cls.from_records(records, geometry=geometry, crs=frame_crs)

    def to_geoparquet(self, path: str, geometry: str = "geometry") -> None:
        """Write the frame to GeoParquet.

        Requires ``geopandas`` with a parquet backend such as ``pyarrow``.
        """
        try:
            import geopandas as gpd
        except ImportError as exc:
            raise ImportError("Install geopandas and pyarrow to write GeoParquet files") from exc
        from .overlay import geometry_to_shapely

        rows: list[Record] = []
        for row in self._rows:
            resolved = {k: v for k, v in row.items() if k != self.geometry_column}
            geom = row.get(self.geometry_column)
            resolved[geometry] = geometry_to_shapely(geom) if geom is not None else None
            rows.append(resolved)
        gdf = gpd.GeoDataFrame(rows, geometry=geometry, crs=self.crs)
        gdf.to_parquet(path)

    # ------------------------------------------------------------------
    # Tool 201: read_flatgeobuf / to_flatgeobuf
    # ------------------------------------------------------------------
    @classmethod
    def read_flatgeobuf(cls, path: str, geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read a FlatGeobuf file.

        Requires ``geopandas`` with an I/O engine backed by Fiona or Pyogrio.
        """
        try:
            import geopandas as gpd
        except ImportError as exc:
            raise ImportError("Install geopandas with fiona or pyogrio to read FlatGeobuf files") from exc
        gdf = gpd.read_file(path)
        active_geometry = getattr(gdf, "geometry", None)
        geometry_name = getattr(active_geometry, "name", geometry)
        records: list[Record] = []
        for _, source_row in gdf.iterrows():
            rec = dict(source_row)
            geom = rec.pop(geometry_name, None)
            rec[geometry] = geometry_from_shapely(geom) if geom is not None else None
            records.append(rec)
        frame_crs = crs or (str(getattr(gdf, "crs", "")) if getattr(gdf, "crs", None) else None)
        return cls.from_records(records, geometry=geometry, crs=frame_crs)

    def to_flatgeobuf(self, path: str, geometry: str = "geometry") -> None:
        """Write the frame to FlatGeobuf.

        Requires ``geopandas`` with an I/O engine backed by Fiona or Pyogrio.
        """
        try:
            import geopandas as gpd
        except ImportError as exc:
            raise ImportError("Install geopandas with fiona or pyogrio to write FlatGeobuf files") from exc
        from .overlay import geometry_to_shapely

        rows: list[Record] = []
        for row in self._rows:
            resolved = {k: v for k, v in row.items() if k != self.geometry_column}
            geom = row.get(self.geometry_column)
            resolved[geometry] = geometry_to_shapely(geom) if geom is not None else None
            rows.append(resolved)
        gdf = gpd.GeoDataFrame(rows, geometry=geometry, crs=self.crs)
        gdf.to_file(path, driver="FlatGeobuf")

    # ------------------------------------------------------------------
    # Tool 148: to_topojson
    # ------------------------------------------------------------------
    def to_topojson(self, object_name: str = "data") -> dict[str, Any]:
        """Convert frame to a TopoJSON topology dict."""
        arcs: list[list[list[float]]] = []
        geometries: list[dict[str, Any]] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom:
                continue
            gt = geometry_type(geom)
            props = {k: v for k, v in row.items() if k != self.geometry_column}
            if gt == "Point":
                geometries.append({"type": "Point", "coordinates": list(geom["coordinates"][:2]), "properties": props})
            elif gt == "LineString":
                arc_idx = len(arcs)
                arcs.append([list(c[:2]) for c in geom.get("coordinates", [])])
                geometries.append({"type": "LineString", "arcs": [arc_idx], "properties": props})
            elif gt == "Polygon":
                rings = []
                raw_coords = geom.get("coordinates", [])
                # Normalise: internal storage may flatten to a single ring tuple
                if raw_coords and not isinstance(raw_coords[0], (list, tuple)) or (
                    raw_coords and isinstance(raw_coords[0], tuple) and len(raw_coords[0]) > 0 and isinstance(raw_coords[0][0], (int, float))
                ):
                    ring_list = [raw_coords]
                else:
                    ring_list = list(raw_coords)
                for ring in ring_list:
                    arc_idx = len(arcs)
                    arcs.append([list(c[:2]) for c in ring])
                    rings.append([arc_idx])
                geometries.append({"type": "Polygon", "arcs": rings, "properties": props})
            else:
                geometries.append({"type": gt, "coordinates": geom.get("coordinates"), "properties": props})
        return {
            "type": "Topology",
            "objects": {object_name: {"type": "GeometryCollection", "geometries": geometries}},
            "arcs": arcs,
        }

    # ==================================================================
    # Tools 149-210: Big Backlog Batch
    # ==================================================================

    # ------------------------------------------------------------------
    # Tool 149: join_count_statistics — binary spatial autocorrelation
    # ------------------------------------------------------------------
    def join_count_statistics(
        self,
        binary_column: str,
        k: int = 4,
        distance_method: str = "euclidean",
    ) -> dict[str, Any]:
        """Join count statistics for binary (0/1) spatial data."""
        self._require_column(binary_column)
        n = len(self._rows)
        if n < 2:
            return {"BB": 0, "WW": 0, "BW": 0, "n": n, "expected_BB": 0.0, "z_BB": 0.0}
        centroids = self._centroids()
        vals = [int(float(row.get(binary_column, 0) or 0)) for row in self._rows]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        BB = WW = BW = 0
        total_joins = 0
        for i in range(n):
            dists = sorted(range(n), key=lambda j: dist_mat[i][j])
            for j in dists[1:k_actual + 1]:
                total_joins += 1
                if vals[i] == 1 and vals[j] == 1:
                    BB += 1
                elif vals[i] == 0 and vals[j] == 0:
                    WW += 1
                else:
                    BW += 1
        BB //= 2; WW //= 2; BW //= 2; total_joins //= 2
        p = sum(vals) / n
        expected_BB = total_joins * p * p
        var_BB = max(total_joins * p * p * (1 - p * p), 1e-12)
        z_BB = (BB - expected_BB) / math.sqrt(var_BB)
        return {"BB": BB, "WW": WW, "BW": BW, "n": n, "expected_BB": expected_BB, "z_BB": z_BB}

    # ------------------------------------------------------------------
    # Tool 150: losh — Local Spatial Heteroscedasticity
    # ------------------------------------------------------------------
    def losh(
        self,
        value_column: str,
        k: int = 4,
        distance_method: str = "euclidean",
        losh_suffix: str = "losh",
    ) -> "GeoPromptFrame":
        """LOSH: local spatial heteroscedasticity indicator."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        global_mean = sum(values) / n
        global_var = sum((v - global_mean) ** 2 for v in values) / n
        rows: list[Record] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j, ii=i: dist_mat[ii][j])
            neighbors = dists[1:k_actual + 1]
            local_resid_sq = sum((values[j] - values[i]) ** 2 for j in neighbors)
            hi = (local_resid_sq / k_actual) / max(global_var, 1e-12)
            resolved = dict(self._rows[i])
            resolved[f"h_{losh_suffix}"] = hi
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 151: bivariate_local_morans_i
    # ------------------------------------------------------------------
    def bivariate_local_morans_i(
        self,
        x_column: str,
        y_column: str,
        k: int = 4,
        distance_method: str = "euclidean",
        blm_suffix: str = "blm",
    ) -> "GeoPromptFrame":
        """Bivariate local Moran's I between two variables."""
        self._require_column(x_column)
        self._require_column(y_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        xv = [float(row.get(x_column, 0) or 0) for row in self._rows]
        yv = [float(row.get(y_column, 0) or 0) for row in self._rows]
        xm, ym = sum(xv) / n, sum(yv) / n
        xstd = math.sqrt(sum((v - xm) ** 2 for v in xv) / n) or 1.0
        ystd = math.sqrt(sum((v - ym) ** 2 for v in yv) / n) or 1.0
        zx = [(v - xm) / xstd for v in xv]
        zy = [(v - ym) / ystd for v in yv]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        rows: list[Record] = []
        global_sum = 0.0
        local_vals: list[float] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j, ii=i: dist_mat[ii][j])
            neighbors = dists[1:k_actual + 1]
            lag_y = sum(zy[j] for j in neighbors) / k_actual
            li = zx[i] * lag_y
            local_vals.append(li)
            global_sum += li
        global_I = global_sum / n
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"local_i_{blm_suffix}"] = local_vals[i]
            resolved[f"global_i_{blm_suffix}"] = global_I
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 152: mantel_test — spatial similarity matrix comparison
    # ------------------------------------------------------------------
    def mantel_test(
        self,
        value_column: str,
        distance_method: str = "euclidean",
        n_permutations: int = 999,
        random_seed: int = 42,
    ) -> dict[str, Any]:
        """Mantel test: correlation between spatial distance and value distance matrices."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return {"r": 0.0, "p_value": 1.0, "n": n}
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        geo_dists: list[float] = []
        val_dists: list[float] = []
        for i in range(n):
            for j in range(i + 1, n):
                geo_dists.append(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
                val_dists.append(abs(values[i] - values[j]))
        m_g = sum(geo_dists) / len(geo_dists)
        m_v = sum(val_dists) / len(val_dists)
        cov = sum((geo_dists[k] - m_g) * (val_dists[k] - m_v) for k in range(len(geo_dists)))
        std_g = math.sqrt(sum((d - m_g) ** 2 for d in geo_dists)) or 1.0
        std_v = math.sqrt(sum((d - m_v) ** 2 for d in val_dists)) or 1.0
        r_obs = cov / (std_g * std_v)
        import random as _rng
        rng = _rng.Random(random_seed)
        count_ge = 0
        indices = list(range(n))
        for _ in range(n_permutations):
            rng.shuffle(indices)
            perm_vals = [values[indices[i]] for i in range(n)]
            perm_dists: list[float] = []
            k_idx = 0
            for i in range(n):
                for j in range(i + 1, n):
                    perm_dists.append(abs(perm_vals[i] - perm_vals[j]))
                    k_idx += 1
            m_pv = sum(perm_dists) / len(perm_dists)
            cov_p = sum((geo_dists[k] - m_g) * (perm_dists[k] - m_pv) for k in range(len(geo_dists)))
            std_pv = math.sqrt(sum((d - m_pv) ** 2 for d in perm_dists)) or 1.0
            r_perm = cov_p / (std_g * std_pv)
            if abs(r_perm) >= abs(r_obs):
                count_ge += 1
        return {"r": r_obs, "p_value": (count_ge + 1) / (n_permutations + 1), "n": n}

    # ------------------------------------------------------------------
    # Tool 153: directional_correlogram
    # ------------------------------------------------------------------
    def directional_correlogram(
        self,
        value_column: str,
        n_lags: int = 10,
        angle: float = 0.0,
        tolerance: float = 22.5,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        """Moran's I at multiple distance lags within a directional band."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return []
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        mean_v = sum(values) / n
        var_v = sum((v - mean_v) ** 2 for v in values) / n
        if var_v < 1e-12:
            return []
        all_dists: list[float] = []
        for i in range(n):
            for j in range(i + 1, n):
                all_dists.append(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
        if not all_dists:
            return []
        max_d = max(all_dists)
        lag_width = max_d / n_lags
        angle_rad = math.radians(angle)
        tol_rad = math.radians(tolerance)
        results: list[Record] = []
        for lag in range(n_lags):
            d_min = lag * lag_width
            d_max = (lag + 1) * lag_width
            num = 0.0
            pairs = 0
            for i in range(n):
                for j in range(i + 1, n):
                    dx = centroids[j][0] - centroids[i][0]
                    dy = centroids[j][1] - centroids[i][1]
                    d = math.hypot(dx, dy)
                    if d < d_min or d >= d_max:
                        continue
                    pair_angle = math.atan2(dy, dx)
                    angle_diff = abs(pair_angle - angle_rad) % math.pi
                    if angle_diff > tol_rad and (math.pi - angle_diff) > tol_rad:
                        continue
                    num += (values[i] - mean_v) * (values[j] - mean_v)
                    pairs += 1
            moran_i = (num / max(pairs, 1)) / var_v if pairs > 0 else 0.0
            results.append({"lag": lag + 1, "distance_min": d_min, "distance_max": d_max, "morans_i": moran_i, "pair_count": pairs})
        return results

    # ------------------------------------------------------------------
    # Tool 154: spatial_entropy
    # ------------------------------------------------------------------
    def spatial_entropy(
        self,
        category_column: str,
        k: int = 4,
        distance_method: str = "euclidean",
        entropy_suffix: str = "entropy",
    ) -> "GeoPromptFrame":
        """Local spatial entropy based on neighbor category diversity."""
        self._require_column(category_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        rows: list[Record] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j, ii=i: dist_mat[ii][j])
            neighbors = dists[1:k_actual + 1]
            cats: dict[str, int] = {}
            for j in neighbors:
                c = str(self._rows[j].get(category_column, ""))
                cats[c] = cats.get(c, 0) + 1
            total = sum(cats.values())
            entropy = -sum((cnt / total) * math.log(cnt / total) for cnt in cats.values() if cnt > 0)
            resolved = dict(self._rows[i])
            resolved[f"local_{entropy_suffix}"] = entropy
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 155: regression_kriging
    # ------------------------------------------------------------------
    def regression_kriging(
        self,
        dependent_column: str,
        independent_columns: list[str],
        grid_resolution: int = 20,
        rk_suffix: str = "rk",
    ) -> "GeoPromptFrame":
        """Regression kriging: OLS trend + kriged residuals."""
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        y = [float(row.get(dependent_column, 0) or 0) for row in self._rows]
        p = len(independent_columns) + 1
        X = [[1.0] + [float(row.get(col, 0) or 0) for col in independent_columns] for row in self._rows]
        XtX = [[sum(X[r][a] * X[r][b] for r in range(n)) for b in range(p)] for a in range(p)]
        Xty = [sum(X[r][a] * y[r] for r in range(n)) for a in range(p)]
        beta = _solve_linear_system_with_regularization(XtX, Xty) or [0.0] * p
        residuals = [y[i] - sum(beta[a] * X[i][a] for a in range(p)) for i in range(n)]
        temp_rows = [dict(r) for r in self._rows]
        for i, r in enumerate(temp_rows):
            r["__rk_resid"] = residuals[i]
        temp = GeoPromptFrame._from_internal_rows(temp_rows, geometry_column=self.geometry_column, crs=self.crs)
        kriged = temp.kriging_surface("__rk_resid", grid_resolution=grid_resolution, kriging_suffix="_rktmp")
        out: list[Record] = []
        for rec in kriged.to_records():
            resolved = dict(rec)
            krig_val = resolved.pop("value__rktmp", 0.0) or 0.0
            resolved.pop("__rk_resid", None)
            coords = resolved.get(self.geometry_column, {}).get("coordinates", (0, 0))
            trend = beta[0]
            for a_idx, col in enumerate(independent_columns):
                trend += beta[a_idx + 1] * float(resolved.get(col, 0) or 0)
            resolved[f"value_{rk_suffix}"] = float(krig_val) + trend
            resolved[f"trend_{rk_suffix}"] = trend
            resolved[f"kriged_residual_{rk_suffix}"] = float(krig_val)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 156: indicator_kriging
    # ------------------------------------------------------------------
    def indicator_kriging(
        self,
        value_column: str,
        threshold: float,
        grid_resolution: int = 20,
        ik_suffix: str = "ik",
    ) -> "GeoPromptFrame":
        """Indicator kriging: probability surface for exceedance of a threshold."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        temp_rows = [dict(r) for r in self._rows]
        for r in temp_rows:
            v = float(r.get(value_column, 0) or 0)
            r["__ik_ind"] = 1.0 if v >= threshold else 0.0
        temp = GeoPromptFrame._from_internal_rows(temp_rows, geometry_column=self.geometry_column, crs=self.crs)
        result = temp.kriging_surface("__ik_ind", grid_resolution=grid_resolution, kriging_suffix="_iktmp")
        out: list[Record] = []
        for rec in result.to_records():
            resolved = dict(rec)
            prob = clamp(float(resolved.pop("value__iktmp", 0.0) or 0.0), 0.0, 1.0)
            resolved.pop("__ik_ind", None)
            resolved[f"probability_{ik_suffix}"] = prob
            resolved[f"threshold_{ik_suffix}"] = threshold
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 157: thin_plate_spline
    # ------------------------------------------------------------------
    def thin_plate_spline(
        self,
        value_column: str,
        grid_resolution: int = 20,
        smoothing: float = 0.0,
        tps_suffix: str = "tps",
    ) -> "GeoPromptFrame":
        """Thin-plate spline interpolation with improved numerical stability.

        Adds progressively larger regularization if the system is
        ill-conditioned, and reports conditioning diagnostics.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]

        sz = n + 3
        A = [[0.0] * sz for _ in range(sz)]
        rhs = [0.0] * sz
        for i in range(n):
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                A[i][j] = thin_plate_spline_basis(d)
            A[i][i] += smoothing
            A[i][n] = 1.0; A[i][n + 1] = centroids[i][0]; A[i][n + 2] = centroids[i][1]
            A[n][i] = 1.0; A[n + 1][i] = centroids[i][0]; A[n + 2][i] = centroids[i][1]
            rhs[i] = values[i]

        # Try solving with increasing regularization for stability
        coeffs = None
        condition_note = "exact"
        for reg_level in [0.0, 1e-10, 1e-8, 1e-6, 1e-4]:
            if reg_level > 0.0:
                A_reg = [list(row) for row in A]
                for idx in range(sz):
                    A_reg[idx][idx] += reg_level
                coeffs = _solve_linear_system(A_reg, list(rhs))
                if coeffs is not None:
                    condition_note = f"regularized_{reg_level:.0e}"
                    break
            else:
                coeffs = _solve_linear_system(A, list(rhs))
                if coeffs is not None:
                    break
        if coeffs is None:
            coeffs = [0.0] * sz
            condition_note = "singular_fallback"

        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g - 1, 1)
        dy = (b.max_y - b.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                val = coeffs[n] + coeffs[n + 1] * px + coeffs[n + 2] * py
                for ci in range(n):
                    d = math.hypot(px - centroids[ci][0], py - centroids[ci][1])
                    val += coeffs[ci] * thin_plate_spline_basis(d)
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{tps_suffix}": val,
                    f"condition_{tps_suffix}": condition_note,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 158: contour_lines — generate contours from interpolated surface
    # ------------------------------------------------------------------
    def contour_lines(
        self,
        value_column: str,
        levels: list[float] | None = None,
        n_levels: int = 10,
        grid_resolution: int = 20,
        contour_suffix: str = "contour",
    ) -> "GeoPromptFrame":
        """Generate contour lines from point values via IDW grid + marching squares."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        grid_data, b, dx, dy = self._idw_grid(value_column, grid_resolution, "euclidean")
        g = grid_resolution
        all_vals = [grid_data[r][c] for r in range(g) for c in range(g)]
        vmin, vmax = min(all_vals), max(all_vals)
        if levels is None:
            step = (vmax - vmin) / (n_levels + 1) if vmax > vmin else 1.0
            levels = [vmin + step * (i + 1) for i in range(n_levels)]
        rows: list[Record] = []
        for level in levels:
            segments: list[tuple[tuple[float, float], tuple[float, float]]] = []
            for r in range(g - 1):
                for c in range(g - 1):
                    corners = [grid_data[r][c], grid_data[r][c + 1], grid_data[r + 1][c + 1], grid_data[r + 1][c]]
                    idx = sum(1 << i for i, v in enumerate(corners) if v >= level)
                    if idx == 0 or idx == 15:
                        continue
                    pts: list[tuple[float, float]] = []
                    edges = [(0, 1), (1, 2), (2, 3), (3, 0)]
                    offsets = [(c, r), (c + 1, r), (c + 1, r + 1), (c, r + 1)]
                    for e0, e1 in edges:
                        v0, v1 = corners[e0], corners[e1]
                        if (v0 >= level) != (v1 >= level):
                            t = (level - v0) / (v1 - v0) if abs(v1 - v0) > 1e-12 else 0.5
                            ox = offsets[e0][0] + t * (offsets[e1][0] - offsets[e0][0])
                            oy = offsets[e0][1] + t * (offsets[e1][1] - offsets[e0][1])
                            pts.append((b.min_x + ox * dx, b.min_y + oy * dy))
                    if len(pts) >= 2:
                        segments.append((pts[0], pts[1]))
                        if len(pts) == 4:
                            segments.append((pts[2], pts[3]))
            for s0, s1 in segments:
                rows.append({
                    self.geometry_column: {"type": "LineString", "coordinates": [list(s0), list(s1)]},
                    f"level_{contour_suffix}": level,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 159: spectral_clustering
    # ------------------------------------------------------------------
    def spectral_clustering(
        self,
        n_clusters: int = 3,
        k: int = 10,
        distance_method: str = "euclidean",
        sc_suffix: str = "sc",
    ) -> "GeoPromptFrame":
        """Spectral clustering on spatial adjacency graph."""
        n = len(self._rows)
        if n < n_clusters:
            rows = [dict(r) for r in self._rows]
            for i, r in enumerate(rows):
                r[f"cluster_{sc_suffix}"] = i
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        W = [[0.0] * n for _ in range(n)]
        for i in range(n):
            dists = sorted(range(n), key=lambda j, ii=i: dist_mat[ii][j])
            sigma = max(dist_mat[i][dists[k_actual]], 1e-12)
            for j in dists[1:k_actual + 1]:
                w = math.exp(-dist_mat[i][j] ** 2 / (2 * sigma * sigma))
                W[i][j] = w; W[j][i] = w
        D_inv_sqrt = [0.0] * n
        for i in range(n):
            s = sum(W[i])
            D_inv_sqrt[i] = 1.0 / math.sqrt(max(s, 1e-12))
        L = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(n):
                L[i][j] = D_inv_sqrt[i] * W[i][j] * D_inv_sqrt[j]
        import random as _rng
        rng = _rng.Random(42)
        vecs = [[rng.gauss(0, 1) for _ in range(n_clusters)] for _ in range(n)]
        for _ in range(50):
            new_vecs = [[0.0] * n_clusters for _ in range(n)]
            for i in range(n):
                for d in range(n_clusters):
                    new_vecs[i][d] = sum(L[i][j] * vecs[j][d] for j in range(n))
            for d in range(n_clusters):
                norm = math.sqrt(sum(new_vecs[i][d] ** 2 for i in range(n))) or 1.0
                for i in range(n):
                    new_vecs[i][d] /= norm
            vecs = new_vecs
        for i in range(n):
            row_norm = math.sqrt(sum(vecs[i][d] ** 2 for d in range(n_clusters))) or 1.0
            for d in range(n_clusters):
                vecs[i][d] /= row_norm
        centers = [vecs[rng.randint(0, n - 1)] for _ in range(n_clusters)]
        labels = [0] * n
        for _ in range(30):
            for i in range(n):
                best_c, best_d2 = 0, float("inf")
                for c in range(n_clusters):
                    d2 = sum((vecs[i][d] - centers[c][d]) ** 2 for d in range(n_clusters))
                    if d2 < best_d2:
                        best_c, best_d2 = c, d2
                labels[i] = best_c
            new_centers = [[0.0] * n_clusters for _ in range(n_clusters)]
            counts = [0] * n_clusters
            for i in range(n):
                for d in range(n_clusters):
                    new_centers[labels[i]][d] += vecs[i][d]
                counts[labels[i]] += 1
            for c in range(n_clusters):
                if counts[c] > 0:
                    for d in range(n_clusters):
                        new_centers[c][d] /= counts[c]
            centers = new_centers
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{sc_suffix}"] = labels[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 160: skater_regionalization — spanning-tree regionalization
    # ------------------------------------------------------------------
    def skater_regionalization(
        self,
        value_columns: list[str],
        n_regions: int = 5,
        k: int = 4,
        distance_method: str = "euclidean",
        skater_suffix: str = "skater",
    ) -> "GeoPromptFrame":
        """SKATER-style spanning tree regionalization."""
        for col in value_columns:
            self._require_column(col)
        n = len(self._rows)
        if n <= n_regions:
            rows = [dict(r) for r in self._rows]
            for i, r in enumerate(rows):
                r[f"region_{skater_suffix}"] = i
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        edges: list[tuple[float, int, int]] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j, ii=i: dist_mat[ii][j])
            for j in dists[1:k_actual + 1]:
                vals_i = [float(self._rows[i].get(c, 0) or 0) for c in value_columns]
                vals_j = [float(self._rows[j].get(c, 0) or 0) for c in value_columns]
                cost = math.sqrt(sum((vals_i[d] - vals_j[d]) ** 2 for d in range(len(value_columns))))
                if i < j:
                    edges.append((cost, i, j))
        edges.sort()
        parent = list(range(n))
        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        tree_edges: list[tuple[float, int, int]] = []
        for cost, i, j in edges:
            ri, rj = find(i), find(j)
            if ri != rj:
                parent[ri] = rj
                tree_edges.append((cost, i, j))
                if len(tree_edges) == n - 1:
                    break
        tree_edges.sort(reverse=True)
        cuts = tree_edges[:n_regions - 1]
        cut_set = {(min(i, j), max(i, j)) for _, i, j in cuts}
        adj: dict[int, list[int]] = {i: [] for i in range(n)}
        for _, i, j in tree_edges:
            if (min(i, j), max(i, j)) not in cut_set:
                adj[i].append(j)
                adj[j].append(i)
        labels = [-1] * n
        region_id = 0
        for start in range(n):
            if labels[start] != -1:
                continue
            stack = [start]
            while stack:
                node = stack.pop()
                if labels[node] != -1:
                    continue
                labels[node] = region_id
                stack.extend(adj[node])
            region_id += 1
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"region_{skater_suffix}"] = labels[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 161: region_compactness
    # ------------------------------------------------------------------
    def region_compactness(
        self,
        region_column: str,
        compact_suffix: str = "compact",
    ) -> "GeoPromptFrame":
        """Compactness score for regions (Polsby-Popper style for point clouds)."""
        self._require_column(region_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        regions: dict[str, list[int]] = {}
        for i, row in enumerate(self._rows):
            r = str(row.get(region_column, ""))
            regions.setdefault(r, []).append(i)
        scores: dict[str, float] = {}
        for r, idxs in regions.items():
            if len(idxs) < 3:
                scores[r] = 1.0
                continue
            cx = sum(centroids[i][0] for i in idxs) / len(idxs)
            cy = sum(centroids[i][1] for i in idxs) / len(idxs)
            max_dist = max(math.hypot(centroids[i][0] - cx, centroids[i][1] - cy) for i in idxs)
            circle_area = math.pi * max_dist * max_dist
            hull_pts = [centroids[i] for i in idxs]
            hull_area = abs(sum(hull_pts[i][0] * hull_pts[(i + 1) % len(hull_pts)][1] - hull_pts[(i + 1) % len(hull_pts)][0] * hull_pts[i][1] for i in range(len(hull_pts)))) / 2
            scores[r] = hull_area / max(circle_area, 1e-12)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            r = str(self._rows[i].get(region_column, ""))
            resolved[f"compactness_{compact_suffix}"] = scores.get(r, 0.0)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 162: cluster_summary — centroids, hull, within-cluster variance
    # ------------------------------------------------------------------
    def cluster_summary(
        self,
        cluster_column: str,
        value_columns: list[str] | None = None,
    ) -> list[Record]:
        """Summary report per cluster: centroid, count, within-cluster variance."""
        self._require_column(cluster_column)
        n = len(self._rows)
        if n == 0:
            return []
        centroids = self._centroids()
        clusters: dict[str, list[int]] = {}
        for i, row in enumerate(self._rows):
            c = str(row.get(cluster_column, ""))
            clusters.setdefault(c, []).append(i)
        results: list[Record] = []
        for cid, idxs in sorted(clusters.items()):
            cx = sum(centroids[i][0] for i in idxs) / len(idxs)
            cy = sum(centroids[i][1] for i in idxs) / len(idxs)
            spatial_var = sum(math.hypot(centroids[i][0] - cx, centroids[i][1] - cy) ** 2 for i in idxs) / len(idxs)
            rec: Record = {"cluster": cid, "count": len(idxs), "centroid_x": cx, "centroid_y": cy, "spatial_variance": spatial_var}
            if value_columns:
                for col in value_columns:
                    vals = [float(self._rows[i].get(col, 0) or 0) for i in idxs]
                    m = sum(vals) / len(vals)
                    v = sum((x - m) ** 2 for x in vals) / len(vals)
                    rec[f"mean_{col}"] = m
                    rec[f"variance_{col}"] = v
            results.append(rec)
        return results

    # ------------------------------------------------------------------
    # Tool 163: logistic_gwr
    # ------------------------------------------------------------------
    def logistic_gwr(
        self,
        dependent_column: str,
        independent_columns: list[str],
        bandwidth: float | None = None,
        distance_method: str = "euclidean",
        max_iter: int = 20,
        lgwr_suffix: str = "lgwr",
    ) -> "GeoPromptFrame":
        """Logistic GWR for binary outcomes."""
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        p = len(independent_columns) + 1
        y = [float(self._rows[i].get(dependent_column, 0) or 0) for i in range(n)]
        X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in independent_columns] for i in range(n)]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        if bandwidth is None:
            dists_flat = sorted(set(dist_mat[i][j] for i in range(n) for j in range(n) if i != j))
            bandwidth = dists_flat[len(dists_flat) // 2] if dists_flat else 1.0
        predictions = [0.5] * n
        coefficients = [[0.0] * p for _ in range(n)]
        for i in range(n):
            W = [gaussian_kernel(dist_mat[i][j], max(bandwidth, 1e-12)) for j in range(n)]
            beta = [0.0] * p
            for _it in range(max_iter):
                mu = [max(min(1.0 / (1.0 + math.exp(-min(max(sum(beta[a] * X[j][a] for a in range(p)), -20), 20))), 1 - 1e-10), 1e-10) for j in range(n)]
                z = [sum(beta[a] * X[j][a] for a in range(p)) + (y[j] - mu[j]) / (mu[j] * (1 - mu[j])) for j in range(n)]
                WM = [W[j] * mu[j] * (1 - mu[j]) for j in range(n)]
                XtWX = [[0.0] * p for _ in range(p)]
                XtWz = [0.0] * p
                for j in range(n):
                    for a in range(p):
                        XtWz[a] += X[j][a] * WM[j] * z[j]
                        for bb in range(p):
                            XtWX[a][bb] += X[j][a] * WM[j] * X[j][bb]
                for a in range(p):
                    XtWX[a][a] += 1e-10
                new_beta = _solve_linear_system(XtWX, XtWz)
                if new_beta is None:
                    break
                beta = new_beta
            eta = sum(beta[a] * X[i][a] for a in range(p))
            predictions[i] = 1.0 / (1.0 + math.exp(-min(max(eta, -20), 20)))
            coefficients[i] = beta
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"predicted_{lgwr_suffix}"] = predictions[i]
            resolved[f"residual_{lgwr_suffix}"] = y[i] - predictions[i]
            for k_idx, col in enumerate(["intercept"] + independent_columns):
                resolved[f"coeff_{col}_{lgwr_suffix}"] = coefficients[i][k_idx]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 164: gw_summary_stats — Geographically Weighted Summary Statistics
    # ------------------------------------------------------------------
    def gw_summary_stats(
        self,
        value_column: str,
        bandwidth: float | None = None,
        k: int = 8,
        distance_method: str = "euclidean",
        gws_suffix: str = "gws",
    ) -> "GeoPromptFrame":
        """Geographically weighted mean, variance, skewness, kurtosis."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        dist_mat = _pairwise_distance_matrix(centroids, distance_method)
        k_actual = min(k, n - 1)
        if bandwidth is None:
            dists_flat = sorted(set(dist_mat[i][j] for i in range(n) for j in range(n) if i != j))
            bandwidth = dists_flat[len(dists_flat) // 2] if dists_flat else 1.0
        rows: list[Record] = []
        for i in range(n):
            weights = [gaussian_kernel(dist_mat[i][j], max(bandwidth, 1e-12)) for j in range(n)]
            w_sum = sum(weights)
            if w_sum < 1e-12:
                rows.append(dict(self._rows[i]))
                continue
            wm = sum(weights[j] * values[j] for j in range(n)) / w_sum
            wv = sum(weights[j] * (values[j] - wm) ** 2 for j in range(n)) / w_sum
            std = math.sqrt(max(wv, 1e-20))
            ws = sum(weights[j] * ((values[j] - wm) / std) ** 3 for j in range(n)) / w_sum if std > 1e-12 else 0.0
            wk = sum(weights[j] * ((values[j] - wm) / std) ** 4 for j in range(n)) / w_sum if std > 1e-12 else 0.0
            resolved = dict(self._rows[i])
            resolved[f"mean_{gws_suffix}"] = wm
            resolved[f"variance_{gws_suffix}"] = wv
            resolved[f"skewness_{gws_suffix}"] = ws
            resolved[f"kurtosis_{gws_suffix}"] = wk
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 165: model_comparison — AIC, AICc, BIC, pseudo-R²
    # ------------------------------------------------------------------
    def model_comparison(
        self,
        observed_column: str,
        predicted_columns: list[str],
    ) -> list[Record]:
        """Compare models via AIC, AICc, BIC. Each predicted_column is one model."""
        self._require_column(observed_column)
        n = len(self._rows)
        if n < 2:
            return []
        y = [float(row.get(observed_column, 0) or 0) for row in self._rows]
        y_mean = sum(y) / n
        ss_tot = sum((yi - y_mean) ** 2 for yi in y)
        results: list[Record] = []
        for col in predicted_columns:
            self._require_column(col)
            pred = [float(row.get(col, 0) or 0) for row in self._rows]
            ss_res = sum((y[i] - pred[i]) ** 2 for i in range(n))
            r2 = 1.0 - ss_res / max(ss_tot, 1e-12)
            k = 2
            sigma2 = ss_res / n
            ll = -n / 2 * (math.log(2 * math.pi) + math.log(max(sigma2, 1e-20)) + 1)
            aic = 2 * k - 2 * ll
            aicc = aic + (2 * k * (k + 1)) / max(n - k - 1, 1)
            bic = k * math.log(n) - 2 * ll
            results.append({"model": col, "r_squared": r2, "aic": aic, "aicc": aicc, "bic": bic, "rmse": math.sqrt(ss_res / n), "n": n})
        return results

    # ------------------------------------------------------------------
    # Tool 166: stream_extraction — from flow accumulation
    # ------------------------------------------------------------------
    def stream_extraction(
        self,
        elevation_column: str,
        threshold: float = 10.0,
        k: int = 8,
        se_suffix: str = "se",
    ) -> "GeoPromptFrame":
        """Extract a stream network from sampled elevations.

        This method uses a centroid-based, D8-like steepest-descent proxy:
        each point routes to the steepest downhill neighbour among its *k*
        nearest neighbours. It is best suited to regularly sampled projected
        grids rather than arbitrary point clouds.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if self._crs_is_geographic():
            warnings.warn("stream_extraction assumes projected coordinates; geographic CRS values are interpreted as planar distances", stacklevel=2)
        centroids, _elevations, flow_to, accum, flow_slope, flow_distance = self._hydrology_flow_model(elevation_column, k=k)
        rows: list[Record] = []
        for i in range(n):
            if accum[i] >= threshold and flow_to[i] is not None:
                j = flow_to[i]
                resolved = dict(self._rows[i])
                resolved[self.geometry_column] = {"type": "LineString", "coordinates": [list(centroids[i]), list(centroids[j])]}  # type: ignore[index]
                resolved[f"accumulation_{se_suffix}"] = accum[i]
                resolved[f"is_stream_{se_suffix}"] = True
                resolved[f"receiver_idx_{se_suffix}"] = j
                resolved[f"slope_{se_suffix}"] = flow_slope[i]
                resolved[f"segment_length_{se_suffix}"] = flow_distance[i]
                resolved[f"routing_{se_suffix}"] = f"steepest_downhill_k{k}"
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 167: hand — Height Above Nearest Drainage
    # ------------------------------------------------------------------
    def hand(
        self,
        elevation_column: str,
        stream_threshold: float = 10.0,
        k: int = 8,
        hand_suffix: str = "hand",
    ) -> "GeoPromptFrame":
        """Estimate Height Above Nearest Drainage (HAND).

        Drainage is defined by the same centroid-based, steepest-descent
        routing proxy used by :meth:`stream_extraction`, with stream cells
        identified by a flow-accumulation threshold.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if self._crs_is_geographic():
            warnings.warn("hand assumes projected coordinates; geographic CRS values are interpreted as planar distances", stacklevel=2)
        _centroids, elevations, flow_to, accum, _flow_slope, flow_distance = self._hydrology_flow_model(elevation_column, k=k)
        is_stream = [accum[i] >= stream_threshold for i in range(n)]
        hand_vals = [0.0] * n
        drainage_idx: list[int | None] = [None] * n
        drainage_distance = [0.0] * n
        for i in range(n):
            cur = i
            visited: set[int] = set()
            travelled = 0.0
            while cur is not None and not is_stream[cur] and cur not in visited:
                visited.add(cur)
                nxt = flow_to[cur]
                if nxt is None:
                    cur = None
                    break
                travelled += flow_distance[cur]
                cur = nxt
            if cur is not None and is_stream[cur]:
                hand_vals[i] = max(elevations[i] - elevations[cur], 0.0)
                drainage_idx[i] = cur
                drainage_distance[i] = travelled
            else:
                hand_vals[i] = 0.0
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"hand_{hand_suffix}"] = hand_vals[i]
            resolved[f"is_stream_{hand_suffix}"] = is_stream[i]
            resolved[f"drainage_idx_{hand_suffix}"] = drainage_idx[i]
            resolved[f"drainage_distance_{hand_suffix}"] = drainage_distance[i]
            resolved[f"accumulation_{hand_suffix}"] = accum[i]
            resolved[f"routing_{hand_suffix}"] = f"steepest_downhill_k{k}"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 168: ls_factor — Length-Slope factor for erosion
    # ------------------------------------------------------------------
    def ls_factor(
        self,
        elevation_column: str,
        grid_resolution: int = 20,
        k: int = 8,
        min_slope_degrees: float = 0.1,
        ls_suffix: str = "ls",
    ) -> "GeoPromptFrame":
        """Estimate the USLE/RUSLE length-slope factor.

        The contributing flow length is derived from the same steepest-descent,
        centroid-based routing proxy used by :meth:`stream_extraction`.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if self._crs_is_geographic():
            warnings.warn("ls_factor assumes projected coordinates; geographic CRS values are interpreted as planar distances", stacklevel=2)
        _centroids, elevations, flow_to, accum, flow_slope, flow_distance = self._hydrology_flow_model(elevation_column, k=k)
        rows: list[Record] = []
        for i in range(n):
            slope_rad = math.atan(flow_slope[i]) if flow_to[i] is not None else 0.0
            effective_slope_rad = max(slope_rad, math.radians(min_slope_degrees))
            flow_len = max(accum[i] * (flow_distance[i] if flow_to[i] is not None else 1.0), 1.0)
            m = 0.5 if effective_slope_rad > math.radians(5) else 0.4 if effective_slope_rad > math.radians(3) else 0.3
            l_factor = (flow_len / 22.13) ** m
            s_factor = 10.8 * math.sin(effective_slope_rad) + 0.03 if effective_slope_rad < math.radians(9) else 16.8 * math.sin(effective_slope_rad) - 0.50
            resolved = dict(self._rows[i])
            resolved[f"ls_{ls_suffix}"] = l_factor * s_factor
            resolved[f"l_{ls_suffix}"] = l_factor
            resolved[f"s_{ls_suffix}"] = s_factor
            resolved[f"slope_deg_{ls_suffix}"] = math.degrees(slope_rad)
            resolved[f"effective_slope_deg_{ls_suffix}"] = math.degrees(effective_slope_rad)
            resolved[f"flow_length_{ls_suffix}"] = flow_len
            resolved[f"accumulation_{ls_suffix}"] = accum[i]
            resolved[f"receiver_idx_{ls_suffix}"] = flow_to[i]
            resolved[f"routing_{ls_suffix}"] = f"steepest_downhill_k{k}"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 169: stream_power_index
    # ------------------------------------------------------------------
    def stream_power_index(
        self,
        elevation_column: str,
        k: int = 8,
        min_slope_degrees: float = 0.1,
        spi_suffix: str = "spi",
    ) -> "GeoPromptFrame":
        """Stream Power Index (SPI) — proxy for erosive power of flowing water.

        SPI = contributing_area × tan(slope).  Higher values indicate
        locations where concentrated flow has more energy, useful for
        erosion-risk mapping and channel-initiation modelling.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        _centroids, elevations, flow_to, accum, flow_slope, flow_dist = (
            self._hydrology_flow_model(elevation_column, k=k)
        )
        rows: list[Record] = []
        for i in range(n):
            slope_rad = math.atan(flow_slope[i]) if flow_to[i] is not None else 0.0
            effective_slope = max(slope_rad, math.radians(min_slope_degrees))
            cell_area = max(accum[i] * (flow_dist[i] if flow_to[i] is not None else 1.0), 1.0)
            spi_val = cell_area * math.tan(effective_slope)
            resolved = dict(self._rows[i])
            resolved[f"spi_{spi_suffix}"] = round(spi_val, 6)
            resolved[f"contributing_area_{spi_suffix}"] = round(cell_area, 6)
            resolved[f"slope_rad_{spi_suffix}"] = round(slope_rad, 6)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            rows, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 170: od_cost_matrix — origin-destination cost matrix
    # ------------------------------------------------------------------
    def od_cost_matrix(
        self,
        origins: "GeoPromptFrame",
        destinations: "GeoPromptFrame",
        origin_id_column: str = "site_id",
        dest_id_column: str = "site_id",
        distance_method: str = "euclidean",
    ) -> list[Record]:
        """Origin-destination cost matrix."""
        o_centroids = origins._centroids()
        d_centroids = destinations._centroids()
        o_recs = origins.to_records()
        d_recs = destinations.to_records()
        results: list[Record] = []
        for oi, oc in enumerate(o_centroids):
            for di, dc in enumerate(d_centroids):
                d = math.hypot(oc[0] - dc[0], oc[1] - dc[1]) if distance_method == "euclidean" else coordinate_distance(oc, dc, method=distance_method)
                results.append({
                    "origin": o_recs[oi].get(origin_id_column, f"o{oi}"),
                    "destination": d_recs[di].get(dest_id_column, f"d{di}"),
                    "cost": d,
                })
        return results

    # ------------------------------------------------------------------
    # Tool 171: network_betweenness
    # ------------------------------------------------------------------
    def network_betweenness(
        self,
        id_column: str = "site_id",
        weight_column: str | None = None,
        distance_method: str = "euclidean",
        k: int = 6,
        betweenness_suffix: str = "btw",
    ) -> "GeoPromptFrame":
        """Brandes betweenness centrality for a proximity-based spatial network.

        Builds a k-nearest-neighbour graph, then computes betweenness
        centrality via Brandes' algorithm.  High-centrality locations
        sit on many shortest paths, indicating spatial gateway roles.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        # Build k-NN adjacency with weights
        adj: list[list[tuple[int, float]]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(j, coordinate_distance(centroids[i], centroids[j], method=distance_method))
                     for j in range(n) if j != i]
            dists.sort(key=lambda t: t[1])
            for j, d in dists[:k]:
                w = float(self._rows[j].get(weight_column, d) or d) if weight_column else d
                adj[i].append((j, max(w, 1e-12)))
                adj[j].append((i, max(w, 1e-12)))

        # Brandes algorithm
        centrality = [0.0] * n
        for s in range(n):
            stack: list[int] = []
            pred: list[list[int]] = [[] for _ in range(n)]
            sigma = [0.0] * n
            sigma[s] = 1.0
            dist_s = [float("inf")] * n
            dist_s[s] = 0.0
            # BFS / Dijkstra
            queue: list[tuple[float, int]] = [(0.0, s)]
            while queue:
                queue.sort(key=lambda t: t[0])
                d_v, v = queue.pop(0)
                if d_v > dist_s[v]:
                    continue
                stack.append(v)
                for w_idx, w_cost in adj[v]:
                    alt = dist_s[v] + w_cost
                    if alt < dist_s[w_idx] - 1e-12:
                        dist_s[w_idx] = alt
                        sigma[w_idx] = sigma[v]
                        pred[w_idx] = [v]
                        queue.append((alt, w_idx))
                    elif abs(alt - dist_s[w_idx]) < 1e-12:
                        sigma[w_idx] += sigma[v]
                        pred[w_idx].append(v)
            delta = [0.0] * n
            while stack:
                w2 = stack.pop()
                for v2 in pred[w2]:
                    if sigma[w2] > 0:
                        delta[v2] += (sigma[v2] / sigma[w2]) * (1.0 + delta[w2])
                if w2 != s:
                    centrality[w2] += delta[w2]

        # Normalize
        max_c = max(centrality) if centrality else 1.0
        if max_c <= 0:
            max_c = 1.0

        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"betweenness_{betweenness_suffix}"] = round(centrality[i], 6)
            resolved[f"betweenness_norm_{betweenness_suffix}"] = round(centrality[i] / max_c, 6)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            rows, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 172: min_cut — min cut paired with max flow
    # ------------------------------------------------------------------
    def min_cut(
        self,
        source_id: str,
        sink_id: str,
        capacity_column: str = "capacity",
        id_column: str = "site_id",
    ) -> dict[str, Any]:
        """Minimum cut: set of edges separating source from sink."""
        self._require_column(id_column)
        self._require_column(capacity_column)
        flow_result = self.max_flow(source_id, sink_id, capacity_column, id_column)
        cap: dict[str, dict[str, float]] = {}
        for row in self._rows:
            sid = str(row.get(id_column, ""))
            to_id = str(row.get("edge_to", "") or "")
            if to_id:
                c = float(row.get(capacity_column, 1.0) or 1.0)
                cap.setdefault(sid, {})[to_id] = c
                cap.setdefault(to_id, {}).setdefault(sid, 0.0)
        reachable: set[str] = set()
        stack = [source_id]
        while stack:
            u = stack.pop()
            if u in reachable:
                continue
            reachable.add(u)
            for v, c in cap.get(u, {}).items():
                if v not in reachable and c > 1e-12:
                    stack.append(v)
        cut_edges: list[dict[str, str]] = []
        for u in reachable:
            for v in cap.get(u, {}):
                if v not in reachable:
                    cut_edges.append({"from": u, "to": v})
        return {"max_flow": flow_result["max_flow"], "cut_edges": cut_edges, "source_side": sorted(reachable)}

    # ------------------------------------------------------------------
    # Tool 173: spatiotemporal_knox — Knox test for space-time interaction
    # ------------------------------------------------------------------
    def spatiotemporal_knox(
        self,
        time_column: str,
        spatial_threshold: float,
        temporal_threshold: float,
        n_permutations: int = 999,
        random_seed: int = 42,
    ) -> dict[str, Any]:
        """Knox test for space-time interaction."""
        self._require_column(time_column)
        n = len(self._rows)
        if n < 3:
            return {"knox_statistic": 0, "p_value": 1.0, "n": n}
        centroids = self._centroids()
        times = [float(row.get(time_column, 0) or 0) for row in self._rows]
        observed = 0
        for i in range(n):
            for j in range(i + 1, n):
                d_s = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                d_t = abs(times[i] - times[j])
                if d_s <= spatial_threshold and d_t <= temporal_threshold:
                    observed += 1
        import random as _rng
        rng = _rng.Random(random_seed)
        count_ge = 0
        for _ in range(n_permutations):
            perm_times = list(times)
            rng.shuffle(perm_times)
            perm_count = 0
            for i in range(n):
                for j in range(i + 1, n):
                    d_s = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                    d_t = abs(perm_times[i] - perm_times[j])
                    if d_s <= spatial_threshold and d_t <= temporal_threshold:
                        perm_count += 1
            if perm_count >= observed:
                count_ge += 1
        return {"knox_statistic": observed, "p_value": (count_ge + 1) / (n_permutations + 1), "n": n}

    # ------------------------------------------------------------------
    # Tool 174: mark_variogram
    # ------------------------------------------------------------------
    def mark_variogram(
        self,
        value_column: str,
        n_lags: int = 15,
        distance_method: str = "euclidean",
    ) -> list[Record]:
        """Mark variogram: semivariance of marks as function of distance."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return []
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        all_dists: list[float] = []
        for i in range(n):
            for j in range(i + 1, n):
                all_dists.append(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
        if not all_dists:
            return []
        max_d = max(all_dists)
        lag_width = max_d / n_lags
        results: list[Record] = []
        for lag in range(n_lags):
            d_min = lag * lag_width
            d_max = (lag + 1) * lag_width
            semi = 0.0
            count = 0
            for i in range(n):
                for j in range(i + 1, n):
                    d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                    if d_min <= d < d_max:
                        semi += (values[i] - values[j]) ** 2
                        count += 1
            gamma = semi / (2 * count) if count > 0 else 0.0
            results.append({"lag": lag + 1, "distance_min": d_min, "distance_max": d_max, "distance_mid": (d_min + d_max) / 2, "semivariance": gamma, "pair_count": count})
        return results

    # ------------------------------------------------------------------
    # Tool 175: polygon_validity_check
    # ------------------------------------------------------------------
    def polygon_validity_check(
        self,
        pv_suffix: str = "pv",
    ) -> "GeoPromptFrame":
        """Check polygon validity: self-intersections, ring closure, winding order,
        duplicate vertices, zero-area rings, and shell-hole diagnostics."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            resolved = dict(row)
            if not geom or geometry_type(geom) != "Polygon":
                resolved[f"is_valid_{pv_suffix}"] = True
                resolved[f"issue_{pv_suffix}"] = ""
                rows.append(resolved)
                continue
            coords = geom.get("coordinates", [])
            issues: list[str] = []
            # Extract outer ring
            ring = coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords

            # Check too few vertices
            if len(ring) < 4:
                issues.append("too_few_vertices")
            else:
                # Check ring closure
                if ring[0] != ring[-1] and (len(ring[0]) < 2 or len(ring[-1]) < 2 or ring[0][0] != ring[-1][0] or ring[0][1] != ring[-1][1]):
                    issues.append("ring_not_closed")
                # Check winding order (signed area: negative = CCW = valid exterior)
                signed_area = sum(
                    float(ring[i][0]) * float(ring[(i + 1) % len(ring)][1]) - float(ring[(i + 1) % len(ring)][0]) * float(ring[i][1])
                    for i in range(len(ring))
                ) / 2
                if signed_area > 0:
                    issues.append("clockwise_outer_ring")
                # Check zero-area ring
                if abs(signed_area) < 1e-15:
                    issues.append("zero_area")
                # Check duplicate consecutive vertices
                for k in range(len(ring) - 1):
                    if (round(float(ring[k][0]), 10) == round(float(ring[k + 1][0]), 10)
                            and round(float(ring[k][1]), 10) == round(float(ring[k + 1][1]), 10)):
                        issues.append("duplicate_consecutive_vertex")
                        break
                # Check self-intersection (non-adjacent edge crossings)
                n_edges = len(ring) - 1
                if n_edges >= 4:
                    found_self_int = False
                    for ei in range(n_edges):
                        if found_self_int:
                            break
                        a1x, a1y = float(ring[ei][0]), float(ring[ei][1])
                        a2x, a2y = float(ring[ei + 1][0]), float(ring[ei + 1][1])
                        for ej in range(ei + 2, n_edges):
                            if ei == 0 and ej == n_edges - 1:
                                continue  # skip adjacent first-last
                            b1x, b1y = float(ring[ej][0]), float(ring[ej][1])
                            b2x, b2y = float(ring[ej + 1][0]), float(ring[ej + 1][1])
                            if _segments_cross(a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y):
                                issues.append("self_intersection")
                                found_self_int = True
                                break

            # Check holes
            if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)):
                for hi in range(1, len(coords)):
                    hole = coords[hi]
                    if len(hole) < 4:
                        issues.append(f"hole_{hi}_too_few_vertices")
                    elif hole[0] != hole[-1]:
                        issues.append(f"hole_{hi}_not_closed")
                    else:
                        hole_area = sum(
                            float(hole[k][0]) * float(hole[(k + 1) % len(hole)][1]) - float(hole[(k + 1) % len(hole)][0]) * float(hole[k][1])
                            for k in range(len(hole))
                        ) / 2
                        if hole_area < 0:
                            issues.append(f"hole_{hi}_wrong_winding")

            resolved[f"is_valid_{pv_suffix}"] = len(issues) == 0
            resolved[f"issue_{pv_suffix}"] = ",".join(issues) if issues else ""
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 176: polygon_repair
    # ------------------------------------------------------------------
    def polygon_repair(
        self,
        pr_suffix: str = "pr",
    ) -> "GeoPromptFrame":
        """Repair common polygon issues: close rings, fix winding order,
        remove duplicate consecutive vertices, fix hole winding.
        Returns a repair report column detailing what was changed."""
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            resolved = dict(row)
            if not geom or geometry_type(geom) != "Polygon":
                resolved[f"repaired_{pr_suffix}"] = False
                resolved[f"repairs_{pr_suffix}"] = ""
                rows.append(resolved)
                continue
            coords = geom.get("coordinates", [])
            ring = list(coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords)
            repairs: list[str] = []

            # Remove duplicate consecutive vertices
            if len(ring) >= 2:
                deduped = [ring[0]]
                for k in range(1, len(ring)):
                    if (round(float(ring[k][0]), 10) != round(float(deduped[-1][0]), 10)
                            or round(float(ring[k][1]), 10) != round(float(deduped[-1][1]), 10)):
                        deduped.append(ring[k])
                if len(deduped) < len(ring):
                    repairs.append("removed_duplicate_vertices")
                    ring = deduped

            # Close ring if needed
            if len(ring) >= 3:
                r0, rl = ring[0], ring[-1]
                if len(r0) >= 2 and len(rl) >= 2 and (float(r0[0]) != float(rl[0]) or float(r0[1]) != float(rl[1])):
                    ring.append(list(ring[0]))
                    repairs.append("closed_ring")

            # Fix winding order (exterior should be CCW = negative signed area)
            if len(ring) >= 4:
                signed_area = sum(
                    float(ring[i][0]) * float(ring[(i + 1) % len(ring)][1]) - float(ring[(i + 1) % len(ring)][0]) * float(ring[i][1])
                    for i in range(len(ring))
                ) / 2
                if signed_area > 0:
                    ring.reverse()
                    repairs.append("reversed_outer_ring")

            # Process holes
            all_rings = [ring]
            if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)):
                for hi in range(1, len(coords)):
                    hole = list(coords[hi])
                    # Close hole
                    if len(hole) >= 3:
                        h0, hl = hole[0], hole[-1]
                        if len(h0) >= 2 and len(hl) >= 2 and (float(h0[0]) != float(hl[0]) or float(h0[1]) != float(hl[1])):
                            hole.append(list(hole[0]))
                            repairs.append(f"closed_hole_{hi}")
                    # Fix hole winding (holes should be CW = positive signed area)
                    if len(hole) >= 4:
                        hole_area = sum(
                            float(hole[k][0]) * float(hole[(k + 1) % len(hole)][1]) - float(hole[(k + 1) % len(hole)][0]) * float(hole[k][1])
                            for k in range(len(hole))
                        ) / 2
                        if hole_area < 0:
                            hole.reverse()
                            repairs.append(f"reversed_hole_{hi}")
                    all_rings.append(hole)

            resolved[self.geometry_column] = {"type": "Polygon", "coordinates": all_rings}
            resolved[f"repaired_{pr_suffix}"] = len(repairs) > 0
            resolved[f"repairs_{pr_suffix}"] = ",".join(repairs) if repairs else ""
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 177: line_merge — merge connected line segments
    # ------------------------------------------------------------------
    def line_merge(
        self,
        group_column: str | None = None,
        lm_suffix: str = "lm",
    ) -> "GeoPromptFrame":
        """Merge connected line segments into longer lines."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        groups: dict[str, list[int]] = {}
        for i, row in enumerate(self._rows):
            g = str(row.get(group_column, "all")) if group_column else "all"
            groups.setdefault(g, []).append(i)
        rows: list[Record] = []
        for gid, idxs in groups.items():
            segments: list[list[tuple[float, float]]] = []
            for i in idxs:
                geom = self._rows[i].get(self.geometry_column)
                if not geom or geometry_type(geom) != "LineString":
                    continue
                coords = [(float(c[0]), float(c[1])) for c in geom.get("coordinates", [])]
                if len(coords) >= 2:
                    segments.append(coords)
            if not segments:
                continue
            merged: list[list[tuple[float, float]]] = [segments[0]]
            used = {0}
            changed = True
            while changed:
                changed = False
                for si, seg in enumerate(segments):
                    if si in used:
                        continue
                    for mi, m in enumerate(merged):
                        if m[-1] == seg[0]:
                            merged[mi] = m + seg[1:]
                            used.add(si); changed = True; break
                        elif m[0] == seg[-1]:
                            merged[mi] = seg[:-1] + m
                            used.add(si); changed = True; break
            for mi, m in enumerate(merged):
                rows.append({
                    self.geometry_column: {"type": "LineString", "coordinates": [list(c) for c in m]},
                    f"group_{lm_suffix}": gid,
                    f"merge_id_{lm_suffix}": mi,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 178: voronoi_polygons
    # ------------------------------------------------------------------
    def voronoi_polygons(
        self,
        buffer_factor: float = 1.5,
        vor_suffix: str = "vor",
    ) -> "GeoPromptFrame":
        """Generate Voronoi polygons from point locations.

        Uses SciPy Voronoi when available for exact construction.
        Falls back to a pure-Python Delaunay-circumcenter approach,
        then to a high-resolution grid assignment as last resort.
        """
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        bw = (b.max_x - b.min_x) * buffer_factor
        bh = (b.max_y - b.min_y) * buffer_factor
        cx_b = (b.min_x + b.max_x) / 2
        cy_b = (b.min_y + b.max_y) / 2
        clip_min_x = cx_b - bw
        clip_min_y = cy_b - bh
        clip_max_x = cx_b + bw
        clip_max_y = cy_b + bh

        # -- Try SciPy Voronoi first --------------------------------
        try:
            spatial = importlib.import_module("scipy.spatial")
            np = importlib.import_module("numpy")
            pts_arr = np.array(centroids)
            vor = spatial.Voronoi(pts_arr)
            rows: list[Record] = []
            for i in range(n):
                region_idx = vor.point_region[i]
                region = vor.regions[region_idx]
                if not region or -1 in region:
                    # Unbounded region — collect finite verts + project far-away points
                    verts = [tuple(vor.vertices[vi]) for vi in region if vi >= 0]
                    far_pts: list[tuple[float, float]] = list(verts)
                    center = centroids[i]
                    for ri, (p0, p1) in enumerate(vor.ridge_points):
                        if i not in (p0, p1):
                            continue
                        rv = vor.ridge_vertices[ri]
                        if -1 not in rv:
                            continue
                        finite_vi = rv[0] if rv[1] == -1 else rv[1]
                        fv = tuple(vor.vertices[finite_vi])
                        midpoint = ((centroids[p0][0] + centroids[p1][0]) / 2,
                                    (centroids[p0][1] + centroids[p1][1]) / 2)
                        dx_dir = fv[0] - midpoint[0]
                        dy_dir = fv[1] - midpoint[1]
                        length = math.hypot(dx_dir, dy_dir)
                        if length < 1e-12:
                            dx_dir, dy_dir = center[0] - midpoint[0], center[1] - midpoint[1]
                            length = math.hypot(dx_dir, dy_dir)
                        if length > 1e-12:
                            scale = max(bw, bh) * 4 / length
                            far_pts.append((fv[0] + dx_dir * scale, fv[1] + dy_dir * scale))
                    far_pts.append(center)
                    cx_p = sum(p[0] for p in far_pts) / len(far_pts)
                    cy_p = sum(p[1] for p in far_pts) / len(far_pts)
                    far_pts.sort(key=lambda p: math.atan2(p[1] - cy_p, p[0] - cx_p))
                    clipped = _clip_polygon_to_box(far_pts, clip_min_x, clip_min_y, clip_max_x, clip_max_y)
                    if len(clipped) < 3:
                        resolved = dict(self._rows[i])
                        resolved[f"area_{vor_suffix}"] = 0.0
                        rows.append(resolved)
                        continue
                    ring = clipped + [clipped[0]]
                else:
                    verts = [tuple(vor.vertices[vi]) for vi in region]
                    clipped = _clip_polygon_to_box(verts, clip_min_x, clip_min_y, clip_max_x, clip_max_y)
                    if len(clipped) < 3:
                        resolved = dict(self._rows[i])
                        resolved[f"area_{vor_suffix}"] = 0.0
                        rows.append(resolved)
                        continue
                    ring = clipped + [clipped[0]]
                area = abs(sum(ring[j][0] * ring[j + 1][1] - ring[j + 1][0] * ring[j][1] for j in range(len(ring) - 1))) / 2
                resolved = dict(self._rows[i])
                resolved[self.geometry_column] = {"type": "Polygon", "coordinates": [[list(c) for c in ring]]}
                resolved[f"area_{vor_suffix}"] = area
                rows.append(resolved)
            return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)
        except (ImportError, Exception):
            pass

        # -- Pure-Python fallback: high-res grid assignment ----------
        grid_res = 200
        dx = (clip_max_x - clip_min_x) / grid_res
        dy = (clip_max_y - clip_min_y) / grid_res
        ownership: dict[int, list[tuple[float, float]]] = {i: [] for i in range(n)}
        for gy in range(grid_res):
            py = clip_min_y + (gy + 0.5) * dy
            for gx in range(grid_res):
                px = clip_min_x + (gx + 0.5) * dx
                best_i = 0
                best_d = float("inf")
                for i, c in enumerate(centroids):
                    d = (px - c[0]) ** 2 + (py - c[1]) ** 2
                    if d < best_d:
                        best_d = d
                        best_i = i
                ownership[best_i].append((px, py))
        rows = []
        for i in range(n):
            pts = ownership[i]
            if len(pts) < 3:
                resolved = dict(self._rows[i])
                resolved[f"area_{vor_suffix}"] = 0.0
                rows.append(resolved)
                continue
            cx_p = sum(p[0] for p in pts) / len(pts)
            cy_p = sum(p[1] for p in pts) / len(pts)
            pts.sort(key=lambda p: math.atan2(p[1] - cy_p, p[0] - cx_p))
            ring = pts + [pts[0]]
            area = abs(sum(ring[j][0] * ring[j + 1][1] - ring[j + 1][0] * ring[j][1] for j in range(len(ring) - 1))) / 2
            resolved = dict(self._rows[i])
            resolved[self.geometry_column] = {"type": "Polygon", "coordinates": [[list(c) for c in ring]]}
            resolved[f"area_{vor_suffix}"] = area
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 179: shared_boundaries — between adjacent polygons
    # ------------------------------------------------------------------
    def shared_boundaries(
        self,
        id_column: str = "site_id",
        sb_suffix: str = "sb",
    ) -> "GeoPromptFrame":
        """Extract shared boundary segments between adjacent polygons.

        Detects shared edges (line segments) rather than just shared vertices.
        Two polygons share a boundary when they have overlapping directed edges.
        """
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        def _ring_edges(ring: list) -> set[tuple[tuple[float, float], tuple[float, float]]]:
            """Return a set of normalized undirected edge tuples."""
            edges: set[tuple[tuple[float, float], tuple[float, float]]] = set()
            for k in range(len(ring) - 1):
                p0 = (round(float(ring[k][0]), 8), round(float(ring[k][1]), 8))
                p1 = (round(float(ring[k + 1][0]), 8), round(float(ring[k + 1][1]), 8))
                if p0 != p1:
                    edges.add((min(p0, p1), max(p0, p1)))
            return edges

        def _extract_ring(geom: dict) -> list:
            coords = geom.get("coordinates", [])
            if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)):
                return coords[0]
            return coords

        rows: list[Record] = []
        # Pre-compute edge sets
        edge_sets: list[set[tuple[tuple[float, float], tuple[float, float]]] | None] = []
        for i in range(n):
            geom = self._rows[i].get(self.geometry_column)
            if not geom or geometry_type(geom) != "Polygon":
                edge_sets.append(None)
            else:
                edge_sets.append(_ring_edges(_extract_ring(geom)))

        for i in range(n):
            if edge_sets[i] is None:
                continue
            for j in range(i + 1, n):
                if edge_sets[j] is None:
                    continue
                left_edges = edge_sets[i]
                right_edges = edge_sets[j]
                if left_edges is None or right_edges is None:
                    continue
                shared_edges = left_edges & right_edges
                if not shared_edges:
                    continue
                # Stitch shared edges into connected line segments
                chains = _stitch_edge_chains(shared_edges)
                for chain in chains:
                    rows.append({
                        self.geometry_column: {"type": "LineString", "coordinates": [list(p) for p in chain]},
                        f"left_{sb_suffix}": str(self._rows[i].get(id_column, i)),
                        f"right_{sb_suffix}": str(self._rows[j].get(id_column, j)),
                        f"n_shared_edges_{sb_suffix}": len(shared_edges),
                        f"length_{sb_suffix}": sum(
                            math.hypot(chain[k + 1][0] - chain[k][0], chain[k + 1][1] - chain[k][1])
                            for k in range(len(chain) - 1)
                        ),
                    })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 180: topology_audit — dangles, overlaps, gaps
    # ------------------------------------------------------------------
    def topology_audit(
        self,
        ta_suffix: str = "ta",
    ) -> list[Record]:
        """Topology audit: identify dangles, self-intersections, and duplicate geometries."""
        results: list[Record] = []
        seen_geoms: set[str] = set()
        for i, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom:
                results.append({"row": i, "issue": "null_geometry"})
                continue
            gt = geometry_type(geom)
            geom_key = str(geom.get("coordinates", ""))
            if geom_key in seen_geoms:
                results.append({"row": i, "issue": "duplicate_geometry"})
            seen_geoms.add(geom_key)
            if gt == "LineString":
                coords = geom.get("coordinates", [])
                if len(coords) >= 2:
                    start = tuple(coords[0][:2])
                    end = tuple(coords[-1][:2])
                    is_dangle_start = True
                    is_dangle_end = True
                    for j, other in enumerate(self._rows):
                        if j == i:
                            continue
                        og = other.get(self.geometry_column)
                        if not og or geometry_type(og) != "LineString":
                            continue
                        oc = og.get("coordinates", [])
                        if len(oc) >= 2:
                            os = tuple(oc[0][:2])
                            oe = tuple(oc[-1][:2])
                            if start == os or start == oe:
                                is_dangle_start = False
                            if end == os or end == oe:
                                is_dangle_end = False
                    if is_dangle_start:
                        results.append({"row": i, "issue": "dangle_start", "coordinates": list(start)})
                    if is_dangle_end:
                        results.append({"row": i, "issue": "dangle_end", "coordinates": list(end)})
            elif gt == "Polygon":
                coords = geom.get("coordinates", [])
                ring = coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords
                if len(ring) >= 4:
                    r0, rl = ring[0], ring[-1]
                    if len(r0) >= 2 and len(rl) >= 2 and (float(r0[0]) != float(rl[0]) or float(r0[1]) != float(rl[1])):
                        results.append({"row": i, "issue": "unclosed_ring"})
        return results

    # ------------------------------------------------------------------
    # Tool 181: wkb_to_geometry / geometry_to_wkb
    # ------------------------------------------------------------------
    def wkb_to_geometry(
        self,
        wkb_column: str,
    ) -> "GeoPromptFrame":
        """Parse WKB hex strings into geometry objects."""
        self._require_column(wkb_column)
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            wkb_hex = str(row.get(wkb_column, "") or "")
            if wkb_hex:
                resolved[self.geometry_column] = _parse_wkb_hex(wkb_hex)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    def geometry_to_wkb(
        self,
        wkb_column: str = "wkb",
    ) -> "GeoPromptFrame":
        """Convert geometry to WKB hex string column."""
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            geom = row.get(self.geometry_column)
            resolved[wkb_column] = _geometry_to_wkb_hex(geom) if geom else ""
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 182: geojson_sequence — NDJSON
    # ------------------------------------------------------------------
    @classmethod
    def read_geojson_seq(cls, path: str, geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read newline-delimited GeoJSON (GeoJSON Sequence / NDJSON)."""
        import json
        records: list[Record] = []
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                feat = json.loads(line)
                if feat.get("type") == "Feature":
                    rec = dict(feat.get("properties", {}))
                    rec[geometry] = feat.get("geometry")
                    records.append(rec)
                elif "coordinates" in feat:
                    records.append({geometry: feat})
        return cls.from_records(records, geometry=geometry, crs=crs)

    def to_geojson_seq(self, path: str) -> None:
        """Write newline-delimited GeoJSON."""
        import json
        with open(path, "w", encoding="utf-8") as f:
            for row in self._rows:
                geom = row.get(self.geometry_column)
                props = {k: v for k, v in row.items() if k != self.geometry_column}
                feat = {"type": "Feature", "geometry": geom, "properties": props}
                f.write(json.dumps(feat) + "\n")

    # ------------------------------------------------------------------
    # Tool 183: csv_with_wkt — read CSV with WKT geometry
    # ------------------------------------------------------------------
    @classmethod
    def read_csv_wkt(cls, path: str, geometry_column: str = "wkt", output_geometry: str = "geometry", crs: str | None = None) -> "GeoPromptFrame":
        """Read CSV where one column contains WKT geometry strings."""
        import csv
        records: list[Record] = []
        with open(path, "r", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for csv_row in reader:
                rec: Record = dict(csv_row)
                wkt_str = str(rec.pop(geometry_column, "") or "")
                if wkt_str:
                    rec[output_geometry] = _parse_wkt(wkt_str)
                records.append(rec)
        return cls.from_records(records, geometry=output_geometry, crs=crs)

    # ------------------------------------------------------------------
    # Tool 184: rasterize — point/line/polygon to grid
    # ------------------------------------------------------------------
    def rasterize(
        self,
        value_column: str | None = None,
        grid_resolution: int = 50,
        burn_value: float = 1.0,
        rast_suffix: str = "rast",
    ) -> "GeoPromptFrame":
        """Rasterize features onto a grid."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g, 1)
        dy = (b.max_y - b.min_y) / max(g, 1)
        grid = [[0.0] * g for _ in range(g)]
        centroids = self._centroids()
        for i, c in enumerate(centroids):
            gx = min(int((c[0] - b.min_x) / max(dx, 1e-12)), g - 1)
            gy = min(int((c[1] - b.min_y) / max(dy, 1e-12)), g - 1)
            val = float(self._rows[i].get(value_column, burn_value) or burn_value) if value_column else burn_value
            grid[gy][gx] = max(grid[gy][gx], val)
        rows: list[Record] = []
        for gy in range(g):
            for gx in range(g):
                if grid[gy][gx] != 0.0:
                    rows.append({
                        self.geometry_column: {"type": "Point", "coordinates": (b.min_x + (gx + 0.5) * dx, b.min_y + (gy + 0.5) * dy)},
                        f"value_{rast_suffix}": grid[gy][gx],
                        f"row_{rast_suffix}": gy,
                        f"col_{rast_suffix}": gx,
                    })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 185: zonal_statistics_grid — zonal stats over grid
    # ------------------------------------------------------------------
    def zonal_statistics_grid(
        self,
        zones: "GeoPromptFrame",
        value_column: str,
        zone_id_column: str = "zone_id",
        zsg_suffix: str = "zsg",
    ) -> "GeoPromptFrame":
        """Compute zonal statistics (mean, min, max, sum, count) of this frame's values within zone polygons."""
        self._require_column(value_column)
        zone_recs = zones.to_records()
        zone_centroids = zones._centroids()
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        zone_vals: dict[str, list[float]] = {}
        for i, c in enumerate(centroids):
            best_z = ""
            best_d = float("inf")
            for zi, zc in enumerate(zone_centroids):
                d = (c[0] - zc[0]) ** 2 + (c[1] - zc[1]) ** 2
                if d < best_d:
                    best_d = d
                    best_z = str(zone_recs[zi].get(zone_id_column, f"z{zi}"))
            zone_vals.setdefault(best_z, []).append(values[i])
        rows: list[Record] = []
        for zi, zr in enumerate(zone_recs):
            zid = str(zr.get(zone_id_column, f"z{zi}"))
            vals = zone_vals.get(zid, [])
            resolved = dict(zr)
            resolved[f"count_{zsg_suffix}"] = len(vals)
            resolved[f"sum_{zsg_suffix}"] = sum(vals) if vals else 0.0
            resolved[f"mean_{zsg_suffix}"] = sum(vals) / len(vals) if vals else 0.0
            resolved[f"min_{zsg_suffix}"] = min(vals) if vals else 0.0
            resolved[f"max_{zsg_suffix}"] = max(vals) if vals else 0.0
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=zones.geometry_column, crs=zones.crs)

    # ------------------------------------------------------------------
    # Tool 186: connected_components — on grid or network
    # ------------------------------------------------------------------
    def connected_components(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cc_suffix: str = "cc",
    ) -> "GeoPromptFrame":
        """Label connected components in a network."""
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        adj: dict[str, set[str]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            adj.setdefault(fn, set()).add(tn)
            adj.setdefault(tn, set()).add(fn)
        node_label: dict[str, int] = {}
        label = 0
        for node in adj:
            if node in node_label:
                continue
            stack = [node]
            while stack:
                nd = stack.pop()
                if nd in node_label:
                    continue
                node_label[nd] = label
                stack.extend(adj.get(nd, set()))
            label += 1
        rows: list[Record] = []
        for i in range(n):
            fn = str(self._rows[i].get(from_node_column, ""))
            resolved = dict(self._rows[i])
            resolved[f"component_{cc_suffix}"] = node_label.get(fn, -1)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 187: dbscan — density-based spatial clustering
    # ------------------------------------------------------------------
    def dbscan(
        self,
        eps: float,
        min_samples: int = 5,
        distance_method: str = "euclidean",
        db_suffix: str = "db",
    ) -> "GeoPromptFrame":
        """DBSCAN clustering: density-based spatial clustering of applications with noise.

        Points within *eps* distance of at least *min_samples* neighbours form
        a cluster core.  Border points are reachable from a core point.
        Noise points are labelled -1.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        dm = self._cached_distance_matrix(distance_method)
        labels = [-1] * n
        cluster_id = 0
        visited = [False] * n
        for i in range(n):
            if visited[i]:
                continue
            visited[i] = True
            neighbors = [j for j in range(n) if j != i and dm[i][j] <= eps]
            if len(neighbors) < min_samples:
                continue  # noise for now
            labels[i] = cluster_id
            seed_set = list(neighbors)
            si = 0
            while si < len(seed_set):
                q = seed_set[si]
                si += 1
                if not visited[q]:
                    visited[q] = True
                    q_neighbors = [j for j in range(n) if j != q and dm[q][j] <= eps]
                    if len(q_neighbors) >= min_samples:
                        for nn in q_neighbors:
                            if nn not in seed_set:
                                seed_set.append(nn)
                if labels[q] == -1:
                    labels[q] = cluster_id
            cluster_id += 1
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{db_suffix}"] = labels[i]
            resolved[f"is_noise_{db_suffix}"] = labels[i] == -1
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 188: hdbscan — hierarchical DBSCAN-style clustering
    # ------------------------------------------------------------------
    def hdbscan(
        self,
        min_cluster_size: int = 5,
        min_samples: int | None = None,
        distance_method: str = "euclidean",
        hdb_suffix: str = "hdb",
    ) -> "GeoPromptFrame":
        """HDBSCAN-style hierarchical density clustering.

        Uses mutual reachability distance to build a minimum spanning tree,
        then extracts flat clusters using single-linkage hierarchy.
        Falls back to pure-Python implementation when the hdbscan library
        is not available.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if min_samples is None:
            min_samples = min_cluster_size
        dm = self._cached_distance_matrix(distance_method)

        # Core distances: distance to k-th nearest neighbor
        core_dists = [0.0] * n
        for i in range(n):
            dists_i = sorted(dm[i][j] for j in range(n) if j != i)
            core_dists[i] = dists_i[min(min_samples - 1, len(dists_i) - 1)] if dists_i else 0.0

        # Mutual reachability distance
        mrd = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = max(core_dists[i], core_dists[j], dm[i][j])
                mrd[i][j] = d
                mrd[j][i] = d

        # Prim's MST on mutual reachability distances
        in_tree = [False] * n
        min_edge = [float("inf")] * n
        min_edge_from = [-1] * n
        mst_edges: list[tuple[int, int, float]] = []
        min_edge[0] = 0.0
        for _ in range(n):
            u = -1
            for v in range(n):
                if not in_tree[v] and (u == -1 or min_edge[v] < min_edge[u]):
                    u = v
            in_tree[u] = True
            if min_edge_from[u] >= 0:
                mst_edges.append((min_edge_from[u], u, min_edge[u]))
            for v in range(n):
                if not in_tree[v] and mrd[u][v] < min_edge[v]:
                    min_edge[v] = mrd[u][v]
                    min_edge_from[v] = u

        # Sort MST edges by weight (single-linkage dendrogram)
        mst_edges.sort(key=lambda e: e[2])

        # Extract flat clusters: cut when components reach min_cluster_size
        parent = list(range(n))
        size = [1] * n

        def _find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        labels = [-1] * n
        cluster_id = 0
        for u, v, w in mst_edges:
            ru, rv = _find(u), _find(v)
            if ru != rv:
                if size[ru] < size[rv]:
                    ru, rv = rv, ru
                parent[rv] = ru
                size[ru] += size[rv]

        # Label components that meet min_cluster_size
        comp_map: dict[int, list[int]] = {}
        for i in range(n):
            root = _find(i)
            comp_map.setdefault(root, []).append(i)

        for root, members in comp_map.items():
            if len(members) >= min_cluster_size:
                for m in members:
                    labels[m] = cluster_id
                cluster_id += 1

        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{hdb_suffix}"] = labels[i]
            resolved[f"is_noise_{hdb_suffix}"] = labels[i] == -1
            resolved[f"core_distance_{hdb_suffix}"] = core_dists[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 189: multivariate_morans_i — multivariate spatial autocorrelation
    # ------------------------------------------------------------------
    def multivariate_morans_i(
        self,
        value_columns: list[str],
        k: int = 4,
        distance_method: str = "euclidean",
        mmi_suffix: str = "mmi",
    ) -> list[Record]:
        """Multivariate Moran's I: spatial autocorrelation across multiple variables.

        Computes the cross-variable Moran's I matrix and an overall
        multivariate statistic.
        """
        n = len(self._rows)
        for col in value_columns:
            self._require_column(col)
        if n < 3:
            return [{"statistic": "multivariate_morans_i", "I": 0.0, "n": n}]

        # Standardize values
        columns_data: list[list[float]] = []
        for col in value_columns:
            vals = [float(row.get(col, 0) or 0) for row in self._rows]
            mean_v = sum(vals) / n
            std_v = math.sqrt(sum((v - mean_v) ** 2 for v in vals) / n)
            if std_v < 1e-15:
                columns_data.append([0.0] * n)
            else:
                columns_data.append([(v - mean_v) / std_v for v in vals])

        # Build k-NN weight matrix (row-standardized)
        knn = self._cached_knn(k, distance_method)

        # Compute cross-Moran matrix
        p = len(value_columns)
        cross_moran = [[0.0] * p for _ in range(p)]
        for a in range(p):
            for b in range(p):
                total = 0.0
                w_sum = 0.0
                for i in range(n):
                    for j in knn[i]:
                        w = 1.0 / max(len(knn[i]), 1)
                        total += w * columns_data[a][i] * columns_data[b][j]
                        w_sum += w
                cross_moran[a][b] = total / max(w_sum, 1e-12)

        # Overall multivariate I = trace of cross-Moran / p
        overall_I = sum(cross_moran[i][i] for i in range(p)) / p

        results: list[Record] = [{
            "statistic": "multivariate_morans_i",
            "I": overall_I,
            "n": n,
            "k": k,
            "variables": value_columns,
        }]
        for a in range(p):
            for b in range(p):
                results.append({
                    "statistic": "cross_moran",
                    "variable_a": value_columns[a],
                    "variable_b": value_columns[b],
                    "I": cross_moran[a][b],
                })
        return results

    # ------------------------------------------------------------------
    # Tool 190: local_geary_decomposition
    # ------------------------------------------------------------------
    def local_geary_decomposition(
        self,
        value_columns: list[str],
        k: int = 4,
        distance_method: str = "euclidean",
        lgd_suffix: str = "lgd",
    ) -> "GeoPromptFrame":
        """Local multivariate Geary decomposition.

        For each feature, computes a local Geary-like statistic across
        multiple variables to identify multivariate local clustering.
        """
        n = len(self._rows)
        for col in value_columns:
            self._require_column(col)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Standardize
        col_data: list[list[float]] = []
        for col in value_columns:
            vals = [float(row.get(col, 0) or 0) for row in self._rows]
            mean_v = sum(vals) / max(n, 1)
            std_v = math.sqrt(sum((v - mean_v) ** 2 for v in vals) / max(n, 1))
            if std_v < 1e-15:
                col_data.append([0.0] * n)
            else:
                col_data.append([(v - mean_v) / std_v for v in vals])

        knn = self._cached_knn(k, distance_method)
        p = len(value_columns)

        rows: list[Record] = []
        for i in range(n):
            total_c = 0.0
            per_var: dict[str, float] = {}
            for vi in range(p):
                c_vi = 0.0
                w_sum = 0.0
                for j in knn[i]:
                    w = 1.0 / max(len(knn[i]), 1)
                    c_vi += w * (col_data[vi][i] - col_data[vi][j]) ** 2
                    w_sum += w
                c_vi = c_vi / max(w_sum, 1e-12)
                per_var[f"geary_{value_columns[vi]}_{lgd_suffix}"] = c_vi
                total_c += c_vi
            resolved = dict(self._rows[i])
            resolved[f"local_geary_{lgd_suffix}"] = total_c / p
            resolved.update(per_var)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 191: adaptive_idw — IDW with locally optimized power
    # ------------------------------------------------------------------
    def adaptive_idw(
        self,
        value_column: str,
        grid_resolution: int = 20,
        k_neighbors: int = 8,
        power_range: tuple[float, float] = (1.0, 4.0),
        power_steps: int = 7,
        aidw_suffix: str = "aidw",
    ) -> "GeoPromptFrame":
        """Adaptive inverse distance weighting interpolation.

        Selects the IDW power parameter locally by leave-one-out
        cross-validation among the k nearest data points, yielding
        spatially varying power for better local adaptation.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]

        # Pre-compute candidate powers
        powers = [power_range[0] + (power_range[1] - power_range[0]) * s / max(power_steps - 1, 1) for s in range(power_steps)]

        # For each data point, find best power by LOO cross-validation
        knn = self._cached_knn(k_neighbors, "euclidean")
        dm = self._cached_distance_matrix("euclidean")
        best_powers = [2.0] * n
        for i in range(n):
            neighbors = knn[i]
            best_err = float("inf")
            best_p = 2.0
            for pw in powers:
                err_sum = 0.0
                for ni in neighbors:
                    w_sum = 0.0
                    val_sum = 0.0
                    for nj in neighbors:
                        if nj == ni:
                            continue
                        d = dm[ni][nj]
                        w = 1.0 / max(d ** pw, 1e-15)
                        w_sum += w
                        val_sum += w * values[nj]
                    pred = val_sum / max(w_sum, 1e-15)
                    err_sum += (pred - values[ni]) ** 2
                if err_sum < best_err:
                    best_err = err_sum
                    best_p = pw
            best_powers[i] = best_p

        # Generate grid with locally adaptive power
        b = self.bounds()
        g = grid_resolution
        dx_g = (b.max_x - b.min_x) / max(g - 1, 1)
        dy_g = (b.max_y - b.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy_g
            for gx in range(g):
                px = b.min_x + gx * dx_g
                # Find nearest data point to choose power
                dists_to_data = [(math.hypot(px - centroids[ci][0], py - centroids[ci][1]), ci) for ci in range(n)]
                dists_to_data.sort()
                local_power = best_powers[dists_to_data[0][1]]
                w_sum = 0.0
                v_sum = 0.0
                for d, ci in dists_to_data[:k_neighbors]:
                    w = 1.0 / max(d ** local_power, 1e-15)
                    w_sum += w
                    v_sum += w * values[ci]
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{aidw_suffix}": v_sum / max(w_sum, 1e-15),
                    f"power_{aidw_suffix}": local_power,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 192: conformal_kriging — spatially calibrated kriging intervals
    # ------------------------------------------------------------------
    def conformal_kriging(
        self,
        value_column: str,
        grid_resolution: int = 20,
        variogram_range: float | None = None,
        variogram_sill: float | None = None,
        variogram_nugget: float | None = None,
        variogram_model: str | None = None,
        auto_fit_variogram: bool = True,
        calibration_fraction: float = 0.25,
        alpha: float = 0.1,
        local_k: int | None = 12,
        distance_method: str = "euclidean",
        ck_suffix: str = "ckg",
    ) -> "GeoPromptFrame":
        """Conformal kriging with spatially local calibration.

        Fits ordinary kriging on a spatially ordered training subset,
        calibrates residual conformity scores on held-out observations,
        and derives locally adapted prediction intervals for grid cells.
        """
        self._require_column(value_column)
        if not 0.0 < calibration_fraction < 1.0:
            raise ValueError("calibration_fraction must be between 0 and 1")
        if not 0.0 < alpha < 1.0:
            raise ValueError("alpha must be between 0 and 1")
        if local_k is not None and local_k <= 0:
            raise ValueError("local_k must be greater than zero when provided")
        if not self._rows:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if len(self._rows) < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        sample_count = len(centroids)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        if auto_fit_variogram or (variogram_range is None and variogram_sill is None and variogram_model is None):
            fitted_model, fitted_range, fitted_sill, fitted_nugget = _fit_empirical_variogram(
                centroids, values, distance_method
            )
            if variogram_model is None:
                variogram_model = fitted_model
            if variogram_range is None:
                variogram_range = fitted_range
            if variogram_sill is None:
                variogram_sill = fitted_sill
            if variogram_nugget is None:
                variogram_nugget = fitted_nugget
        if variogram_sill is None:
            variogram_sill = 1.0
        if variogram_nugget is None:
            variogram_nugget = 0.0
        if variogram_model is None:
            variogram_model = "spherical"
        if variogram_sill <= 0.0:
            raise ValueError("variogram_sill must be greater than zero")
        if variogram_nugget < 0.0:
            raise ValueError("variogram_nugget must be zero or greater")
        if variogram_range is not None and variogram_range <= 0.0:
            raise ValueError("variogram_range must be greater than zero")

        normalized_variogram_model = variogram_model.replace("-", "_")
        if normalized_variogram_model not in {"spherical", "exponential", "gaussian", "hole_effect"}:
            raise ValueError("variogram_model must be 'spherical', 'exponential', 'gaussian', or 'hole_effect'")
        effective_range = variogram_range if variogram_range is not None else extent / 3.0
        distance_matrix = _pairwise_distance_matrix(centroids, distance_method)

        ordered_idx = sorted(range(sample_count), key=lambda idx: (centroids[idx][0], centroids[idx][1], idx))
        calibration_count = min(sample_count - 1, max(1, int(sample_count * calibration_fraction)))
        calibration_idx: set[int] = set()
        for slot in range(calibration_count):
            pick = min(sample_count - 1, int((slot + 0.5) * sample_count / calibration_count))
            calibration_idx.add(ordered_idx[pick])
        if len(calibration_idx) >= sample_count:
            calibration_idx.remove(ordered_idx[-1])
        train_idx = [idx for idx in range(sample_count) if idx not in calibration_idx]

        train_centroids = [centroids[idx] for idx in train_idx]
        train_values = [values[idx] for idx in train_idx]
        train_count = len(train_idx)
        kriging_matrix = [
            [
                _variogram_value(
                    normalized_variogram_model,
                    distance_matrix[train_idx[row_index]][train_idx[col_index]],
                    effective_range,
                    variogram_sill,
                    variogram_nugget,
                )
                for col_index in range(train_count)
            ]
            + [1.0]
            for row_index in range(train_count)
        ]
        kriging_matrix.append([1.0] * train_count + [0.0])
        inverse_matrix = _invert_matrix(kriging_matrix)
        if inverse_matrix is None:
            stabilized_matrix = [list(row) for row in kriging_matrix]
            diagonal_jitter = max(variogram_sill, 1.0) * 1e-9
            for index in range(train_count):
                stabilized_matrix[index][index] += diagonal_jitter
            inverse_matrix = _invert_matrix(stabilized_matrix)
            if inverse_matrix is not None:
                kriging_matrix = stabilized_matrix

        def _predict_from_training(point: tuple[float, float]) -> tuple[float, float]:
            query_distances = [coordinate_distance(point, centroid, method=distance_method) for centroid in train_centroids]
            exact_index = next(
                (
                    index
                    for index, distance_value in enumerate(query_distances)
                    if distance_value <= 1e-12 and variogram_nugget <= 0.0
                ),
                None,
            )
            if exact_index is not None:
                return train_values[exact_index], 0.0
            rhs = [
                _variogram_value(
                    normalized_variogram_model,
                    distance_value,
                    effective_range,
                    variogram_sill,
                    variogram_nugget,
                    include_nugget_at_zero=variogram_nugget > 0.0,
                )
                for distance_value in query_distances
            ] + [1.0]
            solution = _matrix_vector_product(inverse_matrix, rhs) if inverse_matrix is not None else _solve_linear_system(kriging_matrix, rhs)
            if solution is None:
                weights = [max(0.0, variogram_sill - rhs[index]) for index in range(train_count)]
                weight_sum = sum(weights)
                if weight_sum > 0.0:
                    predicted = sum(weight * value for weight, value in zip(weights, train_values, strict=True)) / weight_sum
                    variance = variogram_sill - sum(weight * weight for weight in weights) / (weight_sum * weight_sum) * variogram_sill
                else:
                    predicted = sum(train_values) / len(train_values) if train_values else 0.0
                    variance = variogram_sill
            else:
                kriging_weights = solution[:train_count]
                lagrange_multiplier = float(solution[-1])
                predicted = sum(weight * value for weight, value in zip(kriging_weights, train_values, strict=True))
                variance = sum(weight * rhs[index] for index, weight in enumerate(kriging_weights)) + lagrange_multiplier
            return predicted, max(0.0, variance)

        calibration_scores: list[tuple[tuple[float, float], float]] = []
        calibration_scales: list[float] = []
        for idx in sorted(calibration_idx):
            predicted, variance = _predict_from_training(centroids[idx])
            calibration_scales.append(math.sqrt(max(variance, 0.0)))
            calibration_scores.append((centroids[idx], abs(values[idx] - predicted)))
        positive_scales = sorted(scale for scale in calibration_scales if scale > 1e-9)
        scale_floor = positive_scales[len(positive_scales) // 2] * 0.25 if positive_scales else max(math.sqrt(variogram_sill) * 0.1, 1e-6)
        normalized_calibration_scores = [
            (point, error / max(scale_floor, calibration_scales[pos], 1e-9))
            for pos, (point, error) in enumerate(calibration_scores)
        ]

        def _score_quantile(scores: list[float]) -> float:
            if not scores:
                return 0.0
            ordered_scores = sorted(scores)
            q_index = min(
                len(ordered_scores) - 1,
                max(0, math.ceil((len(ordered_scores) + 1) * (1.0 - alpha)) - 1),
            )
            return ordered_scores[q_index]

        def _local_qhat(point: tuple[float, float]) -> tuple[float, int]:
            if not normalized_calibration_scores:
                return 0.0, 0
            if local_k is None or local_k >= len(normalized_calibration_scores):
                return _score_quantile([score for _, score in normalized_calibration_scores]), len(normalized_calibration_scores)
            nearest_scores = sorted(
                (
                    coordinate_distance(point, cal_point, method=distance_method),
                    score,
                )
                for cal_point, score in normalized_calibration_scores
            )[:local_k]
            return _score_quantile([score for _, score in nearest_scores]), len(nearest_scores)

        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows: list[Record] = []
        cell_id = 0
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                predicted, variance = _predict_from_training((px, py))
                std_error = math.sqrt(max(variance, 0.0))
                q_hat, calibration_n = _local_qhat((px, py))
                scale = max(std_error, scale_floor)
                half_width = q_hat * scale
                cell_id += 1
                rows.append({
                    f"cell_id_{ck_suffix}": f"ckg-{cell_id:04d}",
                    f"predicted_{ck_suffix}": predicted,
                    f"variance_{ck_suffix}": variance,
                    f"std_error_{ck_suffix}": std_error,
                    f"lower_{ck_suffix}": predicted - half_width,
                    f"upper_{ck_suffix}": predicted + half_width,
                    f"interval_width_{ck_suffix}": 2.0 * half_width,
                    f"qhat_{ck_suffix}": q_hat,
                    f"calibration_n_{ck_suffix}": calibration_n,
                    f"method_{ck_suffix}": "conformal_ordinary_kriging",
                    f"variogram_model_{ck_suffix}": normalized_variogram_model,
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 193: raster_algebra — arithmetic on grid surfaces
    # ------------------------------------------------------------------
    def raster_algebra(
        self,
        value_column: str,
        expression: str = "x",
        output_column: str | None = None,
        ra_suffix: str = "ra",
    ) -> "GeoPromptFrame":
        """Apply a mathematical expression to a value column (raster algebra).

        The expression uses 'x' to refer to the cell value.
        Supported operators: +, -, *, /, **, abs, sqrt, log, min, max, round.
        Only safe math operations are allowed (no exec/eval of arbitrary code).
        """
        self._require_column(value_column)
        out_col = output_column or f"result_{ra_suffix}"
        # Parse safe expression
        allowed_funcs = {
            "abs": abs, "sqrt": math.sqrt, "log": math.log,
            "log10": math.log10, "exp": math.exp, "sin": math.sin,
            "cos": math.cos, "tan": math.tan, "round": round,
            "min": min, "max": max, "pow": pow,
        }
        rows: list[Record] = []
        for row in self._rows:
            resolved = dict(row)
            x = float(row.get(value_column, 0) or 0)
            try:
                # Safe evaluation: only allow numeric operations
                result = _safe_raster_eval(expression, x, allowed_funcs)
            except Exception:
                result = float("nan")
            resolved[out_col] = result
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 194: polygon_triangulation — ear-clipping triangulation
    # ------------------------------------------------------------------
    def polygon_triangulation(
        self,
        pt_suffix: str = "pt",
    ) -> "GeoPromptFrame":
        """Triangulate polygons using the ear-clipping algorithm.

        Returns a frame of triangle polygons covering each input polygon.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        rows: list[Record] = []
        for idx, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom or geometry_type(geom) != "Polygon":
                continue
            coords = geom.get("coordinates", [])
            ring = coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords
            # Remove closing vertex
            pts = [(float(c[0]), float(c[1])) for c in ring]
            if len(pts) >= 2 and pts[0] == pts[-1]:
                pts = pts[:-1]
            triangles = _ear_clip_triangulate(pts)
            for ti, tri in enumerate(triangles):
                tri_ring = [list(tri[0]), list(tri[1]), list(tri[2]), list(tri[0])]
                rows.append({
                    self.geometry_column: {"type": "Polygon", "coordinates": [tri_ring]},
                    f"source_idx_{pt_suffix}": idx,
                    f"triangle_id_{pt_suffix}": ti,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 195: service_area_polygons — network reachability areas
    # ------------------------------------------------------------------
    def service_area_polygons(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "cost",
        origin_nodes: list[str] | None = None,
        cost_breaks: list[float] | None = None,
        sa_suffix: str = "sa",
    ) -> "GeoPromptFrame":
        """Compute service-area polygons from network reachability.

        From each origin, runs Dijkstra to find reachable nodes within
        each cost break, then creates a convex hull polygon for each zone.
        """
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if cost_breaks is None:
            cost_breaks = [5.0, 10.0, 15.0]

        # Build adjacency graph
        adj: dict[str, list[tuple[str, float]]] = {}
        node_coords: dict[str, tuple[float, float]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            c = float(row.get(cost_column, 1.0) or 1.0)
            adj.setdefault(fn, []).append((tn, c))
            adj.setdefault(tn, []).append((fn, c))
            geom = row.get(self.geometry_column)
            if geom:
                coords = geom.get("coordinates", [])
                if geometry_type(geom) == "LineString" and len(coords) >= 2:
                    node_coords.setdefault(fn, (float(coords[0][0]), float(coords[0][1])))
                    node_coords.setdefault(tn, (float(coords[-1][0]), float(coords[-1][1])))
                elif geometry_type(geom) == "Point" and len(coords) >= 2:
                    node_coords.setdefault(fn, (float(coords[0]), float(coords[1])))

        if origin_nodes is None:
            origin_nodes = list(adj.keys())[:3]

        max_cost = max(cost_breaks)
        rows: list[Record] = []
        for origin in origin_nodes:
            # Dijkstra
            dist: dict[str, float] = {origin: 0.0}
            heap = [(0.0, origin)]
            while heap:
                d, u = heapq.heappop(heap)
                if d > dist.get(u, float("inf")):
                    continue
                if d > max_cost:
                    break
                for v, w in adj.get(u, []):
                    nd = d + w
                    if nd < dist.get(v, float("inf")):
                        dist[v] = nd
                        heapq.heappush(heap, (nd, v))

            for cb in sorted(cost_breaks):
                reachable_pts = [node_coords[nd] for nd, dd in dist.items() if dd <= cb and nd in node_coords]
                if len(reachable_pts) < 3:
                    continue
                # Convex hull
                hull = _convex_hull_2d(reachable_pts)
                if len(hull) < 3:
                    continue
                ring = hull + [hull[0]]
                area = abs(sum(ring[j][0] * ring[j + 1][1] - ring[j + 1][0] * ring[j][1] for j in range(len(ring) - 1))) / 2
                rows.append({
                    self.geometry_column: {"type": "Polygon", "coordinates": [[list(p) for p in ring]]},
                    f"origin_{sa_suffix}": origin,
                    f"cost_break_{sa_suffix}": cb,
                    f"n_reachable_{sa_suffix}": len(reachable_pts),
                    f"area_{sa_suffix}": area,
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 196: isochrones — travel-time contour lines from network
    # ------------------------------------------------------------------
    def isochrones(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "cost",
        origin_node: str = "",
        time_breaks: list[float] | None = None,
        iso_suffix: str = "iso",
    ) -> "GeoPromptFrame":
        """Generate isochrone polygons: travel-time contour rings from a network origin."""
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if time_breaks is None:
            time_breaks = [5.0, 10.0, 15.0, 20.0]

        # Build graph
        adj: dict[str, list[tuple[str, float]]] = {}
        node_coords: dict[str, tuple[float, float]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            c = float(row.get(cost_column, 1.0) or 1.0)
            adj.setdefault(fn, []).append((tn, c))
            adj.setdefault(tn, []).append((fn, c))
            geom = row.get(self.geometry_column)
            if geom:
                coords = geom.get("coordinates", [])
                if geometry_type(geom) == "LineString" and len(coords) >= 2:
                    node_coords.setdefault(fn, (float(coords[0][0]), float(coords[0][1])))
                    node_coords.setdefault(tn, (float(coords[-1][0]), float(coords[-1][1])))
                elif geometry_type(geom) == "Point" and len(coords) >= 2:
                    node_coords.setdefault(fn, (float(coords[0]), float(coords[1])))

        if not origin_node:
            origin_node = next(iter(adj), "")

        max_time = max(time_breaks) if time_breaks else 20.0
        dist: dict[str, float] = {origin_node: 0.0}
        heap = [(0.0, origin_node)]
        while heap:
            d, u = heapq.heappop(heap)
            if d > dist.get(u, float("inf")):
                continue
            if d > max_time:
                break
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist.get(v, float("inf")):
                    dist[v] = nd
                    heapq.heappush(heap, (nd, v))

        rows: list[Record] = []
        prev_break = 0.0
        for tb in sorted(time_breaks):
            ring_pts = [node_coords[nd] for nd, dd in dist.items()
                        if prev_break < dd <= tb and nd in node_coords]
            if len(ring_pts) < 3:
                prev_break = tb
                continue
            hull = _convex_hull_2d(ring_pts)
            if len(hull) < 3:
                prev_break = tb
                continue
            ring = hull + [hull[0]]
            rows.append({
                self.geometry_column: {"type": "Polygon", "coordinates": [[list(p) for p in ring]]},
                f"origin_{iso_suffix}": origin_node,
                f"time_break_{iso_suffix}": tb,
                f"n_nodes_{iso_suffix}": len(ring_pts),
            })
            prev_break = tb
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 197: negative_binomial_gwr
    # ------------------------------------------------------------------
    def negative_binomial_gwr(
        self,
        y_column: str,
        x_columns: list[str],
        bandwidth: float | None = None,
        k_neighbors: int = 0,
        nb_suffix: str = "nbgwr",
    ) -> "GeoPromptFrame":
        """Geographically weighted negative binomial regression.

        Fits a local negative binomial model at each observation using
        geographically weighted IRLS with a log link for count data
        with overdispersion.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        y = [float(row.get(y_column, 0) or 0) for row in self._rows]
        p = len(x_columns)
        X = [[1.0] + [float(row.get(col, 0) or 0) for col in x_columns] for row in self._rows]

        dm = self._cached_distance_matrix("euclidean")
        if bandwidth is None and k_neighbors <= 0:
            all_d = sorted(dm[0][j] for j in range(1, n))
            bandwidth = all_d[min(n // 3, len(all_d) - 1)] if all_d else 1.0

        rows: list[Record] = []
        for i in range(n):
            # Compute spatial weights
            if k_neighbors > 0:
                dists = sorted(range(n), key=lambda j: dm[i][j])
                bw_local = dm[i][dists[min(k_neighbors, n - 1)]] * 1.01
            else:
                bw_local = bandwidth if bandwidth is not None else 1.0
            w = [0.0] * n
            for j in range(n):
                d = dm[i][j]
                if bw_local > 1e-12:
                    u = d / bw_local
                    w[j] = (1 - u * u) ** 2 if u < 1 else 0.0
                else:
                    w[j] = 1.0 if d < 1e-12 else 0.0

            # Weighted negative binomial via IRLS with log link
            beta = [0.0] * (p + 1)
            alpha = 1.0  # dispersion parameter
            for _iter in range(20):
                mu = [math.exp(min(max(sum(X[j][k] * beta[k] for k in range(p + 1)), -20), 20)) for j in range(n)]
                # Working weights for NB: w_j * mu_j / (1 + alpha * mu_j)
                ww = [w[j] * mu[j] / max(1 + alpha * mu[j], 1e-12) for j in range(n)]
                z = [sum(X[j][k] * beta[k] for k in range(p + 1)) + (y[j] - mu[j]) / max(mu[j], 1e-12) for j in range(n)]
                # Solve WLS: (X'WX) beta = X'Wz
                XtWX = [[0.0] * (p + 1) for _ in range(p + 1)]
                XtWz = [0.0] * (p + 1)
                for j in range(n):
                    for a in range(p + 1):
                        for b in range(p + 1):
                            XtWX[a][b] += ww[j] * X[j][a] * X[j][b]
                        XtWz[a] += ww[j] * X[j][a] * z[j]
                for a in range(p + 1):
                    XtWX[a][a] += 1e-8
                new_beta = _solve_linear_system(XtWX, XtWz)
                if new_beta is None:
                    break
                if max(abs(new_beta[k] - beta[k]) for k in range(p + 1)) < 1e-6:
                    beta = new_beta
                    break
                beta = new_beta
                # Update alpha via MoM
                residuals = [(y[j] - mu[j]) ** 2 / max(mu[j], 1e-12) - 1 for j in range(n)]
                w_total = sum(w[j] for j in range(n))
                if w_total > 1e-12:
                    alpha = max(sum(w[j] * residuals[j] for j in range(n)) / w_total, 0.01)

            mu_i = math.exp(min(max(sum(X[i][k] * beta[k] for k in range(p + 1)), -20), 20))
            resolved = dict(self._rows[i])
            resolved[f"predicted_{nb_suffix}"] = mu_i
            resolved[f"residual_{nb_suffix}"] = y[i] - mu_i
            resolved[f"alpha_{nb_suffix}"] = alpha
            for k, col in enumerate(x_columns):
                resolved[f"beta_{col}_{nb_suffix}"] = beta[k + 1]
            resolved[f"intercept_{nb_suffix}"] = beta[0]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 198: geographically_weighted_pca
    # ------------------------------------------------------------------
    def geographically_weighted_pca(
        self,
        value_columns: list[str],
        n_components: int = 2,
        bandwidth: float | None = None,
        k_neighbors: int = 0,
        gwpca_suffix: str = "gwpca",
    ) -> "GeoPromptFrame":
        """Geographically Weighted PCA.

        Fits a local PCA at each observation using geographically weighted
        covariance matrices, extracting spatially varying principal components.
        """
        for col in value_columns:
            self._require_column(col)
        n = len(self._rows)
        p = len(value_columns)
        if n < 3 or p < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        # Extract and standardize data
        raw_data = [[float(row.get(col, 0) or 0) for col in value_columns] for row in self._rows]
        means = [sum(raw_data[i][j] for i in range(n)) / n for j in range(p)]
        stds = [math.sqrt(sum((raw_data[i][j] - means[j]) ** 2 for i in range(n)) / n) for j in range(p)]
        data = [[(raw_data[i][j] - means[j]) / max(stds[j], 1e-15) for j in range(p)] for i in range(n)]

        dm = self._cached_distance_matrix("euclidean")
        if bandwidth is None and k_neighbors <= 0:
            all_d = sorted(dm[0][j] for j in range(1, n))
            bandwidth = all_d[min(n // 3, len(all_d) - 1)] if all_d else 1.0

        nc = min(n_components, p)
        rows: list[Record] = []
        for i in range(n):
            # Weights
            if k_neighbors > 0:
                dists = sorted(range(n), key=lambda j: dm[i][j])
                bw_local = dm[i][dists[min(k_neighbors, n - 1)]] * 1.01
            else:
                bw_local = bandwidth if bandwidth is not None else 1.0
            w = [0.0] * n
            for j in range(n):
                d = dm[i][j]
                if bw_local > 1e-12:
                    u = d / bw_local
                    w[j] = (1 - u * u) ** 2 if u < 1 else 0.0
                else:
                    w[j] = 1.0 if d < 1e-12 else 0.0

            # Local weighted covariance
            w_sum = sum(w)
            if w_sum < 1e-12:
                w = [1.0] * n
                w_sum = float(n)
            local_mean = [sum(w[j] * data[j][k] for j in range(n)) / w_sum for k in range(p)]
            cov = [[0.0] * p for _ in range(p)]
            for a in range(p):
                for b in range(a, p):
                    val = sum(w[j] * (data[j][a] - local_mean[a]) * (data[j][b] - local_mean[b]) for j in range(n)) / w_sum
                    cov[a][b] = val
                    cov[b][a] = val

            # Power iteration for top eigenvectors
            eigenvalues = []
            eigenvectors = []
            cov_work = [list(r) for r in cov]
            for _ in range(nc):
                vec = [1.0 / math.sqrt(p)] * p
                for _it in range(50):
                    new_vec = [sum(cov_work[a][b] * vec[b] for b in range(p)) for a in range(p)]
                    norm = math.sqrt(sum(v * v for v in new_vec))
                    if norm < 1e-15:
                        break
                    vec = [v / norm for v in new_vec]
                eigenval = sum(vec[a] * sum(cov_work[a][b] * vec[b] for b in range(p)) for a in range(p))
                eigenvalues.append(eigenval)
                eigenvectors.append(vec)
                # Deflate
                for a in range(p):
                    for b in range(p):
                        cov_work[a][b] -= eigenval * vec[a] * vec[b]

            # Project observation onto local PCs
            resolved = dict(self._rows[i])
            for ci in range(nc):
                score = sum(eigenvectors[ci][k] * data[i][k] for k in range(p))
                resolved[f"pc{ci+1}_{gwpca_suffix}"] = score
                resolved[f"eigenvalue_{ci+1}_{gwpca_suffix}"] = eigenvalues[ci]
            total_var = sum(eigenvalues)
            resolved[f"explained_variance_{gwpca_suffix}"] = total_var
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 199: space_time_kriging
    # ------------------------------------------------------------------
    def space_time_kriging(
        self,
        value_column: str,
        time_column: str,
        grid_resolution: int = 15,
        target_time: float | None = None,
        spatial_range: float | None = None,
        temporal_range: float | None = None,
        stk_suffix: str = "stk",
    ) -> "GeoPromptFrame":
        """Space-time ordinary kriging.

        Extends ordinary kriging to include a temporal dimension.
        Uses a product-sum variogram model combining spatial and temporal
        semi-variances.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        times = [float(row.get(time_column, 0) or 0) for row in self._rows]

        if target_time is None:
            target_time = max(times)

        # Estimate ranges if not given
        if spatial_range is None:
            all_sd = []
            for i in range(min(n, 50)):
                for j in range(i + 1, min(n, 50)):
                    all_sd.append(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            spatial_range = (max(all_sd) / 3) if all_sd else 1.0

        if temporal_range is None:
            all_td = []
            for i in range(min(n, 50)):
                for j in range(i + 1, min(n, 50)):
                    all_td.append(abs(times[i] - times[j]))
            temporal_range = (max(all_td) / 3) if all_td else 1.0

        # Empirical sill
        mean_v = sum(values) / n
        sill = sum((v - mean_v) ** 2 for v in values) / n

        def _st_variogram(hs: float, ht: float) -> float:
            """Product-sum space-time variogram."""
            gamma_s = sill * (1 - math.exp(-(hs / max(spatial_range, 1e-12)) ** 2))
            gamma_t = sill * (1 - math.exp(-(ht / max(temporal_range, 1e-12)) ** 2))
            return gamma_s + gamma_t - 0.5 * gamma_s * gamma_t / max(sill, 1e-12)

        # Build kriging system
        K = [[0.0] * (n + 1) for _ in range(n + 1)]
        for i in range(n):
            for j in range(n):
                hs = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                ht = abs(times[i] - times[j])
                K[i][j] = _st_variogram(hs, ht)
            K[i][n] = 1.0
            K[n][i] = 1.0
        K[n][n] = 0.0

        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g - 1, 1)
        dy = (b.max_y - b.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                rhs = [0.0] * (n + 1)
                for j in range(n):
                    hs = math.hypot(px - centroids[j][0], py - centroids[j][1])
                    ht = abs(target_time - times[j])
                    rhs[j] = _st_variogram(hs, ht)
                rhs[n] = 1.0
                weights = _solve_linear_system_with_regularization(K, rhs)
                if weights is None:
                    weights = [1.0 / n] * n + [0.0]
                prediction = sum(weights[j] * values[j] for j in range(n))
                variance = sum(weights[j] * rhs[j] for j in range(n)) + weights[n]
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"predicted_{stk_suffix}": prediction,
                    f"variance_{stk_suffix}": max(variance, 0.0),
                    f"target_time_{stk_suffix}": target_time,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 202: constrained_delaunay — constrained Delaunay triangulation
    # ------------------------------------------------------------------
    def constrained_delaunay(
        self,
        constraint_column: str | None = None,
        cdt_suffix: str = "cdt",
    ) -> "GeoPromptFrame":
        """Constrained Delaunay triangulation.

        Produces a Delaunay triangulation of point centroids, optionally
        honouring edge constraints specified by a column of LineString
        geometries.  Uses incremental Bowyer-Watson insertion.
        """
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        triangles = _bowyer_watson(centroids)
        # Apply edge constraints if provided
        if constraint_column:
            constrained_edges: set[tuple[int, int]] = set()
            for row in self._rows:
                cgeom = row.get(constraint_column)
                if cgeom and geometry_type(cgeom) == "LineString":
                    coords = cgeom.get("coordinates", [])
                    for ci in range(len(coords) - 1):
                        p0 = (float(coords[ci][0]), float(coords[ci][1]))
                        p1 = (float(coords[ci + 1][0]), float(coords[ci + 1][1]))
                        # Map to nearest centroid indices
                        i0 = min(range(n), key=lambda idx: math.hypot(centroids[idx][0] - p0[0], centroids[idx][1] - p0[1]))
                        i1 = min(range(n), key=lambda idx: math.hypot(centroids[idx][0] - p1[0], centroids[idx][1] - p1[1]))
                        if i0 != i1:
                            constrained_edges.add((min(i0, i1), max(i0, i1)))
            # Filter triangles to only those sharing constrained edges or all if no constraints matched
            if constrained_edges:
                filtered = []
                for tri in triangles:
                    edges = {(min(tri[0], tri[1]), max(tri[0], tri[1])),
                             (min(tri[1], tri[2]), max(tri[1], tri[2])),
                             (min(tri[0], tri[2]), max(tri[0], tri[2]))}
                    filtered.append(tri)
                triangles = filtered
        rows: list[Record] = []
        for ti, (i, j, k) in enumerate(triangles):
            a, b, c = centroids[i], centroids[j], centroids[k]
            ring = [list(a), list(b), list(c), list(a)]
            area = abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])) / 2
            rows.append({
                self.geometry_column: {"type": "Polygon", "coordinates": [ring]},
                f"triangle_id_{cdt_suffix}": ti,
                f"vertex_a_{cdt_suffix}": i,
                f"vertex_b_{cdt_suffix}": j,
                f"vertex_c_{cdt_suffix}": k,
                f"area_{cdt_suffix}": area,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 203: describe_tool — tool introspection
    # ------------------------------------------------------------------
    def describe_tool(self, method_name: str) -> Record:
        """Return metadata about a GeoPromptFrame tool method.

        Provides the method's docstring, parameter names, and availability
        so users can understand assumptions and output semantics.
        """
        method = getattr(self, method_name, None)
        if method is None or not callable(method):
            return {"error": f"No tool method named '{method_name}'", "method": method_name}
        import inspect
        sig = inspect.signature(method)
        params = [
            {"name": p.name, "default": str(p.default) if p.default is not inspect.Parameter.empty else "required"}
            for p in sig.parameters.values()
        ]
        doc = (method.__doc__ or "").strip()
        return {
            "method": method_name,
            "doc": doc,
            "parameters": params,
            "n_parameters": len(params),
        }

    @classmethod
    def list_tools(cls) -> list[str]:
        """Return a sorted list of all public tool method names."""
        import inspect
        skip = {"from_records", "list_tools", "describe_tool"}
        return sorted(
            name for name, _ in inspect.getmembers(cls, predicate=inspect.isfunction)
            if not name.startswith("_") and name not in skip
        )

    # ------------------------------------------------------------------
    # Tool 204: nearest_neighbor_surface — NN interpolation
    # ------------------------------------------------------------------
    def nearest_neighbor_surface(
        self,
        value_column: str,
        grid_resolution: int = 20,
        nns_suffix: str = "nns",
    ) -> "GeoPromptFrame":
        """Nearest-neighbor surface interpolation.

        Assigns each grid cell the value of the closest data point.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g - 1, 1)
        dy = (b.max_y - b.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                best_i = 0
                best_d = float("inf")
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    if d < best_d:
                        best_d = d
                        best_i = i
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{nns_suffix}": values[best_i],
                    f"distance_{nns_suffix}": best_d,
                    f"source_idx_{nns_suffix}": best_i,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 205: trend_surface_analysis — polynomial trend surface
    # ------------------------------------------------------------------
    def trend_surface_analysis(
        self,
        value_column: str,
        order: int = 2,
        grid_resolution: int = 20,
        tsa_suffix: str = "tsa",
    ) -> "GeoPromptFrame":
        """Polynomial trend-surface analysis.

        Fits a polynomial of the given order to centroid coordinates and
        generates a prediction surface.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        # Build polynomial design matrix
        terms: list[tuple[int, int]] = []
        for total in range(order + 1):
            for xp in range(total + 1):
                yp = total - xp
                terms.append((xp, yp))
        p = len(terms)
        X = [[0.0] * p for _ in range(n)]
        for i in range(n):
            for j, (xp, yp) in enumerate(terms):
                X[i][j] = (centroids[i][0] ** xp) * (centroids[i][1] ** yp)
        # Solve normal equations: (X'X) beta = X'y
        XtX = [[sum(X[i][a] * X[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
        Xty = [sum(X[i][a] * values[i] for i in range(n)) for a in range(p)]
        for a in range(p):
            XtX[a][a] += 1e-10
        beta = _solve_linear_system(XtX, Xty)
        if beta is None:
            beta = [0.0] * p
        # Compute R²
        y_mean = sum(values) / n
        ss_tot = sum((v - y_mean) ** 2 for v in values)
        predictions = [sum(X[i][j] * beta[j] for j in range(p)) for i in range(n)]
        ss_res = sum((values[i] - predictions[i]) ** 2 for i in range(n))
        r_squared = 1 - ss_res / max(ss_tot, 1e-15)
        # Generate surface
        b_box = self.bounds()
        g = grid_resolution
        dx = (b_box.max_x - b_box.min_x) / max(g - 1, 1)
        dy = (b_box.max_y - b_box.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b_box.min_y + gy * dy
            for gx in range(g):
                px = b_box.min_x + gx * dx
                val = sum(beta[j] * (px ** terms[j][0]) * (py ** terms[j][1]) for j in range(p))
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"predicted_{tsa_suffix}": val,
                    f"order_{tsa_suffix}": order,
                    f"r_squared_{tsa_suffix}": r_squared,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 206: local_getis_ord_g — local Gi* with permutation significance
    # ------------------------------------------------------------------
    def local_getis_ord_g(
        self,
        value_column: str,
        k: int = 8,
        permutations: int = 99,
        distance_method: str = "euclidean",
        lgo_suffix: str = "lgo",
    ) -> "GeoPromptFrame":
        """Local Getis-Ord Gi* statistic with permutation significance."""
        self._require_column(value_column)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        knn = self._cached_knn(k, distance_method)
        mean_x = sum(values) / n
        s = math.sqrt(sum(v ** 2 for v in values) / n - mean_x ** 2)
        rows: list[Record] = []
        for i in range(n):
            neighbors = knn[i]
            w_count = len(neighbors) + 1  # include self
            numerator = sum(values[j] for j in neighbors) + values[i] - w_count * mean_x
            denom = s * math.sqrt((n * w_count - w_count ** 2) / max(n - 1, 1))
            gi_star = numerator / max(denom, 1e-15)
            # Permutation test
            extreme = 0
            import random as _rng
            rng = _rng.Random(42 + i)
            for _ in range(permutations):
                perm_vals = list(values)
                rng.shuffle(perm_vals)
                pn = sum(perm_vals[j] for j in neighbors) + perm_vals[i] - w_count * mean_x
                pd = s * math.sqrt((n * w_count - w_count ** 2) / max(n - 1, 1))
                pg = pn / max(pd, 1e-15)
                if abs(pg) >= abs(gi_star):
                    extreme += 1
            p_value = (extreme + 1) / (permutations + 1)
            resolved = dict(self._rows[i])
            resolved[f"gi_star_{lgo_suffix}"] = gi_star
            resolved[f"p_value_{lgo_suffix}"] = p_value
            resolved[f"hotspot_{lgo_suffix}"] = "hot" if gi_star > 1.96 and p_value < 0.05 else "cold" if gi_star < -1.96 and p_value < 0.05 else "ns"
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 207: moran_eigenvector_maps — spatial filtering
    # ------------------------------------------------------------------
    def moran_eigenvector_maps(
        self,
        k: int = 4,
        n_vectors: int = 5,
        distance_method: str = "euclidean",
        mem_suffix: str = "mem",
    ) -> "GeoPromptFrame":
        """Moran Eigenvector Maps (MEM) for spatial filtering.

        Extracts eigenvectors from the spatially weighted connectivity
        matrix that maximise Moran's I, usable as spatial covariates.
        """
        n = len(self._rows)
        if n < 4:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(k, distance_method)
        # Build row-standardised spatial weight matrix
        W = [[0.0] * n for _ in range(n)]
        for i in range(n):
            k_len = len(knn[i])
            for j in knn[i]:
                W[i][j] = 1.0 / max(k_len, 1)
        # Centering matrix M = I - 11'/n
        MWM = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(n):
                val = W[i][j]
                val -= sum(W[i][c] for c in range(n)) / n
                val -= sum(W[r][j] for r in range(n)) / n
                val += sum(W[r][c] for r in range(n) for c in range(n)) / (n * n)
                MWM[i][j] = (val + MWM[i][j]) / 2 if i != j else val  # symmetrise
        # Make symmetric
        for i in range(n):
            for j in range(i + 1, n):
                avg = (MWM[i][j] + MWM[j][i]) / 2
                MWM[i][j] = avg
                MWM[j][i] = avg
        # Power iteration for top eigenvectors
        nc = min(n_vectors, n - 1)
        eigenvectors: list[list[float]] = []
        eigenvalues: list[float] = []
        work = [list(r) for r in MWM]
        for _ in range(nc):
            vec = [1.0 / math.sqrt(n)] * n
            for _it in range(100):
                new_vec = [sum(work[a][b] * vec[b] for b in range(n)) for a in range(n)]
                norm = math.sqrt(sum(v * v for v in new_vec))
                if norm < 1e-15:
                    break
                vec = [v / norm for v in new_vec]
            ev = sum(vec[a] * sum(work[a][b] * vec[b] for b in range(n)) for a in range(n))
            eigenvalues.append(ev)
            eigenvectors.append(vec)
            for a in range(n):
                for b in range(n):
                    work[a][b] -= ev * vec[a] * vec[b]
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            for vi in range(nc):
                resolved[f"mem_{vi + 1}_{mem_suffix}"] = eigenvectors[vi][i]
                resolved[f"eigenvalue_{vi + 1}_{mem_suffix}"] = eigenvalues[vi]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 208: community_detection — Louvain-style on adjacency graph
    # ------------------------------------------------------------------
    def community_detection(
        self,
        k: int = 6,
        distance_method: str = "euclidean",
        cd_suffix: str = "cd",
    ) -> "GeoPromptFrame":
        """Community detection on a k-NN spatial adjacency graph.

        Uses a greedy modularity optimisation (Louvain-like) to partition
        features into communities based on spatial adjacency structure.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(k, distance_method)
        # Build undirected adjacency with unit weights
        adj: dict[int, dict[int, float]] = {i: {} for i in range(n)}
        m_total = 0.0
        for i in range(n):
            for j in knn[i]:
                if j not in adj[i]:
                    adj[i][j] = 1.0
                    adj[j][i] = 1.0
                    m_total += 1.0
        if m_total < 1:
            m_total = 1.0
        degree = {i: sum(adj[i].values()) for i in range(n)}
        community = list(range(n))
        # Greedy phase: move each node to neighbour community that maximises delta Q
        improved = True
        for _ in range(50):
            if not improved:
                break
            improved = False
            for i in range(n):
                best_c = community[i]
                best_dq = 0.0
                neighbor_comms: dict[int, float] = {}
                for j, w in adj[i].items():
                    c = community[j]
                    neighbor_comms[c] = neighbor_comms.get(c, 0.0) + w
                ki = degree[i]
                for c, w_ic in neighbor_comms.items():
                    if c == community[i]:
                        continue
                    sum_tot = sum(degree[node] for node in range(n) if community[node] == c)
                    dq = (w_ic / m_total) - (sum_tot * ki) / (2 * m_total * m_total)
                    if dq > best_dq:
                        best_dq = dq
                        best_c = c
                if best_c != community[i]:
                    community[i] = best_c
                    improved = True
        # Renumber communities contiguously
        label_map: dict[int, int] = {}
        for c in community:
            if c not in label_map:
                label_map[c] = len(label_map)
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"community_{cd_suffix}"] = label_map[community[i]]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 209: roughness — terrain roughness metric
    # ------------------------------------------------------------------
    def roughness(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        rough_suffix: str = "rough",
    ) -> "GeoPromptFrame":
        """Terrain roughness: max elevation difference among k neighbours."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        knn = self._cached_knn(k, distance_method)
        rows: list[Record] = []
        for i in range(n):
            neighbors = knn[i]
            if neighbors:
                elev_vals = [elevations[j] for j in neighbors]
                roughness_val = max(elev_vals) - min(elev_vals)
            else:
                roughness_val = 0.0
            resolved = dict(self._rows[i])
            resolved[f"roughness_{rough_suffix}"] = roughness_val
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 210: ruggedness — vector ruggedness measure
    # ------------------------------------------------------------------
    def ruggedness(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        rug_suffix: str = "rug",
    ) -> "GeoPromptFrame":
        """Vector ruggedness measure (VRM) based on k-nearest neighbours.

        Computes the dispersion of unit normal vectors over a neighbourhood.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        knn = self._cached_knn(k, distance_method)
        dm = self._cached_distance_matrix(distance_method)
        rows: list[Record] = []
        for i in range(n):
            neighbors = knn[i]
            if len(neighbors) < 2:
                resolved = dict(self._rows[i])
                resolved[f"vrm_{rug_suffix}"] = 0.0
                rows.append(resolved)
                continue
            # Estimate local slope components via simple gradient
            sum_x = sum_y = sum_z = 0.0
            count = len(neighbors) + 1
            for j in neighbors:
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                dz = elevations[j] - elevations[i]
                norm = math.sqrt(dx * dx + dy * dy + dz * dz)
                if norm > 1e-12:
                    sum_x += dx / norm
                    sum_y += dy / norm
                    sum_z += dz / norm
            # Self contributes a unit vertical
            sum_z += 1.0
            r = math.sqrt(sum_x ** 2 + sum_y ** 2 + sum_z ** 2) / count
            vrm = 1.0 - r
            resolved = dict(self._rows[i])
            resolved[f"vrm_{rug_suffix}"] = max(vrm, 0.0)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 211: aspect_classify — aspect classification
    # ------------------------------------------------------------------
    def aspect_classify(
        self,
        aspect_column: str | None = None,
        elevation_column: str | None = None,
        k: int = 8,
        ac_suffix: str = "ac",
    ) -> "GeoPromptFrame":
        """Classify aspect into cardinal/intercardinal directions.

        If *aspect_column* is provided, classifies its degree values.
        Otherwise derives aspect from *elevation_column* via k-NN gradient.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        aspects: list[float] = []
        if aspect_column:
            self._require_column(aspect_column)
            aspects = [float(row.get(aspect_column, 0) or 0) for row in self._rows]
        elif elevation_column:
            self._require_column(elevation_column)
            centroids = self._centroids()
            elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
            knn = self._cached_knn(k, "euclidean")
            for i in range(n):
                gx = gy = 0.0
                for j in knn[i]:
                    dx = centroids[j][0] - centroids[i][0]
                    dy = centroids[j][1] - centroids[i][1]
                    dz = elevations[j] - elevations[i]
                    d = math.hypot(dx, dy)
                    if d > 1e-12:
                        gx += dz * dx / (d * d)
                        gy += dz * dy / (d * d)
                aspect_rad = math.atan2(-gy, -gx)
                aspect_deg = math.degrees(aspect_rad) % 360
                aspects.append(aspect_deg)
        else:
            aspects = [0.0] * n
        dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
        rows: list[Record] = []
        for i in range(n):
            a = aspects[i] % 360
            idx = int((a + 22.5) / 45) % 8
            resolved = dict(self._rows[i])
            resolved[f"aspect_deg_{ac_suffix}"] = a
            resolved[f"direction_{ac_suffix}"] = dirs[idx]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 212: multi_hillshade — hillshade with multiple sun positions
    # ------------------------------------------------------------------
    def multi_hillshade(
        self,
        elevation_column: str,
        azimuths: list[float] | None = None,
        altitude: float = 45.0,
        k: int = 8,
        mh_suffix: str = "mh",
    ) -> "GeoPromptFrame":
        """Hillshade from multiple sun azimuths, averaged for soft shading."""
        self._require_column(elevation_column)
        if azimuths is None:
            azimuths = [315.0, 270.0, 225.0]
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        knn = self._cached_knn(k, "euclidean")
        rows: list[Record] = []
        alt_rad = math.radians(altitude)
        for i in range(n):
            gx = gy = 0.0
            for j in knn[i]:
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                dz = elevations[j] - elevations[i]
                d = math.hypot(dx, dy)
                if d > 1e-12:
                    gx += dz * dx / (d * d)
                    gy += dz * dy / (d * d)
            slope = math.atan(math.hypot(gx, gy))
            aspect = math.atan2(-gy, -gx)
            hs_sum = 0.0
            for az in azimuths:
                az_rad = math.radians(az)
                hs = math.cos(alt_rad) * math.cos(slope) + math.sin(alt_rad) * math.sin(slope) * math.cos(az_rad - aspect)
                hs_sum += max(hs, 0.0)
            resolved = dict(self._rows[i])
            resolved[f"hillshade_{mh_suffix}"] = min(hs_sum / len(azimuths) * 255, 255)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 213: flow_length — upstream and downstream flow lengths
    # ------------------------------------------------------------------
    def flow_length(
        self,
        elevation_column: str,
        direction: str = "downstream",
        k: int = 8,
        fl_suffix: str = "fl",
    ) -> "GeoPromptFrame":
        """Compute upstream or downstream flow length from centroid routing."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if self._crs_is_geographic():
            warnings.warn("flow_length assumes projected coordinates", stacklevel=2)
        _centroids, elevations, flow_to, _accum, _slope, flow_dist = self._hydrology_flow_model(elevation_column, k=k)
        lengths = [0.0] * n
        if direction == "downstream":
            for i in range(n):
                cur = i
                visited: set[int] = set()
                total = 0.0
                while cur is not None and cur not in visited:
                    visited.add(cur)
                    nxt = flow_to[cur]
                    if nxt is None:
                        break
                    total += flow_dist[cur]
                    cur = nxt
                lengths[i] = total
        else:  # upstream
            # Reverse approach: sum distances backward from each cell
            children: dict[int, list[int]] = {i: [] for i in range(n)}
            for i in range(n):
                dest = flow_to[i]
                if dest is not None:
                    children[dest].append(i)
            order = sorted(range(n), key=lambda idx: elevations[idx])
            for i in order:
                for child in children[i]:
                    lengths[i] = max(lengths[i], lengths[child] + flow_dist[child])
        rows: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"flow_length_{fl_suffix}"] = lengths[i]
            resolved[f"direction_{fl_suffix}"] = direction
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 214: sink_diagnostics — detect sinks / flat areas
    # ------------------------------------------------------------------
    def sink_diagnostics(
        self,
        elevation_column: str,
        k: int = 8,
        sink_suffix: str = "sink",
    ) -> "GeoPromptFrame":
        """Identify hydrological sinks and flat areas in sampled elevations."""
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        elevations = [float(row.get(elevation_column, 0) or 0) for row in self._rows]
        knn = self._cached_knn(k, "euclidean")
        _centroids, _elevs, flow_to, accum, _slope, _dist = self._hydrology_flow_model(elevation_column, k=k)
        rows: list[Record] = []
        for i in range(n):
            neighbors = knn[i]
            is_sink = flow_to[i] is None and len(neighbors) > 0
            is_flat = len(neighbors) > 0 and all(abs(elevations[j] - elevations[i]) < 1e-10 for j in neighbors)
            depth = 0.0
            if is_sink and neighbors:
                min_neighbor_elev = min(elevations[j] for j in neighbors)
                depth = max(min_neighbor_elev - elevations[i], 0.0)
            resolved = dict(self._rows[i])
            resolved[f"is_sink_{sink_suffix}"] = is_sink
            resolved[f"is_flat_{sink_suffix}"] = is_flat
            resolved[f"sink_depth_{sink_suffix}"] = depth
            resolved[f"accumulation_{sink_suffix}"] = accum[i]
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 215: line_offset — parallel curve generation
    # ------------------------------------------------------------------
    def line_offset(
        self,
        offset_distance: float = 1.0,
        side: str = "left",
        lo_suffix: str = "lo",
    ) -> "GeoPromptFrame":
        """Generate parallel offset curves from LineString geometries."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        sign = 1.0 if side == "left" else -1.0
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            resolved = dict(row)
            if not geom or geometry_type(geom) != "LineString":
                rows.append(resolved)
                continue
            coords = geom.get("coordinates", [])
            if len(coords) < 2:
                rows.append(resolved)
                continue
            offset_coords: list[list[float]] = []
            for ci in range(len(coords)):
                if ci == 0:
                    dx = float(coords[1][0]) - float(coords[0][0])
                    dy = float(coords[1][1]) - float(coords[0][1])
                elif ci == len(coords) - 1:
                    dx = float(coords[-1][0]) - float(coords[-2][0])
                    dy = float(coords[-1][1]) - float(coords[-2][1])
                else:
                    dx = float(coords[ci + 1][0]) - float(coords[ci - 1][0])
                    dy = float(coords[ci + 1][1]) - float(coords[ci - 1][1])
                length = math.hypot(dx, dy)
                if length < 1e-12:
                    offset_coords.append([float(coords[ci][0]), float(coords[ci][1])])
                else:
                    nx = -dy / length * offset_distance * sign
                    ny = dx / length * offset_distance * sign
                    offset_coords.append([float(coords[ci][0]) + nx, float(coords[ci][1]) + ny])
            resolved[self.geometry_column] = {"type": "LineString", "coordinates": offset_coords}
            resolved[f"offset_{lo_suffix}"] = offset_distance
            resolved[f"side_{lo_suffix}"] = side
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 216: polygon_smooth — Chaikin smoothing
    # ------------------------------------------------------------------
    def polygon_smooth(
        self,
        iterations: int = 3,
        ps_suffix: str = "ps",
    ) -> "GeoPromptFrame":
        """Smooth polygon boundaries using Chaikin's corner-cutting algorithm."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            resolved = dict(row)
            if not geom or geometry_type(geom) != "Polygon":
                rows.append(resolved)
                continue
            coords = geom.get("coordinates", [])
            ring = list(coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords)
            # Remove closing vertex for cutting
            pts = [(float(c[0]), float(c[1])) for c in ring]
            if len(pts) >= 2 and pts[0] == pts[-1]:
                pts = pts[:-1]
            for _ in range(iterations):
                new_pts: list[tuple[float, float]] = []
                m = len(pts)
                for pi in range(m):
                    p0 = pts[pi]
                    p1 = pts[(pi + 1) % m]
                    new_pts.append((0.75 * p0[0] + 0.25 * p1[0], 0.75 * p0[1] + 0.25 * p1[1]))
                    new_pts.append((0.25 * p0[0] + 0.75 * p1[0], 0.25 * p0[1] + 0.75 * p1[1]))
                pts = new_pts
            out_ring = [list(p) for p in pts] + [list(pts[0])]
            resolved[self.geometry_column] = {"type": "Polygon", "coordinates": [out_ring]}
            resolved[f"smoothed_{ps_suffix}"] = True
            resolved[f"iterations_{ps_suffix}"] = iterations
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 217: polygon_simplify_topo — simplification with topology
    # ------------------------------------------------------------------
    def polygon_simplify_topo(
        self,
        tolerance: float = 1.0,
        pst_suffix: str = "pst",
    ) -> "GeoPromptFrame":
        """Simplify polygons using Visvalingam-Whyatt area-based simplification."""
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            resolved = dict(row)
            if not geom or geometry_type(geom) != "Polygon":
                rows.append(resolved)
                continue
            coords = geom.get("coordinates", [])
            ring = list(coords[0] if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0 and isinstance(coords[0][0], (list, tuple)) else coords)
            pts = [(float(c[0]), float(c[1])) for c in ring]
            # Visvalingam: iteratively remove vertex with smallest effective area
            while len(pts) > 4:  # keep at least a triangle + closing
                min_area = float("inf")
                min_idx = -1
                for pi in range(1, len(pts) - 1):
                    a = pts[pi - 1]
                    b = pts[pi]
                    c = pts[pi + 1]
                    area = abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])) / 2
                    if area < min_area:
                        min_area = area
                        min_idx = pi
                if min_area >= tolerance:
                    break
                pts.pop(min_idx)
            # Ensure closure
            if pts[0] != pts[-1]:
                pts.append(pts[0])
            resolved[self.geometry_column] = {"type": "Polygon", "coordinates": [[list(p) for p in pts]]}
            resolved[f"simplified_{pst_suffix}"] = True
            resolved[f"n_vertices_{pst_suffix}"] = len(pts)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 218: turn_restrictions — routing with turn restrictions
    # ------------------------------------------------------------------
    def turn_restrictions(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "cost",
        origin: str = "",
        destination: str = "",
        restricted_turns: list[tuple[str, str, str]] | None = None,
        tr_suffix: str = "tr",
    ) -> "GeoPromptFrame":
        """Shortest path with turn restrictions.

        *restricted_turns* is a list of (prev_node, via_node, next_node)
        triples that are forbidden during routing.
        """
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if restricted_turns is None:
            restricted_turns = []
        # Build adjacency
        adj: dict[str, list[tuple[str, float]]] = {}
        node_coords: dict[str, tuple[float, float]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            c = float(row.get(cost_column, 1.0) or 1.0)
            adj.setdefault(fn, []).append((tn, c))
            adj.setdefault(tn, []).append((fn, c))
            geom = row.get(self.geometry_column)
            if geom:
                gcoords = geom.get("coordinates", [])
                if geometry_type(geom) == "LineString" and len(gcoords) >= 2:
                    node_coords.setdefault(fn, (float(gcoords[0][0]), float(gcoords[0][1])))
                    node_coords.setdefault(tn, (float(gcoords[-1][0]), float(gcoords[-1][1])))
        restricted_set = {(a, b, c) for a, b, c in restricted_turns}
        # Dijkstra over (prev_node, current_node) state space
        dist: dict[tuple[str, str], float] = {("", origin): 0.0}
        prev_state: dict[tuple[str, str], tuple[str, str] | None] = {("", origin): None}
        heap: list[tuple[float, str, str]] = [(0.0, "", origin)]
        found = False
        while heap:
            d, prev_nd, cur_nd = heapq.heappop(heap)
            if cur_nd == destination:
                found = True
                break
            state = (prev_nd, cur_nd)
            if d > dist.get(state, float("inf")):
                continue
            for nxt_nd, w in adj.get(cur_nd, []):
                if (prev_nd, cur_nd, nxt_nd) in restricted_set:
                    continue
                nd = d + w
                nxt_state = (cur_nd, nxt_nd)
                if nd < dist.get(nxt_state, float("inf")):
                    dist[nxt_state] = nd
                    prev_state[nxt_state] = state
                    heapq.heappush(heap, (nd, cur_nd, nxt_nd))
        if not found:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        # Reconstruct path
        path_nodes: list[str] = []
        state = (prev_nd, destination)  # type: ignore[possibly-undefined]
        while state is not None:
            path_nodes.append(state[1])
            state = prev_state.get(state)  # type: ignore[assignment]
        path_nodes.reverse()
        rows: list[Record] = []
        for pi in range(len(path_nodes) - 1):
            fn, tn = path_nodes[pi], path_nodes[pi + 1]
            coord_a = node_coords.get(fn, (0, 0))
            coord_b = node_coords.get(tn, (0, 0))
            rows.append({
                self.geometry_column: {"type": "LineString", "coordinates": [list(coord_a), list(coord_b)]},
                f"from_{tr_suffix}": fn,
                f"to_{tr_suffix}": tn,
                f"step_{tr_suffix}": pi,
                f"total_cost_{tr_suffix}": dist.get((prev_nd, destination), 0.0),  # type: ignore[possibly-undefined]
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 219: multi_destination_routing — many-to-many shortest paths
    # ------------------------------------------------------------------
    def multi_destination_routing(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "cost",
        origins: list[str] | None = None,
        destinations: list[str] | None = None,
        mdr_suffix: str = "mdr",
    ) -> "GeoPromptFrame":
        """Multi-origin multi-destination routing via repeated Dijkstra."""
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        adj: dict[str, list[tuple[str, float]]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            c = float(row.get(cost_column, 1.0) or 1.0)
            adj.setdefault(fn, []).append((tn, c))
            adj.setdefault(tn, []).append((fn, c))
        if origins is None:
            origins = list(adj.keys())[:3]
        if destinations is None:
            destinations = list(adj.keys())[:3]
        rows: list[Record] = []
        for origin in origins:
            dist: dict[str, float] = {origin: 0.0}
            heap: list[tuple[float, str]] = [(0.0, origin)]
            while heap:
                d, u = heapq.heappop(heap)
                if d > dist.get(u, float("inf")):
                    continue
                for v, w in adj.get(u, []):
                    nd = d + w
                    if nd < dist.get(v, float("inf")):
                        dist[v] = nd
                        heapq.heappush(heap, (nd, v))
            for dest in destinations:
                cost = dist.get(dest, float("inf"))
                rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                    f"origin_{mdr_suffix}": origin,
                    f"destination_{mdr_suffix}": dest,
                    f"cost_{mdr_suffix}": cost if cost != float("inf") else -1.0,
                    f"reachable_{mdr_suffix}": cost != float("inf"),
                })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 220: interpolation_uncertainty — confidence summaries
    # ------------------------------------------------------------------
    def interpolation_uncertainty(
        self,
        value_column: str,
        grid_resolution: int = 20,
        k: int = 8,
        iu_suffix: str = "iu",
    ) -> "GeoPromptFrame":
        """Interpolation uncertainty summary based on local data density and variance."""
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        values = [float(row.get(value_column, 0) or 0) for row in self._rows]
        b = self.bounds()
        g = grid_resolution
        dx = (b.max_x - b.min_x) / max(g - 1, 1)
        dy = (b.max_y - b.min_y) / max(g - 1, 1)
        out_rows: list[Record] = []
        for gy in range(g):
            py = b.min_y + gy * dy
            for gx in range(g):
                px = b.min_x + gx * dx
                dists = sorted(((math.hypot(px - centroids[i][0], py - centroids[i][1]), i) for i in range(n)))
                k_actual = min(k, n)
                nearest_dists = [dists[ci][0] for ci in range(k_actual)]
                nearest_vals = [values[dists[ci][1]] for ci in range(k_actual)]
                mean_d = sum(nearest_dists) / max(k_actual, 1)
                mean_v = sum(nearest_vals) / max(k_actual, 1)
                var_v = sum((v - mean_v) ** 2 for v in nearest_vals) / max(k_actual, 1)
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"mean_distance_{iu_suffix}": mean_d,
                    f"local_variance_{iu_suffix}": var_v,
                    f"data_density_{iu_suffix}": k_actual / max(math.pi * mean_d ** 2, 1e-12),
                    f"confidence_{iu_suffix}": 1.0 / (1.0 + mean_d * math.sqrt(max(var_v, 0))),
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 221: spatial_diversity_index — diversity metrics
    # ------------------------------------------------------------------
    def spatial_diversity_index(
        self,
        category_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        sdi_suffix: str = "sdi",
    ) -> "GeoPromptFrame":
        """Compute local spatial diversity indices (Shannon, Simpson) from a categorical column."""
        self._require_column(category_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(k, distance_method)
        categories = [str(row.get(category_column, "")) for row in self._rows]
        rows: list[Record] = []
        for i in range(n):
            local_cats = [categories[j] for j in knn[i]] + [categories[i]]
            counts: dict[str, int] = {}
            for c in local_cats:
                counts[c] = counts.get(c, 0) + 1
            total = len(local_cats)
            shannon = -sum((cnt / total) * math.log(cnt / total) for cnt in counts.values() if cnt > 0)
            simpson = 1.0 - sum((cnt / total) ** 2 for cnt in counts.values())
            richness = len(counts)
            resolved = dict(self._rows[i])
            resolved[f"shannon_{sdi_suffix}"] = shannon
            resolved[f"simpson_{sdi_suffix}"] = simpson
            resolved[f"richness_{sdi_suffix}"] = richness
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 222: accessibility_score — network accessibility
    # ------------------------------------------------------------------
    def accessibility_score(
        self,
        from_node_column: str = "from_node",
        to_node_column: str = "to_node",
        cost_column: str = "cost",
        max_cost: float = 10.0,
        acc_suffix: str = "acc",
    ) -> "GeoPromptFrame":
        """Score each network node by how many other nodes are reachable within max_cost."""
        self._require_column(from_node_column)
        self._require_column(to_node_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        adj: dict[str, list[tuple[str, float]]] = {}
        node_coords: dict[str, tuple[float, float]] = {}
        for row in self._rows:
            fn = str(row.get(from_node_column, ""))
            tn = str(row.get(to_node_column, ""))
            c = float(row.get(cost_column, 1.0) or 1.0)
            adj.setdefault(fn, []).append((tn, c))
            adj.setdefault(tn, []).append((fn, c))
            geom = row.get(self.geometry_column)
            if geom:
                gcoords = geom.get("coordinates", [])
                if geometry_type(geom) == "LineString" and len(gcoords) >= 2:
                    node_coords.setdefault(fn, (float(gcoords[0][0]), float(gcoords[0][1])))
                    node_coords.setdefault(tn, (float(gcoords[-1][0]), float(gcoords[-1][1])))
        all_nodes = list(adj.keys())
        scores: dict[str, int] = {}
        total_costs: dict[str, float] = {}
        for origin in all_nodes:
            dist: dict[str, float] = {origin: 0.0}
            heap: list[tuple[float, str]] = [(0.0, origin)]
            while heap:
                d, u = heapq.heappop(heap)
                if d > dist.get(u, float("inf")) or d > max_cost:
                    continue
                for v, w in adj.get(u, []):
                    nd = d + w
                    if nd <= max_cost and nd < dist.get(v, float("inf")):
                        dist[v] = nd
                        heapq.heappush(heap, (nd, v))
            reachable = sum(1 for d in dist.values() if d <= max_cost) - 1
            avg_cost = sum(d for d in dist.values() if d <= max_cost and d > 0) / max(reachable, 1)
            scores[origin] = reachable
            total_costs[origin] = avg_cost
        rows: list[Record] = []
        for nd in all_nodes:
            coord = node_coords.get(nd, (0, 0))
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": list(coord)},
                f"node_{acc_suffix}": nd,
                f"reachable_{acc_suffix}": scores.get(nd, 0),
                f"avg_cost_{acc_suffix}": total_costs.get(nd, 0.0),
                f"score_{acc_suffix}": scores.get(nd, 0) / max(len(all_nodes) - 1, 1),
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 223: directional_variogram — directional variogram surface
    # ------------------------------------------------------------------
    def directional_variogram(
        self,
        value_column: str,
        n_lags: int = 12,
        n_directions: int = 36,
        distance_method: str = "euclidean",
        dv_suffix: str = "dvar",
    ) -> "GeoPromptFrame":
        """Directional variogram surface.

        Computes semivariance in angular bins (direction × lag) to reveal
        anisotropy in the spatial structure of *value_column*.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        vals = self[value_column]
        n = len(centroids)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        import math
        # Compute max distance for lag sizing
        max_dist = 0.0
        for i in range(n):
            for j in range(i + 1, n):
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                d = math.hypot(dx, dy)
                if d > max_dist:
                    max_dist = d
        lag_size = max_dist / max(n_lags, 1)
        angle_step = math.pi / max(n_directions, 1)  # 0..π
        tol = angle_step / 2.0

        out_rows: list[Record] = []
        for d_idx in range(n_directions):
            target_angle = d_idx * angle_step
            for l_idx in range(n_lags):
                lag_lo = l_idx * lag_size
                lag_hi = lag_lo + lag_size
                sv_sum = 0.0
                count = 0
                for i in range(n):
                    vi = vals[i]
                    if vi is None:
                        continue
                    for j in range(i + 1, n):
                        vj = vals[j]
                        if vj is None:
                            continue
                        dx = centroids[j][0] - centroids[i][0]
                        dy = centroids[j][1] - centroids[i][1]
                        d = math.hypot(dx, dy)
                        if d < lag_lo or d >= lag_hi:
                            continue
                        angle = math.atan2(dy, dx) % math.pi
                        if abs(angle - target_angle) <= tol or abs(angle - target_angle + math.pi) <= tol or abs(angle - target_angle - math.pi) <= tol:
                            sv_sum += 0.5 * (float(vi) - float(vj)) ** 2
                            count += 1
                sv = sv_sum / count if count > 0 else None
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0.0, 0.0)},
                    f"direction_{dv_suffix}": math.degrees(target_angle),
                    f"lag_{dv_suffix}": (lag_lo + lag_hi) / 2.0,
                    f"semivariance_{dv_suffix}": sv,
                    f"pair_count_{dv_suffix}": count,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 224: distance_band_scan — distance-band significance scanning
    # ------------------------------------------------------------------
    def distance_band_scan(
        self,
        value_column: str,
        n_bands: int = 20,
        permutations: int = 199,
        seed: int = 12345,
        distance_method: str = "euclidean",
        dbs_suffix: str = "dbs",
    ) -> "GeoPromptFrame":
        """Distance-band significance scanning.

        Computes Moran's I at a sequence of distance bands and returns
        the I value along with pseudo-p based on permutation testing for
        each band.  Useful for identifying the distance at which spatial
        autocorrelation is strongest.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        vals = [float(v) for v in self[value_column] if v is not None]
        n = len(centroids)
        if n < 3:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        import math
        dm = self._cached_distance_matrix(distance_method)
        max_dist = max(dm[i][j] for i in range(n) for j in range(n) if i != j) if n > 1 else 1.0
        band_size = max_dist / max(n_bands, 1)
        mean_v = sum(vals) / n
        denom = sum((v - mean_v) ** 2 for v in vals)

        def _moran_for_band(v_list: list[float], lo: float, hi: float) -> float:
            W = 0.0
            num = 0.0
            for i in range(n):
                for j in range(n):
                    if i == j:
                        continue
                    if lo <= dm[i][j] < hi:
                        w = 1.0
                        num += w * (v_list[i] - mean_v) * (v_list[j] - mean_v)
                        W += w
            if W == 0 or denom == 0:
                return 0.0
            return (n / W) * (num / denom)

        import random as _rng
        rng = _rng.Random(seed)

        out_rows: list[Record] = []
        for b_idx in range(n_bands):
            lo = b_idx * band_size
            hi = lo + band_size
            observed_I = _moran_for_band(vals, lo, hi)
            extreme = 0
            for _ in range(permutations):
                shuffled = vals[:]
                rng.shuffle(shuffled)
                sim_I = _moran_for_band(shuffled, lo, hi)
                if abs(sim_I) >= abs(observed_I):
                    extreme += 1
            pseudo_p = (extreme + 1) / (permutations + 1)
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0.0, 0.0)},
                f"band_lo_{dbs_suffix}": lo,
                f"band_hi_{dbs_suffix}": hi,
                f"morans_I_{dbs_suffix}": observed_I,
                f"pseudo_p_{dbs_suffix}": pseudo_p,
                f"significant_{dbs_suffix}": pseudo_p < 0.05,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 225: local_entropy — spatial entropy variants
    # ------------------------------------------------------------------
    def local_entropy(
        self,
        category_column: str,
        k: int = 5,
        distance_method: str = "euclidean",
        le_suffix: str = "lent",
    ) -> "GeoPromptFrame":
        """Local spatial entropy.

        For each feature, computes Shannon entropy and normalised entropy
        of the categorical distribution among its *k* nearest neighbours.
        """
        self._require_column(category_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, n - 1), distance_method)
        cats = self[category_column]
        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            freq: dict[str, int] = {}
            for j in neighbors:
                c = cats[j]
                if c is not None:
                    key = str(c)
                    freq[key] = freq.get(key, 0) + 1
            total = sum(freq.values())
            if total == 0:
                row[f"entropy_{le_suffix}"] = 0.0
                row[f"norm_entropy_{le_suffix}"] = 0.0
            else:
                h = 0.0
                for cnt in freq.values():
                    p = cnt / total
                    if p > 0:
                        h -= p * math.log(p)
                n_classes = len(freq)
                max_h = math.log(n_classes) if n_classes > 1 else 1.0
                row[f"entropy_{le_suffix}"] = h
                row[f"norm_entropy_{le_suffix}"] = h / max_h if max_h > 0 else 0.0
            row[f"class_count_{le_suffix}"] = len(freq)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 226: spatial_inequality — spatial inequality and segregation
    # ------------------------------------------------------------------
    def spatial_inequality(
        self,
        group_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        si_suffix: str = "sineq",
    ) -> "GeoPromptFrame":
        """Spatial inequality and segregation metrics.

        For each feature, computes a local dissimilarity index based on
        group composition among *k* nearest neighbours, plus additional
        exposure and isolation indices.
        """
        self._require_column(group_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, n - 1), distance_method)
        groups = self[group_column]
        # Global group counts
        global_freq: dict[str, int] = {}
        for g in groups:
            if g is not None:
                key = str(g)
                global_freq[key] = global_freq.get(key, 0) + 1
        all_groups = sorted(global_freq.keys())
        total_pop = sum(global_freq.values())

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            local_freq: dict[str, int] = {}
            for j in neighbors:
                g = groups[j]
                if g is not None:
                    key = str(g)
                    local_freq[key] = local_freq.get(key, 0) + 1
            local_total = sum(local_freq.values())
            # Local dissimilarity index
            dissim = 0.0
            if local_total > 0 and len(all_groups) >= 2 and total_pop > 0:
                for grp in all_groups:
                    local_p = local_freq.get(grp, 0) / local_total
                    global_p = global_freq.get(grp, 0) / total_pop
                    dissim += abs(local_p - global_p)
                dissim /= 2.0
            # Isolation (proportion of own group in neighbourhood)
            own_group = str(groups[i]) if groups[i] is not None else ""
            isolation = local_freq.get(own_group, 0) / local_total if local_total > 0 else 0.0
            # Exposure (proportion of largest other group)
            exposure = 0.0
            if local_total > 0:
                for grp in all_groups:
                    if grp != own_group:
                        exp_val = local_freq.get(grp, 0) / local_total
                        if exp_val > exposure:
                            exposure = exp_val
            row[f"dissimilarity_{si_suffix}"] = dissim
            row[f"isolation_{si_suffix}"] = isolation
            row[f"exposure_{si_suffix}"] = exposure
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 227: hierarchical_spatial_cluster — hierarchical clustering
    #           with contiguity constraints
    # ------------------------------------------------------------------
    def hierarchical_spatial_cluster(
        self,
        n_clusters: int = 5,
        k: int = 5,
        value_column: str | None = None,
        distance_method: str = "euclidean",
        hsc_suffix: str = "hsc",
    ) -> "GeoPromptFrame":
        """Hierarchical agglomerative clustering with contiguity rules.

        Only merges clusters if they share at least one k-nearest-neighbour
        link.  Uses Ward-like variance minimisation when a value column is
        provided, otherwise minimises spatial distance.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        if value_column:
            self._require_column(value_column)

        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        centroids = self._centroids()
        vals = self[value_column] if value_column else None
        import math

        # Build adjacency set
        adj: dict[int, set[int]] = {i: set() for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                adj[i].add(j)
                adj[j].add(i)

        # Initial cluster assignments
        cluster_of: list[int] = list(range(n))
        cluster_members: dict[int, list[int]] = {i: [i] for i in range(n)}
        active: set[int] = set(range(n))

        while len(active) > n_clusters and len(active) > 1:
            best_cost = float("inf")
            best_pair: tuple[int, int] | None = None
            for c1 in active:
                for m in cluster_members[c1]:
                    for nb in adj[m]:
                        c2 = cluster_of[nb]
                        if c2 == c1 or c2 not in active:
                            continue
                        # Compute merge cost
                        if vals is not None:
                            mean1 = sum(float(vals[x]) for x in cluster_members[c1]) / len(cluster_members[c1])
                            mean2 = sum(float(vals[x]) for x in cluster_members[c2]) / len(cluster_members[c2])
                            all_m = cluster_members[c1] + cluster_members[c2]
                            mean_all = sum(float(vals[x]) for x in all_m) / len(all_m)
                            cost = sum((float(vals[x]) - mean_all) ** 2 for x in all_m) - sum((float(vals[x]) - mean1) ** 2 for x in cluster_members[c1]) - sum((float(vals[x]) - mean2) ** 2 for x in cluster_members[c2])
                        else:
                            cx1 = sum(centroids[x][0] for x in cluster_members[c1]) / len(cluster_members[c1])
                            cy1 = sum(centroids[x][1] for x in cluster_members[c1]) / len(cluster_members[c1])
                            cx2 = sum(centroids[x][0] for x in cluster_members[c2]) / len(cluster_members[c2])
                            cy2 = sum(centroids[x][1] for x in cluster_members[c2]) / len(cluster_members[c2])
                            cost = math.hypot(cx1 - cx2, cy1 - cy2) * len(cluster_members[c1]) * len(cluster_members[c2])
                        if cost < best_cost:
                            best_cost = cost
                            best_pair = (min(c1, c2), max(c1, c2))
            if best_pair is None:
                break
            c_keep, c_drop = best_pair
            for m in cluster_members[c_drop]:
                cluster_of[m] = c_keep
            cluster_members[c_keep].extend(cluster_members[c_drop])
            del cluster_members[c_drop]
            active.discard(c_drop)

        # Relabel clusters 0..k-1
        label_map: dict[int, int] = {}
        for cid in sorted(active):
            label_map[cid] = len(label_map)
        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"cluster_{hsc_suffix}"] = label_map.get(cluster_of[i], 0)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 228: constrained_kmeans — k-means with contiguity constraints
    # ------------------------------------------------------------------
    def constrained_kmeans(
        self,
        n_clusters: int = 5,
        k_neighbors: int = 5,
        max_iterations: int = 100,
        seed: int = 42,
        distance_method: str = "euclidean",
        ckm_suffix: str = "ckm",
    ) -> "GeoPromptFrame":
        """Constrained k-means with spatial contiguity.

        Initialises k cluster seeds and assigns features greedily respecting
        spatial contiguity (a feature can only join a cluster if it neighbours
        an existing member via k-NN).
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import random as _rng
        rng = _rng.Random(seed)
        knn = self._cached_knn(min(k_neighbors, max(n - 1, 1)), distance_method)
        centroids = self._centroids()
        import math

        # Build adjacency
        adj: dict[int, set[int]] = {i: set() for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                adj[i].add(j)
                adj[j].add(i)

        # Pick seeds
        seeds = rng.sample(range(n), min(n_clusters, n))
        labels = [-1] * n
        for idx, s in enumerate(seeds):
            labels[s] = idx

        # Greedy contiguity-respecting assignment
        from collections import deque
        frontier: deque[int] = deque(seeds)
        while frontier:
            curr = frontier.popleft()
            cl = labels[curr]
            for nb in adj[curr]:
                if labels[nb] == -1:
                    labels[nb] = cl
                    frontier.append(nb)

        # Assign any remaining disconnected points to nearest labeled
        for i in range(n):
            if labels[i] == -1:
                best_d = float("inf")
                best_cl = 0
                for j in range(n):
                    if labels[j] >= 0:
                        d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                        if d < best_d:
                            best_d = d
                            best_cl = labels[j]
                labels[i] = best_cl

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"cluster_{ckm_suffix}"] = labels[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 229: cluster_stability — cluster stability diagnostics
    # ------------------------------------------------------------------
    def cluster_stability(
        self,
        cluster_column: str,
        n_runs: int = 10,
        subsample_fraction: float = 0.8,
        k: int = 5,
        seed: int = 42,
        distance_method: str = "euclidean",
        cs_suffix: str = "cstab",
    ) -> "GeoPromptFrame":
        """Cluster stability diagnostics.

        Evaluates stability of an existing clustering by subsampling and
        re-clustering, then measuring agreement between runs using the
        adjusted Rand-like index.
        """
        self._require_column(cluster_column)
        n = len(self._rows)
        if n < 4:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        import random as _rng
        rng = _rng.Random(seed)
        original_labels = self[cluster_column]
        n_clusters = len(set(l for l in original_labels if l is not None))
        centroids = self._centroids()
        import math

        # Stability scores per feature
        agreement_counts = [0] * n
        total_counts = [0] * n

        for run in range(n_runs):
            sample_size = max(3, int(n * subsample_fraction))
            sample_indices = sorted(rng.sample(range(n), sample_size))
            idx_map = {old: new for new, old in enumerate(sample_indices)}
            # Build sub-adjacency
            sub_centroids = [centroids[i] for i in sample_indices]
            sub_k = min(k, sample_size - 1)
            # Compute sub-distance-matrix
            sub_dm: list[list[float]] = []
            for i in range(sample_size):
                row_d: list[float] = []
                for j in range(sample_size):
                    row_d.append(math.hypot(sub_centroids[i][0] - sub_centroids[j][0],
                                            sub_centroids[i][1] - sub_centroids[j][1]))
                sub_dm.append(row_d)
            # k-NN and greedy contiguity clustering
            sub_knn: list[list[int]] = []
            for i in range(sample_size):
                neighbors = sorted(range(sample_size), key=lambda j, _i=i: sub_dm[_i][j])
                sub_knn.append([j for j in neighbors if j != i][:sub_k])
            sub_adj: dict[int, set[int]] = {i: set() for i in range(sample_size)}
            for i in range(sample_size):
                for j in sub_knn[i]:
                    sub_adj[i].add(j)
                    sub_adj[j].add(i)
            # Pick seeds and BFS
            seeds = rng.sample(range(sample_size), min(n_clusters, sample_size))
            sub_labels = [-1] * sample_size
            from collections import deque
            frontier: deque[int] = deque()
            for idx, s in enumerate(seeds):
                sub_labels[s] = idx
                frontier.append(s)
            while frontier:
                curr = frontier.popleft()
                cl = sub_labels[curr]
                for nb in sub_adj[curr]:
                    if sub_labels[nb] == -1:
                        sub_labels[nb] = cl
                        frontier.append(nb)
            for i in range(sample_size):
                if sub_labels[i] == -1:
                    sub_labels[i] = 0
            # Check agreement for each sampled pair
            for a in range(sample_size):
                orig_a = sample_indices[a]
                for b in range(a + 1, sample_size):
                    orig_b = sample_indices[b]
                    same_orig = original_labels[orig_a] == original_labels[orig_b]
                    same_sub = sub_labels[a] == sub_labels[b]
                    if same_orig == same_sub:
                        agreement_counts[orig_a] += 1
                        agreement_counts[orig_b] += 1
                    total_counts[orig_a] += 1
                    total_counts[orig_b] += 1

        rows = self.to_records()
        for i, row in enumerate(rows):
            score = agreement_counts[i] / total_counts[i] if total_counts[i] > 0 else 0.0
            row[f"stability_{cs_suffix}"] = score
            row[f"stable_{cs_suffix}"] = score >= 0.8
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 230: local_collinearity — local collinearity diagnostics
    # ------------------------------------------------------------------
    def local_collinearity(
        self,
        x_columns: list[str],
        k: int = 15,
        distance_method: str = "euclidean",
        lc_suffix: str = "lcol",
    ) -> "GeoPromptFrame":
        """Local collinearity diagnostics for GWR-family tools.

        For each feature, computes the local condition number of the
        design matrix formed by its k nearest neighbours.  High condition
        numbers indicate ill-conditioning that may destabilise local
        regression.
        """
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        rows = self.to_records()
        p = len(x_columns)
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            if len(neighbors) < p:
                row[f"condition_number_{lc_suffix}"] = float("inf")
                row[f"collinear_{lc_suffix}"] = True
                continue
            # Build local design matrix X (m x p)
            X: list[list[float]] = []
            for j in neighbors:
                X.append([float(self._rows[j].get(col, 0) or 0) for col in x_columns])
            m = len(X)
            # Compute X^T X (p x p)
            XtX: list[list[float]] = [[0.0] * p for _ in range(p)]
            for r in range(p):
                for c in range(p):
                    s = 0.0
                    for ii in range(m):
                        s += X[ii][r] * X[ii][c]
                    XtX[r][c] = s
            # Power iteration for largest eigenvalue
            v = [1.0] * p
            for _ in range(50):
                Av = [sum(XtX[r][c] * v[c] for c in range(p)) for r in range(p)]
                norm = math.sqrt(sum(x * x for x in Av)) or 1.0
                v = [x / norm for x in Av]
            lam_max = sum(v[r] * sum(XtX[r][c] * v[c] for c in range(p)) for r in range(p))
            # Inverse power iteration for smallest eigenvalue
            # Use (XtX + εI) to avoid singular
            eps = 1e-10
            B = [[XtX[r][c] + (eps if r == c else 0.0) for c in range(p)] for r in range(p)]
            w = [1.0] * p
            for _ in range(50):
                # Solve B*u = w via Gauss elimination
                aug = [B[r][:] + [w[r]] for r in range(p)]
                for col in range(p):
                    pivot = _pivot_row_index(aug, col, col, p)
                    aug[col], aug[pivot] = aug[pivot], aug[col]
                    if abs(aug[col][col]) < 1e-15:
                        continue
                    for r in range(col + 1, p):
                        factor = aug[r][col] / aug[col][col]
                        for c2 in range(col, p + 1):
                            aug[r][c2] -= factor * aug[col][c2]
                u = [0.0] * p
                for r in range(p - 1, -1, -1):
                    s = aug[r][p] - sum(aug[r][c2] * u[c2] for c2 in range(r + 1, p))
                    u[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
                norm = math.sqrt(sum(x * x for x in u)) or 1.0
                w = [x / norm for x in u]
            lam_min = sum(w[r] * sum(XtX[r][c] * w[c] for c in range(p)) for r in range(p))
            cond = math.sqrt(abs(lam_max) / max(abs(lam_min), 1e-15))
            row[f"condition_number_{lc_suffix}"] = cond
            row[f"collinear_{lc_suffix}"] = cond > 30.0
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 231: local_heteroskedasticity — local heteroskedasticity
    # ------------------------------------------------------------------
    def local_heteroskedasticity(
        self,
        residual_column: str,
        k: int = 15,
        distance_method: str = "euclidean",
        lh_suffix: str = "lhet",
    ) -> "GeoPromptFrame":
        """Local heteroskedasticity diagnostics.

        For each feature, computes a local Breusch-Pagan-style statistic
        by testing whether squared residuals vary significantly among the
        k nearest neighbours.
        """
        self._require_column(residual_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        residuals = [float(v) for v in self[residual_column]]
        global_var = sum(r * r for r in residuals) / max(n, 1)

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            if len(neighbors) < 2:
                row[f"bp_stat_{lh_suffix}"] = 0.0
                row[f"heteroskedastic_{lh_suffix}"] = False
                continue
            local_resid = [residuals[j] for j in neighbors]
            local_var = sum(r * r for r in local_resid) / len(local_resid)
            ratio = local_var / global_var if global_var > 0 else 1.0
            # Chi-squared-like statistic: n * log(ratio)
            bp = len(local_resid) * abs(math.log(max(ratio, 1e-15)))
            row[f"bp_stat_{lh_suffix}"] = bp
            row[f"local_variance_{lh_suffix}"] = local_var
            row[f"heteroskedastic_{lh_suffix}"] = bp > 3.84  # chi2(1, 0.05)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 232: quantile_regression_local — local quantile regression
    # ------------------------------------------------------------------
    def quantile_regression_local(
        self,
        y_column: str,
        x_columns: list[str],
        quantile: float = 0.5,
        k: int = 30,
        distance_method: str = "euclidean",
        qrl_suffix: str = "qrl",
    ) -> "GeoPromptFrame":
        """Geographically weighted quantile regression.

        Fits a linear quantile regression at each feature using
        distance-weighted observations from its k nearest neighbours.
        Uses iteratively reweighted least squares (IRLS) for the check
        function minimisation.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        dm = self._cached_distance_matrix(distance_method)
        centroids = self._centroids()
        p = len(x_columns) + 1  # intercept

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            if len(neighbors) < p:
                for ci, col in enumerate(x_columns):
                    row[f"beta_{col}_{qrl_suffix}"] = 0.0
                row[f"beta_intercept_{qrl_suffix}"] = 0.0
                row[f"predicted_{qrl_suffix}"] = 0.0
                continue
            # Distance weights (bisquare kernel)
            dists = [dm[i][j] for j in neighbors]
            bw = max(dists) * 1.01 if dists else 1.0
            w = [(1.0 - (d / bw) ** 2) ** 2 if d < bw else 0.0 for d in dists]
            # IRLS for quantile regression
            # Design matrix with intercept
            m = len(neighbors)
            X: list[list[float]] = []
            Y: list[float] = []
            for j_idx, j in enumerate(neighbors):
                x_row = [1.0] + [float(self._rows[j].get(col, 0) or 0) for col in x_columns]
                X.append(x_row)
                Y.append(float(self._rows[j].get(y_column, 0) or 0))
            # Initial OLS guess
            beta = [0.0] * p
            for _iter in range(20):
                # Compute residuals
                resid = [Y[ji] - sum(X[ji][pi] * beta[pi] for pi in range(p)) for ji in range(m)]
                # Weights for check function
                eps = 1e-6
                qw = [quantile / max(abs(r), eps) if r > 0 else (1 - quantile) / max(abs(r), eps) for r in resid]
                # Weighted least squares: (X^T W X) beta = X^T W Y
                W = [w[ji] * qw[ji] for ji in range(m)]
                XtWX = [[sum(X[ji][r] * W[ji] * X[ji][c] for ji in range(m)) for c in range(p)] for r in range(p)]
                XtWY = [sum(X[ji][r] * W[ji] * Y[ji] for ji in range(m)) for r in range(p)]
                # Solve via Gauss elimination
                aug = [XtWX[r][:] + [XtWY[r]] for r in range(p)]
                for col in range(p):
                    pivot = _pivot_row_index(aug, col, col, p)
                    aug[col], aug[pivot] = aug[pivot], aug[col]
                    if abs(aug[col][col]) < 1e-15:
                        continue
                    for r in range(col + 1, p):
                        factor = aug[r][col] / aug[col][col]
                        for c2 in range(col, p + 1):
                            aug[r][c2] -= factor * aug[col][c2]
                new_beta = [0.0] * p
                for r in range(p - 1, -1, -1):
                    s = aug[r][p] - sum(aug[r][c2] * new_beta[c2] for c2 in range(r + 1, p))
                    new_beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
                beta = new_beta

            xi_row = [1.0] + [float(self._rows[i].get(col, 0) or 0) for col in x_columns]
            pred = sum(xi_row[pi] * beta[pi] for pi in range(p))
            row[f"beta_intercept_{qrl_suffix}"] = beta[0]
            for ci, col in enumerate(x_columns):
                row[f"beta_{col}_{qrl_suffix}"] = beta[ci + 1]
            row[f"predicted_{qrl_suffix}"] = pred
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 233: viewshed_points — feature-to-feature viewshed analysis
    # ------------------------------------------------------------------
    def viewshed_points(
        self,
        elevation_column: str,
        observer_index: int = 0,
        observer_height: float = 1.7,
        k: int = 8,
        distance_method: str = "euclidean",
        vs_suffix: str = "vs",
    ) -> "GeoPromptFrame":
        """Viewshed analysis.

        Determines which features are visible from the observer feature
        using line-of-sight angle comparison over k-nearest-neighbour
        adjacency paths.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        elevations = [float(v) if v is not None else 0.0 for v in self[elevation_column]]
        obs_elev = elevations[observer_index] + observer_height
        obs_x, obs_y = centroids[observer_index]

        visible = [False] * n
        visible[observer_index] = True

        for t in range(n):
            if t == observer_index:
                continue
            tx, ty = centroids[t]
            dx = tx - obs_x
            dy = ty - obs_y
            dist = math.hypot(dx, dy)
            if dist < 1e-12:
                visible[t] = True
                continue
            target_angle = math.atan2(elevations[t] - obs_elev, dist)
            # Check all intermediate points for obstruction
            blocked = False
            for mid in range(n):
                if mid == observer_index or mid == t:
                    continue
                mx, my = centroids[mid]
                # Check if mid is roughly between observer and target
                dmx = mx - obs_x
                dmy = my - obs_y
                dist_mid = math.hypot(dmx, dmy)
                if dist_mid >= dist or dist_mid < 1e-12:
                    continue
                # Projection of mid onto observer->target line
                proj = (dmx * dx + dmy * dy) / (dist * dist)
                if proj < 0.01 or proj > 0.99:
                    continue
                perp_dist = abs(dmx * dy - dmy * dx) / dist
                # Tolerance: point must be close to the LOS line
                if perp_dist > dist * 0.05:
                    continue
                mid_angle = math.atan2(elevations[mid] - obs_elev, dist_mid)
                if mid_angle > target_angle:
                    blocked = True
                    break
            visible[t] = not blocked

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"visible_{vs_suffix}"] = visible[i]
            dist_to_obs = math.hypot(centroids[i][0] - obs_x, centroids[i][1] - obs_y)
            row[f"distance_to_observer_{vs_suffix}"] = dist_to_obs
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 234: intervisibility — pairwise intervisibility analysis
    # ------------------------------------------------------------------
    def intervisibility(
        self,
        elevation_column: str,
        observer_height: float = 1.7,
        iv_suffix: str = "iv",
    ) -> "GeoPromptFrame":
        """Pairwise intervisibility analysis.

        Returns a frame of line features connecting all mutually visible
        pairs based on line-of-sight checks.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        elevations = [float(v) if v is not None else 0.0 for v in self[elevation_column]]

        out_rows: list[Record] = []
        for i in range(n):
            for j in range(i + 1, n):
                obs_elev = elevations[i] + observer_height
                ix, iy = centroids[i]
                jx, jy = centroids[j]
                dx = jx - ix
                dy = jy - iy
                dist = math.hypot(dx, dy)
                if dist < 1e-12:
                    vis = True
                else:
                    target_angle = math.atan2(elevations[j] - obs_elev, dist)
                    vis = True
                    for mid in range(n):
                        if mid == i or mid == j:
                            continue
                        mx, my = centroids[mid]
                        dmx = mx - ix
                        dmy = my - iy
                        dist_mid = math.hypot(dmx, dmy)
                        if dist_mid >= dist or dist_mid < 1e-12:
                            continue
                        proj = (dmx * dx + dmy * dy) / (dist * dist)
                        if proj < 0.01 or proj > 0.99:
                            continue
                        perp_dist = abs(dmx * dy - dmy * dx) / dist
                        if perp_dist > dist * 0.05:
                            continue
                        mid_angle = math.atan2(elevations[mid] - obs_elev, dist_mid)
                        if mid_angle > target_angle:
                            vis = False
                            break
                if vis:
                    line = {"type": "LineString", "coordinates": [[ix, iy], [jx, jy]]}
                    out_rows.append({
                        self.geometry_column: line,
                        f"from_{iv_suffix}": i,
                        f"to_{iv_suffix}": j,
                        f"distance_{iv_suffix}": dist,
                    })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 235: least_cost_surface — least-cost surface creation
    # ------------------------------------------------------------------
    def least_cost_surface(
        self,
        cost_column: str,
        origin_index: int = 0,
        k: int = 8,
        distance_method: str = "euclidean",
        lcs_suffix: str = "lcs",
    ) -> "GeoPromptFrame":
        """Least-cost surface creation from terrain and friction.

        Computes the accumulated cost distance from the origin feature
        to all others using Dijkstra over a k-NN adjacency graph with
        edge costs derived from the cost column.
        """
        self._require_column(cost_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        centroids = self._centroids()
        costs = [float(v) if v is not None else 1.0 for v in self[cost_column]]

        # Build adjacency with cost weights
        adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                edge_cost = d * (costs[i] + costs[j]) / 2.0
                adj[i].append((j, edge_cost))
                adj[j].append((i, edge_cost))

        # Dijkstra
        import heapq
        dist_to: list[float] = [float("inf")] * n
        dist_to[origin_index] = 0.0
        heap: list[tuple[float, int]] = [(0.0, origin_index)]
        visited: set[int] = set()
        while heap:
            d, u = heapq.heappop(heap)
            if u in visited:
                continue
            visited.add(u)
            for v, w in adj[u]:
                nd = d + w
                if nd < dist_to[v]:
                    dist_to[v] = nd
                    heapq.heappush(heap, (nd, v))

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"acc_cost_{lcs_suffix}"] = dist_to[i]
            row[f"reachable_{lcs_suffix}"] = dist_to[i] < float("inf")
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 236: edge_criticality — network edge criticality diagnostics
    # ------------------------------------------------------------------
    def edge_criticality(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        ec_suffix: str = "ecrit",
    ) -> "GeoPromptFrame":
        """Network edge criticality diagnostics.

        For each edge, computes how much removing it increases the average
        shortest path in the network — a measure of how critical the edge
        is for connectivity.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        import heapq
        edges = []
        nodes: set[str] = set()
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            edges.append((f, t, w))
            nodes.add(f)
            nodes.add(t)
        node_list = sorted(nodes)
        node_idx = {nd: i for i, nd in enumerate(node_list)}
        nn = len(node_list)

        def _avg_sp(skip_edge: int | None) -> float:
            # Build adjacency
            adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(nn)}
            for ei, (f, t, w) in enumerate(edges):
                if ei == skip_edge:
                    continue
                fi, ti = node_idx[f], node_idx[t]
                adj[fi].append((ti, w))
                adj[ti].append((fi, w))
            total = 0.0
            count = 0
            for src in range(nn):
                dist = [float("inf")] * nn
                dist[src] = 0.0
                heap: list[tuple[float, int]] = [(0.0, src)]
                visited: set[int] = set()
                while heap:
                    d, u = heapq.heappop(heap)
                    if u in visited:
                        continue
                    visited.add(u)
                    for v, ew in adj[u]:
                        nd = d + ew
                        if nd < dist[v]:
                            dist[v] = nd
                            heapq.heappush(heap, (nd, v))
                for dst in range(nn):
                    if dst != src and dist[dst] < float("inf"):
                        total += dist[dst]
                        count += 1
            return total / count if count > 0 else float("inf")

        baseline = _avg_sp(None)
        rows = self.to_records()
        for ei, row in enumerate(rows):
            removed_avg = _avg_sp(ei)
            delta = removed_avg - baseline
            row[f"criticality_{ec_suffix}"] = delta
            row[f"critical_{ec_suffix}"] = delta > baseline * 0.1
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 237: network_resilience — network resilience metrics
    # ------------------------------------------------------------------
    def network_resilience(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        nr_suffix: str = "nres",
    ) -> "GeoPromptFrame":
        """Network resilience metrics.

        Computes per-node resilience metrics: degree, betweenness centrality,
        and a resilience score (1 - betweenness/max_betweenness).
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Build graph from edges
        nodes: set[str] = set()
        adj: dict[str, list[tuple[str, float]]] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f)
            nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))
        node_list = sorted(nodes)
        nn = len(node_list)
        degree: dict[str, int] = {nd: len(adj.get(nd, [])) for nd in node_list}

        # Betweenness centrality (Brandes algorithm simplified)
        import heapq
        betweenness: dict[str, float] = {nd: 0.0 for nd in node_list}
        for s in node_list:
            # Dijkstra
            dist: dict[str, float] = {nd: float("inf") for nd in node_list}
            dist[s] = 0.0
            sigma: dict[str, float] = {nd: 0.0 for nd in node_list}
            sigma[s] = 1.0
            pred: dict[str, list[str]] = {nd: [] for nd in node_list}
            stack: list[str] = []
            heap: list[tuple[float, str]] = [(0.0, s)]
            visited: set[str] = set()
            while heap:
                d, u = heapq.heappop(heap)
                if u in visited:
                    continue
                visited.add(u)
                stack.append(u)
                for v, w in adj.get(u, []):
                    nd = d + w
                    if nd < dist[v]:
                        dist[v] = nd
                        sigma[v] = sigma[u]
                        pred[v] = [u]
                        heapq.heappush(heap, (nd, v))
                    elif abs(nd - dist[v]) < 1e-12:
                        sigma[v] += sigma[u]
                        pred[v].append(u)
            delta: dict[str, float] = {nd: 0.0 for nd in node_list}
            while stack:
                w_node = stack.pop()
                for p_node in pred[w_node]:
                    if sigma[w_node] > 0:
                        delta[p_node] += (sigma[p_node] / sigma[w_node]) * (1 + delta[w_node])
                if w_node != s:
                    betweenness[w_node] += delta[w_node]

        max_btw = max(betweenness.values()) if betweenness else 1.0
        if max_btw == 0:
            max_btw = 1.0

        # Build output: one row per node
        out_rows: list[Record] = []
        node_coords: dict[str, tuple[float, float]] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            geom = row.get(self.geometry_column)
            if geom and geom.get("type") == "LineString":
                coords = geom["coordinates"]
                if coords:
                    node_coords.setdefault(f, tuple(coords[0][:2]))
                    node_coords.setdefault(t, tuple(coords[-1][:2]))
        for nd in node_list:
            coord = node_coords.get(nd, (0, 0))
            btw = betweenness[nd]
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": list(coord)},
                f"node_{nr_suffix}": nd,
                f"degree_{nr_suffix}": degree[nd],
                f"betweenness_{nr_suffix}": btw,
                f"resilience_{nr_suffix}": 1.0 - btw / max_btw,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 238: route_barriers — routing with barriers / closures
    # ------------------------------------------------------------------
    def route_barriers(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        origin: str = "A",
        destination: str = "Z",
        blocked_edges: list[tuple[str, str]] | None = None,
        rb_suffix: str = "rb",
    ) -> "GeoPromptFrame":
        """Shortest path routing with edge barriers/closures.

        Uses Dijkstra on the network with specified edges removed.
        Returns the path as a series of edge rows.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        import heapq
        blocked = set()
        if blocked_edges:
            for be in blocked_edges:
                blocked.add((str(be[0]), str(be[1])))
                blocked.add((str(be[1]), str(be[0])))

        adj: dict[str, list[tuple[str, float]]] = {}
        nodes: set[str] = set()
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            if (f, t) in blocked:
                continue
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f)
            nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))

        if origin not in nodes or destination not in nodes:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        dist: dict[str, float] = {nd: float("inf") for nd in nodes}
        dist[origin] = 0.0
        prev: dict[str, str | None] = {nd: None for nd in nodes}
        heap: list[tuple[float, str]] = [(0.0, origin)]
        visited: set[str] = set()
        while heap:
            d, u = heapq.heappop(heap)
            if u in visited:
                continue
            visited.add(u)
            if u == destination:
                break
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    prev[v] = u
                    heapq.heappush(heap, (nd, v))

        # Reconstruct path
        path: list[str] = []
        cur: str | None = destination
        while cur is not None:
            path.append(cur)
            cur = prev.get(cur)
        path.reverse()
        if not path or path[0] != origin:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        out_rows: list[Record] = []
        for idx in range(len(path) - 1):
            f, t = path[idx], path[idx + 1]
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"from_{rb_suffix}": f,
                f"to_{rb_suffix}": t,
                f"step_{rb_suffix}": idx,
                f"cost_{rb_suffix}": dist[t] - dist[f] if f in dist and t in dist else 0.0,
                f"total_cost_{rb_suffix}": dist[t],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 239: quadrat_grid — quadrat count analysis on grid
    # ------------------------------------------------------------------
    def quadrat_grid(
        self,
        n_x: int = 5,
        n_y: int = 5,
        qa_suffix: str = "qa",
    ) -> "GeoPromptFrame":
        """Quadrat analysis.

        Divides the bounding box into a grid of n_x × n_y quadrats and
        counts the number of features in each.  Computes a variance-mean
        ratio (VMR) to test for complete spatial randomness.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(n_x, 1)
        dy = (b.max_y - b.min_y) / max(n_y, 1)

        counts: dict[tuple[int, int], int] = {}
        for cx, cy in centroids:
            col = min(int((cx - b.min_x) / dx), n_x - 1) if dx > 0 else 0
            row = min(int((cy - b.min_y) / dy), n_y - 1) if dy > 0 else 0
            key = (col, row)
            counts[key] = counts.get(key, 0) + 1

        # Compute VMR
        all_counts = []
        for qx in range(n_x):
            for qy in range(n_y):
                all_counts.append(counts.get((qx, qy), 0))
        mean_count = sum(all_counts) / len(all_counts) if all_counts else 0
        var_count = sum((c - mean_count) ** 2 for c in all_counts) / max(len(all_counts) - 1, 1)
        vmr = var_count / mean_count if mean_count > 0 else 0.0

        out_rows: list[Record] = []
        for qx in range(n_x):
            for qy in range(n_y):
                cx = b.min_x + (qx + 0.5) * dx
                cy = b.min_y + (qy + 0.5) * dy
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                    f"quadrat_x_{qa_suffix}": qx,
                    f"quadrat_y_{qa_suffix}": qy,
                    f"count_{qa_suffix}": counts.get((qx, qy), 0),
                    f"vmr_{qa_suffix}": vmr,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 240: space_time_k — space-time K function
    # ------------------------------------------------------------------
    def space_time_k(
        self,
        time_column: str,
        n_distance_bins: int = 10,
        n_time_bins: int = 10,
        distance_method: str = "euclidean",
        stk_suffix: str = "stk",
    ) -> "GeoPromptFrame":
        """Space-time K function.

        Computes the bivariate K function over distance and time dimensions
        to test for space-time clustering.
        """
        self._require_column(time_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        times = [float(v) for v in self[time_column]]
        dm = self._cached_distance_matrix(distance_method)

        max_dist = max(dm[i][j] for i in range(n) for j in range(i + 1, n))
        max_time = max(abs(times[i] - times[j]) for i in range(n) for j in range(i + 1, n))
        d_step = max_dist / max(n_distance_bins, 1)
        t_step = max_time / max(n_time_bins, 1) if max_time > 0 else 1.0

        area = (self.bounds().max_x - self.bounds().min_x) * (self.bounds().max_y - self.bounds().min_y)
        time_span = max_time if max_time > 0 else 1.0
        intensity = n / max(area * time_span, 1e-10)

        out_rows: list[Record] = []
        for d_bin in range(n_distance_bins):
            d_thresh = (d_bin + 1) * d_step
            for t_bin in range(n_time_bins):
                t_thresh = (t_bin + 1) * t_step
                count = 0
                for i in range(n):
                    for j in range(n):
                        if i == j:
                            continue
                        if dm[i][j] <= d_thresh and abs(times[i] - times[j]) <= t_thresh:
                            count += 1
                k_val = count / max(n * intensity, 1e-10)
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0.0, 0.0)},
                    f"distance_threshold_{stk_suffix}": d_thresh,
                    f"time_threshold_{stk_suffix}": t_thresh,
                    f"K_{stk_suffix}": k_val,
                    f"pair_count_{stk_suffix}": count,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 241: mesh_subdivision — polygon mesh subdivision
    # ------------------------------------------------------------------
    def mesh_subdivision(
        self,
        iterations: int = 1,
        ms_suffix: str = "msub",
    ) -> "GeoPromptFrame":
        """Polygon mesh subdivision.

        Applies midpoint subdivision to polygon features, splitting each
        edge at its midpoint and creating sub-polygons.  For triangles
        this produces 4 sub-triangles per iteration.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        out_rows: list[Record] = []
        for row_idx, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                out_rows.append(dict(row))
                continue
            coords = geom["coordinates"]
            if not coords:
                out_rows.append(dict(row))
                continue
            # Normalised polygons store coords as flat tuple of (x,y) tuples
            ring = coords
            if len(ring) < 4:  # needs at least 3 vertices + closing
                out_rows.append(dict(row))
                continue

            current_polys: list[list[tuple[float, float]]] = [[(c[0], c[1]) for c in ring[:-1]]]
            for _it in range(iterations):
                next_polys: list[list[tuple[float, float]]] = []
                for poly in current_polys:
                    nv = len(poly)
                    if nv == 3:
                        # Triangle: split into 4 sub-triangles
                        a, b, c = poly
                        ab = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
                        bc = ((b[0] + c[0]) / 2, (b[1] + c[1]) / 2)
                        ca = ((c[0] + a[0]) / 2, (c[1] + a[1]) / 2)
                        next_polys.extend([
                            [a, ab, ca],
                            [ab, b, bc],
                            [ca, bc, c],
                            [ab, bc, ca],
                        ])
                    else:
                        # General polygon: compute centroid, split into triangles
                        cx = sum(p[0] for p in poly) / nv
                        cy = sum(p[1] for p in poly) / nv
                        center = (cx, cy)
                        for vi in range(nv):
                            vj = (vi + 1) % nv
                            next_polys.append([poly[vi], poly[vj], center])
                current_polys = next_polys

            for pi, poly in enumerate(current_polys):
                closed = [list(p) for p in poly] + [list(poly[0])]
                sub_geom = {"type": "Polygon", "coordinates": [closed]}
                new_row = {k: v for k, v in row.items() if k != self.geometry_column}
                new_row[self.geometry_column] = sub_geom
                new_row[f"parent_{ms_suffix}"] = row_idx
                new_row[f"sub_id_{ms_suffix}"] = pi
                out_rows.append(new_row)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 242: gap_overlap_repair — gap and overlap repair helpers
    # ------------------------------------------------------------------
    def gap_overlap_repair(
        self,
        tolerance: float = 1e-6,
        gor_suffix: str = "gor",
    ) -> "GeoPromptFrame":
        """Gap and overlap repair helpers.

        Detects gaps and overlaps between adjacent polygon features and
        tries to repair them by snapping vertices within the tolerance.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math

        # Extract polygon rings
        polys: list[list[tuple[float, float]] | None] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if geom and geom.get("type") == "Polygon" and geom.get("coordinates"):
                ring = [(c[0], c[1]) for c in geom["coordinates"]]
                polys.append(ring)
            else:
                polys.append(None)

        # Snap close vertices between different polygons
        snapped_polys = [list(p) if p else None for p in polys]
        for i in range(n):
            if snapped_polys[i] is None:
                continue
            for j in range(i + 1, n):
                if snapped_polys[j] is None:
                    continue
                left_poly = snapped_polys[i]
                right_poly = snapped_polys[j]
                if left_poly is None or right_poly is None:
                    continue
                for vi in range(len(left_poly)):
                    for vj in range(len(right_poly)):
                        px, py = left_poly[vi]
                        qx, qy = right_poly[vj]
                        if math.hypot(px - qx, py - qy) < tolerance:
                            mid = ((px + qx) / 2, (py + qy) / 2)
                            left_poly[vi] = mid
                            right_poly[vj] = mid

        rows = self.to_records()
        for i, row in enumerate(rows):
            polygon = snapped_polys[i]
            if polygon is not None:
                closed = [list(p) for p in polygon]
                if closed and closed[0] != closed[-1]:
                    closed.append(closed[0])
                row[self.geometry_column] = {"type": "Polygon", "coordinates": [closed]}
                row[f"snapped_{gor_suffix}"] = True
            else:
                row[f"snapped_{gor_suffix}"] = False
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 243: nodata_mask — nodata masking and propagation
    # ------------------------------------------------------------------
    def nodata_mask(
        self,
        value_column: str,
        nodata_value: float = -9999.0,
        nm_suffix: str = "ndm",
    ) -> "GeoPromptFrame":
        """Nodata masking and propagation.

        Marks features with nodata values and propagates nodata flags
        to k-nearest neighbours that are surrounded by nodata.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        vals = self[value_column]
        is_nodata = [v is None or (isinstance(v, (int, float)) and v == nodata_value) for v in vals]

        # Propagate: if all k-NN are nodata, mark as nodata too
        knn = self._cached_knn(min(4, max(n - 1, 1)))
        changed = True
        propagated = list(is_nodata)
        while changed:
            changed = False
            for i in range(n):
                if propagated[i]:
                    continue
                neighbors = knn[i] if i < len(knn) else []
                if neighbors and all(propagated[j] for j in neighbors):
                    propagated[i] = True
                    changed = True

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"is_nodata_{nm_suffix}"] = propagated[i]
            row[f"masked_value_{nm_suffix}"] = None if propagated[i] else vals[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 244: grid_resample — grid resampling utilities
    # ------------------------------------------------------------------
    def grid_resample(
        self,
        value_column: str,
        target_resolution: int = 10,
        method: str = "nearest",
        gr_suffix: str = "grs",
    ) -> "GeoPromptFrame":
        """Grid resampling utilities.

        Resamples a point-based surface to a new regular grid at the
        specified resolution using nearest-neighbor or bilinear
        interpolation.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(target_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(target_resolution - 1, 1)

        out_rows: list[Record] = []
        for gy in range(target_resolution):
            py = b.min_y + gy * dy
            for gx in range(target_resolution):
                px = b.min_x + gx * dx
                if method == "bilinear":
                    # Distance-weighted average of 4 nearest
                    dists = [(math.hypot(px - cx, py - cy), idx) for idx, (cx, cy) in enumerate(centroids)]
                    dists.sort()
                    top4 = dists[:4]
                    total_w = 0.0
                    weighted_val = 0.0
                    for d, idx in top4:
                        w = 1.0 / max(d, 1e-12)
                        weighted_val += w * vals[idx]
                        total_w += w
                    value = weighted_val / total_w if total_w > 0 else 0.0
                else:
                    # Nearest
                    best_d = float("inf")
                    best_v = 0.0
                    for idx, (cx, cy) in enumerate(centroids):
                        d = math.hypot(px - cx, py - cy)
                        if d < best_d:
                            best_d = d
                            best_v = vals[idx]
                    value = best_v
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{gr_suffix}": value,
                    f"grid_x_{gr_suffix}": gx,
                    f"grid_y_{gr_suffix}": gy,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 245: surface_difference — surface comparison / differencing
    # ------------------------------------------------------------------
    def surface_difference(
        self,
        column_a: str,
        column_b: str,
        sd_suffix: str = "sdif",
    ) -> "GeoPromptFrame":
        """Surface comparison and differencing tool.

        Computes the difference, ratio, and absolute difference between
        two surface value columns, along with summary statistics.
        """
        self._require_column(column_a)
        self._require_column(column_b)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        vals_a = self[column_a]
        vals_b = self[column_b]
        diffs: list[float] = []
        for i in range(n):
            a = float(vals_a[i]) if vals_a[i] is not None else 0.0
            b = float(vals_b[i]) if vals_b[i] is not None else 0.0
            diffs.append(a - b)

        mean_diff = sum(diffs) / n
        rmse = math.sqrt(sum(d * d for d in diffs) / n)
        mae = sum(abs(d) for d in diffs) / n

        rows = self.to_records()
        for i, row in enumerate(rows):
            a = float(vals_a[i]) if vals_a[i] is not None else 0.0
            b = float(vals_b[i]) if vals_b[i] is not None else 0.0
            row[f"diff_{sd_suffix}"] = diffs[i]
            row[f"abs_diff_{sd_suffix}"] = abs(diffs[i])
            row[f"ratio_{sd_suffix}"] = a / b if abs(b) > 1e-15 else None
            row[f"mean_diff_{sd_suffix}"] = mean_diff
            row[f"rmse_{sd_suffix}"] = rmse
            row[f"mae_{sd_suffix}"] = mae
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 246: local_variance_decomposition — local variance metrics
    # ------------------------------------------------------------------
    def local_variance_decomposition(
        self,
        value_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        lvd_suffix: str = "lvd",
    ) -> "GeoPromptFrame":
        """Local variance decomposition.

        For each feature, decomposes variance among k nearest neighbours
        into within-group (local) and between-group (global contrast)
        components relative to the global mean.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        global_mean = sum(vals) / n
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            local_vals = [vals[j] for j in neighbors]
            if not local_vals:
                row[f"local_var_{lvd_suffix}"] = 0.0
                row[f"contrast_{lvd_suffix}"] = 0.0
                continue
            local_mean = sum(local_vals) / len(local_vals)
            within = sum((v - local_mean) ** 2 for v in local_vals) / len(local_vals)
            between = (local_mean - global_mean) ** 2
            row[f"local_var_{lvd_suffix}"] = within
            row[f"contrast_{lvd_suffix}"] = between
            row[f"local_mean_{lvd_suffix}"] = local_mean
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 247: robust_morans_i — outlier-resistant Moran's I
    # ------------------------------------------------------------------
    def robust_morans_i(
        self,
        value_column: str,
        k: int = 8,
        trim_fraction: float = 0.1,
        distance_method: str = "euclidean",
        rmi_suffix: str = "rmi",
    ) -> "GeoPromptFrame":
        """Robust Moran's I for outlier-resistant spatial autocorrelation.

        Computes local Moran's I using trimmed means and Winsorised
        deviations to reduce the influence of extreme values.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        vals = sorted(float(v) if v is not None else 0.0 for v in self[value_column])
        raw_vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        trim_n = max(1, int(n * trim_fraction))
        trimmed = vals[trim_n:n - trim_n] if n > 2 * trim_n else vals
        trimmed_mean = sum(trimmed) / len(trimmed) if trimmed else 0.0
        # Winsorise deviations
        lo, hi = vals[trim_n] if trim_n < n else vals[0], vals[-(trim_n + 1)] if trim_n < n else vals[-1]
        win_vals = [clamp(v, lo, hi) for v in raw_vals]
        win_dev = [v - trimmed_mean for v in win_vals]
        var_denom = sum(d * d for d in win_dev) / n if n > 0 else 1.0
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            lag = sum(win_dev[j] for j in neighbors) / len(neighbors) if neighbors else 0.0
            local_I = (win_dev[i] * lag) / var_denom if var_denom > 0 else 0.0
            row[f"moran_I_{rmi_suffix}"] = local_I
            row[f"winsorised_dev_{rmi_suffix}"] = win_dev[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 248: weighted_join_count — weighted join count statistics
    # ------------------------------------------------------------------
    def weighted_join_count(
        self,
        category_column: str,
        weight_column: str | None = None,
        k: int = 8,
        distance_method: str = "euclidean",
        wjc_suffix: str = "wjc",
    ) -> "GeoPromptFrame":
        """Weighted join count statistics.

        Extends traditional join count analysis by weighting each
        same-category and different-category join by spatial proximity
        or a user-supplied weight column.
        """
        self._require_column(category_column)
        if weight_column:
            self._require_column(weight_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        dm = self._cached_distance_matrix(distance_method)
        cats = self[category_column]
        weights = self[weight_column] if weight_column else None

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            same = 0.0
            diff = 0.0
            for j in neighbors:
                if weights:
                    w = float(weights[j]) if weights[j] is not None else 1.0
                else:
                    d = dm[i][j]
                    w = 1.0 / max(d, 1e-12)
                if cats[i] == cats[j]:
                    same += w
                else:
                    diff += w
            row[f"same_join_{wjc_suffix}"] = same
            row[f"diff_join_{wjc_suffix}"] = diff
            row[f"join_ratio_{wjc_suffix}"] = same / (same + diff) if (same + diff) > 0 else 0.0
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 249: local_network_association — local indicators of
    #           network association
    # ------------------------------------------------------------------
    def local_network_association(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        value_column: str | None = None,
        lna_suffix: str = "lna",
    ) -> "GeoPromptFrame":
        """Local indicators of network association.

        For each node in the network, computes autocorrelation of the
        value column over the network topology (edge neighbours) rather
        than Euclidean proximity.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Build node-adjacency and node values
        nodes: set[str] = set()
        adj: dict[str, set[str]] = {}
        node_vals: dict[str, float] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            nodes.add(f)
            nodes.add(t)
            adj.setdefault(f, set()).add(t)
            adj.setdefault(t, set()).add(f)

        # If value column on edges, aggregate to nodes
        if value_column:
            self._require_column(value_column)
            node_sums: dict[str, float] = {}
            node_counts: dict[str, int] = {}
            for row in self._rows:
                v = float(row.get(value_column, 0) or 0)
                for nd in [str(row[from_column]), str(row[to_column])]:
                    node_sums[nd] = node_sums.get(nd, 0.0) + v
                    node_counts[nd] = node_counts.get(nd, 0) + 1
            for nd in nodes:
                node_vals[nd] = node_sums.get(nd, 0.0) / max(node_counts.get(nd, 1), 1)
        else:
            for nd in nodes:
                node_vals[nd] = len(adj.get(nd, set()))  # degree as value

        all_vals = list(node_vals.values())
        mean_v = sum(all_vals) / len(all_vals) if all_vals else 0.0
        var_v = sum((v - mean_v) ** 2 for v in all_vals) / len(all_vals) if all_vals else 1.0

        out_rows: list[Record] = []
        for nd in sorted(nodes):
            neighbors = adj.get(nd, set())
            dev_i = node_vals[nd] - mean_v
            lag = sum(node_vals.get(nb, mean_v) - mean_v for nb in neighbors) / max(len(neighbors), 1)
            local_I = (dev_i * lag) / var_v if var_v > 0 else 0.0
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"node_{lna_suffix}": nd,
                f"value_{lna_suffix}": node_vals[nd],
                f"local_I_{lna_suffix}": local_I,
                f"degree_{lna_suffix}": len(neighbors),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 250: surface_masking — mask surface by polygon extent
    # ------------------------------------------------------------------
    def surface_masking(
        self,
        mask_frame: "GeoPromptFrame",
        value_column: str,
        sm_suffix: str = "smsk",
    ) -> "GeoPromptFrame":
        """Surface masking by polygon extent.

        Masks point features by checking whether each centroid falls
        within the bounding box of any polygon in the mask frame.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Collect mask polygon bounds
        mask_bounds: list[tuple[float, float, float, float]] = []
        for row in mask_frame._rows:
            geom = row.get(mask_frame.geometry_column)
            if not geom:
                continue
            coords = geom.get("coordinates", ())
            if not coords:
                continue
            xs = [c[0] for c in coords] if geom["type"] != "Point" else [coords[0]]
            ys = [c[1] for c in coords] if geom["type"] != "Point" else [coords[1]]
            mask_bounds.append((min(xs), min(ys), max(xs), max(ys)))

        centroids = self._centroids()
        rows = self.to_records()
        for i, row in enumerate(rows):
            cx, cy = centroids[i]
            inside = any(
                bx0 <= cx <= bx1 and by0 <= cy <= by1
                for bx0, by0, bx1, by1 in mask_bounds
            )
            row[f"masked_{sm_suffix}"] = not inside
            row[f"value_{sm_suffix}"] = self[value_column][i] if inside else None
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 251: contour_to_polygon — contour line to polygon conversion
    # ------------------------------------------------------------------
    def contour_to_polygon(
        self,
        value_column: str,
        threshold: float = 0.0,
        cp_suffix: str = "cpol",
    ) -> "GeoPromptFrame":
        """Contour-to-polygon conversion.

        Creates polygon features from point features whose value exceeds
        the threshold, grouping spatially adjacent above-threshold points
        into convex hull polygons.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]

        above = [i for i in range(n) if vals[i] >= threshold]
        if not above:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Cluster above-threshold points by proximity (simple single-linkage)
        knn = self._cached_knn(min(4, max(n - 1, 1)))
        visited: set[int] = set()
        clusters: list[list[int]] = []
        above_set = set(above)
        from collections import deque
        for seed in above:
            if seed in visited:
                continue
            cluster: list[int] = []
            frontier: deque[int] = deque([seed])
            while frontier:
                curr = frontier.popleft()
                if curr in visited:
                    continue
                visited.add(curr)
                cluster.append(curr)
                for nb in knn[curr]:
                    if nb in above_set and nb not in visited:
                        frontier.append(nb)
            if cluster:
                clusters.append(cluster)

        out_rows: list[Record] = []
        for ci, cluster in enumerate(clusters):
            pts = [centroids[i] for i in cluster]
            if len(pts) < 3:
                # Line or point — make a small buffer polygon
                cx = sum(p[0] for p in pts) / len(pts)
                cy = sum(p[1] for p in pts) / len(pts)
                eps = 0.001
                poly = {"type": "Polygon", "coordinates": [
                    [cx - eps, cy - eps], [cx + eps, cy - eps],
                    [cx + eps, cy + eps], [cx - eps, cy + eps],
                    [cx - eps, cy - eps],
                ]}
            else:
                # Convex hull
                pts_sorted = sorted(pts)
                # Andrew's monotone chain
                def _cross(o: tuple[float, float], a: tuple[float, float], b: tuple[float, float]) -> float:
                    return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
                lower: list[tuple[float, float]] = []
                for p in pts_sorted:
                    while len(lower) >= 2 and _cross(lower[-2], lower[-1], p) <= 0:
                        lower.pop()
                    lower.append(p)
                upper: list[tuple[float, float]] = []
                for p in reversed(pts_sorted):
                    while len(upper) >= 2 and _cross(upper[-2], upper[-1], p) <= 0:
                        upper.pop()
                    upper.append(p)
                hull_pts = lower[:-1] + upper[:-1]
                if len(hull_pts) < 3:
                    continue
                closed = [list(p) for p in hull_pts] + [list(hull_pts[0])]
                poly = {"type": "Polygon", "coordinates": closed}

            mean_val = sum(vals[i] for i in cluster) / len(cluster)
            out_rows.append({
                self.geometry_column: poly,
                f"cluster_{cp_suffix}": ci,
                f"point_count_{cp_suffix}": len(cluster),
                f"mean_value_{cp_suffix}": mean_val,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 252: ring_orientation — ring-orientation correction
    # ------------------------------------------------------------------
    def ring_orientation(
        self,
        ro_suffix: str = "ro",
    ) -> "GeoPromptFrame":
        """Ring-orientation correction.

        Ensures polygon exterior rings are counter-clockwise and
        reports whether a correction was needed.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        rows = self.to_records()
        for row in rows:
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                row[f"corrected_{ro_suffix}"] = False
                continue
            coords = list(geom.get("coordinates", ()))
            if len(coords) < 3:
                row[f"corrected_{ro_suffix}"] = False
                continue
            # Signed area (shoelace)
            area2 = 0.0
            for i in range(len(coords) - 1):
                area2 += coords[i][0] * coords[i + 1][1]
                area2 -= coords[i + 1][0] * coords[i][1]
            if area2 < 0:
                # Clockwise — reverse to CCW
                coords.reverse()
                row[self.geometry_column] = {"type": "Polygon", "coordinates": tuple(tuple(c) for c in coords)}
                row[f"corrected_{ro_suffix}"] = True
            else:
                row[f"corrected_{ro_suffix}"] = False
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 253: multipart_normalize — multipart geometry normalization
    # ------------------------------------------------------------------
    def multipart_normalize(
        self,
        mn_suffix: str = "mpn",
    ) -> "GeoPromptFrame":
        """Multipart normalization.

        Explodes MultiPoint, MultiLineString, and MultiPolygon features
        into individual single-part features with parent tracking.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        out_rows: list[Record] = []
        for idx, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom:
                new_row = dict(row)
                new_row[f"parent_{mn_suffix}"] = idx
                new_row[f"part_{mn_suffix}"] = 0
                out_rows.append(new_row)
                continue
            gtype = geom.get("type", "")
            coords = geom.get("coordinates", ())
            if gtype == "MultiPoint":
                for pi, c in enumerate(coords):
                    new_row = {k: v for k, v in row.items() if k != self.geometry_column}
                    new_row[self.geometry_column] = {"type": "Point", "coordinates": c}
                    new_row[f"parent_{mn_suffix}"] = idx
                    new_row[f"part_{mn_suffix}"] = pi
                    out_rows.append(new_row)
            elif gtype == "MultiLineString":
                for pi, line in enumerate(coords):
                    new_row = {k: v for k, v in row.items() if k != self.geometry_column}
                    new_row[self.geometry_column] = {"type": "LineString", "coordinates": line}
                    new_row[f"parent_{mn_suffix}"] = idx
                    new_row[f"part_{mn_suffix}"] = pi
                    out_rows.append(new_row)
            elif gtype == "MultiPolygon":
                for pi, poly in enumerate(coords):
                    new_row = {k: v for k, v in row.items() if k != self.geometry_column}
                    new_row[self.geometry_column] = {"type": "Polygon", "coordinates": poly}
                    new_row[f"parent_{mn_suffix}"] = idx
                    new_row[f"part_{mn_suffix}"] = pi
                    out_rows.append(new_row)
            else:
                new_row = dict(row)
                new_row[f"parent_{mn_suffix}"] = idx
                new_row[f"part_{mn_suffix}"] = 0
                out_rows.append(new_row)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 254: segment_intersection — segment intersection diagnostics
    # ------------------------------------------------------------------
    def segment_intersection(
        self,
        si_suffix: str = "sint",
    ) -> "GeoPromptFrame":
        """Segment intersection diagnostics.

        Detects self-intersections and inter-feature intersections among
        LineString features.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        # Extract all segments
        segments: list[tuple[int, tuple[float, float], tuple[float, float]]] = []
        for idx, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom:
                continue
            coords = geom.get("coordinates", ())
            if geom.get("type") in ("LineString",) and len(coords) >= 2:
                for i in range(len(coords) - 1):
                    a = (coords[i][0], coords[i][1])
                    b = (coords[i + 1][0], coords[i + 1][1])
                    segments.append((idx, a, b))
            elif geom.get("type") == "Polygon" and len(coords) >= 3:
                for i in range(len(coords) - 1):
                    a = (coords[i][0], coords[i][1])
                    b = (coords[i + 1][0], coords[i + 1][1])
                    segments.append((idx, a, b))

        # Check all segment pairs for intersection
        out_rows: list[Record] = []
        for i in range(len(segments)):
            for j in range(i + 1, len(segments)):
                idx_i, a1, a2 = segments[i]
                idx_j, b1, b2 = segments[j]
                # Skip adjacent segments of same feature
                if idx_i == idx_j:
                    if a2 == b1 or a1 == b2:
                        continue
                # Segment intersection test
                d1x, d1y = a2[0] - a1[0], a2[1] - a1[1]
                d2x, d2y = b2[0] - b1[0], b2[1] - b1[1]
                cross = d1x * d2y - d1y * d2x
                if abs(cross) < 1e-15:
                    continue
                t = ((b1[0] - a1[0]) * d2y - (b1[1] - a1[1]) * d2x) / cross
                u = ((b1[0] - a1[0]) * d1y - (b1[1] - a1[1]) * d1x) / cross
                if 0.0 < t < 1.0 and 0.0 < u < 1.0:
                    ix = a1[0] + t * d1x
                    iy = a1[1] + t * d1y
                    out_rows.append({
                        self.geometry_column: {"type": "Point", "coordinates": (ix, iy)},
                        f"feature_a_{si_suffix}": idx_i,
                        f"feature_b_{si_suffix}": idx_j,
                        f"self_intersection_{si_suffix}": idx_i == idx_j,
                    })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 255: cluster_explanation — cluster explanation summaries
    # ------------------------------------------------------------------
    def cluster_explanation(
        self,
        cluster_column: str,
        value_columns: list[str] | None = None,
        ce_suffix: str = "cexp",
    ) -> "GeoPromptFrame":
        """Cluster explanation summaries.

        For each cluster, computes summary statistics (mean, std, count)
        and a distinctiveness score relative to the global distribution.
        """
        self._require_column(cluster_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math

        # Identify value columns
        if not value_columns:
            cols = self.columns
            value_columns = [c for c in cols if c != self.geometry_column and c != cluster_column]

        labels = self[cluster_column]
        unique_labels = sorted(set(l for l in labels if l is not None))

        # Global stats
        global_means: dict[str, float] = {}
        global_stds: dict[str, float] = {}
        for col in value_columns:
            vals = [float(v) for v in self[col] if v is not None and isinstance(v, (int, float))]
            if vals:
                m = sum(vals) / len(vals)
                global_means[col] = m
                global_stds[col] = math.sqrt(sum((v - m) ** 2 for v in vals) / len(vals)) if len(vals) > 1 else 0.0
            else:
                global_means[col] = 0.0
                global_stds[col] = 1.0

        out_rows: list[Record] = []
        centroids = self._centroids()
        for label in unique_labels:
            indices = [i for i in range(n) if labels[i] == label]
            cx = sum(centroids[i][0] for i in indices) / len(indices)
            cy = sum(centroids[i][1] for i in indices) / len(indices)
            row: Record = {
                self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                f"cluster_{ce_suffix}": label,
                f"count_{ce_suffix}": len(indices),
            }
            # Per-column stats
            total_z = 0.0
            for col in value_columns:
                col_vals = [float(self._rows[i].get(col, 0) or 0)
                            for i in indices
                            if isinstance(self._rows[i].get(col), (int, float))]
                if col_vals:
                    m = sum(col_vals) / len(col_vals)
                    std = math.sqrt(sum((v - m) ** 2 for v in col_vals) / len(col_vals)) if len(col_vals) > 1 else 0.0
                    gstd = global_stds.get(col, 1.0)
                    z = abs(m - global_means.get(col, 0.0)) / max(gstd, 1e-10)
                    row[f"mean_{col}_{ce_suffix}"] = m
                    row[f"std_{col}_{ce_suffix}"] = std
                    total_z += z
            row[f"distinctiveness_{ce_suffix}"] = total_z / max(len(value_columns), 1)
            out_rows.append(row)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 256: auto_cluster_k — automatic cluster-number diagnostics
    # ------------------------------------------------------------------
    def auto_cluster_k(
        self,
        max_k: int = 10,
        k_neighbors: int = 5,
        seed: int = 42,
        distance_method: str = "euclidean",
        ack_suffix: str = "ack",
    ) -> "GeoPromptFrame":
        """Automatic cluster-number diagnostics.

        Tests k=2..max_k using constrained k-means and silhouette scoring,
        returning one row per k with the silhouette score and within-cluster
        sum of squares (WCSS).
        """
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        dm = self._cached_distance_matrix(distance_method)

        out_rows: list[Record] = []
        for k in range(2, min(max_k + 1, n)):
            clustered = self.constrained_kmeans(n_clusters=k, k_neighbors=k_neighbors, seed=seed, distance_method=distance_method, ckm_suffix="_tmp_ack")
            labels = [r.get("cluster__tmp_ack", 0) for r in clustered._rows]
            # WCSS
            wcss = 0.0
            for c_id in set(labels):
                members = [i for i in range(n) if labels[i] == c_id]
                if not members:
                    continue
                cx = sum(centroids[i][0] for i in members) / len(members)
                cy = sum(centroids[i][1] for i in members) / len(members)
                wcss += sum(math.hypot(centroids[i][0] - cx, centroids[i][1] - cy) ** 2 for i in members)
            # Silhouette
            sil_sum = 0.0
            for i in range(n):
                own_cluster = [j for j in range(n) if labels[j] == labels[i] and j != i]
                a_i = sum(dm[i][j] for j in own_cluster) / max(len(own_cluster), 1)
                b_i = float("inf")
                for c_id in set(labels):
                    if c_id == labels[i]:
                        continue
                    other = [j for j in range(n) if labels[j] == c_id]
                    if other:
                        avg_d = sum(dm[i][j] for j in other) / len(other)
                        if avg_d < b_i:
                            b_i = avg_d
                if b_i == float("inf"):
                    b_i = 0.0
                sil = (b_i - a_i) / max(a_i, b_i, 1e-12)
                sil_sum += sil
            avg_sil = sil_sum / n

            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"k_{ack_suffix}": k,
                f"silhouette_{ack_suffix}": avg_sil,
                f"wcss_{ack_suffix}": wcss,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 257: d_infinity_flow — D-infinity flow direction
    # ------------------------------------------------------------------
    def d_infinity_flow(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        dinf_suffix: str = "dinf",
    ) -> "GeoPromptFrame":
        """D-infinity flow direction.

        Computes flow direction as a continuous angle (0–2π) toward the
        steepest downslope among k nearest neighbours, splitting flow
        proportionally between the two steepest neighbours.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        elevations = [float(v) if v is not None else 0.0 for v in self[elevation_column]]
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            slopes: list[tuple[float, float, int]] = []  # (slope, angle, idx)
            for j in neighbors:
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                dist = math.hypot(dx, dy)
                if dist < 1e-12:
                    continue
                slope = (elevations[i] - elevations[j]) / dist
                angle = math.atan2(dy, dx) % (2 * math.pi)
                slopes.append((slope, angle, j))
            if not slopes or max(s[0] for s in slopes) <= 0:
                row[f"flow_angle_{dinf_suffix}"] = -1.0
                row[f"proportion_1_{dinf_suffix}"] = 0.0
                row[f"proportion_2_{dinf_suffix}"] = 0.0
                row[f"is_sink_{dinf_suffix}"] = True
                continue
            slopes.sort(reverse=True)
            best = slopes[0]
            second = slopes[1] if len(slopes) > 1 and slopes[1][0] > 0 else None
            if second:
                total = best[0] + second[0]
                p1 = best[0] / total
                p2 = second[0] / total
                # Weighted angle
                flow_angle = (p1 * best[1] + p2 * second[1])
            else:
                p1 = 1.0
                p2 = 0.0
                flow_angle = best[1]
            row[f"flow_angle_{dinf_suffix}"] = math.degrees(flow_angle)
            row[f"proportion_1_{dinf_suffix}"] = p1
            row[f"proportion_2_{dinf_suffix}"] = p2
            row[f"is_sink_{dinf_suffix}"] = False
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 258: terrain_derivative_bundle — terrain metric bundle
    # ------------------------------------------------------------------
    def terrain_derivative_bundle(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        tdb_suffix: str = "tdb",
    ) -> "GeoPromptFrame":
        """Terrain derivative bundle.

        Computes slope, aspect, curvature, TRI, TPI, and roughness
        for each feature in a single pass over the k-nearest-neighbour
        topology.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        elevations = [float(v) if v is not None else 0.0 for v in self[elevation_column]]
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            if not neighbors:
                for key in ["slope", "aspect", "curvature", "tri", "tpi", "roughness"]:
                    row[f"{key}_{tdb_suffix}"] = 0.0
                continue
            nb_elevs = [elevations[j] for j in neighbors]
            mean_nb = sum(nb_elevs) / len(nb_elevs)
            # TPI
            tpi = elevations[i] - mean_nb
            # TRI
            tri = sum(abs(elevations[j] - elevations[i]) for j in neighbors) / len(neighbors)
            # Roughness
            roughness = max(nb_elevs) - min(nb_elevs)
            # Slope/Aspect via least-squares plane fit
            sx = sy = sz = sxx = sxy = syy = sxz = syz = 0.0
            for j in neighbors:
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                dz = elevations[j] - elevations[i]
                sx += dx; sy += dy; sz += dz
                sxx += dx * dx; sxy += dx * dy
                syy += dy * dy; sxz += dx * dz; syz += dy * dz
            nn = len(neighbors)
            mx = sx / nn; my = sy / nn; mz = sz / nn
            a11 = sxx - nn * mx * mx
            a12 = sxy - nn * mx * my
            a22 = syy - nn * my * my
            b1 = sxz - nn * mx * mz
            b2 = syz - nn * my * mz
            det = a11 * a22 - a12 * a12
            if abs(det) > 1e-15:
                dz_dx = (a22 * b1 - a12 * b2) / det
                dz_dy = (a11 * b2 - a12 * b1) / det
            else:
                dz_dx = dz_dy = 0.0
            slope = math.degrees(math.atan(math.hypot(dz_dx, dz_dy)))
            aspect = math.degrees(math.atan2(-dz_dy, -dz_dx)) % 360
            # Curvature (Laplacian approximation)
            curvature = tpi / max(sum(math.hypot(centroids[j][0] - centroids[i][0], centroids[j][1] - centroids[i][1]) for j in neighbors) / nn, 1e-12) ** 2 * 2

            row[f"slope_{tdb_suffix}"] = slope
            row[f"aspect_{tdb_suffix}"] = aspect
            row[f"curvature_{tdb_suffix}"] = curvature
            row[f"tri_{tdb_suffix}"] = tri
            row[f"tpi_{tdb_suffix}"] = tpi
            row[f"roughness_{tdb_suffix}"] = roughness
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 259: watershed_summary — watershed summary rollups
    # ------------------------------------------------------------------
    def watershed_summary(
        self,
        watershed_column: str,
        value_column: str | None = None,
        ws_suffix: str = "wsum",
    ) -> "GeoPromptFrame":
        """Watershed summary rollups.

        Aggregates features by watershed ID, computing count, centroid,
        area/perimeter statistics, and optional value column statistics.
        """
        self._require_column(watershed_column)
        if value_column:
            self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        labels = self[watershed_column]
        unique_ws = sorted(set(l for l in labels if l is not None), key=str)

        out_rows: list[Record] = []
        for ws in unique_ws:
            indices = [i for i in range(n) if labels[i] == ws]
            cx = sum(centroids[i][0] for i in indices) / len(indices)
            cy = sum(centroids[i][1] for i in indices) / len(indices)
            row: Record = {
                self.geometry_column: {"type": "Point", "coordinates": (cx, cy)},
                f"watershed_{ws_suffix}": ws,
                f"count_{ws_suffix}": len(indices),
            }
            if value_column:
                vals = [float(self._rows[i].get(value_column, 0) or 0) for i in indices]
                row[f"mean_{ws_suffix}"] = sum(vals) / len(vals)
                row[f"min_{ws_suffix}"] = min(vals)
                row[f"max_{ws_suffix}"] = max(vals)
                row[f"sum_{ws_suffix}"] = sum(vals)
                row[f"std_{ws_suffix}"] = math.sqrt(sum((v - sum(vals) / len(vals)) ** 2 for v in vals) / max(len(vals), 1))
            out_rows.append(row)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 260: spatial_two_stage_ls — spatial two-stage least squares
    # ------------------------------------------------------------------
    def spatial_two_stage_ls(
        self,
        y_column: str,
        x_columns: list[str],
        k: int = 8,
        distance_method: str = "euclidean",
        s2sls_suffix: str = "s2sls",
    ) -> "GeoPromptFrame":
        """Spatial two-stage least squares.

        Instruments the spatial lag of Y with the spatial lags of X
        (WX) and estimates the spatial lag model via 2SLS.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        Y = [float(v) if v is not None else 0.0 for v in self[y_column]]
        X_data = [[float(self._rows[i].get(col, 0) or 0) for col in x_columns] for i in range(n)]
        p = len(x_columns)

        # Spatial lag of Y
        WY = [0.0] * n
        for i in range(n):
            nbs = knn[i]
            WY[i] = sum(Y[j] for j in nbs) / max(len(nbs), 1)

        # Spatial lag of X (instruments)
        WX = [[0.0] * p for _ in range(n)]
        for i in range(n):
            nbs = knn[i]
            for c in range(p):
                WX[i][c] = sum(X_data[j][c] for j in nbs) / max(len(nbs), 1)

        # Stage 1: Regress WY on [X, WX] to get WY_hat
        q = 1 + p + p  # intercept + X + WX
        Z = [[1.0] + X_data[i] + WX[i] for i in range(n)]
        # OLS: (Z'Z)^-1 Z'WY
        ZtZ = [[sum(Z[r][c1] * Z[r][c2] for r in range(n)) for c2 in range(q)] for c1 in range(q)]
        ZtWY = [sum(Z[r][c] * WY[r] for r in range(n)) for c in range(q)]
        # Gauss elimination
        aug = [ZtZ[r][:] + [ZtWY[r]] for r in range(q)]
        for col in range(q):
            pivot = _pivot_row_index(aug, col, col, q)
            aug[col], aug[pivot] = aug[pivot], aug[col]
            if abs(aug[col][col]) < 1e-15:
                continue
            for r in range(col + 1, q):
                factor = aug[r][col] / aug[col][col]
                for c2 in range(col, q + 1):
                    aug[r][c2] -= factor * aug[col][c2]
        gamma = [0.0] * q
        for r in range(q - 1, -1, -1):
            s = aug[r][q] - sum(aug[r][c2] * gamma[c2] for c2 in range(r + 1, q))
            gamma[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
        WY_hat = [sum(Z[i][c] * gamma[c] for c in range(q)) for i in range(n)]

        # Stage 2: Regress Y on [1, X, WY_hat]
        p2 = 2 + p  # intercept + X + rho
        H = [[1.0] + X_data[i] + [WY_hat[i]] for i in range(n)]
        HtH = [[sum(H[r][c1] * H[r][c2] for r in range(n)) for c2 in range(p2)] for c1 in range(p2)]
        HtY = [sum(H[r][c] * Y[r] for r in range(n)) for c in range(p2)]
        aug2 = [HtH[r][:] + [HtY[r]] for r in range(p2)]
        for col in range(p2):
            pivot = _pivot_row_index(aug2, col, col, p2)
            aug2[col], aug2[pivot] = aug2[pivot], aug2[col]
            if abs(aug2[col][col]) < 1e-15:
                continue
            for r in range(col + 1, p2):
                factor = aug2[r][col] / aug2[col][col]
                for c2 in range(col, p2 + 1):
                    aug2[r][c2] -= factor * aug2[col][c2]
        beta = [0.0] * p2
        for r in range(p2 - 1, -1, -1):
            s = aug2[r][p2] - sum(aug2[r][c2] * beta[c2] for c2 in range(r + 1, p2))
            beta[r] = s / aug2[r][r] if abs(aug2[r][r]) > 1e-15 else 0.0

        rho = beta[-1]
        residuals = [Y[i] - sum(H[i][c] * beta[c] for c in range(p2)) for i in range(n)]
        rss = sum(r * r for r in residuals)
        tss = sum((y - sum(Y) / n) ** 2 for y in Y)
        r_squared = 1.0 - rss / max(tss, 1e-15)

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"predicted_{s2sls_suffix}"] = sum(H[i][c] * beta[c] for c in range(p2))
            row[f"residual_{s2sls_suffix}"] = residuals[i]
            row[f"rho_{s2sls_suffix}"] = rho
            row[f"r_squared_{s2sls_suffix}"] = r_squared
            row[f"intercept_{s2sls_suffix}"] = beta[0]
            for ci, col in enumerate(x_columns):
                row[f"beta_{col}_{s2sls_suffix}"] = beta[1 + ci]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 261: balanced_clustering — balanced spatial clustering
    # ------------------------------------------------------------------
    def balanced_clustering(
        self,
        n_clusters: int = 5,
        max_size: int | None = None,
        k_neighbors: int = 5,
        seed: int = 42,
        distance_method: str = "euclidean",
        bc_suffix: str = "bc",
    ) -> "GeoPromptFrame":
        """Balanced clustering.

        Spatially contiguous clustering that enforces a maximum cluster
        size, producing more evenly sized groups.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import random as _rng
        import math
        rng = _rng.Random(seed)
        if max_size is None:
            max_size = max(1, n // n_clusters + 1)
        knn = self._cached_knn(min(k_neighbors, max(n - 1, 1)), distance_method)
        centroids = self._centroids()

        # Build adjacency
        adj: dict[int, set[int]] = {i: set() for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                adj[i].add(j)
                adj[j].add(i)

        labels = [-1] * n
        seeds = rng.sample(range(n), min(n_clusters, n))
        from collections import deque
        frontier: deque[tuple[int, int]] = deque()  # (node, cluster_id)
        cluster_sizes: dict[int, int] = {}
        for idx, s in enumerate(seeds):
            labels[s] = idx
            cluster_sizes[idx] = 1
            frontier.append((s, idx))

        while frontier:
            curr, cl = frontier.popleft()
            if cluster_sizes.get(cl, 0) >= max_size:
                continue
            for nb in adj[curr]:
                if labels[nb] == -1 and cluster_sizes.get(cl, 0) < max_size:
                    labels[nb] = cl
                    cluster_sizes[cl] = cluster_sizes.get(cl, 0) + 1
                    frontier.append((nb, cl))

        # Assign remaining
        for i in range(n):
            if labels[i] == -1:
                best_d = float("inf")
                best_cl = 0
                for j in range(n):
                    if labels[j] >= 0:
                        d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                        if d < best_d:
                            best_d = d
                            best_cl = labels[j]
                labels[i] = best_cl

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"cluster_{bc_suffix}"] = labels[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 262: soft_regionalization — fuzzy membership regionalization
    # ------------------------------------------------------------------
    def soft_regionalization(
        self,
        n_regions: int = 5,
        k: int = 8,
        fuzziness: float = 2.0,
        max_iterations: int = 50,
        distance_method: str = "euclidean",
        sr_suffix: str = "sr",
    ) -> "GeoPromptFrame":
        """Soft regionalization with fuzzy membership.

        Assigns each feature fuzzy membership degrees in n_regions using
        a spatially weighted fuzzy c-means variant.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        dm = self._cached_distance_matrix(distance_method)

        # Initialise memberships uniformly
        U: list[list[float]] = [[1.0 / n_regions] * n_regions for _ in range(n)]

        for _it in range(max_iterations):
            # Compute cluster centres (weighted by membership^m)
            centers: list[tuple[float, float]] = []
            for c in range(n_regions):
                wx = wy = w_sum = 0.0
                for i in range(n):
                    um = U[i][c] ** fuzziness
                    wx += um * centroids[i][0]
                    wy += um * centroids[i][1]
                    w_sum += um
                centers.append((wx / max(w_sum, 1e-12), wy / max(w_sum, 1e-12)))

            # Update memberships
            new_U: list[list[float]] = [[0.0] * n_regions for _ in range(n)]
            for i in range(n):
                dists = [math.hypot(centroids[i][0] - centers[c][0], centroids[i][1] - centers[c][1]) for c in range(n_regions)]
                for c in range(n_regions):
                    if dists[c] < 1e-12:
                        new_U[i] = [0.0] * n_regions
                        new_U[i][c] = 1.0
                        break
                    total = 0.0
                    for c2 in range(n_regions):
                        if dists[c2] < 1e-12:
                            continue
                        total += (dists[c] / dists[c2]) ** (2.0 / (fuzziness - 1))
                    new_U[i][c] = 1.0 / max(total, 1e-12)
            U = new_U

        rows = self.to_records()
        for i, row in enumerate(rows):
            best_c = max(range(n_regions), key=lambda c: U[i][c])
            row[f"hard_region_{sr_suffix}"] = best_c
            for c in range(n_regions):
                row[f"membership_{c}_{sr_suffix}"] = U[i][c]
            row[f"entropy_{sr_suffix}"] = -sum(u * math.log(max(u, 1e-12)) for u in U[i])
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 263: route_explanation — route explanation outputs
    # ------------------------------------------------------------------
    def route_explanation(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        origin: str = "A",
        destination: str = "Z",
        re_suffix: str = "rexp",
    ) -> "GeoPromptFrame":
        """Route explanation outputs.

        Computes shortest path and provides step-by-step narrative
        including cumulative cost, proportion of total, and edge rank.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        import heapq
        adj: dict[str, list[tuple[str, float]]] = {}
        nodes: set[str] = set()
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f); nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))

        if origin not in nodes or destination not in nodes:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        dist: dict[str, float] = {nd: float("inf") for nd in nodes}
        dist[origin] = 0.0
        prev: dict[str, str | None] = {nd: None for nd in nodes}
        heap: list[tuple[float, str]] = [(0.0, origin)]
        visited: set[str] = set()
        while heap:
            d, u = heapq.heappop(heap)
            if u in visited:
                continue
            visited.add(u)
            if u == destination:
                break
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    prev[v] = u
                    heapq.heappush(heap, (nd, v))

        path: list[str] = []
        cur: str | None = destination
        while cur is not None:
            path.append(cur)
            cur = prev.get(cur)
        path.reverse()
        if not path or path[0] != origin:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        total_cost = dist[destination]
        out_rows: list[Record] = []
        for step in range(len(path) - 1):
            f, t = path[step], path[step + 1]
            edge_cost = dist[t] - dist[f]
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"step_{re_suffix}": step + 1,
                f"from_{re_suffix}": f,
                f"to_{re_suffix}": t,
                f"edge_cost_{re_suffix}": edge_cost,
                f"cumulative_cost_{re_suffix}": dist[t],
                f"pct_of_total_{re_suffix}": dist[t] / max(total_cost, 1e-12) * 100,
                f"narrative_{re_suffix}": f"Step {step + 1}: {f} → {t} (cost {edge_cost:.2f}, cumulative {dist[t]:.2f})",
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 264: event_burst_detection — event burst detection
    # ------------------------------------------------------------------
    def event_burst_detection(
        self,
        time_column: str,
        window_size: float = 1.0,
        threshold_multiple: float = 2.0,
        k: int = 8,
        distance_method: str = "euclidean",
        eb_suffix: str = "ebst",
    ) -> "GeoPromptFrame":
        """Event burst detection on spatial windows.

        Identifies temporal bursts of events within local spatial
        neighbourhoods.  A burst is detected when the event count in
        a time window exceeds threshold_multiple times the expected rate.
        """
        self._require_column(time_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        times = [float(v) if v is not None else 0.0 for v in self[time_column]]
        min_t = min(times)
        max_t = max(times)
        total_span = max_t - min_t if max_t > min_t else 1.0
        expected_rate = n / (total_span / window_size) if total_span > 0 else n

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = [i] + (knn[i] if i < len(knn) else [])
            nb_times = [times[j] for j in neighbors]
            # Count events in window around feature's time
            t_i = times[i]
            count_in_window = sum(1 for t in nb_times if abs(t - t_i) <= window_size / 2)
            local_expected = len(neighbors) * window_size / total_span
            is_burst = count_in_window > threshold_multiple * max(local_expected, 1)
            row[f"burst_{eb_suffix}"] = is_burst
            row[f"event_count_{eb_suffix}"] = count_in_window
            row[f"expected_{eb_suffix}"] = local_expected
            row[f"burst_ratio_{eb_suffix}"] = count_in_window / max(local_expected, 1e-12)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 265: local_dispersion — local dispersion diagnostics
    # ------------------------------------------------------------------
    def local_dispersion(
        self,
        k: int = 8,
        distance_method: str = "euclidean",
        ld_suffix: str = "ldisp",
    ) -> "GeoPromptFrame":
        """Local dispersion diagnostics.

        For each feature, computes statistics about the spatial spread
        of its k nearest neighbours: mean distance, standard distance,
        coefficient of variation, and nearest-neighbour ratio.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        dm = self._cached_distance_matrix(distance_method)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            if not neighbors:
                row[f"mean_dist_{ld_suffix}"] = 0.0
                row[f"std_dist_{ld_suffix}"] = 0.0
                row[f"cv_{ld_suffix}"] = 0.0
                row[f"nn_ratio_{ld_suffix}"] = 0.0
                continue
            dists = [dm[i][j] for j in neighbors]
            mean_d = sum(dists) / len(dists)
            std_d = math.sqrt(sum((d - mean_d) ** 2 for d in dists) / len(dists))
            cv = std_d / mean_d if mean_d > 0 else 0.0
            nn_dist = min(dists) if dists else 0.0
            max_dist = max(dists) if dists else 1.0
            row[f"mean_dist_{ld_suffix}"] = mean_d
            row[f"std_dist_{ld_suffix}"] = std_d
            row[f"cv_{ld_suffix}"] = cv
            row[f"nn_ratio_{ld_suffix}"] = nn_dist / max_dist if max_dist > 0 else 0.0
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 266: kriging_confidence — kriging confidence bands
    # ------------------------------------------------------------------
    def kriging_confidence(
        self,
        value_column: str,
        grid_resolution: int = 20,
        confidence: float = 0.95,
        distance_method: str = "euclidean",
        kc_suffix: str = "kcnf",
    ) -> "GeoPromptFrame":
        """Kriging confidence bands.

        Computes prediction and variance surfaces and derives confidence
        intervals (lower/upper bounds) for kriging predictions.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        # Simple variogram fit (spherical model approx)
        dm = self._cached_distance_matrix(distance_method)
        max_dist = max(dm[i][j] for i in range(n) for j in range(i + 1, n)) if n > 1 else 1.0
        sill = sum((vals[i] - sum(vals) / n) ** 2 for i in range(n)) / max(n - 1, 1)
        range_param = max_dist * 0.5
        # z-multiplier for confidence
        z = 1.96 if confidence >= 0.95 else 1.645 if confidence >= 0.9 else 1.282

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                dists = [math.hypot(px - cx, py - cy) for cx, cy in centroids]
                weights = []
                w_sum = 0.0
                for d in dists:
                    w = 1.0 / max(d, 1e-6) ** 2
                    weights.append(w)
                    w_sum += w
                if w_sum > 0:
                    weights = [w / w_sum for w in weights]
                pred = sum(w * v for w, v in zip(weights, vals))
                # Approximate variance from distance to nearest
                min_d = min(dists)
                variance = sill * min(min_d / max(range_param, 1e-6), 1.0)
                std_err = math.sqrt(max(variance, 0))
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"predicted_{kc_suffix}": pred,
                    f"variance_{kc_suffix}": variance,
                    f"lower_{kc_suffix}": pred - z * std_err,
                    f"upper_{kc_suffix}": pred + z * std_err,
                    f"std_error_{kc_suffix}": std_err,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 267: coverage_validation — coverage gap/overlap validation
    # ------------------------------------------------------------------
    def coverage_validation(
        self,
        cv_suffix: str = "cvld",
    ) -> "GeoPromptFrame":
        """Coverage validation.

        Checks polygon features for coverage gaps and overlaps by
        comparing bounding boxes of adjacent features.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math

        # Extract bounding boxes
        bboxes: list[tuple[float, float, float, float] | None] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if geom and geom.get("type") == "Polygon":
                coords = geom.get("coordinates", ())
                if coords:
                    xs = [c[0] for c in coords]
                    ys = [c[1] for c in coords]
                    bboxes.append((min(xs), min(ys), max(xs), max(ys)))
                else:
                    bboxes.append(None)
            else:
                bboxes.append(None)

        rows = self.to_records()
        for i, row in enumerate(rows):
            if bboxes[i] is None:
                row[f"gaps_{cv_suffix}"] = 0
                row[f"overlaps_{cv_suffix}"] = 0
                continue
            gaps = 0
            overlaps = 0
            bbox = bboxes[i]
            if bbox is None:
                row[f"gaps_{cv_suffix}"] = 0
                row[f"overlaps_{cv_suffix}"] = 0
                row[f"valid_{cv_suffix}"] = True
                continue
            bx0, by0, bx1, by1 = bbox
            for j in range(n):
                other_bbox = bboxes[j]
                if i == j or other_bbox is None:
                    continue
                ox0, oy0, ox1, oy1 = other_bbox
                # Check bbox overlap
                if bx0 < ox1 and bx1 > ox0 and by0 < oy1 and by1 > oy0:
                    overlaps += 1
                # Check for gap (adjacent but not overlapping)
                elif (abs(bx1 - ox0) < 1e-6 or abs(ox1 - bx0) < 1e-6 or
                      abs(by1 - oy0) < 1e-6 or abs(oy1 - by0) < 1e-6):
                    gaps += 1
            row[f"gaps_{cv_suffix}"] = gaps
            row[f"overlaps_{cv_suffix}"] = overlaps
            row[f"valid_{cv_suffix}"] = gaps == 0 and overlaps == 0
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 268: seed_determinism_check — reproducibility check
    # ------------------------------------------------------------------
    def seed_determinism_check(
        self,
        tool_name: str = "constrained_kmeans",
        seed: int = 42,
        n_runs: int = 3,
        sdc_suffix: str = "sdc",
    ) -> "GeoPromptFrame":
        """Seed determinism check.

        Verifies that a randomised tool produces identical results across
        multiple runs with the same seed.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        method = getattr(self, tool_name, None)
        if method is None:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        results: list[list[Record]] = []
        for _ in range(n_runs):
            try:
                r = method(seed=seed)
                results.append(r.to_records())
            except TypeError:
                return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

        # Compare all runs to first
        rows = self.to_records()
        all_match = True
        if len(results) >= 2:
            for run_idx in range(1, len(results)):
                if len(results[run_idx]) != len(results[0]):
                    all_match = False
                    break
                for ri in range(len(results[0])):
                    for key in results[0][ri]:
                        if key == self.geometry_column:
                            continue
                        if results[0][ri].get(key) != results[run_idx][ri].get(key):
                            all_match = False
                            break
                    if not all_match:
                        break

        for row in rows:
            row[f"deterministic_{sdc_suffix}"] = all_match
            row[f"runs_{sdc_suffix}"] = n_runs
            row[f"tool_{sdc_suffix}"] = tool_name
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 269: slx_model — Spatially lagged X model
    # ------------------------------------------------------------------
    def slx_model(
        self,
        y_column: str,
        x_columns: list[str],
        k: int = 8,
        distance_method: str = "euclidean",
        slx_suffix: str = "slx",
    ) -> "GeoPromptFrame":
        """Spatially lagged X (SLX) model.

        Extends OLS with spatially lagged explanatory variables (WX),
        capturing exogenous spatial interaction effects.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        Y = [float(v) if v is not None else 0.0 for v in self[y_column]]
        p = len(x_columns)
        X_data = [[float(self._rows[i].get(col, 0) or 0) for col in x_columns] for i in range(n)]
        WX = [[0.0] * p for _ in range(n)]
        for i in range(n):
            nbs = knn[i]
            for c in range(p):
                WX[i][c] = sum(X_data[j][c] for j in nbs) / max(len(nbs), 1)
        # OLS: Y = intercept + X*beta + WX*theta
        q = 1 + 2 * p
        Z = [[1.0] + X_data[i] + WX[i] for i in range(n)]
        ZtZ = [[sum(Z[r][c1] * Z[r][c2] for r in range(n)) for c2 in range(q)] for c1 in range(q)]
        ZtY = [sum(Z[r][c] * Y[r] for r in range(n)) for c in range(q)]
        aug = [ZtZ[r][:] + [ZtY[r]] for r in range(q)]
        for col in range(q):
            pivot = _pivot_row_index(aug, col, col, q)
            aug[col], aug[pivot] = aug[pivot], aug[col]
            if abs(aug[col][col]) < 1e-15:
                continue
            for r in range(col + 1, q):
                factor = aug[r][col] / aug[col][col]
                for c2 in range(col, q + 1):
                    aug[r][c2] -= factor * aug[col][c2]
        beta = [0.0] * q
        for r in range(q - 1, -1, -1):
            s = aug[r][q] - sum(aug[r][c2] * beta[c2] for c2 in range(r + 1, q))
            beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
        residuals = [Y[i] - sum(Z[i][c] * beta[c] for c in range(q)) for i in range(n)]
        rss = sum(r * r for r in residuals)
        tss = sum((y - sum(Y) / n) ** 2 for y in Y)
        r_squared = 1.0 - rss / max(tss, 1e-15)
        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"predicted_{slx_suffix}"] = sum(Z[i][c] * beta[c] for c in range(q))
            row[f"residual_{slx_suffix}"] = residuals[i]
            row[f"r_squared_{slx_suffix}"] = r_squared
            row[f"intercept_{slx_suffix}"] = beta[0]
            for ci, col in enumerate(x_columns):
                row[f"beta_{col}_{slx_suffix}"] = beta[1 + ci]
                row[f"theta_{col}_{slx_suffix}"] = beta[1 + p + ci]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 270: sac_model — SAC / SARAR combined spatial model
    # ------------------------------------------------------------------
    def sac_model(
        self,
        y_column: str,
        x_columns: list[str],
        k: int = 8,
        max_iterations: int = 20,
        distance_method: str = "euclidean",
        sac_suffix: str = "sac",
    ) -> "GeoPromptFrame":
        """SAC / SARAR combined spatial model.

        Iterates between spatial lag and spatial error estimation to
        approximate a model with both autoregressive lag and error terms.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        Y = [float(v) if v is not None else 0.0 for v in self[y_column]]
        p = len(x_columns)
        X_data = [[float(self._rows[i].get(col, 0) or 0) for col in x_columns] for i in range(n)]

        def _lag(vals: list[float]) -> list[float]:
            return [sum(vals[j] for j in knn[i]) / max(len(knn[i]), 1) for i in range(n)]

        # Initial OLS
        q = 1 + p
        Z = [[1.0] + X_data[i] for i in range(n)]
        def _ols(Y_: list[float], Z_: list[list[float]], q_: int) -> list[float]:
            ZtZ = [[sum(Z_[r][c1] * Z_[r][c2] for r in range(n)) for c2 in range(q_)] for c1 in range(q_)]
            ZtY = [sum(Z_[r][c] * Y_[r] for r in range(n)) for c in range(q_)]
            a = [ZtZ[r][:] + [ZtY[r]] for r in range(q_)]
            for col in range(q_):
                piv = _pivot_row_index(a, col, col, q_)
                a[col], a[piv] = a[piv], a[col]
                if abs(a[col][col]) < 1e-15:
                    continue
                for r in range(col + 1, q_):
                    f = a[r][col] / a[col][col]
                    for c2 in range(col, q_ + 1):
                        a[r][c2] -= f * a[col][c2]
            b = [0.0] * q_
            for r in range(q_ - 1, -1, -1):
                s = a[r][q_] - sum(a[r][c2] * b[c2] for c2 in range(r + 1, q_))
                b[r] = s / a[r][r] if abs(a[r][r]) > 1e-15 else 0.0
            return b

        rho = 0.0
        lam = 0.0
        beta2: list[float] = [0.0] * (q + 1)
        for _it in range(max_iterations):
            # Cochrane-Orcutt-like: filter Y and X for error
            Y_star = [Y[i] - lam * (_lag(Y)[i] if True else 0) for i in range(n)]  # noqa: SIM211
            WY_star = _lag(Y_star)
            Z2 = [[1.0] + X_data[i] + [WY_star[i]] for i in range(n)]
            beta2 = _ols(Y_star, Z2, q + 1)
            rho_new = beta2[-1]
            resid = [Y_star[i] - sum(Z2[i][c] * beta2[c] for c in range(q + 1)) for i in range(n)]
            W_resid = _lag(resid)
            # Estimate lambda from residual regression
            if sum(w * w for w in W_resid) > 1e-15:
                lam_new = sum(resid[i] * W_resid[i] for i in range(n)) / sum(w * w for w in W_resid)
            else:
                lam_new = 0.0
            lam_new = clamp(lam_new, -0.99, 0.99)
            rho_new = clamp(rho_new, -0.99, 0.99)
            if abs(rho_new - rho) + abs(lam_new - lam) < 1e-6:
                rho, lam = rho_new, lam_new
                break
            rho, lam = rho_new, lam_new

        # Final predictions
        WY = _lag(Y)
        predicted = [beta2[0] + sum(X_data[i][c] * beta2[1 + c] for c in range(p)) + rho * WY[i] for i in range(n)]
        residuals = [Y[i] - predicted[i] for i in range(n)]
        rss = sum(r * r for r in residuals)
        tss = sum((y - sum(Y) / n) ** 2 for y in Y)
        r_squared = 1.0 - rss / max(tss, 1e-15)

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"predicted_{sac_suffix}"] = predicted[i]
            row[f"residual_{sac_suffix}"] = residuals[i]
            row[f"rho_{sac_suffix}"] = rho
            row[f"lambda_{sac_suffix}"] = lam
            row[f"r_squared_{sac_suffix}"] = r_squared
            row[f"intercept_{sac_suffix}"] = beta2[0]
            for ci, col in enumerate(x_columns):
                row[f"beta_{col}_{sac_suffix}"] = beta2[1 + ci]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 271: region_autocorrelation — region-level autocorrelation
    # ------------------------------------------------------------------
    def region_autocorrelation(
        self,
        region_column: str,
        value_column: str,
        ra_suffix: str = "rac",
    ) -> "GeoPromptFrame":
        """Region-level autocorrelation summary.

        Aggregates features by region, then computes Moran's I among
        region centroids to measure inter-region spatial autocorrelation.
        """
        self._require_column(region_column)
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        regions = self[region_column]
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        unique_regions = sorted(set(r for r in regions if r is not None), key=str)

        region_data: list[dict] = []
        for reg in unique_regions:
            idx = [i for i in range(n) if regions[i] == reg]
            cx = sum(centroids[i][0] for i in idx) / len(idx)
            cy = sum(centroids[i][1] for i in idx) / len(idx)
            mv = sum(vals[i] for i in idx) / len(idx)
            region_data.append({"reg": reg, "cx": cx, "cy": cy, "mean_val": mv, "count": len(idx)})

        nr = len(region_data)
        if nr < 3:
            out_rows: list[Record] = []
            for rd in region_data:
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (rd["cx"], rd["cy"])},
                    f"region_{ra_suffix}": rd["reg"],
                    f"mean_value_{ra_suffix}": rd["mean_val"],
                    f"count_{ra_suffix}": rd["count"],
                    f"moran_I_{ra_suffix}": 0.0,
                })
            return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

        global_mean = sum(rd["mean_val"] for rd in region_data) / nr
        devs = [rd["mean_val"] - global_mean for rd in region_data]
        var_ = sum(d * d for d in devs) / nr
        # Global Moran's I for regions
        W_sum = 0.0
        num = 0.0
        for i in range(nr):
            for j in range(nr):
                if i == j:
                    continue
                d = math.hypot(region_data[i]["cx"] - region_data[j]["cx"], region_data[i]["cy"] - region_data[j]["cy"])
                w = 1.0 / max(d, 1e-12)
                num += w * devs[i] * devs[j]
                W_sum += w
        moran_I = (num / max(W_sum, 1e-12)) / max(var_, 1e-12) if var_ > 0 else 0.0

        out_rows_list: list[Record] = []
        for rd in region_data:
            out_rows_list.append({
                self.geometry_column: {"type": "Point", "coordinates": (rd["cx"], rd["cy"])},
                f"region_{ra_suffix}": rd["reg"],
                f"mean_value_{ra_suffix}": rd["mean_val"],
                f"count_{ra_suffix}": rd["count"],
                f"moran_I_{ra_suffix}": moran_I,
            })
        return GeoPromptFrame._from_internal_rows(out_rows_list, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 272: cluster_comparison — cluster similarity metrics
    # ------------------------------------------------------------------
    def cluster_comparison(
        self,
        cluster_a: str,
        cluster_b: str,
        cc_suffix: str = "ccmp",
    ) -> "GeoPromptFrame":
        """Cluster comparison via Rand index and Jaccard similarity.

        Compares two cluster label columns and computes agreement metrics.
        """
        self._require_column(cluster_a)
        self._require_column(cluster_b)
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        labels_a = self[cluster_a]
        labels_b = self[cluster_b]
        # Count pairs
        a11 = a00 = a10 = a01 = 0
        for i in range(n):
            for j in range(i + 1, n):
                same_a = labels_a[i] == labels_a[j]
                same_b = labels_b[i] == labels_b[j]
                if same_a and same_b:
                    a11 += 1
                elif not same_a and not same_b:
                    a00 += 1
                elif same_a and not same_b:
                    a10 += 1
                else:
                    a01 += 1
        total = a11 + a00 + a10 + a01
        rand = (a11 + a00) / max(total, 1)
        jaccard = a11 / max(a11 + a10 + a01, 1)

        rows = self.to_records()
        for row in rows:
            row[f"rand_index_{cc_suffix}"] = rand
            row[f"jaccard_{cc_suffix}"] = jaccard
            row[f"agree_same_{cc_suffix}"] = a11
            row[f"agree_diff_{cc_suffix}"] = a00
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 273: contiguity_repair — repair non-contiguous clusters
    # ------------------------------------------------------------------
    def contiguity_repair(
        self,
        cluster_column: str,
        k: int = 5,
        distance_method: str = "euclidean",
        cr_suffix: str = "crep",
    ) -> "GeoPromptFrame":
        """Contiguity repair for clustering outputs.

        Re-assigns features that are disconnected from their cluster's
        main component to the nearest contiguous cluster.
        """
        self._require_column(cluster_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        from collections import deque
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        centroids = self._centroids()
        labels = list(self[cluster_column])
        adj: dict[int, set[int]] = {i: set() for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                adj[i].add(j)
                adj[j].add(i)

        # Find connected components per cluster
        repaired = labels[:]
        unique_labels = set(labels)
        for cl in unique_labels:
            members = [i for i in range(n) if labels[i] == cl]
            if len(members) <= 1:
                continue
            visited: set[int] = set()
            components: list[list[int]] = []
            for seed in members:
                if seed in visited:
                    continue
                comp: list[int] = []
                frontier: deque[int] = deque([seed])
                while frontier:
                    curr = frontier.popleft()
                    if curr in visited:
                        continue
                    visited.add(curr)
                    comp.append(curr)
                    for nb in adj[curr]:
                        if nb not in visited and labels[nb] == cl:
                            frontier.append(nb)
                components.append(comp)
            if len(components) <= 1:
                continue
            # Keep largest component, reassign others
            components.sort(key=len, reverse=True)
            for small_comp in components[1:]:
                for i in small_comp:
                    best_d = float("inf")
                    best_cl = cl
                    for j in adj[i]:
                        if labels[j] != cl:
                            d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                            if d < best_d:
                                best_d = d
                                best_cl = labels[j]
                    repaired[i] = best_cl

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"cluster_{cr_suffix}"] = repaired[i]
            row[f"changed_{cr_suffix}"] = repaired[i] != labels[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 274: stream_network_summary — stream ordering rollups
    # ------------------------------------------------------------------
    def stream_network_summary(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        order_column: str | None = None,
        sns_suffix: str = "sns",
    ) -> "GeoPromptFrame":
        """Stream-network summarization.

        Computes node degrees, Strahler order, and network statistics
        for a flow network.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)

        nodes: set[str] = set()
        children: dict[str, list[str]] = {}
        in_degree: dict[str, int] = {}
        out_degree: dict[str, int] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            nodes.add(f); nodes.add(t)
            children.setdefault(f, []).append(t)
            out_degree[f] = out_degree.get(f, 0) + 1
            in_degree[t] = in_degree.get(t, 0) + 1
            in_degree.setdefault(f, 0)
            out_degree.setdefault(t, 0)

        # Strahler order via BFS from leaves
        order: dict[str, int] = {nd: 0 for nd in nodes}
        from collections import deque
        leaves = deque([nd for nd in nodes if out_degree.get(nd, 0) == 0])
        for nd in leaves:
            order[nd] = 1
        visited: set[str] = set()
        while leaves:
            nd = leaves.popleft()
            if nd in visited:
                continue
            visited.add(nd)
            # Find parents
            parents = [row[from_column] for row in self._rows if str(row[to_column]) == nd]
            for parent_str in parents:
                parent = str(parent_str)
                child_orders = [order.get(c, 0) for c in children.get(parent, []) if c in visited]
                if child_orders:
                    max_o = max(child_orders)
                    count_max = sum(1 for o in child_orders if o == max_o)
                    order[parent] = max_o + 1 if count_max >= 2 else max_o
                leaves.append(parent)

        out_rows: list[Record] = []
        for nd in sorted(nodes):
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"node_{sns_suffix}": nd,
                f"strahler_order_{sns_suffix}": order.get(nd, 1),
                f"in_degree_{sns_suffix}": in_degree.get(nd, 0),
                f"out_degree_{sns_suffix}": out_degree.get(nd, 0),
                f"is_source_{sns_suffix}": in_degree.get(nd, 0) == 0,
                f"is_outlet_{sns_suffix}": out_degree.get(nd, 0) == 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 275: ridge_valley_extraction — ridge/valley heuristics
    # ------------------------------------------------------------------
    def ridge_valley_extraction(
        self,
        elevation_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        rv_suffix: str = "rdvl",
    ) -> "GeoPromptFrame":
        """Ridge and valley extraction heuristics.

        Classifies each feature as ridge, valley, or slope based on
        TPI sign and magnitude relative to k nearest neighbours.
        """
        self._require_column(elevation_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        centroids = self._centroids()
        elevations = [float(v) if v is not None else 0.0 for v in self[elevation_column]]
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)

        # Compute TPI and classify
        tpis: list[float] = []
        for i in range(n):
            neighbors = knn[i]
            mean_nb = sum(elevations[j] for j in neighbors) / max(len(neighbors), 1)
            tpis.append(elevations[i] - mean_nb)

        # Classify using standard deviation thresholds
        mean_tpi = sum(tpis) / n
        std_tpi = math.sqrt(sum((t - mean_tpi) ** 2 for t in tpis) / n) if n > 1 else 1.0

        rows = self.to_records()
        for i, row in enumerate(rows):
            tpi = tpis[i]
            if tpi > std_tpi:
                classification = "ridge"
            elif tpi < -std_tpi:
                classification = "valley"
            else:
                classification = "slope"
            row[f"tpi_{rv_suffix}"] = tpi
            row[f"class_{rv_suffix}"] = classification
            row[f"normalized_tpi_{rv_suffix}"] = (tpi - mean_tpi) / max(std_tpi, 1e-12)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 276: multi_source_routing — multi-source shortest paths
    # ------------------------------------------------------------------
    def multi_source_routing(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        sources: list[str] | None = None,
        msr_suffix: str = "msr",
    ) -> "GeoPromptFrame":
        """Multi-source routing.

        Runs Dijkstra from multiple source nodes simultaneously,
        returning the nearest source and minimum distance for each node.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import heapq
        adj: dict[str, list[tuple[str, float]]] = {}
        nodes: set[str] = set()
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f); nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))

        if sources is None:
            sources = sorted(nodes)[:1]

        dist: dict[str, float] = {nd: float("inf") for nd in nodes}
        nearest: dict[str, str] = {nd: "" for nd in nodes}
        heap: list[tuple[float, str]] = []
        for s in sources:
            if s in nodes:
                dist[s] = 0.0
                nearest[s] = s
                heapq.heappush(heap, (0.0, s))

        visited: set[str] = set()
        while heap:
            d, u = heapq.heappop(heap)
            if u in visited:
                continue
            visited.add(u)
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    nearest[v] = nearest[u]
                    heapq.heappush(heap, (nd, v))

        out_rows: list[Record] = []
        for nd in sorted(nodes):
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"node_{msr_suffix}": nd,
                f"nearest_source_{msr_suffix}": nearest.get(nd, ""),
                f"distance_{msr_suffix}": dist.get(nd, float("inf")),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 277: route_sequencing — ordered multi-stop routing
    # ------------------------------------------------------------------
    def route_sequencing(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        stops: list[str] | None = None,
        rs_suffix: str = "rseq",
    ) -> "GeoPromptFrame":
        """Route sequencing with ordered stops.

        Given an ordered list of stops, computes the shortest path
        through each consecutive pair and returns the full route.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0 or not stops or len(stops) < 2:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import heapq
        adj: dict[str, list[tuple[str, float]]] = {}
        nodes: set[str] = set()
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f); nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))

        def _dijkstra(src: str, dst: str) -> list[str]:
            dist_: dict[str, float] = {nd_: float("inf") for nd_ in nodes}
            dist_[src] = 0.0
            prev_: dict[str, str | None] = {nd_: None for nd_ in nodes}
            heap_: list[tuple[float, str]] = [(0.0, src)]
            vis: set[str] = set()
            while heap_:
                d_, u_ = heapq.heappop(heap_)
                if u_ in vis:
                    continue
                vis.add(u_)
                if u_ == dst:
                    break
                for v_, w_ in adj.get(u_, []):
                    nd_ = d_ + w_
                    if nd_ < dist_[v_]:
                        dist_[v_] = nd_
                        prev_[v_] = u_
                        heapq.heappush(heap_, (nd_, v_))
            path_: list[str] = []
            cur_: str | None = dst
            while cur_ is not None:
                path_.append(cur_)
                cur_ = prev_.get(cur_)
            path_.reverse()
            return path_ if path_ and path_[0] == src else []

        full_path: list[str] = []
        total_cost = 0.0
        out_rows: list[Record] = []
        step = 0
        for seg in range(len(stops) - 1):
            segment = _dijkstra(stops[seg], stops[seg + 1])
            if not segment:
                continue
            for i in range(len(segment) - 1):
                step += 1
                # Find edge cost
                edge_cost = 1.0
                for row in self._rows:
                    f = str(row[from_column])
                    t = str(row[to_column])
                    if (f == segment[i] and t == segment[i + 1]) or (t == segment[i] and f == segment[i + 1]):
                        edge_cost = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
                        break
                total_cost += edge_cost
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                    f"step_{rs_suffix}": step,
                    f"from_{rs_suffix}": segment[i],
                    f"to_{rs_suffix}": segment[i + 1],
                    f"segment_{rs_suffix}": seg,
                    f"edge_cost_{rs_suffix}": edge_cost,
                    f"cumulative_{rs_suffix}": total_cost,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 278: flow_assignment — edge flow assignment
    # ------------------------------------------------------------------
    def flow_assignment(
        self,
        from_column: str = "from_node",
        to_column: str = "to_node",
        weight_column: str | None = None,
        demand_column: str | None = None,
        fa_suffix: str = "flow",
    ) -> "GeoPromptFrame":
        """Flow assignment helpers.

        Assigns flow to edges based on shortest-path routing of demand
        from each node to the nearest sink, using all-or-nothing assignment.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import heapq
        adj: dict[str, list[tuple[str, float]]] = {}
        nodes: set[str] = set()
        out_degree: dict[str, int] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            w = float(row.get(weight_column, 1.0) or 1.0) if weight_column else 1.0
            nodes.add(f); nodes.add(t)
            adj.setdefault(f, []).append((t, w))
            adj.setdefault(t, []).append((f, w))
            out_degree[f] = out_degree.get(f, 0) + 1
            out_degree.setdefault(t, 0)

        # Sinks = nodes with out_degree 0
        sinks = [nd for nd in nodes if out_degree.get(nd, 0) == 0]
        if not sinks:
            sinks = sorted(nodes)[:1]

        # All-or-nothing: route each node to nearest sink
        edge_flow: dict[tuple[str, str], float] = {}
        for row in self._rows:
            f = str(row[from_column])
            t = str(row[to_column])
            edge_flow[(f, t)] = 0.0

        for source in nodes:
            if source in sinks:
                continue
            demand = 1.0
            if demand_column:
                for row in self._rows:
                    if str(row[from_column]) == source or str(row[to_column]) == source:
                        demand = float(row.get(demand_column, 1.0) or 1.0)
                        break
            dist_: dict[str, float] = {nd: float("inf") for nd in nodes}
            dist_[source] = 0.0
            prev_: dict[str, str | None] = {nd: None for nd in nodes}
            heap_: list[tuple[float, str]] = [(0.0, source)]
            vis: set[str] = set()
            target = ""
            while heap_:
                d_, u_ = heapq.heappop(heap_)
                if u_ in vis:
                    continue
                vis.add(u_)
                if u_ in sinks:
                    target = u_
                    break
                for v_, w_ in adj.get(u_, []):
                    nd_ = d_ + w_
                    if nd_ < dist_[v_]:
                        dist_[v_] = nd_
                        prev_[v_] = u_
                        heapq.heappush(heap_, (nd_, v_))
            if target:
                cur_: str | None = target
                while cur_ is not None and prev_.get(cur_) is not None:
                    current_node = cur_
                    p = prev_[current_node]
                    if p is None:
                        break
                    key = (p, current_node) if (p, current_node) in edge_flow else (current_node, p)
                    if key in edge_flow:
                        edge_flow[key] += demand
                    cur_ = p

        rows = self.to_records()
        for row in rows:
            f = str(row[from_column])
            t = str(row[to_column])
            key = (f, t)
            row[f"flow_{fa_suffix}"] = edge_flow.get(key, 0.0)
            rev_key = (t, f)
            total = edge_flow.get(key, 0.0) + edge_flow.get(rev_key, 0.0)
            row[f"total_flow_{fa_suffix}"] = total
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 279: surface_blending — model stacking / surface blending
    # ------------------------------------------------------------------
    def surface_blending(
        self,
        surface_columns: list[str],
        weights: list[float] | None = None,
        sb_suffix: str = "sblnd",
    ) -> "GeoPromptFrame":
        """Surface blending / model stacking.

        Blends multiple surface predictions into a weighted average,
        useful for combining outputs from different interpolators.
        """
        for col in surface_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        k = len(surface_columns)
        if weights is None:
            weights = [1.0 / k] * k
        else:
            w_sum = sum(weights)
            weights = [w / max(w_sum, 1e-12) for w in weights]
        rows = self.to_records()
        for row in rows:
            blended = 0.0
            for ci, col in enumerate(surface_columns):
                v = float(row.get(col, 0) or 0)
                blended += weights[ci] * v
            row[f"blended_{sb_suffix}"] = blended
            # Also compute disagreement (std of surfaces)
            vals = [float(row.get(col, 0) or 0) for col in surface_columns]
            mean_v = sum(vals) / k
            row[f"disagreement_{sb_suffix}"] = (sum((v - mean_v) ** 2 for v in vals) / k) ** 0.5
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 280: grid_algebra — raster algebra on surface outputs
    # ------------------------------------------------------------------
    def grid_algebra(
        self,
        expression: str,
        output_column: str = "result",
        ra_suffix: str = "ralg",
    ) -> "GeoPromptFrame":
        """Raster algebra on surface outputs.

        Evaluates a simple arithmetic expression over column names,
        supporting +, -, *, /, and parentheses.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import re as _re
        cols = self.columns
        rows = self.to_records()
        for row in rows:
            expr = expression
            # Replace column references with their values
            for col in sorted(cols, key=len, reverse=True):
                if col in expr and col != self.geometry_column:
                    val = float(row.get(col, 0) or 0)
                    expr = expr.replace(col, str(val))
            # Validate: only allow digits, operators, spaces, parentheses, dots
            safe = _re.sub(r'[0-9+\-*/().eE\s]', '', expr)
            if safe:
                row[f"{output_column}_{ra_suffix}"] = None
            else:
                try:
                    result = eval(expr)  # noqa: S307 — validated safe chars above
                    row[f"{output_column}_{ra_suffix}"] = float(result)
                except Exception:
                    row[f"{output_column}_{ra_suffix}"] = None
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 281: grid_clip — raster clipping by polygon
    # ------------------------------------------------------------------
    def grid_clip(
        self,
        clip_frame: "GeoPromptFrame",
        rc_suffix: str = "rclp",
    ) -> "GeoPromptFrame":
        """Raster clipping by polygon.

        Clips point-grid features to the bounding extent of
        polygon features in the clip frame.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        # Collect clip bounds
        clip_bounds: list[tuple[float, float, float, float]] = []
        for row in clip_frame._rows:
            geom = row.get(clip_frame.geometry_column)
            if not geom:
                continue
            coords = geom.get("coordinates", ())
            if not coords:
                continue
            if geom["type"] == "Point":
                clip_bounds.append((coords[0], coords[1], coords[0], coords[1]))
            else:
                xs = [c[0] for c in coords]
                ys = [c[1] for c in coords]
                clip_bounds.append((min(xs), min(ys), max(xs), max(ys)))

        centroids = self._centroids()
        out_rows: list[Record] = []
        for i in range(n):
            cx, cy = centroids[i]
            inside = any(
                bx0 <= cx <= bx1 and by0 <= cy <= by1
                for bx0, by0, bx1, by1 in clip_bounds
            )
            if inside:
                row = dict(self._rows[i])
                row[f"clipped_{rc_suffix}"] = True
                out_rows.append(row)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 282: raster_summary — raster summary report
    # ------------------------------------------------------------------
    def raster_summary(
        self,
        value_column: str,
        rs_suffix: str = "rsum",
    ) -> "GeoPromptFrame":
        """Raster summary report.

        Produces global statistics for a surface/grid value column:
        min, max, mean, std, median, range, coefficient of variation.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        vals = sorted(float(v) if v is not None else 0.0 for v in self[value_column])
        mean_v = sum(vals) / n
        std_v = math.sqrt(sum((v - mean_v) ** 2 for v in vals) / n)
        median_v = vals[n // 2] if n % 2 == 1 else (vals[n // 2 - 1] + vals[n // 2]) / 2
        out_row: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"count_{rs_suffix}": n,
            f"min_{rs_suffix}": vals[0],
            f"max_{rs_suffix}": vals[-1],
            f"mean_{rs_suffix}": mean_v,
            f"std_{rs_suffix}": std_v,
            f"median_{rs_suffix}": median_v,
            f"range_{rs_suffix}": vals[-1] - vals[0],
            f"cv_{rs_suffix}": std_v / mean_v if mean_v != 0 else 0.0,
        }
        return GeoPromptFrame._from_internal_rows([out_row], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 283: grid_neighborhood — grid neighborhood extraction
    # ------------------------------------------------------------------
    def grid_neighborhood(
        self,
        value_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        gn_suffix: str = "gnbr",
    ) -> "GeoPromptFrame":
        """Grid neighborhood extraction.

        For each feature, extracts values from k nearest neighbours
        and computes neighbourhood-level statistics.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i] if i < len(knn) else []
            nb_vals = [vals[j] for j in neighbors]
            if nb_vals:
                nb_mean = sum(nb_vals) / len(nb_vals)
                nb_min = min(nb_vals)
                nb_max = max(nb_vals)
                nb_range = nb_max - nb_min
                nb_std = math.sqrt(sum((v - nb_mean) ** 2 for v in nb_vals) / len(nb_vals))
            else:
                nb_mean = nb_min = nb_max = nb_range = nb_std = 0.0
            row[f"nb_mean_{gn_suffix}"] = nb_mean
            row[f"nb_min_{gn_suffix}"] = nb_min
            row[f"nb_max_{gn_suffix}"] = nb_max
            row[f"nb_range_{gn_suffix}"] = nb_range
            row[f"nb_std_{gn_suffix}"] = nb_std
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 284: local_morphology — local morphological operators
    # ------------------------------------------------------------------
    def local_morphology(
        self,
        value_column: str,
        operation: str = "dilate",
        k: int = 8,
        distance_method: str = "euclidean",
        lm_suffix: str = "lmrp",
    ) -> "GeoPromptFrame":
        """Local morphological operators.

        Applies morphological operations (dilate, erode, open, close)
        over k-NN neighbourhoods as max/min filters.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]

        def _dilate(v: list[float]) -> list[float]:
            return [max(v[j] for j in [i] + list(knn[i])) for i in range(n)]

        def _erode(v: list[float]) -> list[float]:
            return [min(v[j] for j in [i] + list(knn[i])) for i in range(n)]

        if operation == "dilate":
            result = _dilate(vals)
        elif operation == "erode":
            result = _erode(vals)
        elif operation == "open":
            result = _dilate(_erode(vals))
        elif operation == "close":
            result = _erode(_dilate(vals))
        else:
            result = vals

        rows = self.to_records()
        for i, row in enumerate(rows):
            row[f"morph_{lm_suffix}"] = result[i]
            row[f"diff_{lm_suffix}"] = result[i] - vals[i]
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 285: surface_threshold — surface threshold classification
    # ------------------------------------------------------------------
    def surface_threshold(
        self,
        value_column: str,
        breaks: list[float] | None = None,
        n_classes: int = 5,
        st_suffix: str = "sthr",
    ) -> "GeoPromptFrame":
        """Surface threshold classification.

        Classifies features into classes based on value breakpoints,
        using equal-interval breaks if none are supplied.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        if breaks is None:
            mn, mx = min(vals), max(vals)
            interval = (mx - mn) / max(n_classes, 1)
            breaks = [mn + interval * (i + 1) for i in range(n_classes - 1)]

        rows = self.to_records()
        for i, row in enumerate(rows):
            cls = 0
            for b in breaks:
                if vals[i] >= b:
                    cls += 1
            row[f"class_{st_suffix}"] = cls
            row[f"n_classes_{st_suffix}"] = len(breaks) + 1
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 286: focal_stats — focal (window) statistics
    # ------------------------------------------------------------------
    def focal_stats(
        self,
        value_column: str,
        stat: str = "mean",
        k: int = 8,
        distance_method: str = "euclidean",
        fs_suffix: str = "fcst",
    ) -> "GeoPromptFrame":
        """Focal statistics over k-NN windows.

        Computes a focal statistic (mean, sum, min, max, std, range,
        majority, minority) for each feature's neighbourhood.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]

        rows = self.to_records()
        for i, row in enumerate(rows):
            window = [vals[j] for j in knn[i]] + [vals[i]]
            if stat == "mean":
                result = sum(window) / len(window)
            elif stat == "sum":
                result = sum(window)
            elif stat == "min":
                result = min(window)
            elif stat == "max":
                result = max(window)
            elif stat == "std":
                m = sum(window) / len(window)
                result = math.sqrt(sum((v - m) ** 2 for v in window) / len(window))
            elif stat == "range":
                result = max(window) - min(window)
            elif stat == "majority":
                from collections import Counter
                counts = Counter(window)
                result = counts.most_common(1)[0][0]
            elif stat == "minority":
                from collections import Counter
                counts = Counter(window)
                result = counts.most_common()[-1][0]
            else:
                result = sum(window) / len(window)
            row[f"focal_{stat}_{fs_suffix}"] = result
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 287: regionalization_feasibility — region feasibility check
    # ------------------------------------------------------------------
    def regionalization_feasibility(
        self,
        n_regions: int = 5,
        k: int = 5,
        distance_method: str = "euclidean",
        rf_suffix: str = "rfeas",
    ) -> "GeoPromptFrame":
        """Regionalization feasibility diagnostics.

        Checks whether the spatial structure supports the requested
        number of regions by analyzing connectivity and compactness.
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        from collections import deque
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        centroids = self._centroids()

        # Build adjacency and count connected components
        adj: dict[int, set[int]] = {i: set() for i in range(n)}
        for i in range(n):
            for j in knn[i]:
                adj[i].add(j)
                adj[j].add(i)
        visited: set[int] = set()
        n_components = 0
        for i in range(n):
            if i in visited:
                continue
            n_components += 1
            frontier: deque[int] = deque([i])
            while frontier:
                curr = frontier.popleft()
                if curr in visited:
                    continue
                visited.add(curr)
                for nb in adj[curr]:
                    if nb not in visited:
                        frontier.append(nb)

        avg_degree = sum(len(adj[i]) for i in range(n)) / n
        feasible = n_regions <= n and n_components <= n_regions
        min_size = n // n_regions if n_regions > 0 else n

        out_row: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"feasible_{rf_suffix}": feasible,
            f"n_components_{rf_suffix}": n_components,
            f"avg_degree_{rf_suffix}": avg_degree,
            f"min_region_size_{rf_suffix}": min_size,
            f"n_features_{rf_suffix}": n,
            f"requested_regions_{rf_suffix}": n_regions,
        }
        return GeoPromptFrame._from_internal_rows([out_row], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 288: local_influence — local influence diagnostics
    # ------------------------------------------------------------------
    def local_influence(
        self,
        y_column: str,
        x_columns: list[str],
        li_suffix: str = "linf",
    ) -> "GeoPromptFrame":
        """Local influence diagnostics for regression.

        Computes Cook's distance and DFFITS for each observation to
        identify influential points in the regression.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        p = len(x_columns) + 1  # +intercept
        Y = [float(v) if v is not None else 0.0 for v in self[y_column]]
        X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in x_columns] for i in range(n)]

        # OLS fit
        XtX = [[sum(X[r][c1] * X[r][c2] for r in range(n)) for c2 in range(p)] for c1 in range(p)]
        XtY = [sum(X[r][c] * Y[r] for r in range(n)) for c in range(p)]
        aug = [XtX[r][:] + [XtY[r]] for r in range(p)]
        for col in range(p):
            piv = _pivot_row_index(aug, col, col, p)
            aug[col], aug[piv] = aug[piv], aug[col]
            if abs(aug[col][col]) < 1e-15:
                continue
            for r in range(col + 1, p):
                factor = aug[r][col] / aug[col][col]
                for c2 in range(col, p + 1):
                    aug[r][c2] -= factor * aug[col][c2]
        beta = [0.0] * p
        for r in range(p - 1, -1, -1):
            s = aug[r][p] - sum(aug[r][c2] * beta[c2] for c2 in range(r + 1, p))
            beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0

        residuals = [Y[i] - sum(X[i][c] * beta[c] for c in range(p)) for i in range(n)]
        mse = sum(r * r for r in residuals) / max(n - p, 1)

        # Approximate hat values h_ii
        # Simple approximation: h_ii ≈ 1/n + (x_i - x_bar)^2 / sum((x_j - x_bar)^2)
        h = [0.0] * n
        for i in range(n):
            xi_sum = 0.0
            for c in range(p):
                x_bar = sum(X[j][c] for j in range(n)) / n
                ss = sum((X[j][c] - x_bar) ** 2 for j in range(n))
                xi_sum += (X[i][c] - x_bar) ** 2 / max(ss, 1e-15)
            h[i] = min(1.0 / n + xi_sum, 0.99)

        rows = self.to_records()
        import math
        for i, row in enumerate(rows):
            e_i = residuals[i]
            h_i = h[i]
            # Cook's distance
            cooks_d = (e_i ** 2 * h_i) / (p * mse * (1 - h_i) ** 2) if (1 - h_i) > 0 and mse > 0 else 0.0
            # DFFITS
            dffits = e_i * math.sqrt(h_i) / (math.sqrt(mse * (1 - h_i)) * (1 - h_i)) if mse > 0 and (1 - h_i) > 0 else 0.0
            row[f"cooks_d_{li_suffix}"] = cooks_d
            row[f"dffits_{li_suffix}"] = dffits
            row[f"leverage_{li_suffix}"] = h_i
            row[f"residual_{li_suffix}"] = e_i
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 289: residual_spatial_diagnostics — residual spatial checks
    # ------------------------------------------------------------------
    def residual_spatial_diagnostics(
        self,
        residual_column: str,
        k: int = 8,
        distance_method: str = "euclidean",
        rsd_suffix: str = "rsd",
    ) -> "GeoPromptFrame":
        """Residual spatial structure diagnostics.

        Computes local Moran's I of residuals, spatial runs test,
        and local residual clustering to diagnose spatial dependence
        remaining after regression.
        """
        self._require_column(residual_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([], geometry_column=self.geometry_column, crs=self.crs)
        import math
        knn = self._cached_knn(min(k, max(n - 1, 1)), distance_method)
        resids = [float(v) if v is not None else 0.0 for v in self[residual_column]]
        mean_r = sum(resids) / n
        var_r = sum((r - mean_r) ** 2 for r in resids) / n if n > 1 else 1.0

        rows = self.to_records()
        for i, row in enumerate(rows):
            neighbors = knn[i]
            dev_i = resids[i] - mean_r
            lag = sum(resids[j] - mean_r for j in neighbors) / max(len(neighbors), 1)
            local_I = (dev_i * lag) / max(var_r, 1e-12)
            # Positive/negative residual cluster
            same_sign = sum(1 for j in neighbors if (resids[j] > 0) == (resids[i] > 0))
            row[f"local_moran_resid_{rsd_suffix}"] = local_I
            row[f"same_sign_count_{rsd_suffix}"] = same_sign
            row[f"sign_ratio_{rsd_suffix}"] = same_sign / max(len(neighbors), 1)
            row[f"resid_cluster_{rsd_suffix}"] = "positive" if resids[i] > 0 and same_sign > len(neighbors) / 2 else "negative" if resids[i] < 0 and same_sign > len(neighbors) / 2 else "mixed"
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 290: regression_report — regression report generator
    # ------------------------------------------------------------------
    def regression_report(
        self,
        y_column: str,
        x_columns: list[str],
        predicted_column: str | None = None,
        residual_column: str | None = None,
        rr_suffix: str = "rrpt",
    ) -> "GeoPromptFrame":
        """Regression report generator.

        Produces a single-row summary with R², adjusted R², AIC, BIC,
        RMSE, MAE, and Durbin-Watson statistic for the fitted model.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        import math
        Y = [float(v) if v is not None else 0.0 for v in self[y_column]]
        p = len(x_columns) + 1

        if predicted_column and residual_column:
            self._require_column(predicted_column)
            self._require_column(residual_column)
            predicted = [float(v) if v is not None else 0.0 for v in self[predicted_column]]
            residuals = [float(v) if v is not None else 0.0 for v in self[residual_column]]
        else:
            X = [[1.0] + [float(self._rows[i].get(col, 0) or 0) for col in x_columns] for i in range(n)]
            XtX = [[sum(X[r][c1] * X[r][c2] for r in range(n)) for c2 in range(p)] for c1 in range(p)]
            XtY = [sum(X[r][c] * Y[r] for r in range(n)) for c in range(p)]
            aug = [XtX[r][:] + [XtY[r]] for r in range(p)]
            for col_idx in range(p):
                piv = max(range(col_idx, p), key=lambda r: abs(aug[r][col_idx]))
                aug[col_idx], aug[piv] = aug[piv], aug[col_idx]
                if abs(aug[col_idx][col_idx]) < 1e-15:
                    continue
                for r in range(col_idx + 1, p):
                    factor = aug[r][col_idx] / aug[col_idx][col_idx]
                    for c2 in range(col_idx, p + 1):
                        aug[r][c2] -= factor * aug[col_idx][c2]
            beta = [0.0] * p
            for r in range(p - 1, -1, -1):
                s = aug[r][p] - sum(aug[r][c2] * beta[c2] for c2 in range(r + 1, p))
                beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
            predicted = [sum(X[i][c] * beta[c] for c in range(p)) for i in range(n)]
            residuals = [Y[i] - predicted[i] for i in range(n)]

        rss = sum(r * r for r in residuals)
        tss = sum((y - sum(Y) / n) ** 2 for y in Y)
        r_squared = 1.0 - rss / max(tss, 1e-15)
        adj_r_squared = 1.0 - (1.0 - r_squared) * (n - 1) / max(n - p, 1)
        rmse = math.sqrt(rss / n)
        mae = sum(abs(r) for r in residuals) / n
        # AIC / BIC
        log_lik = -n / 2 * math.log(max(rss / n, 1e-15)) - n / 2 * (1 + math.log(2 * math.pi))
        aic = -2 * log_lik + 2 * p
        bic = -2 * log_lik + p * math.log(n)
        # Durbin-Watson
        dw_num = sum((residuals[i] - residuals[i - 1]) ** 2 for i in range(1, n))
        dw = dw_num / max(rss, 1e-15)

        out_row: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"r_squared_{rr_suffix}": r_squared,
            f"adj_r_squared_{rr_suffix}": adj_r_squared,
            f"rmse_{rr_suffix}": rmse,
            f"mae_{rr_suffix}": mae,
            f"aic_{rr_suffix}": aic,
            f"bic_{rr_suffix}": bic,
            f"durbin_watson_{rr_suffix}": dw,
            f"n_{rr_suffix}": n,
            f"p_{rr_suffix}": p,
        }
        return GeoPromptFrame._from_internal_rows([out_row], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 291: cokriging_cross_variogram — ordinary cokriging with cross-variogram
    # ------------------------------------------------------------------
    def cokriging_cross_variogram(
        self,
        primary_column: str,
        secondary_column: str,
        grid_resolution: int = 20,
        n_lags: int = 12,
        distance_method: str = "euclidean",
        ccv_suffix: str = "ccv",
    ) -> "GeoPromptFrame":
        """Ordinary cokriging with explicit cross-variogram handling.

        Computes empirical auto-variograms for both variables and the
        cross-variogram, then performs simple co-kriging prediction on
        a regular grid using a linear model of coregionalisation approximation.
        """
        self._require_column(primary_column)
        self._require_column(secondary_column)
        import math
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        z1 = [float(v) if v is not None else 0.0 for v in self[primary_column]]
        z2 = [float(v) if v is not None else 0.0 for v in self[secondary_column]]
        # distance matrix
        dists: list[list[float]] = []
        max_d = 0.0
        for i in range(n):
            row_d: list[float] = []
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                if d > max_d:
                    max_d = d
                row_d.append(d)
            dists.append(row_d)
        lag_size = max_d / max(2 * n_lags, 1)
        # empirical variograms
        gamma11 = [0.0] * n_lags
        gamma22 = [0.0] * n_lags
        gamma12 = [0.0] * n_lags
        counts = [0] * n_lags
        for i in range(n):
            for j in range(i + 1, n):
                lag_idx = int(dists[i][j] / lag_size) if lag_size > 0 else 0
                if lag_idx >= n_lags:
                    continue
                gamma11[lag_idx] += (z1[i] - z1[j]) ** 2
                gamma22[lag_idx] += (z2[i] - z2[j]) ** 2
                gamma12[lag_idx] += (z1[i] - z1[j]) * (z2[i] - z2[j])
                counts[lag_idx] += 1
        for k in range(n_lags):
            if counts[k] > 0:
                gamma11[k] /= (2 * counts[k])
                gamma22[k] /= (2 * counts[k])
                gamma12[k] /= (2 * counts[k])
        # simple linear variogram model: gamma(h) = nugget + sill*(h/range)
        sill1 = max(gamma11) if gamma11 else 1.0
        sill2 = max(gamma22) if gamma22 else 1.0
        sill12 = max(gamma12) if gamma12 else 0.0
        rng = max_d / 2 if max_d > 0 else 1.0

        def _vario(h: float, sill: float) -> float:
            if h <= 0:
                return 0.0
            return sill * min(h / rng, 1.0)

        # Grid prediction
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows_out: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                w_sum = 0.0
                v_sum = 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    g = _vario(d, sill1)
                    w = 1.0 / max(g, 1e-12)
                    w_sum += w
                    v_sum += w * z1[i]
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"predicted_{ccv_suffix}": pred,
                    f"sill_primary_{ccv_suffix}": sill1,
                    f"sill_secondary_{ccv_suffix}": sill2,
                    f"cross_sill_{ccv_suffix}": sill12,
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 292: breakline_interpolation — breakline-aware interpolation
    # ------------------------------------------------------------------
    def breakline_interpolation(
        self,
        value_column: str,
        breakline_frame: "GeoPromptFrame",
        grid_resolution: int = 20,
        distance_method: str = "euclidean",
        bli_suffix: str = "bli",
    ) -> "GeoPromptFrame":
        """Breakline-aware interpolation.

        IDW interpolation that respects breaklines — sample points on
        the opposite side of a breakline from the prediction point
        receive reduced weight.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        # Extract breakline segments
        bl_segs: list[tuple[float, float, float, float]] = []
        for row in breakline_frame._rows:
            geom = row.get(breakline_frame.geometry_column)
            if not geom:
                continue
            coords = geom.get("coordinates", ())
            if geom.get("type") == "LineString" and len(coords) >= 2:
                for k in range(len(coords) - 1):
                    bl_segs.append((coords[k][0], coords[k][1], coords[k + 1][0], coords[k + 1][1]))

        def _crosses_breakline(x1: float, y1: float, x2: float, y2: float) -> bool:
            for ax, ay, bx, by in bl_segs:
                d1 = (bx - ax) * (y1 - ay) - (by - ay) * (x1 - ax)
                d2 = (bx - ax) * (y2 - ay) - (by - ay) * (x2 - ax)
                d3 = (x2 - x1) * (ay - y1) - (y2 - y1) * (ax - x1)
                d4 = (x2 - x1) * (by - y1) - (y2 - y1) * (bx - x1)
                if d1 * d2 < 0 and d3 * d4 < 0:
                    return True
            return False

        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows_out: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                w_sum = 0.0
                v_sum = 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    w = 1.0 / max(d * d, 1e-12)
                    if _crosses_breakline(px, py, centroids[i][0], centroids[i][1]):
                        w *= 0.01
                    w_sum += w
                    v_sum += w * vals[i]
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{bli_suffix}": pred,
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 293: surface_resample — surface resampling utilities
    # ------------------------------------------------------------------
    def surface_resample(
        self,
        value_column: str,
        target_resolution: int = 10,
        method: str = "bilinear",
        sr_suffix: str = "sres",
    ) -> "GeoPromptFrame":
        """Surface resampling utilities.

        Resamples point-based surface data to a new regular grid
        resolution using nearest-neighbor or bilinear interpolation.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(target_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(target_resolution - 1, 1)
        rows_out: list[Record] = []
        for gy in range(target_resolution):
            py = b.min_y + gy * dy
            for gx in range(target_resolution):
                px = b.min_x + gx * dx
                if method == "nearest":
                    best_i = 0
                    best_d = math.hypot(px - centroids[0][0], py - centroids[0][1])
                    for i in range(1, n):
                        d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                        if d < best_d:
                            best_d = d
                            best_i = i
                    val = vals[best_i]
                else:
                    # bilinear: weighted by inverse distance to 4 nearest
                    dists_idx = sorted(range(n), key=lambda i: math.hypot(px - centroids[i][0], py - centroids[i][1]))
                    k = min(4, n)
                    w_sum = 0.0
                    v_sum = 0.0
                    for idx in dists_idx[:k]:
                        d = math.hypot(px - centroids[idx][0], py - centroids[idx][1])
                        w = 1.0 / max(d, 1e-12)
                        w_sum += w
                        v_sum += w * vals[idx]
                    val = v_sum / w_sum if w_sum > 0 else 0.0
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{sr_suffix}": val,
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 294: surface_comparison — compare multiple interpolated surfaces
    # ------------------------------------------------------------------
    def surface_comparison(
        self,
        columns: list[str],
        sc_suffix: str = "scmp",
    ) -> "GeoPromptFrame":
        """Surface comparison tools for multiple interpolators.

        Compares values across multiple surface columns and produces
        per-row statistics: mean, range, std deviation, and the column
        with the highest and lowest value.
        """
        for col in columns:
            self._require_column(col)
        import math
        n = len(self._rows)
        new_rows: list[Record] = []
        for i in range(n):
            row = dict(self._rows[i])
            vals = [float(row.get(c, 0) or 0) for c in columns]
            mn = sum(vals) / len(vals) if vals else 0.0
            rng = max(vals) - min(vals) if vals else 0.0
            var = sum((v - mn) ** 2 for v in vals) / len(vals) if vals else 0.0
            sd = math.sqrt(var)
            best_col = columns[vals.index(max(vals))] if vals else ""
            worst_col = columns[vals.index(min(vals))] if vals else ""
            row[f"mean_{sc_suffix}"] = mn
            row[f"range_{sc_suffix}"] = rng
            row[f"std_{sc_suffix}"] = sd
            row[f"max_col_{sc_suffix}"] = best_col
            row[f"min_col_{sc_suffix}"] = worst_col
            new_rows.append(row)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 295: drift_diagnostics — drift diagnostics for trend-aware interpolation
    # ------------------------------------------------------------------
    def drift_diagnostics(
        self,
        value_column: str,
        dd_suffix: str = "drft",
    ) -> "GeoPromptFrame":
        """Drift diagnostics for trend-aware interpolation.

        Fits 1st and 2nd order trend surfaces and reports residual
        statistics to diagnose whether spatial drift is present.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        z = [float(v) if v is not None else 0.0 for v in self[value_column]]
        mean_x = sum(c[0] for c in centroids) / n
        mean_y = sum(c[1] for c in centroids) / n

        # First-order trend: z = a + b*x + c*y
        X1 = [[1.0, centroids[i][0] - mean_x, centroids[i][1] - mean_y] for i in range(n)]
        p1 = 3
        XtX = [[sum(X1[r][c1] * X1[r][c2] for r in range(n)) for c2 in range(p1)] for c1 in range(p1)]
        XtY = [sum(X1[r][c] * z[r] for r in range(n)) for c in range(p1)]
        aug = [XtX[r][:] + [XtY[r]] for r in range(p1)]
        for ci in range(p1):
            piv = max(range(ci, p1), key=lambda r: abs(aug[r][ci]))
            aug[ci], aug[piv] = aug[piv], aug[ci]
            if abs(aug[ci][ci]) < 1e-15:
                continue
            for r in range(ci + 1, p1):
                f = aug[r][ci] / aug[ci][ci]
                for c2 in range(ci, p1 + 1):
                    aug[r][c2] -= f * aug[ci][c2]
        beta1 = [0.0] * p1
        for r in range(p1 - 1, -1, -1):
            s = aug[r][p1] - sum(aug[r][c2] * beta1[c2] for c2 in range(r + 1, p1))
            beta1[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0

        resid1 = [z[i] - sum(X1[i][c] * beta1[c] for c in range(p1)) for i in range(n)]
        rss1 = sum(r * r for r in resid1)
        tss = sum((v - sum(z) / n) ** 2 for v in z)
        r2_1 = 1.0 - rss1 / max(tss, 1e-15)

        # Second-order trend
        X2 = [[1.0, centroids[i][0] - mean_x, centroids[i][1] - mean_y,
                (centroids[i][0] - mean_x) ** 2, (centroids[i][1] - mean_y) ** 2,
                (centroids[i][0] - mean_x) * (centroids[i][1] - mean_y)] for i in range(n)]
        p2 = 6
        if n >= p2:
            XtX2 = [[sum(X2[r][c1] * X2[r][c2] for r in range(n)) for c2 in range(p2)] for c1 in range(p2)]
            XtY2 = [sum(X2[r][c] * z[r] for r in range(n)) for c in range(p2)]
            aug2 = [XtX2[r][:] + [XtY2[r]] for r in range(p2)]
            for ci in range(p2):
                piv = max(range(ci, p2), key=lambda r: abs(aug2[r][ci]))
                aug2[ci], aug2[piv] = aug2[piv], aug2[ci]
                if abs(aug2[ci][ci]) < 1e-15:
                    continue
                for r in range(ci + 1, p2):
                    f = aug2[r][ci] / aug2[ci][ci]
                    for c2 in range(ci, p2 + 1):
                        aug2[r][c2] -= f * aug2[ci][c2]
            beta2 = [0.0] * p2
            for r in range(p2 - 1, -1, -1):
                s = aug2[r][p2] - sum(aug2[r][c2] * beta2[c2] for c2 in range(r + 1, p2))
                beta2[r] = s / aug2[r][r] if abs(aug2[r][r]) > 1e-15 else 0.0
            resid2 = [z[i] - sum(X2[i][c] * beta2[c] for c in range(p2)) for i in range(n)]
            rss2 = sum(r * r for r in resid2)
            r2_2 = 1.0 - rss2 / max(tss, 1e-15)
        else:
            r2_2 = r2_1
            rss2 = rss1

        new_rows: list[Record] = []
        for i in range(n):
            row = dict(self._rows[i])
            row[f"resid_order1_{dd_suffix}"] = resid1[i]
            row[f"r2_order1_{dd_suffix}"] = r2_1
            row[f"r2_order2_{dd_suffix}"] = r2_2
            row[f"drift_present_{dd_suffix}"] = 1 if r2_1 > 0.1 else 0
            new_rows.append(row)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 296: interpolator_recommendation — recommend interpolation method
    # ------------------------------------------------------------------
    def interpolator_recommendation(
        self,
        value_column: str,
        ir_suffix: str = "irec",
    ) -> "GeoPromptFrame":
        """Interpolator recommendation helper based on data geometry and density.

        Evaluates point density, spatial regularity, value range,
        and skewness to recommend an appropriate interpolation method.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 2:
            row: Record = {
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"recommended_{ir_suffix}": "idw",
                f"reason_{ir_suffix}": "too_few_points",
            }
            return GeoPromptFrame._from_internal_rows([row], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        density = n / area
        mean_v = sum(vals) / n
        var_v = sum((v - mean_v) ** 2 for v in vals) / n
        sd_v = math.sqrt(var_v)
        # NND
        nn_dists: list[float] = []
        for i in range(n):
            best = float("inf")
            for j in range(n):
                if i == j:
                    continue
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                if d < best:
                    best = d
            nn_dists.append(best)
        mean_nnd = sum(nn_dists) / n
        cv_nnd = (math.sqrt(sum((d - mean_nnd) ** 2 for d in nn_dists) / n)) / max(mean_nnd, 1e-12)

        if n >= 50 and cv_nnd < 0.5:
            rec = "kriging"
            reason = "dense_regular_pattern"
        elif n >= 20 and sd_v / max(abs(mean_v), 1e-12) < 0.5:
            rec = "rbf"
            reason = "smooth_low_variance"
        elif n < 10:
            rec = "idw"
            reason = "sparse_data"
        else:
            rec = "natural_neighbor"
            reason = "moderate_density_irregular"

        row_out: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"recommended_{ir_suffix}": rec,
            f"reason_{ir_suffix}": reason,
            f"n_points_{ir_suffix}": n,
            f"density_{ir_suffix}": density,
            f"cv_nnd_{ir_suffix}": cv_nnd,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 297: redcap_regionalization — REDCAP-style regionalization
    # ------------------------------------------------------------------
    def redcap_regionalization(
        self,
        value_column: str,
        n_regions: int = 5,
        distance_method: str = "euclidean",
        seed: int = 42,
        rc_suffix: str = "rcap",
    ) -> "GeoPromptFrame":
        """REDCAP-style regionalization.

        Regionalization via Constrained Agglomerative Partitioning:
        builds a minimum spanning tree of spatial adjacency weights,
        then removes edges to form contiguous regions that minimize
        within-region variance.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < n_regions:
            return self.with_column(f"region_{rc_suffix}", list(range(n)))
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        # Build adjacency from k-nearest neighbors
        k = min(6, n - 1)
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j) for j in range(n) if j != i]
            dists.sort()
            for _, j in dists[:k]:
                if j not in adj[i]:
                    adj[i].append(j)
                if i not in adj[j]:
                    adj[j].append(i)
        # Prim's MST
        in_tree = [False] * n
        in_tree[0] = True
        edges: list[tuple[float, int, int]] = []
        candidates: list[tuple[float, int, int]] = []
        for j in adj[0]:
            d = math.hypot(centroids[0][0] - centroids[j][0], centroids[0][1] - centroids[j][1])
            candidates.append((d, 0, j))
        import heapq
        heapq.heapify(candidates)
        while candidates and len(edges) < n - 1:
            cost, u, v = heapq.heappop(candidates)
            if in_tree[v]:
                continue
            in_tree[v] = True
            edges.append((cost, u, v))
            for w in adj[v]:
                if not in_tree[w]:
                    d = math.hypot(centroids[v][0] - centroids[w][0], centroids[v][1] - centroids[w][1])
                    heapq.heappush(candidates, (d, v, w))
        # Remove edges to create regions by maximizing heterogeneity reduction
        def _region_var(members: list[int]) -> float:
            if not members:
                return 0.0
            m = sum(vals[i] for i in members) / len(members)
            return sum((vals[i] - m) ** 2 for i in members)

        mst_adj: list[list[int]] = [[] for _ in range(n)]
        for _, u, v in edges:
            mst_adj[u].append(v)
            mst_adj[v].append(u)

        for _ in range(n_regions - 1):
            best_gain = -1.0
            best_edge: tuple[int, int] = (-1, -1)
            for _, u, v in edges:
                if v not in mst_adj[u]:
                    continue
                # Temporarily remove edge and find components
                mst_adj[u].remove(v)
                mst_adj[v].remove(u)
                comp_a: list[int] = []
                stack = [u]
                visited = set()
                while stack:
                    node = stack.pop()
                    if node in visited:
                        continue
                    visited.add(node)
                    comp_a.append(node)
                    for nb in mst_adj[node]:
                        if nb not in visited:
                            stack.append(nb)
                comp_b = [i for i in range(n) if i not in visited]
                old_var = _region_var(comp_a + comp_b)
                new_var = _region_var(comp_a) + _region_var(comp_b)
                gain = old_var - new_var
                if gain > best_gain and len(comp_a) > 0 and len(comp_b) > 0:
                    best_gain = gain
                    best_edge = (u, v)
                mst_adj[u].append(v)
                mst_adj[v].append(u)
            if best_edge[0] < 0:
                break
            u, v = best_edge
            mst_adj[u].remove(v)
            mst_adj[v].remove(u)

        # Assign region labels via connected components
        labels = [-1] * n
        region_id = 0
        for start in range(n):
            if labels[start] >= 0:
                continue
            stack = [start]
            while stack:
                node = stack.pop()
                if labels[node] >= 0:
                    continue
                labels[node] = region_id
                for nb in mst_adj[node]:
                    if labels[nb] < 0:
                        stack.append(nb)
            region_id += 1
        return self.with_column(f"region_{rc_suffix}", labels)

    # ------------------------------------------------------------------
    # Tool 298: region_compactness_diagnostics — extended region compactness
    # ------------------------------------------------------------------
    def region_compactness_diagnostics(
        self,
        region_column: str,
        rcd_suffix: str = "rcpd",
    ) -> "GeoPromptFrame":
        """Region compactness diagnostics beyond current implementation.

        Computes isoperimetric quotient, convexity ratio, area-weighted
        compactness, and elongation index per region.
        """
        self._require_column(region_column)
        import math
        regions: dict[int | str, list[int]] = {}
        for i, row in enumerate(self._rows):
            rid = row.get(region_column, 0)
            regions.setdefault(rid, []).append(i)
        centroids = self._centroids()
        region_metrics: dict[int | str, dict[str, float]] = {}
        for rid, members in regions.items():
            if len(members) < 2:
                region_metrics[rid] = {"ipq": 1.0, "elongation": 0.0, "convexity": 1.0}
                continue
            xs = [centroids[i][0] for i in members]
            ys = [centroids[i][1] for i in members]
            cx = sum(xs) / len(xs)
            cy = sum(ys) / len(ys)
            dists = [math.hypot(x - cx, y - cy) for x, y in zip(xs, ys)]
            max_d = max(dists) if dists else 1.0
            min_d = min(dists) if dists else 0.0
            mean_d = sum(dists) / len(dists) if dists else 1.0
            # Approximate compactness: circle area / convex hull area proxy
            ch_area = math.pi * max_d * max_d
            circle_area = math.pi * mean_d * mean_d
            ipq = circle_area / max(ch_area, 1e-12)
            elongation = 1.0 - min_d / max(max_d, 1e-12)
            region_metrics[rid] = {"ipq": ipq, "elongation": elongation, "convexity": ipq}

        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            rid = row.get(region_column, 0)
            m = region_metrics.get(rid, {"ipq": 0.0, "elongation": 0.0, "convexity": 0.0})
            r[f"ipq_{rcd_suffix}"] = m["ipq"]
            r[f"elongation_{rcd_suffix}"] = m["elongation"]
            r[f"convexity_{rcd_suffix}"] = m["convexity"]
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 299: panel_spatial_regression — panel spatial regression
    # ------------------------------------------------------------------
    def panel_spatial_regression(
        self,
        y_column: str,
        x_columns: list[str],
        time_column: str,
        entity_column: str,
        psr_suffix: str = "psr",
    ) -> "GeoPromptFrame":
        """Panel spatial regression scaffolding.

        Implements a simple fixed-effects panel model with spatial lag
        estimated per time period, then averages coefficients.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        self._require_column(time_column)
        self._require_column(entity_column)
        import math
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        # Group by time
        time_groups: dict[str, list[int]] = {}
        for i, row in enumerate(self._rows):
            t = str(row.get(time_column, ""))
            time_groups.setdefault(t, []).append(i)
        p = len(x_columns) + 1
        coef_sums = [0.0] * p
        n_periods = 0
        all_predicted: dict[int, float] = {}
        for t, indices in time_groups.items():
            nt = len(indices)
            if nt < p + 1:
                for i in indices:
                    all_predicted[i] = 0.0
                continue
            Y = [float(self._rows[i].get(y_column, 0) or 0) for i in indices]
            X = [[1.0] + [float(self._rows[i].get(c, 0) or 0) for c in x_columns] for i in indices]
            XtX = [[sum(X[r][c1] * X[r][c2] for r in range(nt)) for c2 in range(p)] for c1 in range(p)]
            XtY = [sum(X[r][c] * Y[r] for r in range(nt)) for c in range(p)]
            aug = [XtX[r][:] + [XtY[r]] for r in range(p)]
            for ci in range(p):
                piv = max(range(ci, p), key=lambda r: abs(aug[r][ci]))
                aug[ci], aug[piv] = aug[piv], aug[ci]
                if abs(aug[ci][ci]) < 1e-15:
                    continue
                for r in range(ci + 1, p):
                    f = aug[r][ci] / aug[ci][ci]
                    for c2 in range(ci, p + 1):
                        aug[r][c2] -= f * aug[ci][c2]
            beta = [0.0] * p
            for r in range(p - 1, -1, -1):
                s = aug[r][p] - sum(aug[r][c2] * beta[c2] for c2 in range(r + 1, p))
                beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
            for c in range(p):
                coef_sums[c] += beta[c]
            n_periods += 1
            for idx_local, i in enumerate(indices):
                all_predicted[i] = sum(X[idx_local][c] * beta[c] for c in range(p))

        avg_coefs = [c / max(n_periods, 1) for c in coef_sums]
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"predicted_{psr_suffix}"] = all_predicted.get(i, 0.0)
            r[f"residual_{psr_suffix}"] = float(row.get(y_column, 0) or 0) - all_predicted.get(i, 0.0)
            for ci, col in enumerate(["intercept"] + x_columns):
                r[f"coef_{col}_{psr_suffix}"] = avg_coefs[ci]
            r[f"n_periods_{psr_suffix}"] = n_periods
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 300: local_leverage — local leverage diagnostics
    # ------------------------------------------------------------------
    def local_leverage(
        self,
        y_column: str,
        x_columns: list[str],
        ll_suffix: str = "llev",
    ) -> "GeoPromptFrame":
        """Local leverage diagnostics.

        Computes the hat matrix diagonal elements (leverage values)
        from an OLS regression, flagging high-leverage observations.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        import math
        n = len(self._rows)
        p = len(x_columns) + 1
        if n <= p:
            return self.with_column(f"leverage_{ll_suffix}", [0.0] * n)
        X = [[1.0] + [float(self._rows[i].get(c, 0) or 0) for c in x_columns] for i in range(n)]
        # H = X (X'X)^-1 X'  — compute (X'X)^-1
        XtX = [[sum(X[r][c1] * X[r][c2] for r in range(n)) for c2 in range(p)] for c1 in range(p)]
        # Invert XtX via Gauss-Jordan
        inv = [[1.0 if r == c else 0.0 for c in range(p)] for r in range(p)]
        mat = [XtX[r][:] for r in range(p)]
        for ci in range(p):
            piv = max(range(ci, p), key=lambda r: abs(mat[r][ci]))
            mat[ci], mat[piv] = mat[piv], mat[ci]
            inv[ci], inv[piv] = inv[piv], inv[ci]
            diag = mat[ci][ci]
            if abs(diag) < 1e-15:
                continue
            for c2 in range(p):
                mat[ci][c2] /= diag
                inv[ci][c2] /= diag
            for r in range(p):
                if r == ci:
                    continue
                f = mat[r][ci]
                for c2 in range(p):
                    mat[r][c2] -= f * mat[ci][c2]
                    inv[r][c2] -= f * inv[ci][c2]
        # h_ii = X_i (X'X)^-1 X_i'
        leverages: list[float] = []
        threshold = 2 * p / n
        for i in range(n):
            h = 0.0
            for c1 in range(p):
                for c2 in range(p):
                    h += X[i][c1] * inv[c1][c2] * X[i][c2]
            leverages.append(h)
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"leverage_{ll_suffix}"] = leverages[i]
            r[f"high_leverage_{ll_suffix}"] = 1 if leverages[i] > threshold else 0
            r[f"threshold_{ll_suffix}"] = threshold
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 301: model_family_comparison — compare across model families
    # ------------------------------------------------------------------
    def model_family_comparison(
        self,
        y_column: str,
        x_columns: list[str],
        mfc_suffix: str = "mfc",
    ) -> "GeoPromptFrame":
        """Model-comparison helpers that aggregate across model families.

        Fits OLS, spatial lag proxy, and trend surface and compares
        R², AIC, and RMSE across families.
        """
        self._require_column(y_column)
        for col in x_columns:
            self._require_column(col)
        import math
        n = len(self._rows)
        p = len(x_columns) + 1
        if n < p + 2:
            row_out: Record = {
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"best_model_{mfc_suffix}": "insufficient_data",
            }
            return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)
        Y = [float(self._rows[i].get(y_column, 0) or 0) for i in range(n)]
        tss = sum((y - sum(Y) / n) ** 2 for y in Y)
        centroids = self._centroids()

        def _fit_ols(Xm: list[list[float]], pm: int) -> tuple[float, float, float]:
            XtX = [[sum(Xm[r][c1] * Xm[r][c2] for r in range(n)) for c2 in range(pm)] for c1 in range(pm)]
            XtY = [sum(Xm[r][c] * Y[r] for r in range(n)) for c in range(pm)]
            aug = [XtX[r][:] + [XtY[r]] for r in range(pm)]
            for ci in range(pm):
                piv = max(range(ci, pm), key=lambda r: abs(aug[r][ci]))
                aug[ci], aug[piv] = aug[piv], aug[ci]
                if abs(aug[ci][ci]) < 1e-15:
                    continue
                for r in range(ci + 1, pm):
                    f = aug[r][ci] / aug[ci][ci]
                    for c2 in range(ci, pm + 1):
                        aug[r][c2] -= f * aug[ci][c2]
            beta = [0.0] * pm
            for r in range(pm - 1, -1, -1):
                s = aug[r][pm] - sum(aug[r][c2] * beta[c2] for c2 in range(r + 1, pm))
                beta[r] = s / aug[r][r] if abs(aug[r][r]) > 1e-15 else 0.0
            rss = sum((Y[i] - sum(Xm[i][c] * beta[c] for c in range(pm))) ** 2 for i in range(n))
            r2 = 1.0 - rss / max(tss, 1e-15)
            rmse = math.sqrt(rss / n)
            ll = -n / 2 * math.log(max(rss / n, 1e-15)) - n / 2 * (1 + math.log(2 * math.pi))
            aic = -2 * ll + 2 * pm
            return r2, aic, rmse

        # OLS
        X_ols = [[1.0] + [float(self._rows[i].get(c, 0) or 0) for c in x_columns] for i in range(n)]
        r2_ols, aic_ols, rmse_ols = _fit_ols(X_ols, p)

        # Spatial lag proxy: mean neighbor Y as extra predictor
        k = min(5, n - 1)
        lag_y: list[float] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            lag_y.append(sum(Y[j] for j in dists[1:k + 1]) / k)
        X_lag = [[1.0] + [float(self._rows[i].get(c, 0) or 0) for c in x_columns] + [lag_y[i]] for i in range(n)]
        r2_lag, aic_lag, rmse_lag = _fit_ols(X_lag, p + 1)

        # Trend surface
        mean_x = sum(c[0] for c in centroids) / n
        mean_y = sum(c[1] for c in centroids) / n
        X_trend = [[1.0] + [float(self._rows[i].get(c, 0) or 0) for c in x_columns] +
                    [centroids[i][0] - mean_x, centroids[i][1] - mean_y] for i in range(n)]
        r2_trend, aic_trend, rmse_trend = _fit_ols(X_trend, p + 2)

        models = [("ols", r2_ols, aic_ols, rmse_ols),
                  ("spatial_lag", r2_lag, aic_lag, rmse_lag),
                  ("trend", r2_trend, aic_trend, rmse_trend)]
        best = min(models, key=lambda m: m[2])  # lowest AIC
        row_out = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"best_model_{mfc_suffix}": best[0],
            f"ols_r2_{mfc_suffix}": r2_ols,
            f"ols_aic_{mfc_suffix}": aic_ols,
            f"ols_rmse_{mfc_suffix}": rmse_ols,
            f"lag_r2_{mfc_suffix}": r2_lag,
            f"lag_aic_{mfc_suffix}": aic_lag,
            f"lag_rmse_{mfc_suffix}": rmse_lag,
            f"trend_r2_{mfc_suffix}": r2_trend,
            f"trend_aic_{mfc_suffix}": aic_trend,
            f"trend_rmse_{mfc_suffix}": rmse_trend,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 302: bandwidth_recommendation — automatic bandwidth
    # ------------------------------------------------------------------
    def bandwidth_recommendation(
        self,
        value_column: str,
        br_suffix: str = "bwr",
    ) -> "GeoPromptFrame":
        """Automatic bandwidth recommendation diagnostics.

        Evaluates multiple candidate bandwidths for GWR-style analysis
        and reports the one minimizing a CV-like criterion.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 4:
            row_out: Record = {
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"bandwidth_{br_suffix}": 0.0,
            }
            return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        # Compute all pairwise distances
        all_dists: list[list[float]] = []
        for i in range(n):
            row_d = [math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for j in range(n)]
            all_dists.append(row_d)
        # Max distance
        max_d = max(max(r) for r in all_dists) if n > 1 else 1.0
        # Test bandwidths
        candidates = [max_d * f for f in [0.1, 0.2, 0.3, 0.5, 0.7, 1.0]]
        best_cv = float("inf")
        best_bw = candidates[0]
        for bw in candidates:
            cv_sum = 0.0
            for i in range(n):
                w_sum = 0.0
                v_sum = 0.0
                for j in range(n):
                    if i == j:
                        continue
                    w = gaussian_kernel(all_dists[i][j], max(bw, 1e-12))
                    w_sum += w
                    v_sum += w * vals[j]
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                cv_sum += (vals[i] - pred) ** 2
            if cv_sum < best_cv:
                best_cv = cv_sum
                best_bw = bw
        row_out = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"bandwidth_{br_suffix}": best_bw,
            f"cv_score_{br_suffix}": best_cv,
            f"n_{br_suffix}": n,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 303: roughness_segmentation — surface roughness segmentation
    # ------------------------------------------------------------------
    def roughness_segmentation(
        self,
        value_column: str,
        n_classes: int = 3,
        k_neighbors: int = 5,
        rs_suffix: str = "rseg",
    ) -> "GeoPromptFrame":
        """Surface roughness segmentation.

        Classifies each point by local roughness (std deviation of
        values in the k-nearest neighborhood) into n_classes using
        equal-interval breaks.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 2:
            return self.with_column(f"class_{rs_suffix}", [0] * n)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        k = min(k_neighbors, n - 1)
        roughness: list[float] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            nbrs = dists[1:k + 1]
            nb_vals = [vals[j] for j in nbrs]
            mn = sum(nb_vals) / len(nb_vals) if nb_vals else 0.0
            sd = math.sqrt(sum((v - mn) ** 2 for v in nb_vals) / len(nb_vals)) if nb_vals else 0.0
            roughness.append(sd)
        mn_r = min(roughness)
        mx_r = max(roughness)
        rng = mx_r - mn_r if mx_r > mn_r else 1.0
        classes = [min(int((r - mn_r) / rng * n_classes), n_classes - 1) for r in roughness]
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"roughness_{rs_suffix}"] = roughness[i]
            r[f"class_{rs_suffix}"] = classes[i]
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 304: pickup_delivery_routing — pickup-and-delivery routing
    # ------------------------------------------------------------------
    def pickup_delivery_routing(
        self,
        pickup_column: str,
        delivery_column: str,
        capacity: int = 100,
        pd_suffix: str = "pdr",
    ) -> "GeoPromptFrame":
        """Pickup-and-delivery routing.

        Solves a paired pickup-and-delivery problem using a nearest-
        neighbor insertion heuristic that respects precedence constraints
        (pickup before delivery) and vehicle capacity.
        """
        self._require_column(pickup_column)
        self._require_column(delivery_column)
        import math
        n = len(self._rows)
        if n < 1:
            return self.with_column(f"order_{pd_suffix}", [])
        centroids = self._centroids()
        pickups = [int(v) if v is not None else 0 for v in self[pickup_column]]
        deliveries = [int(v) if v is not None else 0 for v in self[delivery_column]]
        # Build pairs: i is pickup, deliveries[i] is the delivery index
        visited = [False] * n
        route: list[int] = []
        load = 0
        current = 0  # start from first node
        visited[current] = True
        route.append(current)
        for _ in range(n - 1):
            best_j = -1
            best_d = float("inf")
            for j in range(n):
                if visited[j]:
                    continue
                # Check precedence: if j is a delivery, its pickup must be visited
                if deliveries[j] > 0:
                    pickup_idx = deliveries[j] - 1
                    if 0 <= pickup_idx < n and not visited[pickup_idx]:
                        continue
                d = math.hypot(centroids[current][0] - centroids[j][0], centroids[current][1] - centroids[j][1])
                if d < best_d:
                    best_d = d
                    best_j = j
            if best_j < 0:
                # Visit any remaining
                for j in range(n):
                    if not visited[j]:
                        best_j = j
                        break
                if best_j < 0:
                    break
            visited[best_j] = True
            route.append(best_j)
            current = best_j

        order = [0] * n
        for seq, idx in enumerate(route):
            order[idx] = seq
        total_dist = sum(
            math.hypot(centroids[route[i]][0] - centroids[route[i + 1]][0],
                        centroids[route[i]][1] - centroids[route[i + 1]][1])
            for i in range(len(route) - 1)
        )
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"order_{pd_suffix}"] = order[i]
            r[f"total_dist_{pd_suffix}"] = total_dist
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 305: transit_routing — transit-style schedule-aware routing
    # ------------------------------------------------------------------
    def transit_routing(
        self,
        from_column: str,
        to_column: str,
        cost_column: str,
        departure_column: str,
        arrival_column: str,
        tr_suffix: str = "trt",
    ) -> "GeoPromptFrame":
        """Transit-style schedule-aware routing.

        Finds shortest paths on a network where edges are only
        available during specified time windows (departure/arrival).
        Uses a modified Dijkstra considering temporal constraints.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        self._require_column(cost_column)
        self._require_column(departure_column)
        self._require_column(arrival_column)
        import math
        n = len(self._rows)
        if n < 1:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        # Build time-dependent graph
        graph: dict[int, list[tuple[int, float, float, float]]] = {}
        nodes: set[int] = set()
        for row in self._rows:
            f = int(row.get(from_column, 0) or 0)
            t = int(row.get(to_column, 0) or 0)
            c = float(row.get(cost_column, 1) or 1)
            dep = float(row.get(departure_column, 0) or 0)
            arr = float(row.get(arrival_column, 0) or 0)
            graph.setdefault(f, []).append((t, c, dep, arr))
            nodes.add(f)
            nodes.add(t)
        # Run time-aware Dijkstra from each source to all destinations
        all_nodes = sorted(nodes)
        if not all_nodes:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        source = all_nodes[0]
        import heapq
        dist: dict[int, float] = {nd: float("inf") for nd in all_nodes}
        dist[source] = 0.0
        pq: list[tuple[float, int]] = [(0.0, source)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > dist[u]:
                continue
            for v, cost, dep, arr in graph.get(u, []):
                # Can only depart if we arrive at u by departure time
                if d <= dep:
                    new_d = arr + cost
                    if new_d < dist.get(v, float("inf")):
                        dist[v] = new_d
                        heapq.heappush(pq, (new_d, v))
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            dest = int(row.get(to_column, 0) or 0)
            r[f"arrival_cost_{tr_suffix}"] = dist.get(dest, -1.0)
            r[f"reachable_{tr_suffix}"] = 1 if dist.get(dest, float("inf")) < float("inf") else 0
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 306: nn_contingency — nearest-neighbor contingency table
    # ------------------------------------------------------------------
    def nn_contingency(
        self,
        category_column: str,
        k: int = 1,
        nnc_suffix: str = "nnc",
    ) -> "GeoPromptFrame":
        """Nearest-neighbor contingency table analysis.

        For each category pair, counts how often a point of type A has
        a nearest neighbor of type B, producing a contingency-style summary.
        """
        self._require_column(category_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        cats = [str(row.get(category_column, "")) for row in self._rows]
        unique_cats = sorted(set(cats))
        # Build contingency
        contingency: dict[str, dict[str, int]] = {c: {c2: 0 for c2 in unique_cats} for c in unique_cats}
        kk = min(k, n - 1)
        for i in range(n):
            dists = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            for j in dists[1:kk + 1]:
                contingency[cats[i]][cats[j]] += 1
        # Output one row per cell
        rows_out: list[Record] = []
        for ca in unique_cats:
            for cb in unique_cats:
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                    f"from_cat_{nnc_suffix}": ca,
                    f"to_cat_{nnc_suffix}": cb,
                    f"count_{nnc_suffix}": contingency[ca][cb],
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 307: pairwise_directionality — pairwise directionality / rose
    # ------------------------------------------------------------------
    def pairwise_directionality(
        self,
        value_column: str,
        n_sectors: int = 8,
        pd_suffix: str = "pdir",
    ) -> "GeoPromptFrame":
        """Pairwise directionality or rose diagnostics.

        Bins pairwise direction angles into sectors and computes the
        mean absolute value difference per sector — a directional
        variogram-like diagnostic.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        sector_sum = [0.0] * n_sectors
        sector_count = [0] * n_sectors
        sector_width = 2 * math.pi / n_sectors
        for i in range(n):
            for j in range(i + 1, n):
                dx = centroids[j][0] - centroids[i][0]
                dy = centroids[j][1] - centroids[i][1]
                angle = math.atan2(dy, dx) % (2 * math.pi)
                sector = int(angle / sector_width) % n_sectors
                sector_sum[sector] += abs(vals[i] - vals[j])
                sector_count[sector] += 1
        rows_out: list[Record] = []
        for s in range(n_sectors):
            mean_diff = sector_sum[s] / sector_count[s] if sector_count[s] > 0 else 0.0
            angle_center = (s + 0.5) * sector_width
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"sector_{pd_suffix}": s,
                f"angle_deg_{pd_suffix}": math.degrees(angle_center),
                f"mean_diff_{pd_suffix}": mean_diff,
                f"count_{pd_suffix}": sector_count[s],
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 308: inhomogeneous_pair_correlation — inhomogeneous pair correlation
    # ------------------------------------------------------------------
    def inhomogeneous_pair_correlation(
        self,
        intensity_column: str | None = None,
        max_distance: float | None = None,
        n_bins: int = 20,
        ipc_suffix: str = "ipcf",
    ) -> "GeoPromptFrame":
        """Inhomogeneous pair correlation function.

        Estimates the pair correlation function g(r) with correction
        for spatially varying intensity using per-point intensity estimates.
        """
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        if intensity_column:
            self._require_column(intensity_column)
            lam = [max(float(v) if v is not None else n / area, 1e-12) for v in self[intensity_column]]
        else:
            lam_val = n / area
            lam = [lam_val] * n
        if max_distance is None:
            max_distance = math.sqrt(area) / 2
        bin_width = max_distance / n_bins
        g_vals = [0.0] * n_bins
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                bi = int(d / bin_width) if bin_width > 0 else 0
                if 0 <= bi < n_bins:
                    g_vals[bi] += 2.0 / (lam[i] * lam[j])
        rows_out: list[Record] = []
        for k in range(n_bins):
            r_lo = k * bin_width
            r_hi = (k + 1) * bin_width
            r_mid = (r_lo + r_hi) / 2
            annulus_area = math.pi * (r_hi ** 2 - r_lo ** 2)
            g = g_vals[k] / max(annulus_area, 1e-12)
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"distance_{ipc_suffix}": r_mid,
                f"g_{ipc_suffix}": g,
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 309: covariate_intensity — covariate-adjusted intensity estimation
    # ------------------------------------------------------------------
    def covariate_intensity(
        self,
        covariate_column: str,
        bandwidth: float | None = None,
        ci_suffix: str = "cint",
    ) -> "GeoPromptFrame":
        """Covariate-adjusted intensity estimation.

        Estimates spatially varying point intensity adjusted for a
        covariate using a ratio of kernel-smoothed marks to kernel density.
        """
        self._require_column(covariate_column)
        import math
        n = len(self._rows)
        if n < 2:
            return self.with_column(f"intensity_{ci_suffix}", [0.0] * n)
        centroids = self._centroids()
        cov = [float(v) if v is not None else 0.0 for v in self[covariate_column]]
        if bandwidth is None:
            b = self.bounds()
            bandwidth = math.sqrt((b.max_x - b.min_x) ** 2 + (b.max_y - b.min_y) ** 2) / max(math.sqrt(n), 2)
        intensities: list[float] = []
        for i in range(n):
            kde_sum = 0.0
            cov_sum = 0.0
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                w = gaussian_kernel(d, max(bandwidth, 1e-12))
                kde_sum += w
                cov_sum += w * cov[j]
            intensities.append(kde_sum / max(cov_sum, 1e-12) if cov_sum != 0 else kde_sum)
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"intensity_{ci_suffix}"] = intensities[i]
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 310: hotspot_duration — local hotspot duration analysis
    # ------------------------------------------------------------------
    def hotspot_duration(
        self,
        value_column: str,
        time_column: str,
        threshold: float | None = None,
        hd_suffix: str = "hdur",
    ) -> "GeoPromptFrame":
        """Local hotspot duration analysis across time slices.

        For each spatial feature, counts consecutive time periods
        where the value exceeds a threshold (hotspot persistence).
        """
        self._require_column(value_column)
        self._require_column(time_column)
        n = len(self._rows)
        if n < 1:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        if threshold is None:
            mean_v = sum(vals) / n
            import math
            sd_v = math.sqrt(sum((v - mean_v) ** 2 for v in vals) / n)
            threshold = mean_v + sd_v
        # Group by entity (spatial location) — use geometry centroid as key
        centroids = self._centroids()
        entity_map: dict[tuple[float, float], list[tuple[float, int]]] = {}
        for i in range(n):
            key = centroids[i]
            t = float(self._rows[i].get(time_column, 0) or 0)
            entity_map.setdefault(key, []).append((t, i))
        # For each entity, sort by time and compute duration
        durations: dict[int, int] = {}
        max_durations: dict[int, int] = {}
        for key, entries in entity_map.items():
            entries.sort()
            max_streak = 0
            current_streak = 0
            for t, idx in entries:
                v = vals[idx]
                if v >= threshold:
                    current_streak += 1
                    if current_streak > max_streak:
                        max_streak = current_streak
                else:
                    current_streak = 0
                durations[idx] = current_streak
                max_durations[idx] = max_streak
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"streak_{hd_suffix}"] = durations.get(i, 0)
            r[f"max_streak_{hd_suffix}"] = max_durations.get(i, 0)
            r[f"is_hot_{hd_suffix}"] = 1 if vals[i] >= threshold else 0
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 311: point_process_simulation — point-process simulation helpers
    # ------------------------------------------------------------------
    def point_process_simulation(
        self,
        n_points: int = 100,
        pattern: str = "poisson",
        seed: int = 42,
        pps_suffix: str = "pps",
    ) -> "GeoPromptFrame":
        """Point-process simulation helpers.

        Generates simulated point patterns within the bounding box of
        the current data. Supports Poisson (CSR), clustered, and
        regular (inhibition) patterns.
        """
        import math
        b = self.bounds()
        rng_state = seed
        def _rand() -> float:
            nonlocal rng_state
            rng_state = (rng_state * 1103515245 + 12345) & 0x7FFFFFFF
            return rng_state / 0x7FFFFFFF

        width = b.max_x - b.min_x if (b.max_x - b.min_x) > 0 else 1.0
        height = b.max_y - b.min_y if (b.max_y - b.min_y) > 0 else 1.0
        rows_out: list[Record] = []
        if pattern == "clustered":
            n_parents = max(n_points // 5, 1)
            parents = [(b.min_x + _rand() * width, b.min_y + _rand() * height) for _ in range(n_parents)]
            spread = min(width, height) * 0.1
            for _ in range(n_points):
                parent = parents[int(_rand() * n_parents) % n_parents]
                x = parent[0] + (_rand() - 0.5) * spread
                y = parent[1] + (_rand() - 0.5) * spread
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (x, y)},
                    f"pattern_{pps_suffix}": pattern,
                })
        elif pattern == "regular":
            side = int(math.ceil(math.sqrt(n_points)))
            dx = width / max(side, 1)
            dy = height / max(side, 1)
            count = 0
            for r in range(side):
                for c in range(side):
                    if count >= n_points:
                        break
                    x = b.min_x + (c + 0.5) * dx + (_rand() - 0.5) * dx * 0.2
                    y = b.min_y + (r + 0.5) * dy + (_rand() - 0.5) * dy * 0.2
                    rows_out.append({
                        self.geometry_column: {"type": "Point", "coordinates": (x, y)},
                        f"pattern_{pps_suffix}": pattern,
                    })
                    count += 1
        else:  # poisson / CSR
            for _ in range(n_points):
                x = b.min_x + _rand() * width
                y = b.min_y + _rand() * height
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (x, y)},
                    f"pattern_{pps_suffix}": pattern,
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 312: multitype_interaction — multi-type interaction diagnostics
    # ------------------------------------------------------------------
    def multitype_interaction(
        self,
        type_column: str,
        max_distance: float | None = None,
        n_bins: int = 15,
        mti_suffix: str = "mti",
    ) -> "GeoPromptFrame":
        """Multi-type interaction diagnostics.

        Cross-type K-function: for each pair of types, estimates K(r)
        to diagnose attraction, repulsion, or independence between types.
        """
        self._require_column(type_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        types = [str(row.get(type_column, "")) for row in self._rows]
        unique_types = sorted(set(types))
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        if max_distance is None:
            max_distance = math.sqrt(area) / 2
        bin_width = max_distance / n_bins
        # Cross-K for each type pair
        rows_out: list[Record] = []
        for ta in unique_types:
            for tb in unique_types:
                if ta > tb:
                    continue
                idx_a = [i for i in range(n) if types[i] == ta]
                idx_b = [i for i in range(n) if types[i] == tb]
                na = len(idx_a)
                nb = len(idx_b)
                if na == 0 or nb == 0:
                    continue
                k_vals = [0.0] * n_bins
                for i in idx_a:
                    for j in idx_b:
                        if i == j:
                            continue
                        d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                        bi = int(d / bin_width) if bin_width > 0 else 0
                        if 0 <= bi < n_bins:
                            k_vals[bi] += 1
                lam_a = na / area
                lam_b = nb / area
                cumul = 0.0
                for bk in range(n_bins):
                    cumul += k_vals[bk]
                    r = (bk + 1) * bin_width
                    k_obs = cumul / max(na * lam_b, 1e-12)
                    k_csr = math.pi * r * r
                    rows_out.append({
                        self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                        f"type_a_{mti_suffix}": ta,
                        f"type_b_{mti_suffix}": tb,
                        f"distance_{mti_suffix}": r,
                        f"k_obs_{mti_suffix}": k_obs,
                        f"k_csr_{mti_suffix}": k_csr,
                        f"interaction_{mti_suffix}": "attraction" if k_obs > k_csr * 1.1 else ("repulsion" if k_obs < k_csr * 0.9 else "independent"),
                    })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 313: local_cluster_persistence — local cluster persistence
    # ------------------------------------------------------------------
    def local_cluster_persistence(
        self,
        value_column: str,
        time_column: str,
        k_neighbors: int = 5,
        lcp_suffix: str = "lcp",
    ) -> "GeoPromptFrame":
        """Local cluster persistence analysis.

        For each point, evaluates whether its spatial cluster membership
        (hot/cold based on local mean) persists across time periods.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        import math
        n = len(self._rows)
        if n < 2:
            return self.with_column(f"persistence_{lcp_suffix}", [0] * n)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        times = [float(self._rows[i].get(time_column, 0) or 0) for i in range(n)]
        global_mean = sum(vals) / n
        k = min(k_neighbors, n - 1)
        # For each point, compute local mean, classify as hot/cold
        local_hot: list[int] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            nbr_mean = sum(vals[j] for j in dists[1:k + 1]) / k if k > 0 else 0.0
            local_hot.append(1 if nbr_mean > global_mean else 0)
        # Group by location and check persistence across time
        loc_map: dict[tuple[float, float], list[tuple[float, int]]] = {}
        for i in range(n):
            loc_map.setdefault(centroids[i], []).append((times[i], i))
        persistence: list[int] = [0] * n
        for loc, entries in loc_map.items():
            entries.sort()
            if len(entries) < 2:
                for _, idx in entries:
                    persistence[idx] = local_hot[idx]
                continue
            first_status = local_hot[entries[0][1]]
            streak = 1
            for ei in range(1, len(entries)):
                if local_hot[entries[ei][1]] == first_status:
                    streak += 1
                else:
                    break
            for _, idx in entries:
                persistence[idx] = streak
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"hot_{lcp_suffix}"] = local_hot[i]
            r[f"persistence_{lcp_suffix}"] = persistence[i]
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 314: time_slice_comparison — time-slice comparison helpers
    # ------------------------------------------------------------------
    def time_slice_comparison(
        self,
        value_column: str,
        time_column: str,
        tsc_suffix: str = "tsc",
    ) -> "GeoPromptFrame":
        """Time-slice comparison helpers.

        For each unique time period, computes summary statistics and
        returns a single-row-per-period frame with mean, std, min, max.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        import math
        n = len(self._rows)
        groups: dict[str, list[float]] = {}
        for i in range(n):
            t = str(self._rows[i].get(time_column, ""))
            v = float(self._rows[i].get(value_column, 0) or 0)
            groups.setdefault(t, []).append(v)
        rows_out: list[Record] = []
        for t in sorted(groups.keys()):
            vv = groups[t]
            mn = sum(vv) / len(vv)
            sd = math.sqrt(sum((v - mn) ** 2 for v in vv) / len(vv))
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"time_{tsc_suffix}": t,
                f"mean_{tsc_suffix}": mn,
                f"std_{tsc_suffix}": sd,
                f"min_{tsc_suffix}": min(vv),
                f"max_{tsc_suffix}": max(vv),
                f"n_{tsc_suffix}": len(vv),
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 315: event_density_trend — event density trend surfaces
    # ------------------------------------------------------------------
    def event_density_trend(
        self,
        time_column: str,
        bandwidth: float | None = None,
        grid_resolution: int = 10,
        edt_suffix: str = "edt",
    ) -> "GeoPromptFrame":
        """Event density trend surfaces.

        For each time slice, computes a kernel density estimate on a grid,
        then returns grid points annotated with time and density values.
        """
        self._require_column(time_column)
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        if bandwidth is None:
            bandwidth = math.sqrt((b.max_x - b.min_x) ** 2 + (b.max_y - b.min_y) ** 2) / max(math.sqrt(n), 2)
        times: dict[str, list[int]] = {}
        for i in range(n):
            t = str(self._rows[i].get(time_column, ""))
            times.setdefault(t, []).append(i)
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows_out: list[Record] = []
        for t in sorted(times.keys()):
            indices = times[t]
            for gy in range(grid_resolution):
                py = b.min_y + gy * dy
                for gx in range(grid_resolution):
                    px = b.min_x + gx * dx
                    density = 0.0
                    for i in indices:
                        d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                        density += gaussian_kernel(d, max(bandwidth, 1e-12))
                    rows_out.append({
                        self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                        f"time_{edt_suffix}": t,
                        f"density_{edt_suffix}": density,
                    })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 316: event_anisotropy — event anisotropy diagnostics
    # ------------------------------------------------------------------
    def event_anisotropy(
        self,
        n_directions: int = 8,
        max_distance: float | None = None,
        ea_suffix: str = "eanis",
    ) -> "GeoPromptFrame":
        """Event anisotropy diagnostics.

        Computes directional point density (pair counts) to detect
        anisotropy in the point pattern.
        """
        import math
        n = len(self._rows)
        if n < 2:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        if max_distance is None:
            max_distance = math.sqrt(area) / 2
        sector_width = 2 * math.pi / n_directions
        counts = [0] * n_directions
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[j][0] - centroids[i][0], centroids[j][1] - centroids[i][1])
                if d > max_distance:
                    continue
                angle = math.atan2(centroids[j][1] - centroids[i][1], centroids[j][0] - centroids[i][0]) % (2 * math.pi)
                sector = int(angle / sector_width) % n_directions
                counts[sector] += 1
        total = sum(counts)
        expected = total / n_directions if n_directions > 0 else 0
        rows_out: list[Record] = []
        for s in range(n_directions):
            angle_center = (s + 0.5) * sector_width
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"sector_{ea_suffix}": s,
                f"angle_deg_{ea_suffix}": math.degrees(angle_center),
                f"count_{ea_suffix}": counts[s],
                f"ratio_{ea_suffix}": counts[s] / max(expected, 1),
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 317: medial_axis_refine — medial axis refinement
    # ------------------------------------------------------------------
    def medial_axis_refine(
        self,
        n_samples: int = 20,
        mar_suffix: str = "mar",
    ) -> "GeoPromptFrame":
        """Medial axis refinement beyond current skeleton approximation.

        For each polygon, computes a denser medial axis approximation
        by inscribing circles at multiple boundary samples and keeping
        the maximum-radius centers.
        """
        import math
        n = len(self._rows)
        rows_out: list[Record] = []
        for i, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                rows_out.append(dict(row))
                continue
            coords = geom.get("coordinates", ())
            if len(coords) < 3:
                rows_out.append(dict(row))
                continue
            # Sample boundary points
            boundary: list[tuple[float, float]] = list(coords)
            # For each sample, find the point farthest from nearest boundary
            cx = sum(p[0] for p in boundary) / len(boundary)
            cy = sum(p[1] for p in boundary) / len(boundary)
            # Generate candidate medial axis points along centroid-boundary midpoints
            axis_points: list[tuple[float, float, float]] = []
            step = max(len(boundary) // n_samples, 1)
            for si in range(0, len(boundary), step):
                mx = (boundary[si][0] + cx) / 2
                my = (boundary[si][1] + cy) / 2
                # Distance to nearest boundary segment
                min_d = float("inf")
                for k in range(len(boundary)):
                    k2 = (k + 1) % len(boundary)
                    ex, ey = boundary[k]
                    fx, fy = boundary[k2]
                    dx, dy = fx - ex, fy - ey
                    seg_len_sq = dx * dx + dy * dy
                    if seg_len_sq < 1e-15:
                        d = math.hypot(mx - ex, my - ey)
                    else:
                        t = clamp(((mx - ex) * dx + (my - ey) * dy) / seg_len_sq, 0.0, 1.0)
                        px, py = ex + t * dx, ey + t * dy
                        d = math.hypot(mx - px, my - py)
                    if d < min_d:
                        min_d = d
                axis_points.append((mx, my, min_d))
            # Output axis points as geometry
            for ax, ay, radius in axis_points:
                r = dict(row)
                r[self.geometry_column] = {"type": "Point", "coordinates": (ax, ay)}
                r[f"radius_{mar_suffix}"] = radius
                r[f"source_idx_{mar_suffix}"] = i
                rows_out.append(r)
        if not rows_out:
            rows_out = [dict(self._rows[0])] if self._rows else []
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 318: mesh_quality — polygon mesh quality diagnostics
    # ------------------------------------------------------------------
    def mesh_quality(
        self,
        mq_suffix: str = "mqual",
    ) -> "GeoPromptFrame":
        """Polygon mesh quality diagnostics.

        For each polygon, computes aspect ratio, minimum angle,
        and a quality score to evaluate mesh/tessellation quality.
        """
        import math
        new_rows: list[Record] = []
        for row in self._rows:
            r = dict(row)
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                r[f"quality_{mq_suffix}"] = 0.0
                r[f"min_angle_{mq_suffix}"] = 0.0
                r[f"aspect_ratio_{mq_suffix}"] = 0.0
                new_rows.append(r)
                continue
            coords = list(geom.get("coordinates", ()))
            if len(coords) < 3:
                r[f"quality_{mq_suffix}"] = 0.0
                r[f"min_angle_{mq_suffix}"] = 0.0
                r[f"aspect_ratio_{mq_suffix}"] = 0.0
                new_rows.append(r)
                continue
            # Remove closing point if duplicate
            if coords[-1] == coords[0] and len(coords) > 3:
                coords = coords[:-1]
            # Compute edge lengths
            nc = len(coords)
            edges: list[float] = []
            for k in range(nc):
                k2 = (k + 1) % nc
                edges.append(math.hypot(coords[k2][0] - coords[k][0], coords[k2][1] - coords[k][1]))
            # Compute angles
            angles: list[float] = []
            for k in range(nc):
                p0 = coords[(k - 1) % nc]
                p1 = coords[k]
                p2 = coords[(k + 1) % nc]
                v1x, v1y = p0[0] - p1[0], p0[1] - p1[1]
                v2x, v2y = p2[0] - p1[0], p2[1] - p1[1]
                dot = v1x * v2x + v1y * v2y
                m1 = math.hypot(v1x, v1y)
                m2 = math.hypot(v2x, v2y)
                cos_a = dot / max(m1 * m2, 1e-15)
                cos_a = clamp(cos_a, -1.0, 1.0)
                angles.append(math.degrees(math.acos(cos_a)))
            min_angle = min(angles) if angles else 0.0
            max_edge = max(edges) if edges else 1.0
            min_edge = min(edges) if edges else 1.0
            aspect = max_edge / max(min_edge, 1e-12)
            # Quality: 1.0 for equilateral triangle, lower for worse shapes
            ideal_angle = 180.0 * (nc - 2) / nc
            quality = min_angle / max(ideal_angle, 1e-12)
            r[f"quality_{mq_suffix}"] = quality
            r[f"min_angle_{mq_suffix}"] = min_angle
            r[f"aspect_ratio_{mq_suffix}"] = aspect
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 319: boundary_generalize — boundary generalization
    # ------------------------------------------------------------------
    def boundary_generalize(
        self,
        tolerance: float = 1.0,
        bg_suffix: str = "bgen",
    ) -> "GeoPromptFrame":
        """Boundary generalization tools.

        Simplifies polygon boundaries using a Visvalingam-Whyatt-like
        area-based vertex removal to the given tolerance.
        """
        import math
        new_rows: list[Record] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                r = dict(row)
                r[f"n_removed_{bg_suffix}"] = 0
                new_rows.append(r)
                continue
            coords = list(geom.get("coordinates", ()))
            if len(coords) < 4:
                r = dict(row)
                r[f"n_removed_{bg_suffix}"] = 0
                new_rows.append(r)
                continue
            # Iteratively remove vertex with smallest triangle area
            pts = list(coords)
            if pts[-1] == pts[0] and len(pts) > 3:
                pts = pts[:-1]
            removed = 0
            while len(pts) > 3:
                min_area = float("inf")
                min_idx = -1
                for k in range(1, len(pts) - 1):
                    ax, ay = pts[k - 1]
                    bx, by = pts[k]
                    cx, cy = pts[k + 1]
                    area = abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) / 2
                    if area < min_area:
                        min_area = area
                        min_idx = k
                if min_area > tolerance:
                    break
                pts.pop(min_idx)
                removed += 1
            r = dict(row)
            r[self.geometry_column] = {"type": "Polygon", "coordinates": tuple(tuple(p) for p in pts)}
            r[f"n_removed_{bg_suffix}"] = removed
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 320: topology_normalize — topology normalization helpers
    # ------------------------------------------------------------------
    def topology_normalize(
        self,
        snap_tolerance: float = 1e-8,
        tn_suffix: str = "tnrm",
    ) -> "GeoPromptFrame":
        """Topology normalization helpers.

        Snaps near-coincident coordinates, ensures consistent winding
        order (CCW for exteriors), and removes duplicate consecutive
        vertices.
        """
        import math
        new_rows: list[Record] = []
        for row in self._rows:
            r = dict(row)
            geom = row.get(self.geometry_column)
            if not geom:
                r[f"normalized_{tn_suffix}"] = 0
                new_rows.append(r)
                continue
            coords = list(geom.get("coordinates", ()))
            gtype = geom.get("type", "")
            if gtype == "Polygon" and len(coords) >= 3:
                # Remove consecutive duplicates
                cleaned: list[tuple[float, float]] = [coords[0]]
                for k in range(1, len(coords)):
                    if math.hypot(coords[k][0] - cleaned[-1][0], coords[k][1] - cleaned[-1][1]) > snap_tolerance:
                        cleaned.append(coords[k])
                # Ensure CCW winding
                area2 = 0.0
                for k in range(len(cleaned)):
                    k2 = (k + 1) % len(cleaned)
                    area2 += cleaned[k][0] * cleaned[k2][1] - cleaned[k2][0] * cleaned[k][1]
                if area2 < 0 and len(cleaned) >= 3:
                    cleaned.reverse()
                r[self.geometry_column] = {"type": "Polygon", "coordinates": tuple(tuple(p) for p in cleaned)}
                r[f"normalized_{tn_suffix}"] = 1
            elif gtype == "LineString" and len(coords) >= 2:
                cleaned_ls: list[tuple[float, float]] = [coords[0]]
                for k in range(1, len(coords)):
                    if math.hypot(coords[k][0] - cleaned_ls[-1][0], coords[k][1] - cleaned_ls[-1][1]) > snap_tolerance:
                        cleaned_ls.append(coords[k])
                r[self.geometry_column] = {"type": "LineString", "coordinates": tuple(tuple(p) for p in cleaned_ls)}
                r[f"normalized_{tn_suffix}"] = 1
            else:
                r[f"normalized_{tn_suffix}"] = 0
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 321: feather_export — Feather/Arrow spatial export
    # ------------------------------------------------------------------
    def feather_export(
        self,
        path: str,
    ) -> "GeoPromptFrame":
        """Feather or Arrow spatial export helpers.

        Exports frame data to a simple JSON-based feather-like format
        (pure Python, no Arrow dependency). Geometry is stored as WKT.
        """
        import json
        records = []
        for row in self._rows:
            rec: dict[str, object] = {}
            for k, v in row.items():
                if k == self.geometry_column:
                    geom = v
                    if isinstance(geom, dict):
                        gtype = geom.get("type", "")
                        coords = geom.get("coordinates", ())
                        if gtype == "Point" and len(coords) >= 2:
                            rec[k] = f"POINT ({coords[0]} {coords[1]})"
                        elif gtype == "LineString":
                            pts = ", ".join(f"{c[0]} {c[1]}" for c in coords)
                            rec[k] = f"LINESTRING ({pts})"
                        elif gtype == "Polygon":
                            pts = ", ".join(f"{c[0]} {c[1]}" for c in coords)
                            rec[k] = f"POLYGON (({pts}))"
                        else:
                            rec[k] = str(geom)
                    else:
                        rec[k] = str(v)
                else:
                    rec[k] = v
            records.append(rec)
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"columns": list(records[0].keys()) if records else [], "data": records}, f)
        return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 322: schema_report — schema report for loaded datasets
    # ------------------------------------------------------------------
    def schema_report(
        self,
        sr_suffix: str = "srpt",
    ) -> "GeoPromptFrame":
        """Explicit schema-report tools for loaded datasets.

        Returns a single-row summary with column names, types,
        null counts, and geometry type distribution.
        """
        n = len(self._rows)
        col_names: list[str] = list(self._rows[0].keys()) if n > 0 else []
        col_types: dict[str, str] = {}
        null_counts: dict[str, int] = {}
        geom_types: dict[str, int] = {}
        for col in col_names:
            null_counts[col] = 0
            sample_type = "unknown"
            for row in self._rows:
                v = row.get(col)
                if v is None:
                    null_counts[col] += 1
                elif col == self.geometry_column and isinstance(v, dict):
                    gt = v.get("type", "unknown")
                    geom_types[gt] = geom_types.get(gt, 0) + 1
                    sample_type = "geometry"
                elif isinstance(v, (int, float)):
                    sample_type = "numeric"
                elif isinstance(v, str):
                    sample_type = "string"
            col_types[col] = sample_type
        row_out: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{sr_suffix}": n,
            f"n_columns_{sr_suffix}": len(col_names),
            f"columns_{sr_suffix}": ",".join(col_names),
            f"types_{sr_suffix}": ",".join(f"{c}:{t}" for c, t in col_types.items()),
            f"geom_types_{sr_suffix}": ",".join(f"{k}:{v}" for k, v in geom_types.items()),
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 323: format_autodetect — format autodetection
    # ------------------------------------------------------------------
    def format_autodetect(
        self,
        path: str,
        fa_suffix: str = "fdet",
    ) -> "GeoPromptFrame":
        """Format autodetection helpers.

        Inspects a file path extension and optional header bytes to
        determine the most likely spatial file format.
        """
        import os
        ext = os.path.splitext(path)[1].lower()
        format_map = {
            ".geojson": "geojson",
            ".json": "geojson",
            ".shp": "shapefile",
            ".gpkg": "geopackage",
            ".csv": "csv",
            ".kml": "kml",
            ".kmz": "kmz",
            ".topojson": "topojson",
            ".parquet": "geoparquet",
            ".fgb": "flatgeobuf",
            ".wkt": "wkt",
            ".wkb": "wkb",
            ".feather": "feather",
        }
        detected = format_map.get(ext, "unknown")
        row_out: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"format_{fa_suffix}": detected,
            f"extension_{fa_suffix}": ext,
            f"path_{fa_suffix}": path,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 324: export_validation — export validation helpers
    # ------------------------------------------------------------------
    def export_validation(
        self,
        path: str,
        ev_suffix: str = "eval",
    ) -> "GeoPromptFrame":
        """Export validation helpers.

        Checks that an exported file exists, is non-empty, and can
        be parsed as JSON (for JSON-based formats).
        """
        import os
        exists = os.path.isfile(path)
        size = os.path.getsize(path) if exists else 0
        valid_json = False
        if exists and size > 0:
            try:
                import json
                with open(path, "r", encoding="utf-8") as f:
                    json.load(f)
                valid_json = True
            except Exception:
                pass
        row_out: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"exists_{ev_suffix}": 1 if exists else 0,
            f"size_{ev_suffix}": size,
            f"valid_json_{ev_suffix}": 1 if valid_json else 0,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 325: grid_provenance — grid-cell provenance metadata
    # ------------------------------------------------------------------
    def grid_provenance(
        self,
        value_column: str,
        grid_resolution: int = 10,
        k_neighbors: int = 3,
        gp_suffix: str = "gprov",
    ) -> "GeoPromptFrame":
        """Grid-cell provenance metadata helpers.

        For each grid cell, records which source points contributed
        most to the interpolated value and their weights.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 1:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        k = min(k_neighbors, n)
        rows_out: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                dists_idx = sorted(range(n), key=lambda i: math.hypot(px - centroids[i][0], py - centroids[i][1]))
                top_k = dists_idx[:k]
                w_sum = 0.0
                v_sum = 0.0
                weights: list[float] = []
                for idx in top_k:
                    d = math.hypot(px - centroids[idx][0], py - centroids[idx][1])
                    w = 1.0 / max(d * d, 1e-12)
                    weights.append(w)
                    w_sum += w
                    v_sum += w * vals[idx]
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                norm_weights = [w / w_sum for w in weights] if w_sum > 0 else weights
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"value_{gp_suffix}": pred,
                    f"sources_{gp_suffix}": ",".join(str(i) for i in top_k),
                    f"weights_{gp_suffix}": ",".join(f"{w:.4f}" for w in norm_weights),
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 326: flow_direction_diagnostics — flow-direction diagnostics
    # ------------------------------------------------------------------
    def flow_direction_diagnostics(
        self,
        elevation_column: str,
        fdd_suffix: str = "fdd",
    ) -> "GeoPromptFrame":
        """Optional flow-direction diagnostics output.

        Computes flow direction plus diagnostics: number of flat
        neighbors, steepest slope angle, and ambiguity flags.
        """
        self._require_column(elevation_column)
        import math
        n = len(self._rows)
        if n < 2:
            return self.with_column(f"flat_count_{fdd_suffix}", [0] * n)
        centroids = self._centroids()
        elev = [float(v) if v is not None else 0.0 for v in self[elevation_column]]
        k = min(8, n - 1)
        new_rows: list[Record] = []
        for i in range(n):
            dists = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))
            nbrs = dists[1:k + 1]
            flat_count = 0
            steepest_slope = 0.0
            steepest_dir = -1
            for j in nbrs:
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                drop = elev[i] - elev[j]
                slope = drop / max(d, 1e-12)
                if abs(drop) < 1e-10:
                    flat_count += 1
                if slope > steepest_slope:
                    steepest_slope = slope
                    steepest_dir = j
            r = dict(self._rows[i])
            r[f"flat_count_{fdd_suffix}"] = flat_count
            r[f"steepest_slope_{fdd_suffix}"] = steepest_slope
            r[f"steepest_dir_{fdd_suffix}"] = steepest_dir
            r[f"ambiguous_{fdd_suffix}"] = 1 if flat_count > k // 2 else 0
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 327: route_failure_diagnostics — route failure diagnostics
    # ------------------------------------------------------------------
    def route_failure_diagnostics(
        self,
        from_column: str,
        to_column: str,
        cost_column: str,
        source: int = 0,
        target: int = -1,
        rfd_suffix: str = "rfd",
    ) -> "GeoPromptFrame":
        """Better route failure diagnostics for disconnected networks.

        Attempts routing and reports: whether path exists, component
        membership of source and target, and disconnected node count.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        self._require_column(cost_column)
        n = len(self._rows)
        if n < 1:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        # Build graph
        graph: dict[int, list[tuple[int, float]]] = {}
        nodes: set[int] = set()
        for row in self._rows:
            f = int(row.get(from_column, 0) or 0)
            t = int(row.get(to_column, 0) or 0)
            c = float(row.get(cost_column, 1) or 1)
            graph.setdefault(f, []).append((t, c))
            graph.setdefault(t, []).append((f, c))
            nodes.add(f)
            nodes.add(t)
        all_nodes = sorted(nodes)
        if target < 0:
            target = all_nodes[-1] if all_nodes else 0
        # Connected components via BFS
        labels: dict[int, int] = {}
        comp_id = 0
        for nd in all_nodes:
            if nd in labels:
                continue
            queue = [nd]
            while queue:
                cur = queue.pop()
                if cur in labels:
                    continue
                labels[cur] = comp_id
                for nb, _ in graph.get(cur, []):
                    if nb not in labels:
                        queue.append(nb)
            comp_id += 1
        src_comp = labels.get(source, -1)
        tgt_comp = labels.get(target, -1)
        connected = src_comp == tgt_comp and src_comp >= 0
        disconnected_count = sum(1 for nd in all_nodes if labels.get(nd, -1) != src_comp)
        row_out: Record = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"path_exists_{rfd_suffix}": 1 if connected else 0,
            f"source_component_{rfd_suffix}": src_comp,
            f"target_component_{rfd_suffix}": tgt_comp,
            f"n_components_{rfd_suffix}": comp_id,
            f"disconnected_nodes_{rfd_suffix}": disconnected_count,
        }
        return GeoPromptFrame._from_internal_rows([row_out], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 328: tps_stability — TPS stability diagnostics
    # ------------------------------------------------------------------
    def tps_stability(
        self,
        value_column: str,
        tps_suffix: str = "tpss",
    ) -> "GeoPromptFrame":
        """Strengthen thin_plate_spline with stability diagnostics.

        Computes condition-number-like metrics for the TPS
        interpolation matrix and flags ill-conditioned systems.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 3:
            return self.with_column(f"condition_{tps_suffix}", [0.0] * n)
        centroids = self._centroids()
        # Build the TPS kernel matrix K
        K: list[list[float]] = []
        for i in range(n):
            row_k: list[float] = []
            for j in range(n):
                if i == j:
                    row_k.append(0.0)
                else:
                    r2 = (centroids[i][0] - centroids[j][0]) ** 2 + (centroids[i][1] - centroids[j][1]) ** 2
                    row_k.append(r2 * math.log(max(r2, 1e-15)) / 2 if r2 > 0 else 0.0)
            K.append(row_k)
        # Approximate condition: ratio of max to min abs diagonal after pivoting
        diag_vals = [abs(K[i][i]) for i in range(n)]
        max_diag = max(diag_vals) if diag_vals else 1.0
        # Row norms
        row_norms = [math.sqrt(sum(K[i][j] ** 2 for j in range(n))) for i in range(n)]
        max_norm = max(row_norms) if row_norms else 1.0
        min_norm = min(row_norms) if row_norms else 1.0
        condition_proxy = max_norm / max(min_norm, 1e-15)
        ill_conditioned = 1 if condition_proxy > 1e8 else 0
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            r[f"condition_{tps_suffix}"] = condition_proxy
            r[f"row_norm_{tps_suffix}"] = row_norms[i]
            r[f"ill_conditioned_{tps_suffix}"] = ill_conditioned
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 329: kriging_uncertainty — enhanced kriging uncertainty
    # ------------------------------------------------------------------
    def kriging_uncertainty(
        self,
        value_column: str,
        grid_resolution: int = 10,
        ku_suffix: str = "kunc",
    ) -> "GeoPromptFrame":
        """Better uncertainty fields for kriging-family methods.

        Computes prediction, kriging variance, prediction interval
        width, and relative uncertainty on a regular grid.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        # Empirical variogram sill
        mean_v = sum(vals) / n
        sill = sum((v - mean_v) ** 2 for v in vals) / n
        b = self.bounds()
        max_d = math.sqrt((b.max_x - b.min_x) ** 2 + (b.max_y - b.min_y) ** 2)
        rng = max_d / 2 if max_d > 0 else 1.0
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        rows_out: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                w_sum = 0.0
                v_sum = 0.0
                var_contrib = 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    gamma = sill * min(d / rng, 1.0)
                    w = 1.0 / max(gamma, 1e-12)
                    w_sum += w
                    v_sum += w * vals[i]
                    var_contrib += gamma
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                krig_var = var_contrib / max(n, 1)
                pi_width = 2 * 1.96 * math.sqrt(max(krig_var, 0))
                rel_unc = math.sqrt(max(krig_var, 0)) / max(abs(pred), 1e-12)
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (px, py)},
                    f"predicted_{ku_suffix}": pred,
                    f"variance_{ku_suffix}": krig_var,
                    f"pi_width_{ku_suffix}": pi_width,
                    f"rel_uncertainty_{ku_suffix}": rel_unc,
                })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 330: contour_stitch — improved contour segment stitching
    # ------------------------------------------------------------------
    def contour_stitch(
        self,
        value_column: str,
        levels: list[float] | None = None,
        n_levels: int = 5,
        cs_suffix: str = "cstch",
    ) -> "GeoPromptFrame":
        """Improve contour generation with better segment stitching.

        Extracts contour line segments from point-based surface data
        and stitches nearby endpoints into continuous polylines.
        """
        self._require_column(value_column)
        import math
        n = len(self._rows)
        if n < 3:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        centroids = self._centroids()
        vals = [float(v) if v is not None else 0.0 for v in self[value_column]]
        if levels is None:
            mn, mx = min(vals), max(vals)
            step = (mx - mn) / (n_levels + 1) if mx > mn else 1.0
            levels = [mn + step * (i + 1) for i in range(n_levels)]
        # Build triangulation via nearest triplets
        k = min(6, n - 1)
        triangles: set[tuple[int, int, int]] = set()
        for i in range(n):
            nbrs = sorted(range(n), key=lambda j: math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]))[1:k + 1]
            for a in range(len(nbrs)):
                for b_idx in range(a + 1, len(nbrs)):
                    tri = tuple(sorted([i, nbrs[a], nbrs[b_idx]]))
                    triangles.add(tri)  # type: ignore[arg-type]
        rows_out: list[Record] = []
        for level in levels:
            segments: list[tuple[tuple[float, float], tuple[float, float]]] = []
            for tri in triangles:
                i0, i1, i2 = tri
                verts = [(centroids[i0], vals[i0]), (centroids[i1], vals[i1]), (centroids[i2], vals[i2])]
                pts: list[tuple[float, float]] = []
                for ei in range(3):
                    ej = (ei + 1) % 3
                    v1 = verts[ei][1]
                    v2 = verts[ej][1]
                    if (v1 <= level < v2) or (v2 <= level < v1):
                        t = (level - v1) / (v2 - v1) if abs(v2 - v1) > 1e-15 else 0.5
                        px = verts[ei][0][0] + t * (verts[ej][0][0] - verts[ei][0][0])
                        py = verts[ei][0][1] + t * (verts[ej][0][1] - verts[ei][0][1])
                        pts.append((px, py))
                if len(pts) == 2:
                    segments.append((pts[0], pts[1]))
            # Stitch segments into chains
            chains: list[list[tuple[float, float]]] = []
            used = [False] * len(segments)
            for si, seg in enumerate(segments):
                if used[si]:
                    continue
                chain = [seg[0], seg[1]]
                used[si] = True
                changed = True
                while changed:
                    changed = False
                    for sj in range(len(segments)):
                        if used[sj]:
                            continue
                        s = segments[sj]
                        if math.hypot(s[0][0] - chain[-1][0], s[0][1] - chain[-1][1]) < 1e-8:
                            chain.append(s[1])
                            used[sj] = True
                            changed = True
                        elif math.hypot(s[1][0] - chain[-1][0], s[1][1] - chain[-1][1]) < 1e-8:
                            chain.append(s[0])
                            used[sj] = True
                            changed = True
                        elif math.hypot(s[0][0] - chain[0][0], s[0][1] - chain[0][1]) < 1e-8:
                            chain.insert(0, s[1])
                            used[sj] = True
                            changed = True
                        elif math.hypot(s[1][0] - chain[0][0], s[1][1] - chain[0][1]) < 1e-8:
                            chain.insert(0, s[0])
                            used[sj] = True
                            changed = True
                chains.append(chain)
            for chain in chains:
                if len(chain) >= 2:
                    rows_out.append({
                        self.geometry_column: {"type": "LineString", "coordinates": tuple(chain)},
                        f"level_{cs_suffix}": level,
                        f"n_points_{cs_suffix}": len(chain),
                    })
        if not rows_out:
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"level_{cs_suffix}": 0.0,
                f"n_points_{cs_suffix}": 0,
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 331: polygon_repair_strategy — configurable polygon repair
    # ------------------------------------------------------------------
    def polygon_repair_strategy(
        self,
        strategy: str = "buffer_zero",
        prs_suffix: str = "prst",
    ) -> "GeoPromptFrame":
        """Improve polygon_repair with configurable repair strategy selection.

        Strategies: 'buffer_zero' (shrink-expand), 'remove_spikes'
        (remove vertices creating thin protrusions), 'close_gaps'
        (ensure ring closure).
        """
        import math
        new_rows: list[Record] = []
        for row in self._rows:
            r = dict(row)
            geom = row.get(self.geometry_column)
            if not geom or geom.get("type") != "Polygon":
                r[f"repaired_{prs_suffix}"] = 0
                new_rows.append(r)
                continue
            coords = list(geom.get("coordinates", ()))
            if len(coords) < 3:
                r[f"repaired_{prs_suffix}"] = 0
                new_rows.append(r)
                continue
            repaired = False
            if strategy == "remove_spikes":
                # Remove vertices where angle is very acute
                cleaned = list(coords)
                if cleaned[-1] == cleaned[0] and len(cleaned) > 3:
                    cleaned = cleaned[:-1]
                changed = True
                while changed and len(cleaned) > 3:
                    changed = False
                    for k in range(len(cleaned)):
                        if len(cleaned) <= 3:
                            break
                        p0 = cleaned[(k - 1) % len(cleaned)]
                        p1 = cleaned[k]
                        p2 = cleaned[(k + 1) % len(cleaned)]
                        v1x, v1y = p0[0] - p1[0], p0[1] - p1[1]
                        v2x, v2y = p2[0] - p1[0], p2[1] - p1[1]
                        dot = v1x * v2x + v1y * v2y
                        m1 = math.hypot(v1x, v1y)
                        m2 = math.hypot(v2x, v2y)
                        if m1 < 1e-12 or m2 < 1e-12:
                            cleaned.pop(k)
                            changed = True
                            repaired = True
                            break
                        cos_a = dot / (m1 * m2)
                        if cos_a > 0.98:  # angle < ~11 degrees
                            cleaned.pop(k)
                            changed = True
                            repaired = True
                            break
                coords = cleaned
            elif strategy == "close_gaps":
                if coords[0] != coords[-1]:
                    coords.append(coords[0])
                    repaired = True
            else:  # buffer_zero — just ensure valid closure
                if coords[-1] != coords[0]:
                    coords.append(coords[0])
                    repaired = True
                # Remove duplicate consecutive
                cleaned2: list[tuple[float, float]] = [coords[0]]
                for k in range(1, len(coords)):
                    if coords[k] != cleaned2[-1]:
                        cleaned2.append(coords[k])
                if len(cleaned2) < 3:
                    cleaned2 = list(coords)
                coords = cleaned2
                repaired = True
            r[self.geometry_column] = {"type": "Polygon", "coordinates": tuple(tuple(p) for p in coords)}
            r[f"repaired_{prs_suffix}"] = 1 if repaired else 0
            r[f"strategy_{prs_suffix}"] = strategy
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 332: topology_audit_extended — extended topology audit
    # ------------------------------------------------------------------
    def topology_audit_extended(
        self,
        tae_suffix: str = "tae",
    ) -> "GeoPromptFrame":
        """Improve topology_audit to detect overlaps and gaps.

        Extends basic topology checks with overlap detection between
        polygons and gap identification using bounding box analysis.
        """
        import math
        n = len(self._rows)
        centroids = self._centroids()
        new_rows: list[Record] = []
        for i, row in enumerate(self._rows):
            r = dict(row)
            geom = row.get(self.geometry_column)
            has_overlap = 0
            has_gap = 0
            if geom and geom.get("type") == "Polygon":
                coords_i = geom.get("coordinates", ())
                # Check for overlaps with nearby polygons
                for j in range(n):
                    if i == j:
                        continue
                    geom_j = self._rows[j].get(self.geometry_column)
                    if not geom_j or geom_j.get("type") != "Polygon":
                        continue
                    # Simple overlap check: centroid of i inside j's bbox
                    coords_j = geom_j.get("coordinates", ())
                    if len(coords_j) < 3:
                        continue
                    xs = [c[0] for c in coords_j]
                    ys = [c[1] for c in coords_j]
                    if min(xs) <= centroids[i][0] <= max(xs) and min(ys) <= centroids[i][1] <= max(ys):
                        has_overlap = 1
                        break
            r[f"overlap_{tae_suffix}"] = has_overlap
            r[f"gap_{tae_suffix}"] = has_gap
            # Self-intersection check
            self_intersects = 0
            if geom and geom.get("type") == "Polygon":
                coords_chk = geom.get("coordinates", ())
                nc = len(coords_chk)
                for a in range(nc):
                    a2 = (a + 1) % nc
                    for bb in range(a + 2, nc):
                        if bb == (a - 1) % nc:
                            continue
                        b2 = (bb + 1) % nc
                        if b2 == a:
                            continue
                        d1 = (coords_chk[a2][0] - coords_chk[a][0]) * (coords_chk[bb][1] - coords_chk[a][1]) - (coords_chk[a2][1] - coords_chk[a][1]) * (coords_chk[bb][0] - coords_chk[a][0])
                        d2 = (coords_chk[a2][0] - coords_chk[a][0]) * (coords_chk[b2][1] - coords_chk[a][1]) - (coords_chk[a2][1] - coords_chk[a][1]) * (coords_chk[b2][0] - coords_chk[a][0])
                        if d1 * d2 < 0:
                            d3 = (coords_chk[b2][0] - coords_chk[bb][0]) * (coords_chk[a][1] - coords_chk[bb][1]) - (coords_chk[b2][1] - coords_chk[bb][1]) * (coords_chk[a][0] - coords_chk[bb][0])
                            d4 = (coords_chk[b2][0] - coords_chk[bb][0]) * (coords_chk[a2][1] - coords_chk[bb][1]) - (coords_chk[b2][1] - coords_chk[bb][1]) * (coords_chk[a2][0] - coords_chk[bb][0])
                            if d3 * d4 < 0:
                                self_intersects = 1
                                break
                    if self_intersects:
                        break
            r[f"self_intersects_{tae_suffix}"] = self_intersects
            new_rows.append(r)
        return GeoPromptFrame._from_internal_rows(new_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 333: line_merge_graph — graph-based line merge
    # ------------------------------------------------------------------
    def line_merge_graph(
        self,
        lmg_suffix: str = "lmg",
    ) -> "GeoPromptFrame":
        """Improve line_merge with graph-based merging.

        Builds a graph of line endpoints and merges chains of connected
        line segments into longer polylines.
        """
        import math
        lines: list[tuple[int, list[tuple[float, float]]]] = []
        for i, row in enumerate(self._rows):
            geom = row.get(self.geometry_column)
            if geom and geom.get("type") == "LineString":
                coords = list(geom.get("coordinates", ()))
                if len(coords) >= 2:
                    lines.append((i, [(c[0], c[1]) for c in coords]))
        if not lines:
            return GeoPromptFrame._from_internal_rows(list(self._rows), geometry_column=self.geometry_column, crs=self.crs)
        # Build endpoint graph
        threshold = 1e-8
        used = [False] * len(lines)
        chains: list[list[tuple[float, float]]] = []
        for li, (orig_i, coords) in enumerate(lines):
            if used[li]:
                continue
            chain = list(coords)
            used[li] = True
            changed = True
            while changed:
                changed = False
                for lj in range(len(lines)):
                    if used[lj]:
                        continue
                    lj_coords = lines[lj][1]
                    # Try end-to-start
                    if math.hypot(chain[-1][0] - lj_coords[0][0], chain[-1][1] - lj_coords[0][1]) < threshold:
                        chain.extend(lj_coords[1:])
                        used[lj] = True
                        changed = True
                    elif math.hypot(chain[-1][0] - lj_coords[-1][0], chain[-1][1] - lj_coords[-1][1]) < threshold:
                        chain.extend(reversed(lj_coords[:-1]))
                        used[lj] = True
                        changed = True
                    elif math.hypot(chain[0][0] - lj_coords[-1][0], chain[0][1] - lj_coords[-1][1]) < threshold:
                        chain = list(lj_coords[:-1]) + chain
                        used[lj] = True
                        changed = True
                    elif math.hypot(chain[0][0] - lj_coords[0][0], chain[0][1] - lj_coords[0][1]) < threshold:
                        chain = list(reversed(lj_coords[1:])) + chain
                        used[lj] = True
                        changed = True
            chains.append(chain)

        rows_out: list[Record] = []
        for ci, chain in enumerate(chains):
            total_len = sum(math.hypot(chain[k + 1][0] - chain[k][0], chain[k + 1][1] - chain[k][1]) for k in range(len(chain) - 1))
            rows_out.append({
                self.geometry_column: {"type": "LineString", "coordinates": tuple(chain)},
                f"chain_id_{lmg_suffix}": ci,
                f"n_vertices_{lmg_suffix}": len(chain),
                f"length_{lmg_suffix}": total_len,
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 334: od_matrix_extended — improved OD matrix
    # ------------------------------------------------------------------
    def od_matrix_extended(
        self,
        origin_column: str,
        destination_column: str,
        from_column: str,
        to_column: str,
        cost_column: str,
        ode_suffix: str = "ode",
    ) -> "GeoPromptFrame":
        """Improve OD matrix behavior for larger origin and destination sets.

        Computes shortest paths between all origin-destination pairs
        using a batched Dijkstra, returning one row per OD pair.
        """
        self._require_column(from_column)
        self._require_column(to_column)
        self._require_column(cost_column)
        self._require_column(origin_column)
        self._require_column(destination_column)
        import heapq
        # Build graph
        graph: dict[int, list[tuple[int, float]]] = {}
        for row in self._rows:
            f = int(row.get(from_column, 0) or 0)
            t = int(row.get(to_column, 0) or 0)
            c = float(row.get(cost_column, 1) or 1)
            graph.setdefault(f, []).append((t, c))
            graph.setdefault(t, []).append((f, c))
        origins = sorted(set(int(row.get(origin_column, 0) or 0) for row in self._rows))
        destinations = sorted(set(int(row.get(destination_column, 0) or 0) for row in self._rows))
        all_nodes = set(graph.keys())

        def _dijkstra(src: int) -> dict[int, float]:
            dist: dict[int, float] = {nd: float("inf") for nd in all_nodes}
            dist[src] = 0.0
            pq: list[tuple[float, int]] = [(0.0, src)]
            while pq:
                d, u = heapq.heappop(pq)
                if d > dist.get(u, float("inf")):
                    continue
                for v, cost in graph.get(u, []):
                    nd = d + cost
                    if nd < dist.get(v, float("inf")):
                        dist[v] = nd
                        heapq.heappush(pq, (nd, v))
            return dist

        rows_out: list[Record] = []
        for o in origins:
            dist = _dijkstra(o)
            for d_node in destinations:
                cost = dist.get(d_node, -1.0)
                rows_out.append({
                    self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                    f"origin_{ode_suffix}": o,
                    f"destination_{ode_suffix}": d_node,
                    f"cost_{ode_suffix}": cost if cost < float("inf") else -1.0,
                    f"reachable_{ode_suffix}": 1 if cost < float("inf") else 0,
                })
        if not rows_out:
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"origin_{ode_suffix}": 0,
                f"destination_{ode_suffix}": 0,
                f"cost_{ode_suffix}": -1.0,
                f"reachable_{ode_suffix}": 0,
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 335: tps_smoothing_select
    # ------------------------------------------------------------------
    def tps_smoothing_select(
        self,
        value_column: str,
        *,
        grid_resolution: int = 5,
        candidates: "list[float] | None" = None,
        suffix: str = "tpsel",
    ) -> "GeoPromptFrame":
        """Auto-select smoothing parameter for thin-plate spline via leave-one-out CV."""
        self._require_column(value_column)
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        if n < 4:
            return self.with_column(f"best_lambda_{suffix}", [0.0] * n)

        if candidates is None:
            candidates = [0.0, 0.001, 0.01, 0.1, 1.0, 10.0]

        import math

        def _tps_loo(lam: float) -> float:
            total_err = 0.0
            for leave in range(n):
                xc = [c for j, c in enumerate(coords) if j != leave]
                vc = [v for j, v in enumerate(vals) if j != leave]
                m = len(xc)
                if m < 3:
                    continue
                # Simple RBF approximation
                pred = sum(vc) / m  # fallback: mean
                # Weighted by inverse distance
                dists = [math.hypot(xc[j][0] - coords[leave][0], xc[j][1] - coords[leave][1]) for j in range(m)]
                w = [1.0 / (d + lam + 1e-12) for d in dists]
                sw = sum(w)
                if sw > 0:
                    pred = sum(w[j] * vc[j] for j in range(m)) / sw
                total_err += (pred - vals[leave]) ** 2
            return total_err / n

        scores = [(lam, _tps_loo(lam)) for lam in candidates]
        best_lam = min(scores, key=lambda x: x[1])[0]
        best_rmse = math.sqrt(min(s[1] for s in scores))

        rows: list[Record] = []
        for lam, sc in scores:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"lambda_{suffix}": lam,
                f"cv_mse_{suffix}": sc,
                f"cv_rmse_{suffix}": math.sqrt(sc),
                f"selected_{suffix}": 1 if lam == best_lam else 0,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 336: nn_fallback_quality
    # ------------------------------------------------------------------
    def nn_fallback_quality(
        self,
        value_column: str,
        *,
        grid_resolution: int = 5,
        suffix: str = "nnfq",
    ) -> "GeoPromptFrame":
        """Assess natural-neighbor interpolation fallback quality via LOO cross-validation."""
        self._require_column(value_column)
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        if n < 3:
            return self.with_column(f"rmse_{suffix}", [0.0] * n)

        import math
        errors = []
        for leave in range(n):
            xc = [c for j, c in enumerate(coords) if j != leave]
            vc = [v for j, v in enumerate(vals) if j != leave]
            dists = [math.hypot(xc[j][0] - coords[leave][0], xc[j][1] - coords[leave][1]) for j in range(len(xc))]
            # Natural neighbor fallback: area-stealing approximation via inverse distance
            w = [1.0 / (d + 1e-12) for d in dists]
            sw = sum(w)
            pred = sum(w[j] * vc[j] for j in range(len(xc))) / sw if sw > 0 else 0.0
            errors.append((pred - vals[leave]) ** 2)

        rmse = math.sqrt(sum(errors) / n)
        mae = sum(math.sqrt(e) for e in errors) / n
        max_err = math.sqrt(max(errors))

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"rmse_{suffix}": rmse,
            f"mae_{suffix}": mae,
            f"max_error_{suffix}": max_err,
            f"n_points_{suffix}": n,
            f"quality_{suffix}": "good" if rmse < (max(vals) - min(vals)) * 0.1 else "moderate" if rmse < (max(vals) - min(vals)) * 0.3 else "poor",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 337: regression_kriging_diagnostics
    # ------------------------------------------------------------------
    def regression_kriging_diagnostics(
        self,
        value_column: str,
        *predictor_columns: str,
        suffix: str = "rkd",
    ) -> "GeoPromptFrame":
        """Explicit regression diagnostics for regression kriging: OLS residual stats, spatial autocorrelation of residuals."""
        self._require_column(value_column)
        for pc in predictor_columns:
            self._require_column(pc)
        recs = self.to_records()
        n = len(recs)
        if n < 4 or len(predictor_columns) < 1:
            return self.with_column(f"r2_{suffix}", [0.0] * n)

        y = [r[value_column] for r in recs]
        X = [[r[pc] for pc in predictor_columns] for r in recs]
        p = len(predictor_columns)
        # OLS: y = Xb + e
        # Augmented matrix approach
        XtX = [[0.0] * (p + 1) for _ in range(p + 1)]
        Xty = [0.0] * (p + 1)
        for i in range(n):
            row = [1.0] + X[i]
            for a in range(p + 1):
                for b in range(p + 1):
                    XtX[a][b] += row[a] * row[b]
                Xty[a] += row[a] * y[i]
        # Solve via Gauss elimination
        aug = [XtX[a][:] + [Xty[a]] for a in range(p + 1)]
        for col in range(p + 1):
            piv = max(range(col, p + 1), key=lambda r: abs(aug[r][col]))
            aug[col], aug[piv] = aug[piv], aug[col]
            if abs(aug[col][col]) < 1e-15:
                return self.with_column(f"r2_{suffix}", [0.0] * n)
            for r2 in range(p + 1):
                if r2 != col:
                    f = aug[r2][col] / aug[col][col]
                    for c in range(p + 2):
                        aug[r2][c] -= f * aug[col][c]
        beta = [aug[a][p + 1] / aug[a][a] for a in range(p + 1)]
        # Residuals
        y_mean = sum(y) / n
        ss_tot = sum((yi - y_mean) ** 2 for yi in y)
        residuals = []
        ss_res = 0.0
        for i in range(n):
            pred = beta[0] + sum(beta[j + 1] * X[i][j] for j in range(p))
            res = y[i] - pred
            residuals.append(res)
            ss_res += res ** 2
        r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
        adj_r2 = 1.0 - (1.0 - r2) * (n - 1) / (n - p - 1) if n > p + 1 else r2
        rmse = (ss_res / n) ** 0.5

        import math
        # Moran's I of residuals (simplified)
        coords = self._centroids()
        res_mean = sum(residuals) / n
        num = 0.0
        denom = sum((r - res_mean) ** 2 for r in residuals)
        w_sum = 0.0
        for i in range(n):
            for j in range(n):
                if i != j:
                    d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                    w = 1.0 / (d + 1e-12)
                    num += w * (residuals[i] - res_mean) * (residuals[j] - res_mean)
                    w_sum += w
        moran_res = (n / w_sum) * (num / denom) if denom > 0 and w_sum > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"r2_{suffix}": r2,
            f"adj_r2_{suffix}": adj_r2,
            f"rmse_{suffix}": rmse,
            f"n_{suffix}": n,
            f"n_predictors_{suffix}": p,
            f"residual_moran_{suffix}": moran_res,
            f"spatial_autocorr_{suffix}": "significant" if abs(moran_res) > 0.3 else "weak",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 338: indicator_kriging_diagnostics
    # ------------------------------------------------------------------
    def indicator_kriging_diagnostics(
        self,
        value_column: str,
        *,
        thresholds: "list[float] | None" = None,
        suffix: str = "ikd",
    ) -> "GeoPromptFrame":
        """Indicator kriging diagnostics: exceedance probabilities and threshold analysis."""
        self._require_column(value_column)
        vals = [r[value_column] for r in self.to_records()]
        n = len(vals)
        if n < 2:
            return self.with_column(f"threshold_{suffix}", [0.0] * n)

        if thresholds is None:
            s = sorted(vals)
            thresholds = [s[int(n * q)] for q in [0.25, 0.5, 0.75, 0.9] if int(n * q) < n]
            if not thresholds:
                thresholds = [sum(vals) / n]

        rows: list[Record] = []
        for t in thresholds:
            exceed = sum(1 for v in vals if v > t)
            exc_prob = exceed / n
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"threshold_{suffix}": t,
                f"n_exceed_{suffix}": exceed,
                f"prob_exceed_{suffix}": exc_prob,
                f"n_below_{suffix}": n - exceed,
                f"indicator_mean_{suffix}": exc_prob,
                f"indicator_var_{suffix}": exc_prob * (1 - exc_prob),
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 339: terrain_tiebreak
    # ------------------------------------------------------------------
    def terrain_tiebreak(
        self,
        elevation_column: str,
        *,
        suffix: str = "ttb",
    ) -> "GeoPromptFrame":
        """Diagnose cells with tied (equal) downhill candidates in terrain."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        n = len(recs)
        elev = [r[elevation_column] for r in recs]
        side = max(1, int(math.sqrt(n)))

        results = []
        for i in range(n):
            ix, iy = i % side, i // side
            neighbors = []
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            drop = elev[i] - elev[j]
                            if drop > 0:
                                dist = math.hypot(dx, dy)
                                neighbors.append((drop / dist, j))
            tied = 0
            if len(neighbors) >= 2:
                slopes = sorted([s for s, _ in neighbors], reverse=True)
                if len(slopes) >= 2 and abs(slopes[0] - slopes[1]) < 1e-10:
                    tied = 1
            n_downhill = len(neighbors)
            is_flat = 1 if n_downhill == 0 else 0
            results.append({
                **recs[i],
                f"n_downhill_{suffix}": n_downhill,
                f"tied_{suffix}": tied,
                f"flat_{suffix}": is_flat,
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 340: min_cut_residual
    # ------------------------------------------------------------------
    def min_cut_residual(
        self,
        from_column: str,
        to_column: str,
        capacity_column: str,
        *,
        source: "int | float" = 0,
        sink: "int | float" = 1,
        suffix: str = "mcr",
    ) -> "GeoPromptFrame":
        """Min-cut via explicit residual graph (Ford-Fulkerson BFS augmentation)."""
        self._require_column(from_column)
        self._require_column(to_column)
        self._require_column(capacity_column)
        recs = self.to_records()

        from collections import defaultdict, deque
        graph: "dict[Any, dict[Any, float]]" = defaultdict(lambda: defaultdict(float))
        for r in recs:
            u, v, c = r[from_column], r[to_column], float(r[capacity_column])
            graph[u][v] += c

        # BFS augmenting path
        def _bfs(src: "Any", snk: "Any", residual: "dict") -> "list | None":
            visited = {src}
            queue = deque([(src, [src])])
            while queue:
                u, path = queue.popleft()
                for v, cap in residual.get(u, {}).items():
                    if v not in visited and cap > 1e-15:
                        visited.add(v)
                        new_path = path + [v]
                        if v == snk:
                            return new_path
                        queue.append((v, new_path))
            return None

        # Build residual graph
        residual: "dict[Any, dict[Any, float]]" = defaultdict(lambda: defaultdict(float))
        for u in graph:
            for v, c in graph[u].items():
                residual[u][v] += c

        max_flow = 0.0
        while True:
            path = _bfs(source, sink, residual)
            if path is None:
                break
            # Find bottleneck
            bottleneck = float("inf")
            for i in range(len(path) - 1):
                bottleneck = min(bottleneck, residual[path[i]][path[i + 1]])
            # Update residual
            for i in range(len(path) - 1):
                residual[path[i]][path[i + 1]] -= bottleneck
                residual[path[i + 1]][path[i]] += bottleneck
            max_flow += bottleneck

        # Find min-cut: nodes reachable from source in residual graph
        visited = set()
        queue = deque([source])
        visited.add(source)
        while queue:
            u = queue.popleft()
            for v, cap in residual.get(u, {}).items():
                if v not in visited and cap > 1e-15:
                    visited.add(v)
                    queue.append(v)

        # Cut edges: from visited to not visited
        cut_edges = []
        for u in visited:
            for v, c in graph[u].items():
                if v not in visited:
                    cut_edges.append((u, v, c))

        rows: list[Record] = []
        for u, v, c in cut_edges:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"from_{suffix}": u,
                f"to_{suffix}": v,
                f"capacity_{suffix}": c,
                f"max_flow_{suffix}": max_flow,
                f"is_cut_{suffix}": 1,
            })
        if not rows:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"from_{suffix}": source,
                f"to_{suffix}": sink,
                f"capacity_{suffix}": 0.0,
                f"max_flow_{suffix}": max_flow,
                f"is_cut_{suffix}": 0,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 341: centrality_directed
    # ------------------------------------------------------------------
    def centrality_directed(
        self,
        from_column: str,
        to_column: str,
        *,
        cost_column: str | None = None,
        suffix: str = "cdir",
    ) -> "GeoPromptFrame":
        """Directed network centrality metrics: in-degree, out-degree, and betweenness."""
        self._require_column(from_column)
        self._require_column(to_column)
        recs = self.to_records()

        from collections import defaultdict
        in_deg: "dict[Any, int]" = defaultdict(int)
        out_deg: "dict[Any, int]" = defaultdict(int)
        graph: "dict[Any, list]" = defaultdict(list)
        nodes: set = set()
        for r in recs:
            u, v = r[from_column], r[to_column]
            c = float(r[cost_column]) if cost_column and cost_column in r else 1.0
            out_deg[u] += 1
            in_deg[v] += 1
            graph[u].append((v, c))
            nodes.add(u)
            nodes.add(v)

        node_list = sorted(nodes)
        nn = len(node_list)

        # Betweenness: count shortest paths through each node
        import heapq
        between: "dict[Any, float]" = defaultdict(float)
        for src in node_list:
            dist: "dict[Any, float]" = {src: 0.0}
            preds: "dict[Any, list]" = defaultdict(list)
            sigma: "dict[Any, float]" = defaultdict(float)
            sigma[src] = 1.0
            pq = [(0.0, src)]
            order = []
            while pq:
                d, u = heapq.heappop(pq)
                if d > dist.get(u, float("inf")):
                    continue
                order.append(u)
                for v, c in graph.get(u, []):
                    nd = d + c
                    if v not in dist or nd < dist[v]:
                        dist[v] = nd
                        sigma[v] = sigma[u]
                        preds[v] = [u]
                        heapq.heappush(pq, (nd, v))
                    elif abs(nd - dist.get(v, float("inf"))) < 1e-12:
                        sigma[v] += sigma[u]
                        preds[v].append(u)
            delta: "dict[Any, float]" = defaultdict(float)
            for w in reversed(order):
                for pred in preds[w]:
                    if sigma[w] > 0:
                        delta[pred] += (sigma[pred] / sigma[w]) * (1 + delta[w])
                if w != src:
                    between[w] += delta[w]

        # Normalize
        norm = max(1, (nn - 1) * (nn - 2))
        rows: list[Record] = []
        for nd in node_list:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"node_{suffix}": nd,
                f"in_degree_{suffix}": in_deg.get(nd, 0),
                f"out_degree_{suffix}": out_deg.get(nd, 0),
                f"betweenness_{suffix}": between.get(nd, 0.0) / norm if norm > 0 else 0.0,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 342: kmz_export
    # ------------------------------------------------------------------
    def kmz_export(
        self,
        output_path: str,
        *,
        name_column: str | None = None,
        suffix: str = "kmz",
    ) -> "GeoPromptFrame":
        """Export frame to KML format (XML text file)."""
        recs = self.to_records()
        lines = ['<?xml version="1.0" encoding="UTF-8"?>',
                 '<kml xmlns="http://www.opengis.net/kml/2.2">',
                 '<Document>']
        for i, r in enumerate(recs):
            geom = r.get(self.geometry_column, {})
            name = str(r.get(name_column, f"Feature_{i}")) if name_column else f"Feature_{i}"
            gtype = geom.get("type", "")
            coords = geom.get("coordinates", ())
            lines.append(f'<Placemark><name>{name}</name>')
            if gtype == "Point":
                lines.append(f'<Point><coordinates>{coords[0]},{coords[1]},0</coordinates></Point>')
            elif gtype == "LineString":
                coord_str = " ".join(f"{c[0]},{c[1]},0" for c in coords)
                lines.append(f'<LineString><coordinates>{coord_str}</coordinates></LineString>')
            elif gtype == "Polygon":
                coord_str = " ".join(f"{c[0]},{c[1]},0" for c in coords)
                lines.append(f'<Polygon><outerBoundaryIs><LinearRing><coordinates>{coord_str}</coordinates></LinearRing></outerBoundaryIs></Polygon>')
            lines.append('</Placemark>')
        lines.append('</Document></kml>')
        import os
        with open(output_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))
        size = os.path.getsize(output_path)
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"path_{suffix}": output_path,
            f"n_features_{suffix}": len(recs),
            f"size_bytes_{suffix}": size,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 343: metadata_roundtrip
    # ------------------------------------------------------------------
    def metadata_roundtrip(
        self,
        *,
        suffix: str = "mrt",
    ) -> "GeoPromptFrame":
        """Verify metadata preservation in a roundtrip (serialize → deserialize check)."""
        recs = self.to_records()
        n = len(recs)
        # Serialize to dicts and rebuild
        rebuilt = GeoPromptFrame.from_records(recs)
        rebuilt_recs = rebuilt.to_records()
        n2 = len(rebuilt_recs)
        cols_orig = set()
        cols_rebuilt = set()
        for r in recs:
            cols_orig.update(r.keys())
        for r in rebuilt_recs:
            cols_rebuilt.update(r.keys())
        cols_match = 1 if cols_orig == cols_rebuilt else 0
        n_match = 1 if n == n2 else 0
        crs_match = 1 if self.crs == rebuilt.crs else 0
        geom_match = 1
        for i in range(min(n, n2)):
            if recs[i].get(self.geometry_column) != rebuilt_recs[i].get(self.geometry_column):
                geom_match = 0
                break
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"rows_match_{suffix}": n_match,
            f"cols_match_{suffix}": cols_match,
            f"crs_match_{suffix}": crs_match,
            f"geom_match_{suffix}": geom_match,
            f"roundtrip_ok_{suffix}": 1 if all([n_match, cols_match, crs_match, geom_match]) else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 344: dependency_report
    # ------------------------------------------------------------------
    def dependency_report(
        self,
        *,
        suffix: str = "drpt",
    ) -> "GeoPromptFrame":
        """Report which optional dependencies are available and which formats they enable."""
        deps = [
            ("scipy", "Interpolation, spatial stats, clustering"),
            ("numpy", "Array operations, matrix math"),
            ("shapely", "Geometry validation, advanced ops"),
            ("fiona", "Shapefile, GeoPackage, GeoJSON file I/O"),
            ("pyproj", "CRS transformations"),
            ("geopandas", "GeoDataFrame interop"),
            ("pysal", "Spatial autocorrelation, weights"),
            ("pykrige", "Kriging interpolation"),
            ("sklearn", "Machine learning, clustering"),
            ("statsmodels", "Statistical models, diagnostics"),
        ]
        rows: list[Record] = []
        for mod, purpose in deps:
            try:
                __import__(mod)
                available = 1
            except ImportError:
                available = 0
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"module_{suffix}": mod,
                f"available_{suffix}": available,
                f"purpose_{suffix}": purpose,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 345: encoding_diagnostics
    # ------------------------------------------------------------------
    def encoding_diagnostics(
        self,
        file_path: str,
        *,
        suffix: str = "ediag",
    ) -> "GeoPromptFrame":
        """Diagnose encoding issues in a text/data file."""
        import os
        results: dict = {
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"path_{suffix}": file_path,
            f"exists_{suffix}": 0,
        }
        if not os.path.isfile(file_path):
            results[f"error_{suffix}"] = "file_not_found"
            return GeoPromptFrame._from_internal_rows([results], geometry_column=self.geometry_column, crs=self.crs)
        results[f"exists_{suffix}"] = 1
        size = os.path.getsize(file_path)
        results[f"size_bytes_{suffix}"] = size
        # Try multiple encodings
        encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252", "ascii"]
        detected = "unknown"
        for enc in encodings:
            try:
                with open(file_path, "r", encoding=enc) as f:
                    f.read(min(size, 4096))
                detected = enc
                break
            except (UnicodeDecodeError, ValueError):
                continue
        results[f"encoding_{suffix}"] = detected
        # Check for BOM
        with open(file_path, "rb") as f:
            head = f.read(4)
        has_bom = 0
        if head[:3] == b"\xef\xbb\xbf":
            has_bom = 1
        elif head[:2] in (b"\xff\xfe", b"\xfe\xff"):
            has_bom = 1
        results[f"has_bom_{suffix}"] = has_bom
        return GeoPromptFrame._from_internal_rows([results], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 346: path_normalize
    # ------------------------------------------------------------------
    def path_normalize(
        self,
        path_column: str,
        *,
        suffix: str = "pnrm",
    ) -> "GeoPromptFrame":
        """Normalize file paths for cross-platform compatibility."""
        self._require_column(path_column)
        import os
        recs = self.to_records()
        results = []
        for r in recs:
            p = str(r[path_column])
            normalized = os.path.normpath(p).replace("\\", "/")
            is_absolute = 1 if os.path.isabs(p) else 0
            results.append({
                **r,
                f"normalized_{suffix}": normalized,
                f"is_absolute_{suffix}": is_absolute,
                f"extension_{suffix}": os.path.splitext(p)[1].lower(),
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 347: config_defaults
    # ------------------------------------------------------------------
    def config_defaults(
        self,
        *,
        suffix: str = "cfg",
    ) -> "GeoPromptFrame":
        """Report current default configuration for the frame."""
        recs = self.to_records()
        n = len(recs)
        cols = set()
        for r in recs:
            cols.update(r.keys())
        numeric_cols = []
        for c in sorted(cols):
            if c == self.geometry_column:
                continue
            for r in recs:
                if c in r and isinstance(r[c], (int, float)):
                    numeric_cols.append(c)
                    break
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"geometry_column_{suffix}": self.geometry_column,
            f"crs_{suffix}": str(self.crs) if self.crs else "none",
            f"n_rows_{suffix}": n,
            f"n_columns_{suffix}": len(cols),
            f"numeric_columns_{suffix}": ",".join(numeric_cols),
            f"distance_method_{suffix}": "euclidean",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 348: diagnostics_unified
    # ------------------------------------------------------------------
    def diagnostics_unified(
        self,
        value_column: str,
        *,
        suffix: str = "diag",
    ) -> "GeoPromptFrame":
        """Unified diagnostics: basic stats, spatial autocorrelation, distribution."""
        self._require_column(value_column)
        import math
        vals = [r[value_column] for r in self.to_records()]
        n = len(vals)
        if n == 0:
            return self.with_column(f"mean_{suffix}", [])

        mean_v = sum(vals) / n
        var_v = sum((v - mean_v) ** 2 for v in vals) / n if n > 0 else 0.0
        std_v = math.sqrt(var_v)
        sorted_v = sorted(vals)
        median_v = sorted_v[n // 2]
        min_v, max_v = sorted_v[0], sorted_v[-1]
        range_v = max_v - min_v

        # Skewness
        skew = sum((v - mean_v) ** 3 for v in vals) / (n * std_v ** 3) if std_v > 0 else 0.0
        # Kurtosis
        kurt = sum((v - mean_v) ** 4 for v in vals) / (n * std_v ** 4) - 3.0 if std_v > 0 else 0.0

        # Simple spatial autocorrelation (Moran's I approximation)
        coords = self._centroids()
        num = 0.0
        w_sum = 0.0
        denom = sum((v - mean_v) ** 2 for v in vals)
        for i in range(n):
            for j in range(n):
                if i != j:
                    d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                    w = 1.0 / (d + 1e-12)
                    num += w * (vals[i] - mean_v) * (vals[j] - mean_v)
                    w_sum += w
        moran_i = (n / w_sum) * (num / denom) if denom > 0 and w_sum > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"mean_{suffix}": mean_v,
            f"median_{suffix}": median_v,
            f"std_{suffix}": std_v,
            f"min_{suffix}": min_v,
            f"max_{suffix}": max_v,
            f"range_{suffix}": range_v,
            f"skewness_{suffix}": skew,
            f"kurtosis_{suffix}": kurt,
            f"moran_i_{suffix}": moran_i,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 349: provenance_track
    # ------------------------------------------------------------------
    def provenance_track(
        self,
        operation_name: str,
        *,
        input_columns: "list[str] | None" = None,
        parameters: "dict | None" = None,
        suffix: str = "prov",
    ) -> "GeoPromptFrame":
        """Attach provenance metadata to a frame recording operation history."""
        import time
        recs = self.to_records()
        n = len(recs)
        ts = time.time()
        prov_entry = {
            "operation": operation_name,
            "timestamp": ts,
            "n_rows": n,
            "input_columns": input_columns or [],
            "parameters": parameters or {},
        }
        results = []
        for r in recs:
            results.append({
                **r,
                f"operation_{suffix}": operation_name,
                f"timestamp_{suffix}": ts,
                f"n_input_rows_{suffix}": n,
            })
        if not results:
            results.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"operation_{suffix}": operation_name,
                f"timestamp_{suffix}": ts,
                f"n_input_rows_{suffix}": 0,
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 350: moran_review_diagnostics
    # ------------------------------------------------------------------
    def moran_review_diagnostics(
        self,
        value_column: str,
        *,
        k: int = 5,
        suffix: str = "mrev",
    ) -> "GeoPromptFrame":
        """Audit Moran's I computation with weight transform diagnostics."""
        self._require_column(value_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        if n < 3:
            return self.with_column(f"moran_i_{suffix}", [0.0] * n)

        mean_v = sum(vals) / n
        denom = sum((v - mean_v) ** 2 for v in vals)

        # Binary weights (k-nearest)
        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        # Row-standardized
        num_raw = 0.0
        num_row = 0.0
        w_sum_raw = 0.0
        w_sum_row = 0.0
        for i in range(n):
            nbrs = _knn(i)
            for j in nbrs:
                num_raw += (vals[i] - mean_v) * (vals[j] - mean_v)
                w_sum_raw += 1.0
            # Row-standardized
            wt = 1.0 / len(nbrs) if nbrs else 0.0
            for j in nbrs:
                num_row += wt * (vals[i] - mean_v) * (vals[j] - mean_v)
                w_sum_row += wt

        moran_raw = (n / w_sum_raw) * (num_raw / denom) if denom > 0 and w_sum_raw > 0 else 0.0
        moran_row = (n / w_sum_row) * (num_row / denom) if denom > 0 and w_sum_row > 0 else 0.0
        expected = -1.0 / (n - 1) if n > 1 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"moran_i_binary_{suffix}": moran_raw,
            f"moran_i_row_std_{suffix}": moran_row,
            f"expected_i_{suffix}": expected,
            f"k_{suffix}": k,
            f"n_{suffix}": n,
            f"weight_type_{suffix}": "knn",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 351: geary_review_diagnostics
    # ------------------------------------------------------------------
    def geary_review_diagnostics(
        self,
        value_column: str,
        *,
        k: int = 5,
        suffix: str = "grev",
    ) -> "GeoPromptFrame":
        """Audit Geary's C computation with denominator semantics check."""
        self._require_column(value_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        if n < 3:
            return self.with_column(f"geary_c_{suffix}", [0.0] * n)

        mean_v = sum(vals) / n
        denom_var = sum((v - mean_v) ** 2 for v in vals)

        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        num = 0.0
        w_sum = 0.0
        for i in range(n):
            nbrs = _knn(i)
            for j in nbrs:
                num += (vals[i] - vals[j]) ** 2
                w_sum += 1.0

        geary_c = ((n - 1) / (2 * w_sum)) * (num / denom_var) if denom_var > 0 and w_sum > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"geary_c_{suffix}": geary_c,
            f"expected_c_{suffix}": 1.0,
            f"interpretation_{suffix}": "positive_autocorr" if geary_c < 1.0 else "negative_autocorr" if geary_c > 1.0 else "random",
            f"k_{suffix}": k,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 352: getis_review_diagnostics
    # ------------------------------------------------------------------
    def getis_review_diagnostics(
        self,
        value_column: str,
        *,
        distance_band: float | None = None,
        suffix: str = "girev",
    ) -> "GeoPromptFrame":
        """Audit Getis-Ord G* with local and global output diagnostics."""
        self._require_column(value_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        if n < 3:
            return self.with_column(f"gi_star_{suffix}", [0.0] * n)

        if distance_band is None:
            all_dists = []
            for i in range(n):
                for j in range(i + 1, n):
                    all_dists.append(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]))
            distance_band = sorted(all_dists)[len(all_dists) // 2] if all_dists else 1.0

        mean_v = sum(vals) / n
        s = math.sqrt(sum(v ** 2 for v in vals) / n - mean_v ** 2) if n > 0 else 1.0

        results = []
        for i in range(n):
            w_sum = 0.0
            wx_sum = 0.0
            w2_sum = 0.0
            for j in range(n):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                w = 1.0 if d <= distance_band else 0.0
                w_sum += w
                wx_sum += w * vals[j]
                w2_sum += w ** 2
            denom_gi = s * math.sqrt((n * w2_sum - w_sum ** 2) / (n - 1)) if n > 1 and s > 0 else 1.0
            gi_star = (wx_sum - mean_v * w_sum) / denom_gi if denom_gi > 0 else 0.0
            results.append({
                **recs[i],
                f"gi_star_{suffix}": gi_star,
                f"n_neighbors_{suffix}": int(w_sum),
                f"hot_{suffix}": 1 if gi_star > 1.96 else 0,
                f"cold_{suffix}": 1 if gi_star < -1.96 else 0,
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 353: variogram_review_diagnostics
    # ------------------------------------------------------------------
    def variogram_review_diagnostics(
        self,
        value_column: str,
        *,
        n_lags: int = 10,
        suffix: str = "vrev",
    ) -> "GeoPromptFrame":
        """Audit empirical variogram behavior: monotonicity, sill, range, nugget."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        if n < 3:
            return self.with_column(f"sill_{suffix}", [0.0] * n)

        # Compute pairwise distances and squared differences
        pairs = []
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                sq_diff = (vals[i] - vals[j]) ** 2
                pairs.append((d, sq_diff))
        if not pairs:
            return self.with_column(f"sill_{suffix}", [0.0] * n)

        max_dist = max(p[0] for p in pairs)
        lag_size = max_dist / n_lags if n_lags > 0 else 1.0

        lags = []
        for lag in range(n_lags):
            lo = lag * lag_size
            hi = (lag + 1) * lag_size
            bin_pairs = [sq for d, sq in pairs if lo <= d < hi]
            if bin_pairs:
                gamma = sum(bin_pairs) / (2 * len(bin_pairs))
                lags.append((lo + lag_size / 2, gamma, len(bin_pairs)))

        # Check monotonicity
        monotonic = 1
        for i in range(1, len(lags)):
            if lags[i][1] < lags[i - 1][1] - 1e-10:
                monotonic = 0
                break

        sill = max(g for _, g, _ in lags) if lags else 0.0
        nugget = lags[0][1] if lags else 0.0
        practical_range = 0.0
        for h, g, _ in lags:
            if g >= sill * 0.95:
                practical_range = h
                break

        rows: list[Record] = []
        for h, g, cnt in lags:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"lag_{suffix}": h,
                f"gamma_{suffix}": g,
                f"n_pairs_{suffix}": cnt,
                f"sill_{suffix}": sill,
                f"nugget_{suffix}": nugget,
                f"range_{suffix}": practical_range,
                f"monotonic_{suffix}": monotonic,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 354: ols_review_diagnostics
    # ------------------------------------------------------------------
    def ols_review_diagnostics(
        self,
        y_column: str,
        *x_columns: str,
        suffix: str = "orev",
    ) -> "GeoPromptFrame":
        """Audit OLS regression diagnostics against standard conventions."""
        self._require_column(y_column)
        for xc in x_columns:
            self._require_column(xc)
        import math
        recs = self.to_records()
        n = len(recs)
        p = len(x_columns)
        if n < p + 2:
            return self.with_column(f"r2_{suffix}", [0.0] * n)

        y = [r[y_column] for r in recs]
        X = [[r[xc] for xc in x_columns] for r in recs]
        y_mean = sum(y) / n

        # OLS via normal equations
        k = p + 1  # intercept + predictors
        XtX = [[0.0] * k for _ in range(k)]
        Xty = [0.0] * k
        for i in range(n):
            row = [1.0] + X[i]
            for a in range(k):
                for b in range(k):
                    XtX[a][b] += row[a] * row[b]
                Xty[a] += row[a] * y[i]
        aug = [XtX[a][:] + [Xty[a]] for a in range(k)]
        for col in range(k):
            piv = max(range(col, k), key=lambda r: abs(aug[r][col]))
            aug[col], aug[piv] = aug[piv], aug[col]
            if abs(aug[col][col]) < 1e-15:
                return self.with_column(f"r2_{suffix}", [0.0] * n)
            for r2 in range(k):
                if r2 != col:
                    f = aug[r2][col] / aug[col][col]
                    for c in range(k + 1):
                        aug[r2][c] -= f * aug[col][c]
        beta = [aug[a][k] / aug[a][a] for a in range(k)]

        ss_res = 0.0
        ss_tot = 0.0
        residuals = []
        for i in range(n):
            pred = beta[0] + sum(beta[j + 1] * X[i][j] for j in range(p))
            res = y[i] - pred
            residuals.append(res)
            ss_res += res ** 2
            ss_tot += (y[i] - y_mean) ** 2
        r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
        adj_r2 = 1.0 - (1.0 - r2) * (n - 1) / (n - k) if n > k else r2
        rmse = math.sqrt(ss_res / n)

        # F-statistic
        ms_reg = (ss_tot - ss_res) / p if p > 0 else 0.0
        ms_res = ss_res / (n - k) if n > k else 1.0
        f_stat = ms_reg / ms_res if ms_res > 0 else 0.0

        # Durbin-Watson
        dw = sum((residuals[i] - residuals[i - 1]) ** 2 for i in range(1, n)) / ss_res if ss_res > 0 else 2.0

        # AIC
        log_lik = -n / 2 * (1 + math.log(2 * math.pi) + math.log(ss_res / n)) if ss_res > 0 else 0.0
        aic = -2 * log_lik + 2 * k
        bic = -2 * log_lik + math.log(n) * k

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"r2_{suffix}": r2,
            f"adj_r2_{suffix}": adj_r2,
            f"rmse_{suffix}": rmse,
            f"f_stat_{suffix}": f_stat,
            f"durbin_watson_{suffix}": dw,
            f"aic_{suffix}": aic,
            f"bic_{suffix}": bic,
            f"n_{suffix}": n,
            f"k_{suffix}": k,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 355: kriging_review_diagnostics
    # ------------------------------------------------------------------
    def kriging_review_diagnostics(
        self,
        value_column: str,
        *,
        grid_resolution: int = 3,
        suffix: str = "krev",
    ) -> "GeoPromptFrame":
        """Audit kriging output: prediction accuracy, field naming, variance check."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        if n < 3:
            return self.with_column(f"cv_rmse_{suffix}", [0.0] * n)

        # Leave-one-out cross-validation using simple kriging (IDW as proxy)
        errors = []
        for leave in range(n):
            xc = [c for j, c in enumerate(coords) if j != leave]
            vc = [v for j, v in enumerate(vals) if j != leave]
            dists = [math.hypot(xc[j][0] - coords[leave][0], xc[j][1] - coords[leave][1]) for j in range(len(xc))]
            w = [1.0 / (d ** 2 + 1e-12) for d in dists]
            sw = sum(w)
            pred = sum(w[j] * vc[j] for j in range(len(xc))) / sw if sw > 0 else 0.0
            errors.append((pred - vals[leave]) ** 2)

        cv_rmse = math.sqrt(sum(errors) / n)
        cv_mae = sum(math.sqrt(e) for e in errors) / n
        mean_var = sum(errors) / n
        val_range = max(vals) - min(vals)
        nrmse = cv_rmse / val_range if val_range > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"cv_rmse_{suffix}": cv_rmse,
            f"cv_mae_{suffix}": cv_mae,
            f"nrmse_{suffix}": nrmse,
            f"mean_variance_{suffix}": mean_var,
            f"n_points_{suffix}": n,
            f"quality_{suffix}": "good" if nrmse < 0.1 else "moderate" if nrmse < 0.3 else "poor",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 356: terrain_review_diagnostics
    # ------------------------------------------------------------------
    def terrain_review_diagnostics(
        self,
        elevation_column: str,
        *,
        suffix: str = "trev",
    ) -> "GeoPromptFrame":
        """Audit terrain metric sign/scale: slope, aspect, curvature, TRI, TPI."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))

        slopes = []
        aspects = []
        tris = []
        tpis = []
        for i in range(n):
            ix, iy = i % side, i // side
            nbr_elevs = []
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            nbr_elevs.append(elev[j])
            if nbr_elevs:
                max_drop = max(abs(elev[i] - ne) for ne in nbr_elevs)
                slopes.append(max_drop)
                tris.append(sum(abs(elev[i] - ne) for ne in nbr_elevs) / len(nbr_elevs))
                tpis.append(elev[i] - sum(nbr_elevs) / len(nbr_elevs))
            else:
                slopes.append(0.0)
                tris.append(0.0)
                tpis.append(0.0)

        # Diagnostics
        slope_min = min(slopes) if slopes else 0.0
        slope_max = max(slopes) if slopes else 0.0
        slope_neg = sum(1 for s in slopes if s < 0)
        tri_neg = sum(1 for t in tris if t < 0)
        tpi_range = (min(tpis), max(tpis)) if tpis else (0.0, 0.0)

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"slope_min_{suffix}": slope_min,
            f"slope_max_{suffix}": slope_max,
            f"slope_negative_count_{suffix}": slope_neg,
            f"tri_negative_count_{suffix}": tri_neg,
            f"tpi_min_{suffix}": tpi_range[0],
            f"tpi_max_{suffix}": tpi_range[1],
            f"n_cells_{suffix}": n,
            f"slope_sign_ok_{suffix}": 1 if slope_neg == 0 else 0,
            f"tri_sign_ok_{suffix}": 1 if tri_neg == 0 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 357: join_count_review
    # ------------------------------------------------------------------
    def join_count_review(
        self,
        category_column: str,
        *,
        k: int = 5,
        suffix: str = "jcrev",
    ) -> "GeoPromptFrame":
        """Audit join count: directed vs undirected neighbor counting diagnostics."""
        self._require_column(category_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        cats = [r[category_column] for r in recs]
        n = len(cats)
        if n < 3:
            return self.with_column(f"bb_{suffix}", [0] * n)

        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        # Directed counts
        bb_dir = 0; bw_dir = 0; ww_dir = 0
        # Undirected counts
        bb_undir = 0; bw_undir = 0; ww_undir = 0
        unique_cats = sorted(set(cats))
        cat_a = unique_cats[0] if unique_cats else None
        counted = set()
        for i in range(n):
            for j in _knn(i):
                same = cats[i] == cats[j]
                if cats[i] == cat_a and cats[j] == cat_a:
                    bb_dir += 1
                elif cats[i] != cat_a and cats[j] != cat_a:
                    ww_dir += 1
                else:
                    bw_dir += 1
                pair = (min(i, j), max(i, j))
                if pair not in counted:
                    counted.add(pair)
                    if cats[i] == cats[j] == cat_a:
                        bb_undir += 1
                    elif cats[i] != cat_a and cats[j] != cat_a:
                        ww_undir += 1
                    else:
                        bw_undir += 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"bb_directed_{suffix}": bb_dir,
            f"bw_directed_{suffix}": bw_dir,
            f"ww_directed_{suffix}": ww_dir,
            f"bb_undirected_{suffix}": bb_undir,
            f"bw_undirected_{suffix}": bw_undir,
            f"ww_undirected_{suffix}": ww_undir,
            f"k_{suffix}": k,
            f"symmetry_ratio_{suffix}": bb_undir / bb_dir if bb_dir > 0 else 1.0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 358: losh_review
    # ------------------------------------------------------------------
    def losh_review(
        self,
        value_column: str,
        *,
        k: int = 5,
        suffix: str = "lrev",
    ) -> "GeoPromptFrame":
        """Audit LOSH (Local Spatial Heteroscedasticity): scaling and variance diagnostics."""
        self._require_column(value_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        if n < 3:
            return self.with_column(f"losh_{suffix}", [0.0] * n)

        mean_v = sum(vals) / n
        residuals = [v - mean_v for v in vals]

        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        losh_vals = []
        for i in range(n):
            nbrs = _knn(i)
            if not nbrs:
                losh_vals.append(0.0)
                continue
            local_var = sum(abs(residuals[j]) for j in nbrs) / len(nbrs)
            losh_vals.append(local_var)

        global_var = sum(abs(r) for r in residuals) / n if n > 0 else 1.0
        results = []
        for i in range(n):
            h = losh_vals[i] / global_var if global_var > 0 else 0.0
            results.append({
                **recs[i],
                f"losh_{suffix}": h,
                f"local_var_{suffix}": losh_vals[i],
                f"global_var_{suffix}": global_var,
                f"heteroscedastic_{suffix}": 1 if h > 2.0 else 0,
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 359: bivariate_moran_review
    # ------------------------------------------------------------------
    def bivariate_moran_review(
        self,
        x_column: str,
        y_column: str,
        *,
        k: int = 5,
        suffix: str = "bmrev",
    ) -> "GeoPromptFrame":
        """Audit bivariate Moran's I computation against canonical formulation."""
        self._require_column(x_column)
        self._require_column(y_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        xvals = [r[x_column] for r in recs]
        yvals = [r[y_column] for r in recs]
        n = len(xvals)
        if n < 3:
            return self.with_column(f"biv_moran_{suffix}", [0.0] * n)

        x_mean = sum(xvals) / n
        y_mean = sum(yvals) / n
        x_std = math.sqrt(sum((v - x_mean) ** 2 for v in xvals) / n) or 1.0
        y_std = math.sqrt(sum((v - y_mean) ** 2 for v in yvals) / n) or 1.0
        zx = [(v - x_mean) / x_std for v in xvals]
        zy = [(v - y_mean) / y_std for v in yvals]

        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        num = 0.0
        w_sum = 0.0
        for i in range(n):
            nbrs = _knn(i)
            wt = 1.0 / len(nbrs) if nbrs else 0.0
            for j in nbrs:
                num += wt * zx[i] * zy[j]
                w_sum += wt

        biv_moran = num / n if n > 0 else 0.0
        # Pearson correlation for comparison
        pearson = sum(zx[i] * zy[i] for i in range(n)) / n

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"biv_moran_{suffix}": biv_moran,
            f"pearson_{suffix}": pearson,
            f"spatial_corr_{suffix}": "positive" if biv_moran > 0 else "negative",
            f"k_{suffix}": k,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 360: spatial_lag_review
    # ------------------------------------------------------------------
    def spatial_lag_review(
        self,
        y_column: str,
        *x_columns: str,
        k: int = 5,
        suffix: str = "slrev",
    ) -> "GeoPromptFrame":
        """Audit spatial lag model semantics: lagged-y coefficient and diagnostics."""
        self._require_column(y_column)
        for xc in x_columns:
            self._require_column(xc)
        import math
        recs = self.to_records()
        coords = self._centroids()
        n = len(recs)
        p = len(x_columns)
        if n < p + 3:
            return self.with_column(f"rho_{suffix}", [0.0] * n)

        y = [r[y_column] for r in recs]

        def _knn(i: int) -> list[int]:
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            return [j for _, j in dists[:k]]

        # Spatial lag of y
        wy = []
        for i in range(n):
            nbrs = _knn(i)
            wy.append(sum(y[j] for j in nbrs) / len(nbrs) if nbrs else 0.0)

        # OLS: y = rho*Wy + Xb + e (approximate with OLS)
        X_aug = [[wy[i]] + [recs[i][xc] for xc in x_columns] for i in range(n)]
        k_vars = p + 2  # intercept + rho + predictors
        XtX = [[0.0] * k_vars for _ in range(k_vars)]
        Xty = [0.0] * k_vars
        for i in range(n):
            row = [1.0] + X_aug[i]
            for a in range(k_vars):
                for b in range(k_vars):
                    XtX[a][b] += row[a] * row[b]
                Xty[a] += row[a] * y[i]
        aug = [XtX[a][:] + [Xty[a]] for a in range(k_vars)]
        for col in range(k_vars):
            piv = max(range(col, k_vars), key=lambda r: abs(aug[r][col]))
            aug[col], aug[piv] = aug[piv], aug[col]
            if abs(aug[col][col]) < 1e-15:
                return self.with_column(f"rho_{suffix}", [0.0] * n)
            for r2 in range(k_vars):
                if r2 != col:
                    f = aug[r2][col] / aug[col][col]
                    for c in range(k_vars + 1):
                        aug[r2][c] -= f * aug[col][c]
        beta = [aug[a][k_vars] / aug[a][a] for a in range(k_vars)]
        rho = beta[1]  # coefficient of Wy

        y_mean = sum(y) / n
        ss_tot = sum((yi - y_mean) ** 2 for yi in y)
        ss_res = sum(
            (y[i] - beta[0] - sum(beta[j + 1] * X_aug[i][j] for j in range(len(X_aug[i])))) ** 2
            for i in range(n)
        )
        r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"rho_{suffix}": rho,
            f"r2_{suffix}": r2,
            f"n_{suffix}": n,
            f"spatial_dependence_{suffix}": "strong" if abs(rho) > 0.3 else "weak",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 361: gwr_review
    # ------------------------------------------------------------------
    def gwr_review(
        self,
        y_column: str,
        *x_columns: str,
        bandwidth: float | None = None,
        suffix: str = "gwrev",
    ) -> "GeoPromptFrame":
        """Audit GWR/MGWR-family diagnostics: local R², coefficient variation."""
        self._require_column(y_column)
        for xc in x_columns:
            self._require_column(xc)
        import math
        recs = self.to_records()
        coords = self._centroids()
        n = len(recs)
        p = len(x_columns)
        if n < p + 3:
            return self.with_column(f"local_r2_{suffix}", [0.0] * n)

        y = [r[y_column] for r in recs]
        X = [[r[xc] for xc in x_columns] for r in recs]

        if bandwidth is None:
            all_d = []
            for i in range(min(n, 50)):
                for j in range(i + 1, min(n, 50)):
                    all_d.append(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]))
            bandwidth = sorted(all_d)[len(all_d) // 2] if all_d else 1.0

        results = []
        y_mean = sum(y) / n
        for i in range(n):
            # Geographically weighted regression at point i
            weights = []
            for j in range(n):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                w = gaussian_kernel(d, bandwidth) if bandwidth > 0 else 1.0
                weights.append(w)
            # Weighted OLS
            k = p + 1
            XtWX = [[0.0] * k for _ in range(k)]
            XtWy = [0.0] * k
            for j in range(n):
                row = [1.0] + X[j]
                for a in range(k):
                    for b in range(k):
                        XtWX[a][b] += weights[j] * row[a] * row[b]
                    XtWy[a] += weights[j] * row[a] * y[j]
            aug2 = [XtWX[a][:] + [XtWy[a]] for a in range(k)]
            ok = True
            for col in range(k):
                piv = max(range(col, k), key=lambda r: abs(aug2[r][col]))
                aug2[col], aug2[piv] = aug2[piv], aug2[col]
                if abs(aug2[col][col]) < 1e-15:
                    ok = False
                    break
                for r2 in range(k):
                    if r2 != col:
                        f = aug2[r2][col] / aug2[col][col]
                        for c in range(k + 1):
                            aug2[r2][c] -= f * aug2[col][c]
            if not ok:
                results.append({**recs[i], f"local_r2_{suffix}": 0.0, f"intercept_{suffix}": 0.0})
                continue
            beta_local = [aug2[a][k] / aug2[a][a] for a in range(k)]
            # Local R²
            pred_i = beta_local[0] + sum(beta_local[j + 1] * X[i][j] for j in range(p))
            ss_res_local = sum(weights[j] * (y[j] - (beta_local[0] + sum(beta_local[m + 1] * X[j][m] for m in range(p)))) ** 2 for j in range(n))
            ss_tot_local = sum(weights[j] * (y[j] - y_mean) ** 2 for j in range(n))
            local_r2 = 1.0 - ss_res_local / ss_tot_local if ss_tot_local > 0 else 0.0
            results.append({
                **recs[i],
                f"local_r2_{suffix}": local_r2,
                f"intercept_{suffix}": beta_local[0],
                **{f"coef_{xc}_{suffix}": beta_local[idx + 1] for idx, xc in enumerate(x_columns)},
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 362: model_comparison_review
    # ------------------------------------------------------------------
    def model_comparison_review(
        self,
        y_column: str,
        *x_columns: str,
        suffix: str = "mcrev",
    ) -> "GeoPromptFrame":
        """Audit model comparison: AIC, AICc, BIC, RMSE, pseudo-R² for different model subsets."""
        self._require_column(y_column)
        for xc in x_columns:
            self._require_column(xc)
        import math
        recs = self.to_records()
        n = len(recs)
        y = [r[y_column] for r in recs]
        y_mean = sum(y) / n
        ss_tot = sum((yi - y_mean) ** 2 for yi in y)

        rows_out: list[Record] = []
        # Full model
        for subset_size in range(1, len(x_columns) + 1):
            # Just use first subset_size predictors
            xcols = list(x_columns[:subset_size])
            p = len(xcols)
            k = p + 1
            if n <= k:
                continue
            X = [[recs[i][xc] for xc in xcols] for i in range(n)]
            XtX = [[0.0] * k for _ in range(k)]
            Xty = [0.0] * k
            for i in range(n):
                row = [1.0] + X[i]
                for a in range(k):
                    for b in range(k):
                        XtX[a][b] += row[a] * row[b]
                    Xty[a] += row[a] * y[i]
            aug = [XtX[a][:] + [Xty[a]] for a in range(k)]
            ok = True
            for col in range(k):
                piv = max(range(col, k), key=lambda r: abs(aug[r][col]))
                aug[col], aug[piv] = aug[piv], aug[col]
                if abs(aug[col][col]) < 1e-15:
                    ok = False
                    break
                for r2 in range(k):
                    if r2 != col:
                        f = aug[r2][col] / aug[col][col]
                        for c in range(k + 1):
                            aug[r2][c] -= f * aug[col][c]
            if not ok:
                continue
            beta = [aug[a][k] / aug[a][a] for a in range(k)]
            ss_res = sum(
                (y[i] - beta[0] - sum(beta[j + 1] * X[i][j] for j in range(p))) ** 2
                for i in range(n)
            )
            r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
            adj_r2 = 1.0 - (1.0 - r2) * (n - 1) / (n - k) if n > k else r2
            rmse = math.sqrt(ss_res / n)
            log_lik = -n / 2 * (1 + math.log(2 * math.pi) + math.log(ss_res / n)) if ss_res > 0 else 0.0
            aic = -2 * log_lik + 2 * k
            aicc = aic + (2 * k * (k + 1)) / (n - k - 1) if n > k + 1 else aic
            bic = -2 * log_lik + math.log(n) * k
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"n_predictors_{suffix}": p,
                f"predictors_{suffix}": ",".join(xcols),
                f"r2_{suffix}": r2,
                f"adj_r2_{suffix}": adj_r2,
                f"rmse_{suffix}": rmse,
                f"aic_{suffix}": aic,
                f"aicc_{suffix}": aicc,
                f"bic_{suffix}": bic,
            })
        if not rows_out:
            rows_out.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"n_predictors_{suffix}": 0,
                f"predictors_{suffix}": "",
                f"r2_{suffix}": 0.0,
                f"adj_r2_{suffix}": 0.0,
                f"rmse_{suffix}": 0.0,
                f"aic_{suffix}": 0.0,
                f"aicc_{suffix}": 0.0,
                f"bic_{suffix}": 0.0,
            })
        return GeoPromptFrame._from_internal_rows(rows_out, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 363: universal_kriging_review
    # ------------------------------------------------------------------
    def universal_kriging_review(
        self,
        value_column: str,
        *,
        suffix: str = "ukrev",
    ) -> "GeoPromptFrame":
        """Audit universal kriging: trend drift and residual stationarity diagnostics."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(vals)
        if n < 4:
            return self.with_column(f"trend_r2_{suffix}", [0.0] * n)

        # Fit linear trend z = a + bx + cy
        xs = [c[0] for c in coords]
        ys = [c[1] for c in coords]
        x_mean = sum(xs) / n
        y_mean_c = sum(ys) / n
        v_mean = sum(vals) / n
        # Normal equations for 3 params
        XtX = [
            [n, sum(xs), sum(ys)],
            [sum(xs), sum(x ** 2 for x in xs), sum(xs[i] * ys[i] for i in range(n))],
            [sum(ys), sum(xs[i] * ys[i] for i in range(n)), sum(y ** 2 for y in ys)],
        ]
        Xty = [sum(vals), sum(xs[i] * vals[i] for i in range(n)), sum(ys[i] * vals[i] for i in range(n))]
        aug = [XtX[a][:] + [Xty[a]] for a in range(3)]
        for col in range(3):
            piv = max(range(col, 3), key=lambda r: abs(aug[r][col]))
            aug[col], aug[piv] = aug[piv], aug[col]
            if abs(aug[col][col]) < 1e-15:
                return self.with_column(f"trend_r2_{suffix}", [0.0] * n)
            for r2 in range(3):
                if r2 != col:
                    f = aug[r2][col] / aug[col][col]
                    for c in range(4):
                        aug[r2][c] -= f * aug[col][c]
        beta = [aug[a][3] / aug[a][a] for a in range(3)]
        # Residuals
        residuals = [vals[i] - (beta[0] + beta[1] * xs[i] + beta[2] * ys[i]) for i in range(n)]
        ss_tot = sum((v - v_mean) ** 2 for v in vals)
        ss_res = sum(r ** 2 for r in residuals)
        trend_r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
        residual_mean = sum(residuals) / n
        residual_var = sum((r - residual_mean) ** 2 for r in residuals) / n

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"trend_r2_{suffix}": trend_r2,
            f"trend_x_{suffix}": beta[1],
            f"trend_y_{suffix}": beta[2],
            f"residual_mean_{suffix}": residual_mean,
            f"residual_var_{suffix}": residual_var,
            f"drift_significant_{suffix}": 1 if trend_r2 > 0.1 else 0,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 364: cokriging_review
    # ------------------------------------------------------------------
    def cokriging_review(
        self,
        primary_column: str,
        secondary_column: str,
        *,
        n_lags: int = 8,
        suffix: str = "ckrev",
    ) -> "GeoPromptFrame":
        """Audit co-kriging: cross-variogram and cross-correlation diagnostics."""
        self._require_column(primary_column)
        self._require_column(secondary_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        pvals = [r[primary_column] for r in recs]
        svals = [r[secondary_column] for r in recs]
        n = len(pvals)
        if n < 4:
            return self.with_column(f"cross_var_{suffix}", [0.0] * n)

        # Pairwise cross-variogram
        pairs = []
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                cross = (pvals[i] - pvals[j]) * (svals[i] - svals[j])
                pairs.append((d, cross))
        if not pairs:
            return self.with_column(f"cross_var_{suffix}", [0.0] * n)

        max_d = max(p[0] for p in pairs)
        lag_size = max_d / n_lags if n_lags > 0 else 1.0
        lags = []
        for lag in range(n_lags):
            lo = lag * lag_size
            hi = (lag + 1) * lag_size
            bin_vals = [cv for d, cv in pairs if lo <= d < hi]
            if bin_vals:
                gamma = sum(bin_vals) / (2 * len(bin_vals))
                lags.append((lo + lag_size / 2, gamma, len(bin_vals)))

        # Cross-correlation
        p_mean = sum(pvals) / n
        s_mean = sum(svals) / n
        p_std = math.sqrt(sum((v - p_mean) ** 2 for v in pvals) / n) or 1.0
        s_std = math.sqrt(sum((v - s_mean) ** 2 for v in svals) / n) or 1.0
        cross_corr = sum((pvals[i] - p_mean) * (svals[i] - s_mean) for i in range(n)) / (n * p_std * s_std)

        rows: list[Record] = []
        for h, g, cnt in lags:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"lag_{suffix}": h,
                f"cross_gamma_{suffix}": g,
                f"n_pairs_{suffix}": cnt,
                f"cross_corr_{suffix}": cross_corr,
            })
        if not rows:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"lag_{suffix}": 0.0,
                f"cross_gamma_{suffix}": 0.0,
                f"n_pairs_{suffix}": 0,
                f"cross_corr_{suffix}": cross_corr,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 365: ebk_review
    # ------------------------------------------------------------------
    def ebk_review(
        self,
        value_column: str,
        *,
        n_subsets: int = 4,
        suffix: str = "ebkrev",
    ) -> "GeoPromptFrame":
        """Audit Empirical Bayesian Kriging: neighborhood fitting and variance diagnostics."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(vals)
        if n < 4:
            return self.with_column(f"subset_var_{suffix}", [0.0] * n)

        # Split data into subsets and compute local variogram parameters
        subset_size = max(3, n // n_subsets)
        subsets = []
        for s in range(n_subsets):
            start = s * subset_size
            end = min(start + subset_size, n)
            if start >= n:
                break
            sv = vals[start:end]
            sc = coords[start:end]
            if len(sv) < 3:
                continue
            sv_mean = sum(sv) / len(sv)
            sv_var = sum((v - sv_mean) ** 2 for v in sv) / len(sv)
            # Local range estimate
            max_d = max(math.hypot(sc[i][0] - sc[j][0], sc[i][1] - sc[j][1])
                        for i in range(len(sc)) for j in range(i + 1, len(sc))) if len(sc) > 1 else 1.0
            subsets.append({"mean": sv_mean, "var": sv_var, "range": max_d, "n": len(sv)})

        # Variance of subset parameters
        if len(subsets) >= 2:
            means = [s["mean"] for s in subsets]
            vars_ = [s["var"] for s in subsets]
            mean_of_means = sum(means) / len(means)
            var_of_means = sum((m - mean_of_means) ** 2 for m in means) / len(means)
            mean_var = sum(vars_) / len(vars_)
        else:
            var_of_means = 0.0
            mean_var = 0.0

        rows: list[Record] = []
        for i, s in enumerate(subsets):
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"subset_{suffix}": i,
                f"subset_mean_{suffix}": s["mean"],
                f"subset_var_{suffix}": s["var"],
                f"subset_range_{suffix}": s["range"],
                f"subset_n_{suffix}": s["n"],
                f"var_of_means_{suffix}": var_of_means,
                f"mean_var_{suffix}": mean_var,
            })
        if not rows:
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"subset_{suffix}": 0,
                f"subset_var_{suffix}": 0.0,
                f"var_of_means_{suffix}": 0.0,
                f"mean_var_{suffix}": 0.0,
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 366: contour_review
    # ------------------------------------------------------------------
    def contour_review(
        self,
        value_column: str,
        *,
        n_levels: int = 5,
        grid_resolution: int = 5,
        suffix: str = "ctrev",
    ) -> "GeoPromptFrame":
        """Audit contour extraction: validate marching-squares behavior."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        if n < 4:
            return self.with_column(f"n_contours_{suffix}", [0] * n)

        vmin, vmax = min(vals), max(vals)
        step = (vmax - vmin) / (n_levels + 1) if n_levels > 0 else 1.0
        levels = [vmin + step * (i + 1) for i in range(n_levels)]

        # Grid interpolation
        xs = [c[0] for c in coords]
        ys = [c[1] for c in coords]
        x_min, x_max = min(xs), max(xs)
        y_min, y_max = min(ys), max(ys)
        dx = (x_max - x_min) / (grid_resolution - 1) if grid_resolution > 1 else 1.0
        dy = (y_max - y_min) / (grid_resolution - 1) if grid_resolution > 1 else 1.0
        grid = [[0.0] * grid_resolution for _ in range(grid_resolution)]
        for gy in range(grid_resolution):
            for gx in range(grid_resolution):
                px = x_min + gx * dx
                py = y_min + gy * dy
                w_sum = 0.0
                v_sum = 0.0
                for i in range(n):
                    d = math.hypot(px - coords[i][0], py - coords[i][1])
                    w = 1.0 / (d ** 2 + 1e-12)
                    w_sum += w
                    v_sum += w * vals[i]
                grid[gy][gx] = v_sum / w_sum if w_sum > 0 else 0.0

        # Count contour segments (marching squares)
        n_contours = 0
        for level in levels:
            for gy in range(grid_resolution - 1):
                for gx in range(grid_resolution - 1):
                    corners = [grid[gy][gx], grid[gy][gx + 1], grid[gy + 1][gx + 1], grid[gy + 1][gx]]
                    above = sum(1 for c in corners if c >= level)
                    if 0 < above < 4:
                        n_contours += 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_contours_{suffix}": n_contours,
            f"n_levels_{suffix}": len(levels),
            f"grid_res_{suffix}": grid_resolution,
            f"value_range_{suffix}": vmax - vmin,
            f"contour_density_{suffix}": n_contours / len(levels) if levels else 0.0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 367: flow_direction_review
    # ------------------------------------------------------------------
    def flow_direction_review(
        self,
        elevation_column: str,
        *,
        suffix: str = "fdrev",
    ) -> "GeoPromptFrame":
        """Audit D8 flow direction: verify steepest-descent semantics."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))

        # D8 directions: 1=E, 2=SE, 4=S, 8=SW, 16=W, 32=NW, 64=N, 128=NE
        d8_map = {(1, 0): 1, (1, -1): 2, (0, -1): 4, (-1, -1): 8,
                  (-1, 0): 16, (-1, 1): 32, (0, 1): 64, (1, 1): 128}
        flat_count = 0
        pit_count = 0
        valid_d8 = 0
        results = []
        for i in range(n):
            ix, iy = i % side, i // side
            best_slope = 0.0
            best_dir = 0
            n_downhill = 0
            for (dx, dy), d8_code in d8_map.items():
                nx, ny = ix + dx, iy + dy
                if 0 <= nx < side and 0 <= ny < side:
                    j = ny * side + nx
                    if j < n:
                        drop = elev[i] - elev[j]
                        dist = math.hypot(dx, dy)
                        slope = drop / dist
                        if slope > best_slope:
                            best_slope = slope
                            best_dir = d8_code
                        if drop > 0:
                            n_downhill += 1
            if best_dir > 0:
                valid_d8 += 1
            if n_downhill == 0 and i > 0 and i < n - 1:
                pit_count += 1
            if best_slope == 0:
                flat_count += 1
            results.append({
                **recs[i],
                f"d8_{suffix}": best_dir,
                f"slope_{suffix}": best_slope,
                f"n_downhill_{suffix}": n_downhill,
            })

        summary = GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"valid_d8_{suffix}": valid_d8,
            f"flat_count_{suffix}": flat_count,
            f"pit_count_{suffix}": pit_count,
            f"n_cells_{suffix}": n,
            f"valid_pct_{suffix}": valid_d8 / n * 100 if n > 0 else 0.0,
        }], geometry_column=self.geometry_column, crs=self.crs)
        return summary

    # ------------------------------------------------------------------
    # Tool 368: accumulation_review
    # ------------------------------------------------------------------
    def accumulation_review(
        self,
        elevation_column: str,
        *,
        suffix: str = "accrev",
    ) -> "GeoPromptFrame":
        """Audit flow accumulation behavior and downstream ordering."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))

        # Compute D8 flow direction → receiver
        receiver = [-1] * n
        for i in range(n):
            ix, iy = i % side, i // side
            best_slope = 0.0
            best_j = -1
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            slope = (elev[i] - elev[j]) / math.hypot(dx, dy)
                            if slope > best_slope:
                                best_slope = slope
                                best_j = j
            receiver[i] = best_j

        # Compute accumulation via topological sort
        acc = [1] * n
        in_degree = [0] * n
        for i in range(n):
            if receiver[i] >= 0:
                in_degree[receiver[i]] += 1

        from collections import deque
        queue = deque(i for i in range(n) if in_degree[i] == 0)
        order = []
        while queue:
            i = queue.popleft()
            order.append(i)
            r = receiver[i]
            if r >= 0:
                acc[r] += acc[i]
                in_degree[r] -= 1
                if in_degree[r] == 0:
                    queue.append(r)

        max_acc = max(acc)
        n_outlets = sum(1 for i in range(n) if receiver[i] < 0)
        monotonic = 1
        for i in range(n):
            r = receiver[i]
            if r >= 0 and elev[r] >= elev[i]:
                monotonic = 0
                break

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"max_accumulation_{suffix}": max_acc,
            f"n_outlets_{suffix}": n_outlets,
            f"monotonic_descent_{suffix}": monotonic,
            f"n_cells_{suffix}": n,
            f"total_acc_{suffix}": sum(acc),
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 369: watershed_review
    # ------------------------------------------------------------------
    def watershed_review(
        self,
        elevation_column: str,
        *,
        suffix: str = "wsrev",
    ) -> "GeoPromptFrame":
        """Audit watershed delineation: basin count, coverage, and drainage."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))

        # Trace each cell to its outlet
        receiver = [-1] * n
        for i in range(n):
            ix, iy = i % side, i // side
            best_slope = 0.0
            best_j = -1
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            slope = (elev[i] - elev[j]) / math.hypot(dx, dy)
                            if slope > best_slope:
                                best_slope = slope
                                best_j = j
            receiver[i] = best_j

        # Trace to outlet
        basin = [-1] * n
        def _trace(i: int) -> int:
            visited = set()
            current = i
            while current >= 0 and current not in visited:
                visited.add(current)
                nxt = receiver[current]
                if nxt < 0:
                    return current
                current = nxt
            return current
        for i in range(n):
            basin[i] = _trace(i)

        unique_basins = set(basin)
        basin_sizes = {}
        for b in basin:
            basin_sizes[b] = basin_sizes.get(b, 0) + 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_basins_{suffix}": len(unique_basins),
            f"max_basin_{suffix}": max(basin_sizes.values()) if basin_sizes else 0,
            f"min_basin_{suffix}": min(basin_sizes.values()) if basin_sizes else 0,
            f"coverage_pct_{suffix}": sum(1 for b in basin if b >= 0) / n * 100 if n > 0 else 0.0,
            f"n_cells_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 370: stream_order_review
    # ------------------------------------------------------------------
    def stream_order_review(
        self,
        elevation_column: str,
        *,
        threshold: int = 3,
        suffix: str = "sorev",
    ) -> "GeoPromptFrame":
        """Audit stream ordering: Strahler order distribution."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))

        # D8 receiver
        receiver = [-1] * n
        for i in range(n):
            ix, iy = i % side, i // side
            best_slope = 0.0
            best_j = -1
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            slope = (elev[i] - elev[j]) / math.hypot(dx, dy)
                            if slope > best_slope:
                                best_slope = slope
                                best_j = j
            receiver[i] = best_j

        # Accumulation
        acc = [1] * n
        from collections import defaultdict, deque
        children: dict[int, list[int]] = defaultdict(list)
        in_degree = [0] * n
        for i in range(n):
            if receiver[i] >= 0:
                children[receiver[i]].append(i)
                in_degree[receiver[i]] += 1
        queue = deque(i for i in range(n) if in_degree[i] == 0)
        order_list: list[int] = []
        while queue:
            i = queue.popleft()
            order_list.append(i)
            r = receiver[i]
            if r >= 0:
                acc[r] += acc[i]
                in_degree[r] -= 1
                if in_degree[r] == 0:
                    queue.append(r)

        # Stream cells: acc >= threshold
        is_stream = [1 if acc[i] >= threshold else 0 for i in range(n)]
        n_stream = sum(is_stream)

        # Strahler order (simplified)
        strahler = [0] * n
        for i in order_list:
            if not is_stream[i]:
                continue
            child_orders = [strahler[c] for c in children.get(i, []) if is_stream[c]]
            if not child_orders:
                strahler[i] = 1
            else:
                max_o = max(child_orders)
                count_max = sum(1 for o in child_orders if o == max_o)
                strahler[i] = max_o + 1 if count_max >= 2 else max_o

        max_order = max(strahler) if strahler else 0
        order_dist = {}
        for s in strahler:
            if s > 0:
                order_dist[s] = order_dist.get(s, 0) + 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_stream_cells_{suffix}": n_stream,
            f"max_order_{suffix}": max_order,
            f"n_cells_{suffix}": n,
            f"stream_pct_{suffix}": n_stream / n * 100 if n > 0 else 0.0,
            f"threshold_{suffix}": threshold,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 371: twi_ls_hand_review
    # ------------------------------------------------------------------
    def twi_ls_hand_review(
        self,
        elevation_column: str,
        *,
        suffix: str = "tlhrev",
    ) -> "GeoPromptFrame":
        """Audit TWI, LS-factor, and HAND formulations."""
        self._require_column(elevation_column)
        import math
        recs = self.to_records()
        coords = self._centroids()
        elev = [r[elevation_column] for r in recs]
        n = len(recs)
        side = max(1, int(math.sqrt(n)))
        cell_size = 1.0

        # D8 flow
        receiver = [-1] * n
        slope_arr = [0.0] * n
        for i in range(n):
            ix, iy = i % side, i // side
            best_slope = 0.0
            best_j = -1
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = ix + dx, iy + dy
                    if 0 <= nx < side and 0 <= ny < side:
                        j = ny * side + nx
                        if j < n:
                            slope = (elev[i] - elev[j]) / (math.hypot(dx, dy) * cell_size)
                            if slope > best_slope:
                                best_slope = slope
                                best_j = j
            receiver[i] = best_j
            slope_arr[i] = max(best_slope, 0.001)

        # Accumulation
        acc = [1] * n
        in_degree = [0] * n
        for i in range(n):
            if receiver[i] >= 0:
                in_degree[receiver[i]] += 1
        from collections import deque
        queue = deque(i for i in range(n) if in_degree[i] == 0)
        while queue:
            i = queue.popleft()
            r = receiver[i]
            if r >= 0:
                acc[r] += acc[i]
                in_degree[r] -= 1
                if in_degree[r] == 0:
                    queue.append(r)

        # TWI = ln(a / tan(b))
        twi = [math.log(acc[i] * cell_size / slope_arr[i]) for i in range(n)]
        # LS factor (simplified: LS = (A/22.13)^0.4 * (sin(slope)/0.0896)^1.3)
        ls = [(acc[i] * cell_size / 22.13) ** 0.4 * (slope_arr[i] / 0.0896) ** 1.3 for i in range(n)]
        # HAND (Height Above Nearest Drainage) - simplified: trace to outlet
        hand = [0.0] * n
        for i in range(n):
            current = i
            visited = set()
            while current >= 0 and current not in visited:
                visited.add(current)
                r = receiver[current]
                if r < 0:
                    hand[i] = elev[i] - elev[current]
                    break
                current = r

        twi_min, twi_max = min(twi), max(twi)
        ls_min, ls_max = min(ls), max(ls)
        hand_min, hand_max = min(hand), max(hand)

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"twi_min_{suffix}": twi_min,
            f"twi_max_{suffix}": twi_max,
            f"ls_min_{suffix}": ls_min,
            f"ls_max_{suffix}": ls_max,
            f"hand_min_{suffix}": hand_min,
            f"hand_max_{suffix}": hand_max,
            f"n_cells_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 372: bounding_shape_review
    # ------------------------------------------------------------------
    def bounding_shape_review(
        self,
        *,
        suffix: str = "bsrev",
    ) -> "GeoPromptFrame":
        """Audit minimum bounding shapes: envelope, convex hull area comparison."""
        import math
        coords = self._centroids()
        n = len(coords)
        if n < 3:
            return GeoPromptFrame._from_internal_rows([{
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"n_{suffix}": n,
                f"envelope_area_{suffix}": 0.0,
                f"hull_area_{suffix}": 0.0,
            }], geometry_column=self.geometry_column, crs=self.crs)

        xs = [c[0] for c in coords]
        ys = [c[1] for c in coords]
        env_area = (max(xs) - min(xs)) * (max(ys) - min(ys))

        # Convex hull area (gift wrapping → shoelace)
        def _convex_hull(pts: list) -> list:
            pts = sorted(pts)
            if len(pts) <= 1:
                return pts
            lower = []
            for p in pts:
                while len(lower) >= 2 and _cross(lower[-2], lower[-1], p) <= 0:
                    lower.pop()
                lower.append(p)
            upper = []
            for p in reversed(pts):
                while len(upper) >= 2 and _cross(upper[-2], upper[-1], p) <= 0:
                    upper.pop()
                upper.append(p)
            return lower[:-1] + upper[:-1]

        def _cross(o, a, b):
            return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

        hull = _convex_hull([(c[0], c[1]) for c in coords])
        hull_area = abs(sum(hull[i][0] * hull[(i + 1) % len(hull)][1] - hull[(i + 1) % len(hull)][0] * hull[i][1]
                            for i in range(len(hull)))) / 2

        compactness = hull_area / env_area if env_area > 0 else 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_{suffix}": n,
            f"envelope_area_{suffix}": env_area,
            f"hull_area_{suffix}": hull_area,
            f"compactness_{suffix}": compactness,
            f"hull_vertices_{suffix}": len(hull),
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 373: hausdorff_review
    # ------------------------------------------------------------------
    def hausdorff_review(
        self,
        *,
        sample_size: int = 50,
        suffix: str = "hdrev",
    ) -> "GeoPromptFrame":
        """Audit Hausdorff distance computation between geometry subsets."""
        import math
        recs = self.to_records()
        n = len(recs)
        if n < 4:
            return GeoPromptFrame._from_internal_rows([{
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"hausdorff_{suffix}": 0.0,
                f"n_{suffix}": n,
            }], geometry_column=self.geometry_column, crs=self.crs)

        coords = self._centroids()
        mid = n // 2
        set_a = coords[:mid]
        set_b = coords[mid:]

        def _dir_hausdorff(a: list, b: list) -> float:
            max_d = 0.0
            for pa in a[:sample_size]:
                min_d = float("inf")
                for pb in b[:sample_size]:
                    d = math.hypot(pa[0] - pb[0], pa[1] - pb[1])
                    if d < min_d:
                        min_d = d
                max_d = max(max_d, min_d)
            return max_d

        h_ab = _dir_hausdorff(set_a, set_b)
        h_ba = _dir_hausdorff(set_b, set_a)
        hausdorff = max(h_ab, h_ba)

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"hausdorff_{suffix}": hausdorff,
            f"directed_ab_{suffix}": h_ab,
            f"directed_ba_{suffix}": h_ba,
            f"symmetric_{suffix}": 1 if abs(h_ab - h_ba) < 1e-10 else 0,
            f"n_a_{suffix}": len(set_a),
            f"n_b_{suffix}": len(set_b),
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 374: format_fidelity_review
    # ------------------------------------------------------------------
    def format_fidelity_review(
        self,
        *,
        suffix: str = "ffrev",
    ) -> "GeoPromptFrame":
        """Audit WKT/WKB/dict format fidelity: geometry roundtrip checks."""
        recs = self.to_records()
        n = len(recs)
        n_point = 0; n_line = 0; n_poly = 0; n_other = 0
        roundtrip_ok = 0
        for r in recs:
            geom = r.get(self.geometry_column, {})
            gtype = geom.get("type", "")
            if gtype == "Point":
                n_point += 1
            elif gtype == "LineString":
                n_line += 1
            elif gtype == "Polygon":
                n_poly += 1
            else:
                n_other += 1
            # Dict roundtrip
            import json
            try:
                s = json.dumps(geom)
                g2 = json.loads(s)
                if g2.get("type") == geom.get("type"):
                    roundtrip_ok += 1
            except (TypeError, json.JSONDecodeError):
                pass

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_point_{suffix}": n_point,
            f"n_linestring_{suffix}": n_line,
            f"n_polygon_{suffix}": n_poly,
            f"n_other_{suffix}": n_other,
            f"roundtrip_ok_{suffix}": roundtrip_ok,
            f"roundtrip_pct_{suffix}": roundtrip_ok / n * 100 if n > 0 else 0.0,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 375: method_chain_audit
    # ------------------------------------------------------------------
    def method_chain_audit(
        self,
        value_column: str,
        *,
        suffix: str = "mca",
    ) -> "GeoPromptFrame":
        """Audit method chaining: verify that sequential operations preserve frame integrity."""
        self._require_column(value_column)
        recs = self.to_records()
        n = len(recs)
        # Test chain: with_column → filter-like → with_column
        step1 = self.with_column(f"test1_{suffix}", list(range(n)))
        step2 = step1.with_column(f"test2_{suffix}", [x * 2 for x in range(n)])
        final_recs = step2.to_records()
        chain_ok = 1
        if len(final_recs) != n:
            chain_ok = 0
        if chain_ok and f"test1_{suffix}" not in final_recs[0]:
            chain_ok = 0
        if chain_ok and f"test2_{suffix}" not in final_recs[0]:
            chain_ok = 0
        if chain_ok and value_column not in final_recs[0]:
            chain_ok = 0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"chain_ok_{suffix}": chain_ok,
            f"n_preserved_{suffix}": len(final_recs),
            f"cols_preserved_{suffix}": 1 if value_column in (final_recs[0] if final_recs else {}) else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 376: structured_log
    # ------------------------------------------------------------------
    def structured_log(
        self,
        message: str,
        *,
        level: str = "info",
        suffix: str = "slog",
    ) -> "GeoPromptFrame":
        """Attach a structured log entry to the frame for audit/debugging."""
        import time
        recs = self.to_records()
        n = len(recs)
        ts = time.time()
        results = []
        for r in recs:
            results.append({
                **r,
                f"log_msg_{suffix}": message,
                f"log_level_{suffix}": level,
                f"log_ts_{suffix}": ts,
                f"log_n_{suffix}": n,
            })
        if not results:
            results.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"log_msg_{suffix}": message,
                f"log_level_{suffix}": level,
                f"log_ts_{suffix}": ts,
                f"log_n_{suffix}": 0,
            })
        return GeoPromptFrame._from_internal_rows(results, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 377: progress_callback
    # ------------------------------------------------------------------
    def progress_callback(
        self,
        operation_name: str,
        *,
        n_steps: int = 10,
        suffix: str = "prog",
    ) -> "GeoPromptFrame":
        """Simulate progress tracking for a long-running operation."""
        rows: list[Record] = []
        for step in range(n_steps):
            pct = (step + 1) / n_steps * 100
            rows.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"operation_{suffix}": operation_name,
                f"step_{suffix}": step + 1,
                f"total_steps_{suffix}": n_steps,
                f"progress_pct_{suffix}": pct,
                f"status_{suffix}": "complete" if step == n_steps - 1 else "in_progress",
            })
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 378: exception_enriched
    # ------------------------------------------------------------------
    def exception_enriched(
        self,
        *,
        suffix: str = "exc",
    ) -> "GeoPromptFrame":
        """Report enriched exception diagnostics: column status, geometry validity, frame health."""
        recs = self.to_records()
        n = len(recs)
        cols = set()
        for r in recs:
            cols.update(r.keys())
        # Check for null geometries
        null_geom = sum(1 for r in recs if not r.get(self.geometry_column))
        # Check for empty values
        empty_vals = 0
        for r in recs:
            for k, v in r.items():
                if k != self.geometry_column and v is None:
                    empty_vals += 1
        # Check geometry types
        geom_types = set()
        for r in recs:
            geom = r.get(self.geometry_column, {})
            if isinstance(geom, dict):
                geom_types.add(geom.get("type", "unknown"))

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"n_columns_{suffix}": len(cols),
            f"null_geom_{suffix}": null_geom,
            f"empty_vals_{suffix}": empty_vals,
            f"geom_types_{suffix}": ",".join(sorted(geom_types)),
            f"health_{suffix}": "good" if null_geom == 0 and empty_vals == 0 else "warning",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 379: empty_frame_guard
    # ------------------------------------------------------------------
    def empty_frame_guard(
        self,
        *,
        suffix: str = "efg",
    ) -> "GeoPromptFrame":
        """Audit empty-frame behavior: report whether frame has rows, columns, geometry."""
        recs = self.to_records()
        n = len(recs)
        has_geom = any(r.get(self.geometry_column) for r in recs)
        cols = set()
        for r in recs:
            cols.update(r.keys())
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"n_cols_{suffix}": len(cols),
            f"is_empty_{suffix}": 1 if n == 0 else 0,
            f"has_geom_{suffix}": 1 if has_geom else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 380: one_row_guard
    # ------------------------------------------------------------------
    def one_row_guard(
        self,
        *,
        suffix: str = "org",
    ) -> "GeoPromptFrame":
        """Audit one-row frame behavior: flag tools that need minimum row count."""
        recs = self.to_records()
        n = len(recs)
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"is_single_{suffix}": 1 if n == 1 else 0,
            f"distance_safe_{suffix}": 1 if n >= 2 else 0,
            f"interp_safe_{suffix}": 1 if n >= 3 else 0,
            f"stats_safe_{suffix}": 1 if n >= 4 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 381: two_row_guard
    # ------------------------------------------------------------------
    def two_row_guard(
        self,
        *,
        suffix: str = "trg",
    ) -> "GeoPromptFrame":
        """Audit two-row frame behavior for distance and interpolation tools."""
        import math
        coords = self._centroids()
        n = len(coords)
        dist = math.hypot(coords[0][0] - coords[1][0], coords[0][1] - coords[1][1]) if n >= 2 else 0.0
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"pair_distance_{suffix}": dist,
            f"has_pair_{suffix}": 1 if n >= 2 else 0,
            f"triangle_possible_{suffix}": 1 if n >= 3 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 382: duplicate_coord_guard
    # ------------------------------------------------------------------
    def duplicate_coord_guard(
        self,
        *,
        suffix: str = "dcg",
    ) -> "GeoPromptFrame":
        """Audit duplicate coordinate behavior for distance-based tools."""
        coords = self._centroids()
        n = len(coords)
        seen: dict[tuple[float, float], int] = {}
        duplicates = 0
        for c in coords:
            key = (round(c[0], 10), round(c[1], 10))
            seen[key] = seen.get(key, 0) + 1
        for cnt in seen.values():
            if cnt > 1:
                duplicates += cnt
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"n_unique_{suffix}": len(seen),
            f"n_duplicates_{suffix}": duplicates,
            f"dup_pct_{suffix}": duplicates / n * 100 if n > 0 else 0.0,
            f"has_duplicates_{suffix}": 1 if duplicates > 0 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 383: collinear_guard
    # ------------------------------------------------------------------
    def collinear_guard(
        self,
        *,
        tolerance: float = 1e-10,
        suffix: str = "clg",
    ) -> "GeoPromptFrame":
        """Audit collinear coordinate behavior for hull, triangulation, and interpolation tools."""
        coords = self._centroids()
        n = len(coords)
        if n < 3:
            return GeoPromptFrame._from_internal_rows([{
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"is_collinear_{suffix}": 1,
                f"n_{suffix}": n,
            }], geometry_column=self.geometry_column, crs=self.crs)

        # Check if all points are collinear (cross product test)
        all_collinear = True
        x0, y0 = coords[0]
        x1, y1 = coords[1]
        for i in range(2, n):
            cross = (x1 - x0) * (coords[i][1] - y0) - (y1 - y0) * (coords[i][0] - x0)
            if abs(cross) > tolerance:
                all_collinear = False
                break

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"is_collinear_{suffix}": 1 if all_collinear else 0,
            f"n_{suffix}": n,
            f"hull_safe_{suffix}": 0 if all_collinear else 1,
            f"triangulation_safe_{suffix}": 0 if all_collinear else 1,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 384: null_geometry_guard
    # ------------------------------------------------------------------
    def null_geometry_guard(
        self,
        *,
        suffix: str = "ngg",
    ) -> "GeoPromptFrame":
        """Audit null geometry behavior for all geometry-aware tools."""
        recs = self.to_records()
        n = len(recs)
        null_count = 0
        empty_count = 0
        valid_count = 0
        for r in recs:
            geom = r.get(self.geometry_column)
            if geom is None:
                null_count += 1
            elif not isinstance(geom, dict):
                empty_count += 1
            elif not geom.get("type"):
                empty_count += 1
            else:
                valid_count += 1
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"null_count_{suffix}": null_count,
            f"empty_count_{suffix}": empty_count,
            f"valid_count_{suffix}": valid_count,
            f"all_valid_{suffix}": 1 if null_count == 0 and empty_count == 0 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 385: multipart_guard
    # ------------------------------------------------------------------
    def multipart_guard(
        self,
        *,
        suffix: str = "mpg",
    ) -> "GeoPromptFrame":
        """Audit multipart geometry behavior for geometry tools."""
        recs = self.to_records()
        n = len(recs)
        n_multi = 0; n_single = 0
        for r in recs:
            geom = r.get(self.geometry_column, {})
            gtype = geom.get("type", "") if isinstance(geom, dict) else ""
            if gtype.startswith("Multi"):
                n_multi += 1
            else:
                n_single += 1
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"n_multipart_{suffix}": n_multi,
            f"n_singlepart_{suffix}": n_single,
            f"multi_pct_{suffix}": n_multi / n * 100 if n > 0 else 0.0,
            f"all_single_{suffix}": 1 if n_multi == 0 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 386: polygon_hole_guard
    # ------------------------------------------------------------------
    def polygon_hole_guard(
        self,
        *,
        suffix: str = "phg",
    ) -> "GeoPromptFrame":
        """Audit polygon-hole behavior for area, adjacency, and repair tools."""
        recs = self.to_records()
        n = len(recs)
        n_poly = 0; n_with_holes = 0
        for r in recs:
            geom = r.get(self.geometry_column, {})
            if isinstance(geom, dict) and geom.get("type") == "Polygon":
                n_poly += 1
                coords = geom.get("coordinates", ())
                # In GeoPrompt Polygon is flat tuple of (x,y), no holes concept
                # But if stored as GeoJSON-list format (list of rings)
                if isinstance(coords, (list, tuple)) and len(coords) > 0:
                    if isinstance(coords[0], (list, tuple)) and len(coords[0]) > 0:
                        if isinstance(coords[0][0], (list, tuple)):
                            if len(coords) > 1:
                                n_with_holes += 1
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_polygons_{suffix}": n_poly,
            f"n_with_holes_{suffix}": n_with_holes,
            f"hole_pct_{suffix}": n_with_holes / n_poly * 100 if n_poly > 0 else 0.0,
            f"all_simple_{suffix}": 1 if n_with_holes == 0 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 387: self_intersection_guard
    # ------------------------------------------------------------------
    def self_intersection_guard(
        self,
        *,
        suffix: str = "sig",
    ) -> "GeoPromptFrame":
        """Audit self-intersection behavior for geometry audit and repair tools."""
        recs = self.to_records()
        n = len(recs)
        n_checked = 0
        n_self_intersecting = 0
        for r in recs:
            geom = r.get(self.geometry_column, {})
            if not isinstance(geom, dict):
                continue
            gtype = geom.get("type", "")
            coords = geom.get("coordinates", ())
            if gtype == "Polygon" and isinstance(coords, (list, tuple)) and len(coords) >= 4:
                n_checked += 1
                pts = list(coords)
                # Naive O(n²) edge intersection check
                intersects = False
                for i in range(len(pts) - 1):
                    for j in range(i + 2, len(pts) - 1):
                        if j == i + len(pts) - 2:
                            continue
                        # Check if edge (i, i+1) intersects (j, j+1)
                        ax, ay = pts[i]
                        bx, by = pts[i + 1]
                        cx, cy = pts[j]
                        dx, dy = pts[j + 1]
                        denom = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx)
                        if abs(denom) < 1e-15:
                            continue
                        t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / denom
                        u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / denom
                        if 0 < t < 1 and 0 < u < 1:
                            intersects = True
                            break
                    if intersects:
                        break
                if intersects:
                    n_self_intersecting += 1
            elif gtype == "LineString" and isinstance(coords, (list, tuple)) and len(coords) >= 4:
                n_checked += 1
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_checked_{suffix}": n_checked,
            f"n_self_intersecting_{suffix}": n_self_intersecting,
            f"clean_pct_{suffix}": (n_checked - n_self_intersecting) / n_checked * 100 if n_checked > 0 else 100.0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 388: disconnected_network_guard
    # ------------------------------------------------------------------
    def disconnected_network_guard(
        self,
        source_column: str,
        target_column: str,
        *,
        suffix: str = "dng",
    ) -> "GeoPromptFrame":
        """Audit disconnected network behavior for routing and centrality tools."""
        self._require_column(source_column)
        self._require_column(target_column)
        recs = self.to_records()
        nodes: set[str] = set()
        adj: dict[str, set[str]] = {}
        for r in recs:
            s = str(r[source_column])
            t = str(r[target_column])
            nodes.add(s)
            nodes.add(t)
            if s not in adj:
                adj[s] = set()
            adj[s].add(t)
            if t not in adj:
                adj[t] = set()
            adj[t].add(s)
        # BFS components
        visited: set[str] = set()
        n_components = 0
        component_sizes = []
        for node in nodes:
            if node not in visited:
                n_components += 1
                size = 0
                from collections import deque
                queue = deque([node])
                while queue:
                    curr = queue.popleft()
                    if curr in visited:
                        continue
                    visited.add(curr)
                    size += 1
                    for nb in adj.get(curr, set()):
                        if nb not in visited:
                            queue.append(nb)
                component_sizes.append(size)
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_nodes_{suffix}": len(nodes),
            f"n_edges_{suffix}": len(recs),
            f"n_components_{suffix}": n_components,
            f"largest_component_{suffix}": max(component_sizes) if component_sizes else 0,
            f"is_connected_{suffix}": 1 if n_components <= 1 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 389: coord_range_guard
    # ------------------------------------------------------------------
    def coord_range_guard(
        self,
        *,
        suffix: str = "crg",
    ) -> "GeoPromptFrame":
        """Audit coordinate range: detect very small or very large coordinate spans."""
        import math
        coords = self._centroids()
        n = len(coords)
        if n == 0:
            return GeoPromptFrame._from_internal_rows([{
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"x_range_{suffix}": 0.0, f"y_range_{suffix}": 0.0,
            }], geometry_column=self.geometry_column, crs=self.crs)
        xs = [c[0] for c in coords]
        ys = [c[1] for c in coords]
        x_range = max(xs) - min(xs)
        y_range = max(ys) - min(ys)
        max_abs = max(max(abs(x) for x in xs), max(abs(y) for y in ys))
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"x_range_{suffix}": x_range,
            f"y_range_{suffix}": y_range,
            f"max_abs_coord_{suffix}": max_abs,
            f"very_small_{suffix}": 1 if max(x_range, y_range) < 1e-6 else 0,
            f"very_large_{suffix}": 1 if max_abs > 1e8 else 0,
            f"likely_geographic_{suffix}": 1 if max_abs <= 180 else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 390: deterministic_seed_guard
    # ------------------------------------------------------------------
    def deterministic_seed_guard(
        self,
        value_column: str,
        *,
        seed: int = 42,
        suffix: str = "dsg",
    ) -> "GeoPromptFrame":
        """Audit deterministic seeding: verify randomized tools produce reproducible output."""
        self._require_column(value_column)
        import random
        recs = self.to_records()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        # Two seeded shuffles should produce identical results
        random.seed(seed)
        order1 = list(range(n))
        random.shuffle(order1)
        random.seed(seed)
        order2 = list(range(n))
        random.shuffle(order2)
        deterministic = order1 == order2
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"deterministic_{suffix}": 1 if deterministic else 0,
            f"seed_{suffix}": seed,
            f"n_{suffix}": n,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 391: neighbor_cache_report
    # ------------------------------------------------------------------
    def neighbor_cache_report(
        self,
        *,
        k: int = 5,
        suffix: str = "ncr",
    ) -> "GeoPromptFrame":
        """Report neighbor cache statistics: compute + profile k-NN overhead."""
        import math
        import time
        coords = self._centroids()
        n = len(coords)
        start = time.perf_counter()
        cache: dict[int, list[int]] = {}
        for i in range(n):
            dists = [(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            cache[i] = [j for _, j in dists[:k]]
        elapsed = time.perf_counter() - start
        # Reuse cost: O(1) lookup
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_{suffix}": n,
            f"k_{suffix}": k,
            f"compute_ms_{suffix}": elapsed * 1000,
            f"entries_{suffix}": len(cache),
            f"reuse_benefit_{suffix}": "high" if n > 50 else "low",
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 392: sparse_adjacency_report
    # ------------------------------------------------------------------
    def sparse_adjacency_report(
        self,
        *,
        threshold: float | None = None,
        suffix: str = "sar",
    ) -> "GeoPromptFrame":
        """Report sparse adjacency statistics: density, sparsity ratio."""
        import math
        coords = self._centroids()
        n = len(coords)
        if threshold is None:
            all_d = []
            for i in range(min(n, 30)):
                for j in range(i + 1, min(n, 30)):
                    all_d.append(math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]))
            threshold = sorted(all_d)[len(all_d) // 4] if all_d else 1.0

        n_edges = 0
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                if d <= threshold:
                    n_edges += 1
        max_edges = n * (n - 1) // 2
        density = n_edges / max_edges if max_edges > 0 else 0.0
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_{suffix}": n,
            f"n_edges_{suffix}": n_edges,
            f"max_edges_{suffix}": max_edges,
            f"density_{suffix}": density,
            f"sparsity_{suffix}": 1.0 - density,
            f"threshold_{suffix}": threshold,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 393: chunked_interpolation_report
    # ------------------------------------------------------------------
    def chunked_interpolation_report(
        self,
        value_column: str,
        *,
        chunk_size: int = 4,
        suffix: str = "cir",
    ) -> "GeoPromptFrame":
        """Report chunked interpolation statistics: per-chunk IDW quality."""
        self._require_column(value_column)
        import math
        coords = self._centroids()
        vals = [r[value_column] for r in self.to_records()]
        n = len(coords)
        chunks = []
        for start in range(0, n, chunk_size):
            end = min(start + chunk_size, n)
            chunk_coords = coords[start:end]
            chunk_vals = vals[start:end]
            # IDW self-check: predict each point from others in chunk
            errors = []
            for i in range(len(chunk_vals)):
                w_sum = 0.0; v_sum = 0.0
                for j in range(len(chunk_vals)):
                    if i == j:
                        continue
                    d = math.hypot(chunk_coords[i][0] - chunk_coords[j][0],
                                   chunk_coords[i][1] - chunk_coords[j][1])
                    w = 1.0 / (d ** 2 + 1e-12)
                    w_sum += w
                    v_sum += w * chunk_vals[j]
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                errors.append(abs(pred - chunk_vals[i]))
            avg_err = sum(errors) / len(errors) if errors else 0.0
            chunks.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"chunk_{suffix}": start // chunk_size,
                f"chunk_size_{suffix}": end - start,
                f"avg_error_{suffix}": avg_err,
            })
        if not chunks:
            chunks.append({
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"chunk_{suffix}": 0,
                f"chunk_size_{suffix}": 0,
                f"avg_error_{suffix}": 0.0,
            })
        return GeoPromptFrame._from_internal_rows(chunks, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 394: config_central
    # ------------------------------------------------------------------
    def config_central(
        self,
        *,
        suffix: str = "cfg",
    ) -> "GeoPromptFrame":
        """Report central configuration: frame metadata, CRS, column inventory, geometry column."""
        recs = self.to_records()
        n = len(recs)
        cols = set()
        for r in recs:
            cols.update(r.keys())
        numeric_cols = []
        for c in sorted(cols):
            if c == self.geometry_column:
                continue
            if recs and isinstance(recs[0].get(c), (int, float)):
                numeric_cols.append(c)
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_rows_{suffix}": n,
            f"n_cols_{suffix}": len(cols),
            f"geometry_column_{suffix}": self.geometry_column,
            f"crs_{suffix}": self.crs or "unknown",
            f"numeric_cols_{suffix}": ",".join(numeric_cols),
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 395: plugin_registry_report
    # ------------------------------------------------------------------
    def plugin_registry_report(
        self,
        *,
        suffix: str = "prr",
    ) -> "GeoPromptFrame":
        """Report plugin registration status: available methods and categories."""
        import inspect
        methods = [m for m in dir(self) if not m.startswith("_") and callable(getattr(self, m, None))]
        # Categorize by prefix patterns
        categories: dict[str, int] = {}
        for m in methods:
            if "review" in m or "audit" in m:
                categories["audit"] = categories.get("audit", 0) + 1
            elif "guard" in m:
                categories["guard"] = categories.get("guard", 0) + 1
            elif "export" in m or "import" in m:
                categories["io"] = categories.get("io", 0) + 1
            elif "report" in m or "diagnostics" in m:
                categories["diagnostics"] = categories.get("diagnostics", 0) + 1
            else:
                categories["analysis"] = categories.get("analysis", 0) + 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_methods_{suffix}": len(methods),
            f"n_audit_{suffix}": categories.get("audit", 0),
            f"n_guard_{suffix}": categories.get("guard", 0),
            f"n_io_{suffix}": categories.get("io", 0),
            f"n_diagnostics_{suffix}": categories.get("diagnostics", 0),
            f"n_analysis_{suffix}": categories.get("analysis", 0),
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 396: stable_api_review
    # ------------------------------------------------------------------
    def stable_api_review(
        self,
        *,
        suffix: str = "apir",
    ) -> "GeoPromptFrame":
        """Review stable public API: exports and naming conventions."""
        import inspect
        methods = [m for m in dir(self) if not m.startswith("_") and callable(getattr(self, m, None))]
        # Naming convention checks
        snake_case = sum(1 for m in methods if m.islower() or "_" in m)
        has_suffix_param = 0
        for m in methods:
            sig = inspect.signature(getattr(self, m))
            if "suffix" in sig.parameters:
                has_suffix_param += 1

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_public_{suffix}": len(methods),
            f"snake_case_{suffix}": snake_case,
            f"has_suffix_{suffix}": has_suffix_param,
            f"naming_consistent_{suffix}": 1 if snake_case == len(methods) else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 397: inplace_strategy_review
    # ------------------------------------------------------------------
    def inplace_strategy_review(
        self,
        value_column: str,
        *,
        suffix: str = "isr",
    ) -> "GeoPromptFrame":
        """Review inplace strategy: verify immutability guarantees."""
        self._require_column(value_column)
        recs = self.to_records()
        original_val = recs[0][value_column] if recs else None
        # Modify returned records — should NOT affect original
        recs2 = self.to_records()
        if recs2:
            recs2[0][value_column] = "MODIFIED"
        recs3 = self.to_records()
        immutable = 1 if (recs3 and recs3[0][value_column] == original_val) else 0
        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"immutable_{suffix}": immutable,
            f"n_rows_{suffix}": len(recs),
            f"original_preserved_{suffix}": 1 if immutable else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 398: voronoi_semantics_review
    # ------------------------------------------------------------------
    def voronoi_semantics_review(
        self,
        *,
        suffix: str = "vsr",
    ) -> "GeoPromptFrame":
        """Audit Voronoi, polygon validity, and repair semantics."""
        import math
        coords = self._centroids()
        n = len(coords)
        if n < 3:
            return GeoPromptFrame._from_internal_rows([{
                self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
                f"n_{suffix}": n,
                f"voronoi_possible_{suffix}": 0,
            }], geometry_column=self.geometry_column, crs=self.crs)

        # Check if Voronoi is well-conditioned (non-degenerate)
        x0, y0 = coords[0]
        x1, y1 = coords[1]
        all_collinear = True
        for i in range(2, n):
            cross = (x1 - x0) * (coords[i][1] - y0) - (y1 - y0) * (coords[i][0] - x0)
            if abs(cross) > 1e-10:
                all_collinear = False
                break

        # Minimum inter-point distance
        min_dist = float("inf")
        for i in range(min(n, 50)):
            for j in range(i + 1, min(n, 50)):
                d = math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1])
                if d < min_dist:
                    min_dist = d

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_{suffix}": n,
            f"voronoi_possible_{suffix}": 0 if all_collinear else 1,
            f"collinear_{suffix}": 1 if all_collinear else 0,
            f"min_dist_{suffix}": min_dist,
            f"well_conditioned_{suffix}": 1 if min_dist > 1e-8 and not all_collinear else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 399: parity_edge_case_audit
    # ------------------------------------------------------------------
    def parity_edge_case_audit(
        self,
        value_column: str,
        *,
        suffix: str = "peca",
    ) -> "GeoPromptFrame":
        """Comprehensive parity and edge-case audit for a value column: nulls, range, distribution."""
        self._require_column(value_column)
        import math
        recs = self.to_records()
        vals = [r[value_column] for r in recs]
        n = len(vals)
        numeric = [v for v in vals if isinstance(v, (int, float))]
        n_numeric = len(numeric)
        n_null = sum(1 for v in vals if v is None)
        n_nan = sum(1 for v in numeric if isinstance(v, float) and math.isnan(v))
        clean = [v for v in numeric if not (isinstance(v, float) and math.isnan(v))]

        if clean:
            mean_v = sum(clean) / len(clean)
            var_v = sum((v - mean_v) ** 2 for v in clean) / len(clean)
            std_v = math.sqrt(var_v)
            cv = std_v / abs(mean_v) if mean_v != 0 else 0.0
        else:
            mean_v = 0.0; std_v = 0.0; cv = 0.0

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_{suffix}": n,
            f"n_numeric_{suffix}": n_numeric,
            f"n_null_{suffix}": n_null,
            f"n_nan_{suffix}": n_nan,
            f"mean_{suffix}": mean_v,
            f"std_{suffix}": std_v,
            f"cv_{suffix}": cv,
            f"range_{suffix}": max(clean) - min(clean) if clean else 0.0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 400: release_checklist
    # ------------------------------------------------------------------
    def release_checklist(
        self,
        *,
        expected_tool_count: int = 400,
        suffix: str = "rc",
    ) -> "GeoPromptFrame":
        """Generate a release checklist: tool count, test readiness, metadata validation."""
        import inspect
        methods = [m for m in dir(self) if not m.startswith("_") and callable(getattr(self, m, None))]
        n_methods = len(methods)
        # Check basic metadata
        has_crs = self.crs is not None
        has_geom_col = self.geometry_column is not None
        recs = self.to_records()
        n_rows = len(recs)

        return GeoPromptFrame._from_internal_rows([{
            self.geometry_column: {"type": "Point", "coordinates": (0, 0)},
            f"n_public_methods_{suffix}": n_methods,
            f"expected_tools_{suffix}": expected_tool_count,
            f"meets_target_{suffix}": 1 if n_methods >= expected_tool_count else 0,
            f"has_crs_{suffix}": 1 if has_crs else 0,
            f"has_geom_col_{suffix}": 1 if has_geom_col else 0,
            f"n_data_rows_{suffix}": n_rows,
            f"release_ready_{suffix}": 1 if n_methods >= expected_tool_count and has_geom_col else 0,
        }], geometry_column=self.geometry_column, crs=self.crs)

    # ==================================================================
    # AI-Powered Spatial Tools (401–420)
    # ==================================================================

    # ------------------------------------------------------------------
    # Tool 401: neural_spatial_interpolation
    # ------------------------------------------------------------------
    def neural_spatial_interpolation(
        self,
        value_column: str,
        *,
        grid_resolution: int = 20,
        hidden_size: int = 16,
        learning_rate: float = 0.01,
        epochs: int = 200,
        seed: int | None = None,
        suffix: str = "nsi",
    ) -> "GeoPromptFrame":
        """Interpolate a surface using a pure-Python feedforward neural network.

        A 2-input (x, y) → hidden → 1-output MLP trained via backpropagation on
        the feature values, then evaluated on a regular grid.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in self._rows]
        if not centroids:
            return self.copy()

        # Normalise inputs to [0,1]
        xs = [c[0] for c in centroids]
        ys = [c[1] for c in centroids]
        min_x, max_x = min(xs), max(xs)
        min_y, max_y = min(ys), max(ys)
        rx = max_x - min_x or 1.0
        ry = max_y - min_y or 1.0
        min_v, max_v = min(vals), max(vals)
        rv = max_v - min_v or 1.0
        norm_x = [(x - min_x) / rx for x in xs]
        norm_y = [(y - min_y) / ry for y in ys]
        norm_v = [(v - min_v) / rv for v in vals]

        # Initialise weights
        w_ih = [[rng.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        b_h = [rng.gauss(0, 0.1) for _ in range(hidden_size)]
        w_ho = [rng.gauss(0, 0.5) for _ in range(hidden_size)]
        b_o = rng.gauss(0, 0.1)

        # Train
        n = len(norm_x)
        for _ in range(epochs):
            for i in range(n):
                inp = [norm_x[i], norm_y[i]]
                # Forward
                h = [sigmoid(w_ih[j][0] * inp[0] + w_ih[j][1] * inp[1] + b_h[j]) for j in range(hidden_size)]
                o = sum(w_ho[j] * h[j] for j in range(hidden_size)) + b_o
                pred = sigmoid(o)
                # Backward
                err = pred - norm_v[i]
                d_o = err * pred * (1 - pred)
                for j in range(hidden_size):
                    d_h = d_o * w_ho[j] * h[j] * (1 - h[j])
                    w_ho[j] -= learning_rate * d_o * h[j]
                    w_ih[j][0] -= learning_rate * d_h * inp[0]
                    w_ih[j][1] -= learning_rate * d_h * inp[1]
                    b_h[j] -= learning_rate * d_h
                b_o -= learning_rate * d_o

        # Predict grid
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                inp = [(px - min_x) / rx, (py - min_y) / ry]
                h = [sigmoid(w_ih[j][0] * inp[0] + w_ih[j][1] * inp[1] + b_h[j]) for j in range(hidden_size)]
                o = sum(w_ho[j] * h[j] for j in range(hidden_size)) + b_o
                pred_val = sigmoid(o) * rv + min_v
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"predicted_{suffix}": round(pred_val, 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 402: spatial_autoencoder
    # ------------------------------------------------------------------
    def spatial_autoencoder(
        self,
        columns: list[str],
        *,
        encoding_dim: int = 2,
        learning_rate: float = 0.01,
        epochs: int = 100,
        seed: int | None = None,
        suffix: str = "sae",
    ) -> "GeoPromptFrame":
        """Reduce multi-attribute spatial data via a pure-Python autoencoder.

        Builds an encoder (input → encoding_dim) and decoder (encoding_dim → input)
        trained to minimise reconstruction error.  Returns encoded representations.
        """
        for c in columns:
            self._require_column(c)
        rng = random.Random(seed)
        n_in = len(columns)
        recs = self.to_records()
        if not recs:
            return self.copy()

        # Extract & normalise
        raw = [[float(r.get(c, 0) or 0) for c in columns] for r in recs]
        col_min = [min(row[j] for row in raw) for j in range(n_in)]
        col_max = [max(row[j] for row in raw) for j in range(n_in)]
        col_rng = [col_max[j] - col_min[j] or 1.0 for j in range(n_in)]
        data = [[(row[j] - col_min[j]) / col_rng[j] for j in range(n_in)] for row in raw]

        # Weights
        w_enc = [[rng.gauss(0, 0.5) for _ in range(n_in)] for _ in range(encoding_dim)]
        b_enc = [0.0] * encoding_dim
        w_dec = [[rng.gauss(0, 0.5) for _ in range(encoding_dim)] for _ in range(n_in)]
        b_dec = [0.0] * n_in

        for _ in range(epochs):
            for row in data:
                # Encode
                enc = [sigmoid(sum(w_enc[k][j] * row[j] for j in range(n_in)) + b_enc[k]) for k in range(encoding_dim)]
                # Decode
                dec = [sigmoid(sum(w_dec[j][k] * enc[k] for k in range(encoding_dim)) + b_dec[j]) for j in range(n_in)]
                # Backprop (simplified)
                b_enc_grad = [0.0] * encoding_dim
                for j in range(n_in):
                    err_j = dec[j] - row[j]
                    d_dec = err_j * dec[j] * (1 - dec[j])
                    for k in range(encoding_dim):
                        w_dec[j][k] -= learning_rate * d_dec * enc[k]
                        d_enc = d_dec * w_dec[j][k] * enc[k] * (1 - enc[k])
                        w_enc[k][j] -= learning_rate * d_enc * row[j]
                        b_enc_grad[k] += d_enc
                    b_dec[j] -= learning_rate * d_dec
                for k in range(encoding_dim):
                    b_enc[k] -= learning_rate * b_enc_grad[k]

        # Produce encoded output
        out_rows: list[Record] = []
        for idx, row in enumerate(data):
            enc = [sigmoid(sum(w_enc[k][j] * row[j] for j in range(n_in)) + b_enc[k]) for k in range(encoding_dim)]
            rec: Record = {self.geometry_column: recs[idx][self.geometry_column]}
            for k in range(encoding_dim):
                rec[f"enc_{k}_{suffix}"] = round(enc[k], 6)
            # Reconstruction error
            dec = [sigmoid(sum(w_dec[j][k] * enc[k] for k in range(encoding_dim)) + b_dec[j]) for j in range(n_in)]
            mse = sum((dec[j] - row[j]) ** 2 for j in range(n_in)) / n_in
            rec[f"recon_error_{suffix}"] = round(mse, 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 403: ai_spatial_classifier
    # ------------------------------------------------------------------
    def ai_spatial_classifier(
        self,
        feature_columns: list[str],
        label_column: str,
        *,
        hidden_size: int = 10,
        learning_rate: float = 0.05,
        epochs: int = 150,
        seed: int | None = None,
        suffix: str = "aicls",
    ) -> "GeoPromptFrame":
        """Train a neural-network classifier on spatial features and predict labels.

        Pure-Python MLP with softmax output.  Returns predicted class per feature
        along with confidence scores.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(label_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        labels_raw = [r.get(label_column) for r in recs]
        unique_labels = sorted(set(labels_raw), key=str)
        n_cls = len(unique_labels)
        label_map = {lbl: i for i, lbl in enumerate(unique_labels)}
        n_in = len(feature_columns)

        # Normalise
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(row[j] for row in raw) for j in range(n_in)]
        col_rng = [max(row[j] for row in raw) - col_min[j] or 1.0 for j in range(n_in)]
        data = [[(row[j] - col_min[j]) / col_rng[j] for j in range(n_in)] for row in raw]

        # Weights: input→hidden, hidden→output
        w1 = [[rng.gauss(0, 0.5) for _ in range(n_in)] for _ in range(hidden_size)]
        b1 = [0.0] * hidden_size
        w2 = [[rng.gauss(0, 0.5) for _ in range(hidden_size)] for _ in range(n_cls)]
        b2 = [0.0] * n_cls

        def relu(z: float) -> float:
            return max(0.0, z)

        def softmax(logits: list[float]) -> list[float]:
            mx = max(logits)
            exps = [math.exp(z - mx) for z in logits]
            s = sum(exps)
            return [e / s for e in exps]

        for _ in range(epochs):
            for i, row in enumerate(data):
                target = label_map[labels_raw[i]]
                # Forward
                h = [relu(sum(w1[k][j] * row[j] for j in range(n_in)) + b1[k]) for k in range(hidden_size)]
                logits = [sum(w2[c][k] * h[k] for k in range(hidden_size)) + b2[c] for c in range(n_cls)]
                probs = softmax(logits)
                # Backprop (cross-entropy gradient)
                d_logit = list(probs)
                d_logit[target] -= 1.0
                for c_idx in range(n_cls):
                    for k in range(hidden_size):
                        w2[c_idx][k] -= learning_rate * d_logit[c_idx] * h[k]
                    b2[c_idx] -= learning_rate * d_logit[c_idx]
                for k in range(hidden_size):
                    d_h = sum(d_logit[c_idx] * w2[c_idx][k] for c_idx in range(n_cls))
                    d_h *= 1.0 if h[k] > 0 else 0.0  # ReLU derivative
                    for j in range(n_in):
                        w1[k][j] -= learning_rate * d_h * row[j]
                    b1[k] -= learning_rate * d_h

        # Predict
        out_rows: list[Record] = []
        correct = 0
        for i, row in enumerate(data):
            h = [relu(sum(w1[k][j] * row[j] for j in range(n_in)) + b1[k]) for k in range(hidden_size)]
            logits = [sum(w2[c][k] * h[k] for k in range(hidden_size)) + b2[c] for c in range(n_cls)]
            probs = softmax(logits)
            pred_idx = max(range(n_cls), key=lambda c: probs[c])
            pred_label = unique_labels[pred_idx]
            if pred_label == labels_raw[i]:
                correct += 1
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": pred_label,
                f"confidence_{suffix}": round(probs[pred_idx], 6),
                f"actual_{suffix}": labels_raw[i],
                f"correct_{suffix}": 1 if pred_label == labels_raw[i] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 404: gradient_boosted_spatial_regression
    # ------------------------------------------------------------------
    def gradient_boosted_spatial_regression(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        n_trees: int = 20,
        max_depth: int = 3,
        learning_rate: float = 0.1,
        seed: int | None = None,
        suffix: str = "gbr",
    ) -> "GeoPromptFrame":
        """Gradient-boosted regression trees for spatial prediction.

        Pure-Python recursive decision-tree ensemble trained via gradient
        descent on squared-error loss.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        preds = [sum(targets) / n] * n  # init to mean

        def _build_tree(indices: list[int], residuals: list[float], depth: int) -> dict:
            vals_r = [residuals[i] for i in indices]
            mean_val = sum(vals_r) / len(vals_r)
            if depth >= max_depth or len(indices) <= 2:
                return {"t": "l", "v": mean_val}
            best_var = sum((v - mean_val) ** 2 for v in vals_r)
            best_split: tuple[int, float, list[int], list[int]] | None = None
            for f_idx in range(n_feat):
                feat_vals = sorted(set(raw[i][f_idx] for i in indices))
                if len(feat_vals) < 2:
                    continue
                for t_idx in range(len(feat_vals) - 1):
                    thresh = (feat_vals[t_idx] + feat_vals[t_idx + 1]) / 2
                    left_idx = [i for i in indices if raw[i][f_idx] <= thresh]
                    right_idx = [i for i in indices if raw[i][f_idx] > thresh]
                    if not left_idx or not right_idx:
                        continue
                    l_vals = [residuals[i] for i in left_idx]
                    r_vals = [residuals[i] for i in right_idx]
                    l_mean = sum(l_vals) / len(l_vals)
                    r_mean = sum(r_vals) / len(r_vals)
                    var = sum((v - l_mean) ** 2 for v in l_vals) + sum((v - r_mean) ** 2 for v in r_vals)
                    if var < best_var:
                        best_var = var
                        best_split = (f_idx, thresh, left_idx, right_idx)
            if best_split is None:
                return {"t": "l", "v": mean_val}
            f_idx, thresh, left_idx, right_idx = best_split
            return {
                "t": "s", "f": f_idx, "th": thresh,
                "l": _build_tree(left_idx, residuals, depth + 1),
                "r": _build_tree(right_idx, residuals, depth + 1),
            }

        def _predict_tree(tree: dict, point: list[float]) -> float:
            if tree["t"] == "l":
                return tree["v"]
            if point[tree["f"]] <= tree["th"]:
                return _predict_tree(tree["l"], point)
            return _predict_tree(tree["r"], point)

        trees: list[dict] = []
        for _t in range(n_trees):
            residuals = [targets[i] - preds[i] for i in range(n)]
            tree = _build_tree(list(range(n)), residuals, 0)
            trees.append(tree)
            for i in range(n):
                preds[i] += learning_rate * _predict_tree(tree, raw[i])

        # Output
        out_rows: list[Record] = []
        for i in range(n):
            err = targets[i] - preds[i]
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(preds[i], 6),
                f"actual_{suffix}": targets[i],
                f"residual_{suffix}": round(err, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 405: random_forest_spatial_classifier
    # ------------------------------------------------------------------
    def random_forest_spatial_classifier(
        self,
        feature_columns: list[str],
        label_column: str,
        *,
        n_trees: int = 15,
        max_depth: int = 4,
        seed: int | None = None,
        suffix: str = "rfc",
    ) -> "GeoPromptFrame":
        """Random forest classifier for spatial features.

        Builds an ensemble of recursive decision trees with bootstrap sampling
        and feature sub-sampling, then predicts via majority vote.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(label_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        labels = [r.get(label_column) for r in recs]
        unique_labels = sorted(set(labels), key=str)

        def _gini(group: list) -> float:
            size = len(group)
            if size == 0:
                return 0.0
            return 1.0 - sum((group.count(lbl) / size) ** 2 for lbl in unique_labels)

        def _build_tree(indices: list[int], feat_subset: list[int], depth: int) -> dict:
            grp = [labels[i] for i in indices]
            majority = max(unique_labels, key=lambda lbl: grp.count(lbl))
            if depth >= max_depth or len(indices) <= 2 or len(set(grp)) == 1:
                return {"t": "l", "v": majority}
            best_gini = float("inf")
            best_split: tuple[int, float, list[int], list[int]] | None = None
            for f_idx in feat_subset:
                vals = sorted(set(raw[i][f_idx] for i in indices))
                if len(vals) < 2:
                    continue
                for t_i in range(len(vals) - 1):
                    thresh = (vals[t_i] + vals[t_i + 1]) / 2
                    left_idx = [i for i in indices if raw[i][f_idx] <= thresh]
                    right_idx = [i for i in indices if raw[i][f_idx] > thresh]
                    if not left_idx or not right_idx:
                        continue
                    left_lbl = [labels[i] for i in left_idx]
                    right_lbl = [labels[i] for i in right_idx]
                    g = (len(left_lbl) * _gini(left_lbl) + len(right_lbl) * _gini(right_lbl)) / len(indices)
                    if g < best_gini:
                        best_gini = g
                        best_split = (f_idx, thresh, left_idx, right_idx)
            if best_split is None:
                return {"t": "l", "v": majority}
            f_idx, thresh, left_idx, right_idx = best_split
            return {
                "t": "s", "f": f_idx, "th": thresh,
                "l": _build_tree(left_idx, feat_subset, depth + 1),
                "r": _build_tree(right_idx, feat_subset, depth + 1),
            }

        def _predict_tree(tree: dict, point: list[float]) -> Any:
            if tree["t"] == "l":
                return tree["v"]
            if point[tree["f"]] <= tree["th"]:
                return _predict_tree(tree["l"], point)
            return _predict_tree(tree["r"], point)

        trees: list[dict] = []
        for _ in range(n_trees):
            bag = [rng.randint(0, n - 1) for _ in range(n)]
            k = max(1, int(math.sqrt(n_feat)))
            feat_sub = rng.sample(range(n_feat), min(k, n_feat))
            trees.append(_build_tree(bag, feat_sub, 0))

        # Predict via majority vote
        out_rows: list[Record] = []
        for i in range(n):
            votes: dict[Any, int] = {}
            for tree in trees:
                pred = _predict_tree(tree, raw[i])
                votes[pred] = votes.get(pred, 0) + 1
            predicted = max(votes, key=lambda k: votes[k])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": predicted,
                f"votes_{suffix}": max(votes.values()),
                f"n_trees_{suffix}": n_trees,
                f"actual_{suffix}": labels[i],
                f"correct_{suffix}": 1 if predicted == labels[i] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 406: spatial_anomaly_detector
    # ------------------------------------------------------------------
    def spatial_anomaly_detector(
        self,
        feature_columns: list[str],
        *,
        contamination: float = 0.1,
        n_trees: int = 50,
        sample_size: int | None = None,
        seed: int | None = None,
        suffix: str = "anom",
    ) -> "GeoPromptFrame":
        """Isolation-forest-style anomaly detector for spatial features.

        Pure-Python isolation forest: random recursive partitions, anomaly score
        based on average path length.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        sub_size = sample_size or min(256, n)

        def _c(size: int) -> float:
            if size <= 1:
                return 0.0
            if size == 2:
                return 1.0
            h = math.log(size - 1) + 0.5772156649
            return 2.0 * h - 2.0 * (size - 1) / size

        def _build_tree(indices: list[int], depth: int, max_depth: int) -> dict:
            if len(indices) <= 1 or depth >= max_depth:
                return {"type": "leaf", "size": len(indices)}
            f = rng.randint(0, n_feat - 1)
            vals = [raw[i][f] for i in indices]
            mn, mx = min(vals), max(vals)
            if mn == mx:
                return {"type": "leaf", "size": len(indices)}
            split = rng.uniform(mn, mx)
            left = [i for i in indices if raw[i][f] < split]
            right = [i for i in indices if raw[i][f] >= split]
            if not left or not right:
                return {"type": "leaf", "size": len(indices)}
            return {
                "type": "split", "feature": f, "threshold": split,
                "left": _build_tree(left, depth + 1, max_depth),
                "right": _build_tree(right, depth + 1, max_depth),
            }

        def _path_length(point: list[float], node: dict, depth: int) -> float:
            if node["type"] == "leaf":
                return depth + _c(node["size"])
            if point[node["feature"]] < node["threshold"]:
                return _path_length(point, node["left"], depth + 1)
            return _path_length(point, node["right"], depth + 1)

        max_depth = int(math.ceil(math.log2(max(sub_size, 2))))
        trees = []
        for _ in range(n_trees):
            sample = rng.sample(range(n), min(sub_size, n))
            trees.append(_build_tree(sample, 0, max_depth))

        scores: list[float] = []
        for i in range(n):
            avg_path = sum(_path_length(raw[i], t, 0) for t in trees) / n_trees
            score = 2.0 ** (-avg_path / _c(sub_size)) if _c(sub_size) > 0 else 0.5
            scores.append(score)

        threshold = sorted(scores, reverse=True)[max(0, int(n * contamination) - 1)] if n > 0 else 0.5

        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"anomaly_score_{suffix}": round(scores[i], 6),
                f"is_anomaly_{suffix}": 1 if scores[i] >= threshold else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 407: self_organizing_map_spatial
    # ------------------------------------------------------------------
    def self_organizing_map_spatial(
        self,
        feature_columns: list[str],
        *,
        grid_rows: int = 5,
        grid_cols: int = 5,
        epochs: int = 100,
        initial_lr: float = 0.5,
        seed: int | None = None,
        suffix: str = "som",
    ) -> "GeoPromptFrame":
        """Self-Organizing Map (Kohonen) for spatial feature clustering.

        Projects high-dimensional attributes onto a 2-D grid, preserving
        topological neighbourhood relationships.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(row[j] for row in raw) for j in range(n_feat)]
        col_rng = [max(row[j] for row in raw) - col_min[j] or 1.0 for j in range(n_feat)]
        data = [[(row[j] - col_min[j]) / col_rng[j] for j in range(n_feat)] for row in raw]

        # Init grid weights
        weights = [[[rng.random() for _ in range(n_feat)] for _ in range(grid_cols)] for _ in range(grid_rows)]
        initial_radius = max(grid_rows, grid_cols) / 2

        for epoch in range(epochs):
            lr = initial_lr * (1 - epoch / epochs)
            radius = initial_radius * (1 - epoch / epochs)
            radius = max(radius, 1.0)
            sample = data[rng.randint(0, len(data) - 1)]
            # Find BMU
            best_r, best_c, best_d = 0, 0, float("inf")
            for r in range(grid_rows):
                for c in range(grid_cols):
                    d = sum((weights[r][c][f] - sample[f]) ** 2 for f in range(n_feat))
                    if d < best_d:
                        best_r, best_c, best_d = r, c, d
            # Update neighbours
            for r in range(grid_rows):
                for c in range(grid_cols):
                    dist_sq = (r - best_r) ** 2 + (c - best_c) ** 2
                    if dist_sq <= radius * radius:
                        influence = math.exp(-dist_sq / (2 * radius * radius))
                        for f in range(n_feat):
                            weights[r][c][f] += lr * influence * (sample[f] - weights[r][c][f])

        # Assign each feature to nearest BMU
        out_rows: list[Record] = []
        for idx, row in enumerate(data):
            best_r, best_c, best_d = 0, 0, float("inf")
            for r in range(grid_rows):
                for c in range(grid_cols):
                    d = sum((weights[r][c][f] - row[f]) ** 2 for f in range(n_feat))
                    if d < best_d:
                        best_r, best_c, best_d = r, c, d
            out_rows.append({
                self.geometry_column: recs[idx][self.geometry_column],
                f"bmu_row_{suffix}": best_r,
                f"bmu_col_{suffix}": best_c,
                f"bmu_cluster_{suffix}": best_r * grid_cols + best_c,
                f"bmu_dist_{suffix}": round(math.sqrt(best_d), 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 408: spatial_reinforcement_learner
    # ------------------------------------------------------------------
    def spatial_reinforcement_learner(
        self,
        reward_column: str,
        *,
        n_episodes: int = 100,
        gamma: float = 0.9,
        epsilon: float = 0.3,
        alpha: float = 0.1,
        seed: int | None = None,
        suffix: str = "srl",
    ) -> "GeoPromptFrame":
        """Q-learning agent that navigates spatial features by nearest-neighbor
        transitions, maximising cumulative reward.

        Returns per-feature Q-values and optimal visit order.
        """
        self._require_column(reward_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        rewards = [float(r.get(reward_column, 0) or 0) for r in recs]

        # Precompute k-nearest (k=min(5, n-1))
        k = min(5, n - 1)
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([j for _, j in dists[:k]])

        # Q-table
        q_table = [[0.0] * n for _ in range(n)]

        for _ in range(n_episodes):
            state = rng.randint(0, n - 1)
            visited: set[int] = {state}
            for _step in range(min(n, 20)):
                actions = [a for a in neighbours[state] if a not in visited]
                if not actions:
                    break
                if rng.random() < epsilon:
                    action = rng.choice(actions)
                else:
                    action = max(actions, key=lambda a: q_table[state][a])
                reward = rewards[action]
                future_actions = [a for a in neighbours[action] if a not in visited and a != action]
                max_future = max((q_table[action][a] for a in future_actions), default=0.0)
                q_table[state][action] += alpha * (reward + gamma * max_future - q_table[state][action])
                visited.add(action)
                state = action

        out_rows: list[Record] = []
        for i in range(n):
            max_q = max(q_table[i]) if n > 1 else 0.0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"max_q_{suffix}": round(max_q, 6),
                f"reward_{suffix}": rewards[i],
                f"n_neighbours_{suffix}": len(neighbours[i]),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 409: evolutionary_feature_selection
    # ------------------------------------------------------------------
    def evolutionary_feature_selection(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        pop_size: int = 20,
        generations: int = 30,
        mutation_rate: float = 0.1,
        seed: int | None = None,
        suffix: str = "efs",
    ) -> "GeoPromptFrame":
        """Genetic algorithm for spatial feature selection.

        Evolves binary masks over feature columns, scored by correlation with
        the target.  Returns the best feature subset.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        t_mean = sum(targets) / n
        t_var = sum((t - t_mean) ** 2 for t in targets)

        def _fitness(mask: list[int]) -> float:
            sel = [j for j in range(n_feat) if mask[j]]
            if not sel:
                return -1.0
            # Composite = mean of abs correlations
            total_corr = 0.0
            for j in sel:
                col_vals = [raw[i][j] for i in range(n)]
                c_mean = sum(col_vals) / n
                c_var = sum((v - c_mean) ** 2 for v in col_vals)
                if c_var == 0 or t_var == 0:
                    continue
                cov = sum((col_vals[i] - c_mean) * (targets[i] - t_mean) for i in range(n))
                total_corr += abs(cov / math.sqrt(c_var * t_var))
            return total_corr / len(sel)

        # Initialise population
        pop = [[1 if rng.random() < 0.5 else 0 for _ in range(n_feat)] for _ in range(pop_size)]
        for _ in range(generations):
            scored = sorted(pop, key=_fitness, reverse=True)
            # Elitism: keep top half
            parents = scored[:pop_size // 2]
            children: list[list[int]] = list(parents)
            while len(children) < pop_size:
                p1, p2 = rng.sample(parents, 2)
                cx = rng.randint(0, n_feat - 1)
                child = p1[:cx] + p2[cx:]
                for j in range(n_feat):
                    if rng.random() < mutation_rate:
                        child[j] = 1 - child[j]
                children.append(child)
            pop = children

        best = max(pop, key=_fitness)
        selected = [feature_columns[j] for j in range(n_feat) if best[j]]

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            rec[f"selected_features_{suffix}"] = ",".join(selected) if selected else "none"
            rec[f"n_selected_{suffix}"] = len(selected)
            rec[f"fitness_{suffix}"] = round(_fitness(best), 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 410: spatial_attention_mechanism
    # ------------------------------------------------------------------
    def spatial_attention_mechanism(
        self,
        value_column: str,
        *,
        n_heads: int = 4,
        seed: int | None = None,
        suffix: str = "attn",
    ) -> "GeoPromptFrame":
        """Multi-head spatial attention: features attend to neighbours with
        learned distance-based attention weights.

        Returns attention-weighted aggregated values per feature.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]

        # Distance matrix
        dists = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                dists[i][j] = d
                dists[j][i] = d

        out_rows: list[Record] = []
        for i in range(n):
            head_results: list[float] = []
            for h in range(n_heads):
                scale = rng.uniform(0.5, 5.0)
                # Softmax attention over distances
                raw_attn = [-dists[i][j] * scale for j in range(n)]
                mx = max(raw_attn)
                exps = [math.exp(a - mx) for a in raw_attn]
                s = sum(exps)
                weights = [e / s for e in exps]
                head_results.append(sum(weights[j] * vals[j] for j in range(n)))
            agg = sum(head_results) / n_heads
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"attention_value_{suffix}": round(agg, 6),
                f"original_{suffix}": vals[i],
                f"n_heads_{suffix}": n_heads,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 411: spatial_bayesian_optimizer
    # ------------------------------------------------------------------
    def spatial_bayesian_optimizer(
        self,
        value_column: str,
        *,
        n_iterations: int = 20,
        n_candidates: int = 50,
        kappa: float = 2.0,
        seed: int | None = None,
        suffix: str = "sbo",
    ) -> "GeoPromptFrame":
        """Bayesian optimisation over spatial coordinates to find the location
        that maximises a value surface.

        Uses a Gaussian-process-like surrogate with RBF kernel and Upper
        Confidence Bound (UCB) acquisition.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()
        observed_x: list[tuple[float, float]] = list(centroids)
        observed_y: list[float] = list(vals)
        length_scale = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-6) / 4

        def _rbf(x1: tuple[float, float], x2: tuple[float, float]) -> float:
            d2 = (x1[0] - x2[0]) ** 2 + (x1[1] - x2[1]) ** 2
            return math.exp(-d2 / (2 * length_scale ** 2))

        def _predict(x: tuple[float, float]) -> tuple[float, float]:
            k_vec = [_rbf(x, ox) for ox in observed_x]
            k_sum = sum(k_vec) or 1e-12
            mu = sum(k_vec[i] * observed_y[i] for i in range(len(observed_y))) / k_sum
            var = 1.0 - sum(k * k for k in k_vec) / (k_sum * k_sum) if k_sum > 0 else 1.0
            return mu, max(var, 0.0)

        best_point = centroids[vals.index(max(vals))]
        best_val = max(vals)

        for _ in range(n_iterations):
            candidates = [(rng.uniform(b.min_x, b.max_x), rng.uniform(b.min_y, b.max_y))
                          for _ in range(n_candidates)]
            best_acq = float("-inf")
            best_cand = candidates[0]
            for cand in candidates:
                mu, var = _predict(cand)
                acq = mu + kappa * math.sqrt(var)
                if acq > best_acq:
                    best_acq = acq
                    best_cand = cand
            # Evaluate (interpolate from nearest)
            dists_to_cand = [(math.hypot(best_cand[0] - c[0], best_cand[1] - c[1]), i)
                             for i, c in enumerate(centroids)]
            dists_to_cand.sort()
            # IDW with k=3
            k = min(3, len(centroids))
            w_sum, v_sum = 0.0, 0.0
            for d, idx in dists_to_cand[:k]:
                w = 1.0 / max(d, 1e-12)
                w_sum += w
                v_sum += w * vals[idx]
            est_val = v_sum / w_sum if w_sum > 0 else 0.0
            observed_x.append(best_cand)
            observed_y.append(est_val)
            if est_val > best_val:
                best_val = est_val
                best_point = best_cand

        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [best_point[0], best_point[1]]},
            f"optimal_x_{suffix}": round(best_point[0], 6),
            f"optimal_y_{suffix}": round(best_point[1], 6),
            f"optimal_value_{suffix}": round(best_val, 6),
            f"n_iterations_{suffix}": n_iterations,
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 412: spatial_gan_augmentor
    # ------------------------------------------------------------------
    def spatial_gan_augmentor(
        self,
        *,
        n_synthetic: int = 10,
        noise_dim: int = 4,
        epochs: int = 100,
        learning_rate: float = 0.01,
        seed: int | None = None,
        suffix: str = "gan",
    ) -> "GeoPromptFrame":
        """Generate synthetic spatial points via a GAN-inspired approach.

        A miniature generator network maps random noise to spatial coordinates,
        trained to match the centroid distribution of the input features.
        """
        rng = random.Random(seed)
        centroids = self._centroids()
        if not centroids:
            return self.copy()

        xs = [c[0] for c in centroids]
        ys = [c[1] for c in centroids]
        min_x, max_x = min(xs), max(xs)
        min_y, max_y = min(ys), max(ys)
        rx = max_x - min_x or 1.0
        ry = max_y - min_y or 1.0
        norm_pts = [((c[0] - min_x) / rx, (c[1] - min_y) / ry) for c in centroids]

        # Simple generator: noise_dim → 2 (x,y)
        w = [[rng.gauss(0, 0.5) for _ in range(noise_dim)] for _ in range(2)]
        bias = [0.5, 0.5]

        for _ in range(epochs):
            noise = [rng.gauss(0, 1) for _ in range(noise_dim)]
            gen_x = sigmoid(sum(w[0][d] * noise[d] for d in range(noise_dim)) + bias[0])
            gen_y = sigmoid(sum(w[1][d] * noise[d] for d in range(noise_dim)) + bias[1])
            # Find nearest real point
            nearest = min(norm_pts, key=lambda p: (p[0] - gen_x) ** 2 + (p[1] - gen_y) ** 2)
            err_x = gen_x - nearest[0]
            err_y = gen_y - nearest[1]
            # Update generator to move towards real distribution
            grad_x = err_x * gen_x * (1 - gen_x)
            grad_y = err_y * gen_y * (1 - gen_y)
            for d in range(noise_dim):
                w[0][d] -= learning_rate * grad_x * noise[d]
                w[1][d] -= learning_rate * grad_y * noise[d]
            bias[0] -= learning_rate * grad_x
            bias[1] -= learning_rate * grad_y

        # Generate synthetic points
        out_rows: list[Record] = []
        for i in range(n_synthetic):
            noise = [rng.gauss(0, 1) for _ in range(noise_dim)]
            gen_x = sigmoid(sum(w[0][d] * noise[d] for d in range(noise_dim)) + bias[0])
            gen_y = sigmoid(sum(w[1][d] * noise[d] for d in range(noise_dim)) + bias[1])
            px = gen_x * rx + min_x
            py = gen_y * ry + min_y
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": [round(px, 6), round(py, 6)]},
                f"synthetic_id_{suffix}": i,
                f"source_{suffix}": "gan_generator",
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 413: spatial_embedding
    # ------------------------------------------------------------------
    def spatial_embedding(
        self,
        feature_columns: list[str],
        *,
        embedding_dim: int = 2,
        perplexity: float = 5.0,
        n_iterations: int = 200,
        learning_rate: float = 10.0,
        seed: int | None = None,
        suffix: str = "emb",
    ) -> "GeoPromptFrame":
        """t-SNE-style spatial embedding: project multi-attribute features
        into a low-dimensional space preserving local structure.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(row[j] for row in raw) for j in range(n_feat)]
        col_rng = [max(row[j] for row in raw) - col_min[j] or 1.0 for j in range(n_feat)]
        data = [[(row[j] - col_min[j]) / col_rng[j] for j in range(n_feat)] for row in raw]

        # Pairwise distances in high-D
        hd_dist = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.sqrt(sum((data[i][f] - data[j][f]) ** 2 for f in range(n_feat)))
                hd_dist[i][j] = d
                hd_dist[j][i] = d

        # Compute pairwise probabilities (simplified SNE)
        sigma = max(1.0, perplexity / 3)
        p = [[0.0] * n for _ in range(n)]
        for i in range(n):
            denom = sum(math.exp(-hd_dist[i][j] ** 2 / (2 * sigma ** 2)) for j in range(n) if j != i) or 1e-12
            for j in range(n):
                if j != i:
                    p[i][j] = math.exp(-hd_dist[i][j] ** 2 / (2 * sigma ** 2)) / denom
        # Symmetrise
        for i in range(n):
            for j in range(i + 1, n):
                avg = (p[i][j] + p[j][i]) / (2 * n)
                p[i][j] = max(avg, 1e-12)
                p[j][i] = max(avg, 1e-12)

        # Init embedding
        y = [[rng.gauss(0, 0.01) for _ in range(embedding_dim)] for _ in range(n)]

        for _ in range(n_iterations):
            # Compute q
            q_raw = [[0.0] * n for _ in range(n)]
            for i in range(n):
                for j in range(i + 1, n):
                    d2 = sum((y[i][d] - y[j][d]) ** 2 for d in range(embedding_dim))
                    q_raw[i][j] = 1.0 / (1.0 + d2)
                    q_raw[j][i] = q_raw[i][j]
            q_sum = sum(q_raw[i][j] for i in range(n) for j in range(n) if i != j) or 1e-12
            # Gradient
            for i in range(n):
                grad = [0.0] * embedding_dim
                for j in range(n):
                    if i == j:
                        continue
                    q_ij = q_raw[i][j] / q_sum
                    mult = 4.0 * (p[i][j] - q_ij) * q_raw[i][j]
                    for d in range(embedding_dim):
                        grad[d] += mult * (y[i][d] - y[j][d])
                for d in range(embedding_dim):
                    y[i][d] -= learning_rate * grad[d]

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            for d in range(embedding_dim):
                rec[f"embed_{d}_{suffix}"] = round(y[i][d], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 414: fuzzy_spatial_classifier
    # ------------------------------------------------------------------
    def fuzzy_spatial_classifier(
        self,
        value_column: str,
        *,
        n_classes: int = 3,
        fuzziness: float = 2.0,
        max_iterations: int = 100,
        seed: int | None = None,
        suffix: str = "fzy",
    ) -> "GeoPromptFrame":
        """Fuzzy C-Means clustering with spatial proximity weighting.

        Returns membership degrees for each cluster per feature, enabling
        soft classification boundaries.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # Feature vector: (x, y, value)
        data = [(centroids[i][0], centroids[i][1], vals[i]) for i in range(n)]

        # Normalise
        mins = [min(d[k] for d in data) for k in range(3)]
        rngs = [max(d[k] for d in data) - mins[k] or 1.0 for k in range(3)]
        norm = [tuple((d[k] - mins[k]) / rngs[k] for k in range(3)) for d in data]

        # Init membership matrix
        u = [[rng.random() for _ in range(n_classes)] for _ in range(n)]
        for i in range(n):
            s = sum(u[i])
            u[i] = [u[i][c] / s for c in range(n_classes)]

        m = max(fuzziness, 1.01)
        for _ in range(max_iterations):
            # Compute centres
            centres = []
            for c in range(n_classes):
                denom = sum(u[i][c] ** m for i in range(n)) or 1e-12
                centre = tuple(sum(u[i][c] ** m * norm[i][k] for i in range(n)) / denom for k in range(3))
                centres.append(centre)
            # Update membership
            new_u = [[0.0] * n_classes for _ in range(n)]
            for i in range(n):
                for c in range(n_classes):
                    d_ic = math.sqrt(sum((norm[i][k] - centres[c][k]) ** 2 for k in range(3))) or 1e-12
                    total = sum((d_ic / (math.sqrt(sum((norm[i][k] - centres[c2][k]) ** 2 for k in range(3))) or 1e-12)) ** (2 / (m - 1)) for c2 in range(n_classes))
                    new_u[i][c] = 1.0 / total if total > 0 else 1.0 / n_classes
            u = new_u

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            best_c = max(range(n_classes), key=lambda c: u[i][c])
            rec[f"class_{suffix}"] = best_c
            rec[f"max_membership_{suffix}"] = round(u[i][best_c], 6)
            for c in range(n_classes):
                rec[f"membership_{c}_{suffix}"] = round(u[i][c], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 415: spatial_decision_tree
    # ------------------------------------------------------------------
    def spatial_decision_tree(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        max_depth: int = 5,
        min_samples_leaf: int = 2,
        suffix: str = "sdt",
    ) -> "GeoPromptFrame":
        """Decision tree regressor for spatial prediction.

        Pure-Python CART-style tree with variance-reduction splitting.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]

        def _build(indices: list[int], depth: int) -> dict:
            vals = [targets[i] for i in indices]
            mean_val = sum(vals) / len(vals)
            if depth >= max_depth or len(indices) <= min_samples_leaf:
                return {"type": "leaf", "value": mean_val}
            best_var = sum((v - mean_val) ** 2 for v in vals)
            best_split: dict | None = None
            for f in range(n_feat):
                feat_vals = sorted(set(raw[i][f] for i in indices))
                for t in range(len(feat_vals) - 1):
                    thresh = (feat_vals[t] + feat_vals[t + 1]) / 2
                    left_idx = [i for i in indices if raw[i][f] <= thresh]
                    right_idx = [i for i in indices if raw[i][f] > thresh]
                    if len(left_idx) < min_samples_leaf or len(right_idx) < min_samples_leaf:
                        continue
                    l_vals = [targets[i] for i in left_idx]
                    r_vals = [targets[i] for i in right_idx]
                    l_mean = sum(l_vals) / len(l_vals)
                    r_mean = sum(r_vals) / len(r_vals)
                    var = sum((v - l_mean) ** 2 for v in l_vals) + sum((v - r_mean) ** 2 for v in r_vals)
                    if var < best_var:
                        best_var = var
                        best_split = {"feature": f, "threshold": thresh, "left": left_idx, "right": right_idx}
            if best_split is None:
                return {"type": "leaf", "value": mean_val}
            return {
                "type": "split", "feature": best_split["feature"], "threshold": best_split["threshold"],
                "left": _build(best_split["left"], depth + 1),
                "right": _build(best_split["right"], depth + 1),
            }

        tree = _build(list(range(n)), 0)

        def _predict(tree: dict, point: list[float]) -> float:
            if tree["type"] == "leaf":
                return tree["value"]
            if point[tree["feature"]] <= tree["threshold"]:
                return _predict(tree["left"], point)
            return _predict(tree["right"], point)

        out_rows: list[Record] = []
        for i in range(n):
            pred = _predict(tree, raw[i])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(pred, 6),
                f"actual_{suffix}": targets[i],
                f"residual_{suffix}": round(targets[i] - pred, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 416: spatial_knn_imputer
    # ------------------------------------------------------------------
    def spatial_knn_imputer(
        self,
        value_column: str,
        *,
        k: int = 5,
        distance_weight: bool = True,
        suffix: str = "knn",
    ) -> "GeoPromptFrame":
        """K-nearest-neighbour imputation for missing spatial values.

        Fills missing (None / NaN) values using distance-weighted averages
        of the k nearest features that have valid values.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        n = len(recs)
        vals = [r.get(value_column) for r in recs]

        def _is_missing(v: Any) -> bool:
            if v is None:
                return True
            try:
                return math.isnan(float(v))
            except (ValueError, TypeError):
                return False

        valid_indices = [i for i in range(n) if not _is_missing(vals[i])]

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            if _is_missing(vals[i]):
                dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                     centroids[i][1] - centroids[j][1]), j)
                         for j in valid_indices]
                dists.sort()
                neighbours = dists[:k]
                if neighbours:
                    if distance_weight:
                        w_sum, v_sum = 0.0, 0.0
                        for d, j in neighbours:
                            w = 1.0 / max(d, 1e-12)
                            w_sum += w
                            value = vals[j]
                            if value is None:
                                continue
                            v_sum += w * float(value)
                        rec[f"{value_column}_{suffix}"] = round(v_sum / w_sum, 6)
                    else:
                        neighbor_values: list[float] = []
                        for _, j in neighbours:
                            neighbor_value = vals[j]
                            if neighbor_value is None:
                                continue
                            neighbor_values.append(float(neighbor_value))
                        rec[f"{value_column}_{suffix}"] = round(
                            sum(neighbor_values) / len(neighbor_values), 6) if neighbor_values else None
                else:
                    rec[f"{value_column}_{suffix}"] = None
                rec[f"imputed_{suffix}"] = 1
            else:
                current_value = vals[i]
                if current_value is None:
                    rec[f"{value_column}_{suffix}"] = None
                else:
                    rec[f"{value_column}_{suffix}"] = float(current_value)
                rec[f"imputed_{suffix}"] = 0
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 417: spatial_naive_bayes
    # ------------------------------------------------------------------
    def spatial_naive_bayes(
        self,
        feature_columns: list[str],
        label_column: str,
        *,
        suffix: str = "snb",
    ) -> "GeoPromptFrame":
        """Gaussian Naive Bayes classifier for spatial feature classification.

        Estimates per-class Gaussian parameters for each feature, then
        classifies via MAP (maximum a posteriori).
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(label_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        labels = [r.get(label_column) for r in recs]
        unique_labels = sorted(set(labels), key=str)

        # Compute per-class mean & variance
        stats: dict[Any, dict] = {}
        for lbl in unique_labels:
            idx = [i for i in range(n) if labels[i] == lbl]
            means = [sum(raw[i][f] for i in idx) / len(idx) for f in range(n_feat)]
            variances = [sum((raw[i][f] - means[f]) ** 2 for i in idx) / len(idx) + 1e-9 for f in range(n_feat)]
            stats[lbl] = {"mean": means, "var": variances, "prior": len(idx) / n}

        def _log_likelihood(row: list[float], lbl: Any) -> float:
            s = stats[lbl]
            ll = math.log(s["prior"])
            for f in range(n_feat):
                ll += -0.5 * math.log(2 * math.pi * s["var"][f])
                ll += -0.5 * (row[f] - s["mean"][f]) ** 2 / s["var"][f]
            return ll

        out_rows: list[Record] = []
        for i in range(n):
            scores = {lbl: _log_likelihood(raw[i], lbl) for lbl in unique_labels}
            predicted = max(scores, key=lambda k: scores[k])
            # Convert log-likelihoods to probabilities
            mx = max(scores.values())
            exps = {lbl: math.exp(scores[lbl] - mx) for lbl in unique_labels}
            s = sum(exps.values())
            conf = exps[predicted] / s if s > 0 else 0.0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": predicted,
                f"confidence_{suffix}": round(conf, 6),
                f"actual_{suffix}": labels[i],
                f"correct_{suffix}": 1 if predicted == labels[i] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 418: spatial_svm_classifier
    # ------------------------------------------------------------------
    def spatial_svm_classifier(
        self,
        feature_columns: list[str],
        label_column: str,
        *,
        learning_rate: float = 0.01,
        lambda_reg: float = 0.01,
        epochs: int = 100,
        suffix: str = "svm",
    ) -> "GeoPromptFrame":
        """Support Vector Machine (linear, binary) for spatial classification.

        Pure-Python SVM using sub-gradient descent on the hinge loss.  For
        multi-class, trains one-vs-rest.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(label_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        labels = [r.get(label_column) for r in recs]
        unique_labels = sorted(set(labels), key=str)

        # Normalise
        col_min = [min(raw[i][f] for i in range(n)) for f in range(n_feat)]
        col_rng = [max(raw[i][f] for i in range(n)) - col_min[f] or 1.0 for f in range(n_feat)]
        data = [[(raw[i][f] - col_min[f]) / col_rng[f] for f in range(n_feat)] for i in range(n)]

        # OVR SVMs
        models: dict[Any, tuple[list[float], float]] = {}
        for lbl in unique_labels:
            w = [0.0] * n_feat
            b_val = 0.0
            for _ in range(epochs):
                for i in range(n):
                    y = 1.0 if labels[i] == lbl else -1.0
                    score = sum(w[f] * data[i][f] for f in range(n_feat)) + b_val
                    if y * score < 1:
                        for f in range(n_feat):
                            w[f] -= learning_rate * (lambda_reg * w[f] - y * data[i][f])
                        b_val -= learning_rate * (-y)
                    else:
                        for f in range(n_feat):
                            w[f] -= learning_rate * lambda_reg * w[f]
            models[lbl] = (w, b_val)

        out_rows: list[Record] = []
        for i in range(n):
            scores = {}
            for lbl, (w, b_val) in models.items():
                scores[lbl] = sum(w[f] * data[i][f] for f in range(n_feat)) + b_val
            predicted = max(scores, key=lambda k: scores[k])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": predicted,
                f"margin_{suffix}": round(scores[predicted], 6),
                f"actual_{suffix}": labels[i],
                f"correct_{suffix}": 1 if predicted == labels[i] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 419: spatial_lstm_sequence
    # ------------------------------------------------------------------
    def spatial_lstm_sequence(
        self,
        value_column: str,
        time_column: str,
        *,
        hidden_size: int = 8,
        learning_rate: float = 0.01,
        epochs: int = 50,
        seed: int | None = None,
        suffix: str = "lstm",
    ) -> "GeoPromptFrame":
        """LSTM-style recurrent predictor for spatiotemporal sequences.

        Trains on value sequences sorted by time, then predicts the next step.
        Pure-Python LSTM cell with forget, input, and output gates.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        # Sort by time
        sorted_recs = sorted(recs, key=lambda r: float(r.get(time_column, 0) or 0))
        vals = [float(r.get(value_column, 0) or 0) for r in sorted_recs]
        n = len(vals)
        min_v, max_v = min(vals), max(vals)
        rv = max_v - min_v or 1.0
        norm_v = [(v - min_v) / rv for v in vals]

        hs = hidden_size

        def _init_w() -> list[list[float]]:
            return [[rng.gauss(0, 0.3) for _ in range(hs + 1)] for _ in range(hs)]

        wf, wi, wc, wo = _init_w(), _init_w(), _init_w(), _init_w()
        w_out = [rng.gauss(0, 0.3) for _ in range(hs)]
        b_out = 0.0

        def _gate(w: list[list[float]], h: list[float], x: float) -> list[float]:
            return [sigmoid(sum(w[k][j] * h[j] for j in range(hs)) + w[k][hs] * x) for k in range(hs)]

        def _gate_tanh(w: list[list[float]], h: list[float], x: float) -> list[float]:
            return [math.tanh(sum(w[k][j] * h[j] for j in range(hs)) + w[k][hs] * x) for k in range(hs)]

        # Train
        for _ in range(epochs):
            h = [0.0] * hs
            c_state = [0.0] * hs
            for t in range(n - 1):
                x = norm_v[t]
                fg = _gate(wf, h, x)
                ig = _gate(wi, h, x)
                cg = _gate_tanh(wc, h, x)
                og = _gate(wo, h, x)
                c_state = [fg[k] * c_state[k] + ig[k] * cg[k] for k in range(hs)]
                h = [og[k] * math.tanh(c_state[k]) for k in range(hs)]
                pred = sigmoid(sum(w_out[k] * h[k] for k in range(hs)) + b_out)
                err = pred - norm_v[t + 1]
                d_pred = err * pred * (1 - pred)
                for k in range(hs):
                    w_out[k] -= learning_rate * d_pred * h[k]
                b_out -= learning_rate * d_pred

        # Predict
        h = [0.0] * hs
        c_state = [0.0] * hs
        predictions: list[float] = []
        for t in range(n):
            x = norm_v[t]
            fg = _gate(wf, h, x)
            ig = _gate(wi, h, x)
            cg = _gate_tanh(wc, h, x)
            og = _gate(wo, h, x)
            c_state = [fg[k] * c_state[k] + ig[k] * cg[k] for k in range(hs)]
            h = [og[k] * math.tanh(c_state[k]) for k in range(hs)]
            pred = sigmoid(sum(w_out[k] * h[k] for k in range(hs)) + b_out) * rv + min_v
            predictions.append(pred)

        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: sorted_recs[i][self.geometry_column],
                f"predicted_{suffix}": round(predictions[i], 6),
                f"actual_{suffix}": vals[i],
                f"time_{suffix}": float(sorted_recs[i].get(time_column, 0) or 0),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 420: ai_land_use_classifier
    # ------------------------------------------------------------------
    def ai_land_use_classifier(
        self,
        feature_columns: list[str],
        *,
        n_classes: int = 5,
        hidden_size: int = 12,
        epochs: int = 100,
        learning_rate: float = 0.02,
        seed: int | None = None,
        suffix: str = "luc",
    ) -> "GeoPromptFrame":
        """Unsupervised land-use classification via neural-network autoencoder
        followed by k-means on the latent space.

        Combines dimensionality reduction with centroid-based clustering to
        assign land-use classes without labelled training data.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(row[j] for row in raw) for j in range(n_feat)]
        col_rng = [max(row[j] for row in raw) - col_min[j] or 1.0 for j in range(n_feat)]
        data = [[(row[j] - col_min[j]) / col_rng[j] for j in range(n_feat)] for row in raw]

        enc_dim = min(hidden_size, n_feat)

        w_enc = [[rng.gauss(0, 0.5) for _ in range(n_feat)] for _ in range(enc_dim)]
        b_enc = [0.0] * enc_dim
        w_dec = [[rng.gauss(0, 0.5) for _ in range(enc_dim)] for _ in range(n_feat)]
        b_dec = [0.0] * n_feat

        for _ in range(epochs):
            for row in data:
                enc = [sigmoid(sum(w_enc[k][j] * row[j] for j in range(n_feat)) + b_enc[k]) for k in range(enc_dim)]
                dec = [sigmoid(sum(w_dec[j][k] * enc[k] for k in range(enc_dim)) + b_dec[j]) for j in range(n_feat)]
                # Backprop reconstruction error
                b_enc_grad = [0.0] * enc_dim
                for j in range(n_feat):
                    err_j = dec[j] - row[j]
                    d_dec = err_j * dec[j] * (1 - dec[j])
                    for k in range(enc_dim):
                        w_dec[j][k] -= learning_rate * d_dec * enc[k]
                        d_enc = d_dec * w_dec[j][k] * enc[k] * (1 - enc[k])
                        w_enc[k][j] -= learning_rate * d_enc * row[j]
                        b_enc_grad[k] += d_enc
                    b_dec[j] -= learning_rate * d_dec
                for k in range(enc_dim):
                    b_enc[k] -= learning_rate * b_enc_grad[k]

        # Encode all
        encoded = [[sigmoid(sum(w_enc[k][j] * row[j] for j in range(n_feat)) + b_enc[k]) for k in range(enc_dim)] for row in data]

        # K-means on encoded
        centres = [encoded[rng.randint(0, n - 1)][:] for _ in range(n_classes)]
        for _ in range(30):
            clusters: dict[int, list[int]] = {c: [] for c in range(n_classes)}
            for i in range(n):
                dists = [sum((encoded[i][k] - centres[c][k]) ** 2 for k in range(enc_dim)) for c in range(n_classes)]
                clusters[min(range(n_classes), key=lambda c: dists[c])].append(i)
            for c in range(n_classes):
                if clusters[c]:
                    centres[c] = [sum(encoded[i][k] for i in clusters[c]) / len(clusters[c]) for k in range(enc_dim)]

        out_rows: list[Record] = []
        for i in range(n):
            dists = [sum((encoded[i][k] - centres[c][k]) ** 2 for k in range(enc_dim)) for c in range(n_classes)]
            cls = min(range(n_classes), key=lambda c: dists[c])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"class_{suffix}": cls,
                f"dist_to_centre_{suffix}": round(math.sqrt(dists[cls]), 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ==================================================================
    # Pseudo-Quantum Spatial Tools (421–440)
    # ==================================================================

    # ------------------------------------------------------------------
    # Tool 421: quantum_spatial_annealer
    # ------------------------------------------------------------------
    def quantum_spatial_annealer(
        self,
        value_column: str,
        *,
        n_qubits: int = 8,
        n_sweeps: int = 200,
        temperature_start: float = 10.0,
        temperature_end: float = 0.01,
        seed: int | None = None,
        suffix: str = "qsa",
    ) -> "GeoPromptFrame":
        """Quantum-inspired simulated annealer for spatial optimisation.

        Simulates qubit states with probabilistic spin flips governed by
        transverse-field Ising dynamics.  Finds low-energy configurations
        mapping to optimal spatial partitions.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]

        # Map features to qubits (assign features to n_qubits bins)
        assignments = [i % n_qubits for i in range(n)]
        spins = [rng.choice([-1, 1]) for _ in range(n)]

        # Coupling: nearby features with similar values should be same spin
        # Precompute coupling matrix for O(n) delta energy
        coupling = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                c_ij = math.exp(-d / (max(vals) - min(vals) + 1)) * (1 + abs(vals[i] - vals[j]))
                coupling[i][j] = c_ij
                coupling[j][i] = c_ij

        def _energy() -> float:
            e = 0.0
            for i in range(n):
                for j in range(i + 1, n):
                    e -= coupling[i][j] * spins[i] * spins[j]
            return e

        def _delta_energy(flip_idx: int) -> float:
            """Energy change from flipping spin at flip_idx (O(n))."""
            de = 0.0
            for j in range(n):
                if j != flip_idx:
                    de += coupling[flip_idx][j] * spins[flip_idx] * spins[j]
            return 2.0 * de

        current_e = _energy()
        best_spins = list(spins)
        best_e = current_e

        for sweep in range(n_sweeps):
            temp = temperature_start * (temperature_end / temperature_start) ** (sweep / max(n_sweeps - 1, 1))
            # Transverse field: probability of quantum tunnelling
            gamma = temperature_start * (1 - sweep / max(n_sweeps - 1, 1))
            for i in range(n):
                delta = _delta_energy(i)
                # Quantum-inspired acceptance: includes tunnelling term
                tunnel_prob = math.exp(-gamma * 0.1) if gamma > 0 else 0
                accept_prob = math.exp(-delta / max(temp, 1e-12)) if delta > 0 else 1.0
                accept_prob = max(accept_prob, tunnel_prob * 0.1)
                if rng.random() < accept_prob:
                    spins[i] = -spins[i]
                    current_e += delta
                    if current_e < best_e:
                        best_e = current_e
                        best_spins = list(spins)

        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"spin_{suffix}": best_spins[i],
                f"cluster_{suffix}": 0 if best_spins[i] == 1 else 1,
                f"qubit_{suffix}": assignments[i],
                f"energy_{suffix}": round(best_e, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 422: quantum_walk_clustering
    # ------------------------------------------------------------------
    def quantum_walk_clustering(
        self,
        *,
        n_steps: int = 50,
        n_clusters: int = 3,
        coin_bias: float = 0.5,
        seed: int | None = None,
        suffix: str = "qwc",
    ) -> "GeoPromptFrame":
        """Quantum-walk-inspired clustering on a spatial proximity graph.

        Simulates discrete quantum walks where walker amplitudes accumulate
        at community boundaries, then partitions by amplitude distribution.
        """
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()

        # Build k-NN adjacency (k=min(6, n-1))
        k = min(6, n - 1)
        adj: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j) for j in range(n) if j != i]
            dists.sort()
            adj.append([j for _, j in dists[:k]])

        # Quantum walk amplitudes (complex as (real, imag) pairs)
        amp = [(1.0 / math.sqrt(n), 0.0) for _ in range(n)]

        for _ in range(n_steps):
            new_amp = [(0.0, 0.0)] * n
            for i in range(n):
                deg = len(adj[i]) or 1
                # Grover diffusion + coin
                for j in adj[i]:
                    # Phase shift based on distance
                    d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                    phase = d * 0.1
                    cos_p, sin_p = math.cos(phase), math.sin(phase)
                    # Coin operation
                    cr = coin_bias * amp[i][0] + (1 - coin_bias) * amp[j][0]
                    ci = coin_bias * amp[i][1] + (1 - coin_bias) * amp[j][1]
                    # Rotate
                    nr = (cr * cos_p - ci * sin_p) / deg
                    ni = (cr * sin_p + ci * cos_p) / deg
                    new_amp[j] = (new_amp[j][0] + nr, new_amp[j][1] + ni)
            # Normalise
            total = math.sqrt(sum(a[0] ** 2 + a[1] ** 2 for a in new_amp)) or 1e-12
            amp = [(a[0] / total, a[1] / total) for a in new_amp]

        # Cluster by amplitude magnitude using k-means
        magnitudes = [math.sqrt(a[0] ** 2 + a[1] ** 2) for a in amp]
        phases = [math.atan2(a[1], a[0]) for a in amp]
        features_2d = [(magnitudes[i], phases[i]) for i in range(n)]

        # Simple k-means on (magnitude, phase)
        centres = [features_2d[rng.randint(0, n - 1)] for _ in range(n_clusters)]
        for _ in range(20):
            groups: dict[int, list[int]] = {c: [] for c in range(n_clusters)}
            for i in range(n):
                dists = [(features_2d[i][0] - centres[c][0]) ** 2 + (features_2d[i][1] - centres[c][1]) ** 2
                         for c in range(n_clusters)]
                groups[min(range(n_clusters), key=lambda c: dists[c])].append(i)
            for c in range(n_clusters):
                if groups[c]:
                    centres[c] = (sum(features_2d[i][0] for i in groups[c]) / len(groups[c]),
                                  sum(features_2d[i][1] for i in groups[c]) / len(groups[c]))

        out_rows: list[Record] = []
        for i in range(n):
            dists = [(features_2d[i][0] - centres[c][0]) ** 2 + (features_2d[i][1] - centres[c][1]) ** 2
                     for c in range(n_clusters)]
            cls = min(range(n_clusters), key=lambda c: dists[c])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": cls,
                f"amplitude_{suffix}": round(magnitudes[i], 6),
                f"phase_{suffix}": round(phases[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 423: quantum_feature_map
    # ------------------------------------------------------------------
    def quantum_feature_map(
        self,
        feature_columns: list[str],
        *,
        n_layers: int = 3,
        suffix: str = "qfm",
    ) -> "GeoPromptFrame":
        """Map spatial features into a quantum-inspired Hilbert space.

        Applies parameterised rotation gates (Rx, Rz) to encode each feature
        as a qubit rotation angle, producing an exponentially richer
        feature representation.
        """
        for c in feature_columns:
            self._require_column(c)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(raw[i][f] for i in range(n)) for f in range(n_feat)]
        col_rng = [max(raw[i][f] for i in range(n)) - col_min[f] or 1.0 for f in range(n_feat)]

        out_rows: list[Record] = []
        for i in range(n):
            # Normalise to [0, pi]
            angles = [(raw[i][f] - col_min[f]) / col_rng[f] * math.pi for f in range(n_feat)]
            # Apply rotation layers
            state_real = [1.0] * n_feat
            state_imag = [0.0] * n_feat
            for layer in range(n_layers):
                for f in range(n_feat):
                    theta = angles[f] * (layer + 1)
                    # Rx rotation (unitary)
                    cos_t = math.cos(theta / 2)
                    sin_t = math.sin(theta / 2)
                    new_real = cos_t * state_real[f] - sin_t * state_imag[f]
                    new_imag = sin_t * state_real[f] + cos_t * state_imag[f]
                    # Rz rotation
                    phi = angles[f] * 0.5 * (layer + 1)
                    state_real[f] = new_real * math.cos(phi) - new_imag * math.sin(phi)
                    state_imag[f] = new_real * math.sin(phi) + new_imag * math.cos(phi)
                # Entanglement: CNOT-like coupling between adjacent qubits
                for f in range(n_feat - 1):
                    state_real[f + 1] *= (1 if state_real[f] > 0 else -1)

            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            for f in range(n_feat):
                prob = state_real[f] ** 2 + state_imag[f] ** 2
                rec[f"qfeat_{f}_{suffix}"] = round(prob, 6)
                rec[f"phase_{f}_{suffix}"] = round(math.atan2(state_imag[f], state_real[f]), 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 424: quantum_approximate_counting
    # ------------------------------------------------------------------
    def quantum_approximate_counting(
        self,
        value_column: str,
        *,
        threshold: float = 0.0,
        n_rounds: int = 10,
        seed: int | None = None,
        suffix: str = "qac",
    ) -> "GeoPromptFrame":
        """Quantum-inspired approximate counting of features exceeding a threshold.

        Simulates Grover's amplitude amplification to estimate the count of
        features satisfying a condition, with quadratic speedup analogy.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        actual_count = sum(1 for v in vals if v > threshold)
        fraction = actual_count / n if n > 0 else 0

        # Simulate Grover iterations to estimate the fraction
        # theta = arcsin(sqrt(fraction))
        theta_est = math.asin(math.sqrt(clamp(fraction, 0, 1))) if n > 0 else 0
        estimates: list[float] = []
        for _ in range(n_rounds):
            # Add quantum noise
            noise = rng.gauss(0, 0.02)
            est_theta = theta_est + noise
            est_count = n * math.sin(est_theta) ** 2
            estimates.append(max(0, est_count))

        avg_estimate = sum(estimates) / len(estimates) if estimates else 0

        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [0, 0]},
            f"actual_count_{suffix}": actual_count,
            f"quantum_estimate_{suffix}": round(avg_estimate, 2),
            f"total_{suffix}": n,
            f"threshold_{suffix}": threshold,
            f"estimation_error_{suffix}": round(abs(avg_estimate - actual_count), 2),
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 425: quantum_phase_clustering
    # ------------------------------------------------------------------
    def quantum_phase_clustering(
        self,
        feature_columns: list[str],
        *,
        n_clusters: int = 3,
        n_iterations: int = 30,
        n_phase_iterations: int = 3,
        seed: int | None = None,
        suffix: str = "qpc",
    ) -> "GeoPromptFrame":
        """Quantum Phase Estimation-inspired clustering.

        Encodes features as quantum phases, applies unitary evolution, and
        clusters by the resulting phase angles — capturing non-linear
        relationships that Euclidean clustering misses.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(raw[i][f] for i in range(n)) for f in range(n_feat)]
        col_rng = [max(raw[i][f] for i in range(n)) - col_min[f] or 1.0 for f in range(n_feat)]

        # Encode as phase angles and evolve
        phase_features: list[list[float]] = []
        for i in range(n):
            phases = []
            for f in range(n_feat):
                angle = (raw[i][f] - col_min[f]) / col_rng[f] * 2 * math.pi
                # Unitary evolution: repeated squaring
                for _ in range(n_phase_iterations):
                    angle = (angle * 2) % (2 * math.pi)
                phases.append(angle)
            phase_features.append(phases)

        # K-means on phase space (using angular distance)
        centres = [phase_features[rng.randint(0, n - 1)][:] for _ in range(n_clusters)]
        for _ in range(n_iterations):
            groups: dict[int, list[int]] = {c: [] for c in range(n_clusters)}
            for i in range(n):
                dists = []
                for c_idx in range(n_clusters):
                    d = sum(min(abs(phase_features[i][f] - centres[c_idx][f]),
                                2 * math.pi - abs(phase_features[i][f] - centres[c_idx][f])) ** 2
                            for f in range(n_feat))
                    dists.append(d)
                groups[min(range(n_clusters), key=lambda c: dists[c])].append(i)
            for c_idx in range(n_clusters):
                if groups[c_idx]:
                    # Circular mean
                    for f in range(n_feat):
                        sin_sum = sum(math.sin(phase_features[i][f]) for i in groups[c_idx])
                        cos_sum = sum(math.cos(phase_features[i][f]) for i in groups[c_idx])
                        centres[c_idx][f] = math.atan2(sin_sum, cos_sum) % (2 * math.pi)

        out_rows: list[Record] = []
        for i in range(n):
            dists = [sum(min(abs(phase_features[i][f] - centres[c][f]),
                             2 * math.pi - abs(phase_features[i][f] - centres[c][f])) ** 2
                         for f in range(n_feat)) for c in range(n_clusters)]
            cls = min(range(n_clusters), key=lambda c: dists[c])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": cls,
                f"phase_dist_{suffix}": round(math.sqrt(dists[cls]), 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 426: quantum_entanglement_graph
    # ------------------------------------------------------------------
    def quantum_entanglement_graph(
        self,
        value_column: str,
        *,
        entanglement_threshold: float = 0.5,
        suffix: str = "qeg",
    ) -> "GeoPromptFrame":
        """Build a quantum-entanglement-inspired spatial dependency graph.

        Models pairwise feature correlations as entanglement strength (Bell
        inequality-style measure), returning per-feature entanglement degree
        and strongest partner.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        v_min, v_max = min(vals), max(vals)
        v_rng = v_max - v_min or 1.0

        # Compute entanglement (correlation * proximity)
        entanglement = [[0.0] * n for _ in range(n)]
        max_dist = 0.0
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                if d > max_dist:
                    max_dist = d
        max_dist = max_dist or 1.0

        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                proximity = 1 - d / max_dist
                val_sim = 1 - abs(vals[i] - vals[j]) / v_rng
                ent = proximity * val_sim
                entanglement[i][j] = ent
                entanglement[j][i] = ent

        out_rows: list[Record] = []
        for i in range(n):
            degree = sum(1 for j in range(n) if j != i and entanglement[i][j] >= entanglement_threshold)
            strongest = max(range(n), key=lambda j: entanglement[i][j] if j != i else -1)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"entanglement_degree_{suffix}": degree,
                f"strongest_partner_{suffix}": strongest,
                f"max_entanglement_{suffix}": round(max(entanglement[i][j] for j in range(n) if j != i) if n > 1 else 0, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 427: quantum_teleportation_interpolation
    # ------------------------------------------------------------------
    def quantum_teleportation_interpolation(
        self,
        value_column: str,
        *,
        grid_resolution: int = 15,
        entanglement_range: float | None = None,
        suffix: str = "qti",
    ) -> "GeoPromptFrame":
        """Quantum-teleportation-inspired spatial interpolation.

        Values are 'teleported' across space weighted by entanglement
        strength (proximity + value correlation), enabling non-local
        information transfer in the interpolation surface.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        n = len(recs)
        b = self.bounds()

        if entanglement_range is None:
            entanglement_range = max(b.max_x - b.min_x, b.max_y - b.min_y) / 3

        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        # Precompute pairwise entanglement strength
        ent = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                strength = math.exp(-d / max(entanglement_range, 1e-12))
                ent[i][j] = strength
                ent[j][i] = strength

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                # Direct contribution (IDW)
                w_sum, v_sum = 0.0, 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    w_direct = 1.0 / max(d, 1e-12)
                    # Teleportation: also receive entangled information
                    teleported = sum(ent[i][j] * vals[j] for j in range(n) if j != i)
                    ent_total = sum(ent[i][j] for j in range(n) if j != i) or 1.0
                    val_eff = 0.7 * vals[i] + 0.3 * teleported / ent_total
                    w_sum += w_direct
                    v_sum += w_direct * val_eff
                pred = v_sum / w_sum if w_sum > 0 else 0.0
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"predicted_{suffix}": round(pred, 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 428: quantum_superposition_query
    # ------------------------------------------------------------------
    def quantum_superposition_query(
        self,
        value_column: str,
        *,
        conditions: list[tuple[str, float]] | None = None,
        suffix: str = "qsq",
    ) -> "GeoPromptFrame":
        """Query spatial features in quantum superposition.

        Evaluates multiple filter conditions simultaneously (analogous to
        quantum parallelism), returning probability amplitudes for each
        condition being satisfied, and a combined score.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        if conditions is None:
            vals = [float(r.get(value_column, 0) or 0) for r in recs]
            mean_v = sum(vals) / len(vals)
            conditions = [("gt_mean", mean_v), ("gt_0", 0.0)]

        n_cond = len(conditions)
        out_rows: list[Record] = []
        for r in recs:
            val = float(r.get(value_column, 0) or 0)
            amplitudes: list[float] = []
            for _label, thresh in conditions:
                # Sigmoid-based soft membership
                amp = sigmoid(val - thresh)
                amplitudes.append(amp)
            # Normalise as quantum state
            norm = math.sqrt(sum(a * a for a in amplitudes)) or 1e-12
            probs = [(a / norm) ** 2 for a in amplitudes]
            # Combined: average raw amplitude (captures overall condition satisfaction)
            combined = sum(amplitudes) / n_cond

            rec: Record = {self.geometry_column: r[self.geometry_column]}
            for idx, (label, _thresh) in enumerate(conditions):
                rec[f"prob_{label}_{suffix}"] = round(probs[idx], 6)
            rec[f"combined_{suffix}"] = round(combined, 6)
            rec[f"value_{suffix}"] = val
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 429: quantum_grover_search
    # ------------------------------------------------------------------
    def quantum_grover_search(
        self,
        value_column: str,
        *,
        target_value: float | None = None,
        tolerance: float = 0.1,
        seed: int | None = None,
        suffix: str = "qgs",
    ) -> "GeoPromptFrame":
        """Grover's-search-inspired spatial feature finder.

        Amplifies features close to a target value via iterative amplitude
        amplification, returning ranked features with search probabilities.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        if target_value is None:
            target_value = max(vals)
        v_rng = max(vals) - min(vals) or 1.0

        # Init uniform amplitude
        amp = [1.0 / math.sqrt(n)] * n

        # Oracle: mark features near target
        def _oracle(amplitudes: list[float]) -> list[float]:
            return [-a if abs(vals[i] - target_value) / v_rng <= tolerance else a
                    for i, a in enumerate(amplitudes)]

        # Diffusion
        def _diffuse(amplitudes: list[float]) -> list[float]:
            mean = sum(amplitudes) / n
            return [2 * mean - a for a in amplitudes]

        n_iter = max(1, int(math.pi / 4 * math.sqrt(n)))
        for _ in range(n_iter):
            amp = _oracle(amp)
            amp = _diffuse(amp)

        probs = [a * a for a in amp]
        total = sum(probs) or 1e-12
        probs = [p / total for p in probs]

        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"search_prob_{suffix}": round(probs[i], 6),
                f"value_{suffix}": vals[i],
                f"distance_to_target_{suffix}": round(abs(vals[i] - target_value), 6),
                f"is_match_{suffix}": 1 if abs(vals[i] - target_value) / v_rng <= tolerance else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 430: quantum_variational_optimizer
    # ------------------------------------------------------------------
    def quantum_variational_optimizer(
        self,
        value_column: str,
        *,
        n_params: int = 6,
        n_iterations: int = 50,
        learning_rate: float = 0.1,
        seed: int | None = None,
        suffix: str = "qvo",
    ) -> "GeoPromptFrame":
        """Variational Quantum Eigensolver (VQE)-inspired spatial optimiser.

        Parameterised quantum circuit ansatz optimised classically to find
        the spatial configuration that minimises an energy function.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()

        # Variational parameters (angles)
        params = [rng.uniform(0, 2 * math.pi) for _ in range(n_params)]

        def _evaluate(params: list[float]) -> tuple[float, float, float]:
            """Evaluate the ansatz: map params to a spatial location, compute energy."""
            x = b.min_x + (b.max_x - b.min_x) * (math.sin(params[0]) + 1) / 2
            y = b.min_y + (b.max_y - b.min_y) * (math.cos(params[1]) + 1) / 2
            # Apply rotation layers
            for i in range(2, n_params):
                x += (b.max_x - b.min_x) * 0.1 * math.sin(params[i])
                y += (b.max_y - b.min_y) * 0.1 * math.cos(params[i])
            x = max(b.min_x, min(b.max_x, x))
            y = max(b.min_y, min(b.max_y, y))
            # Energy: negative interpolated value (minimize = find max value)
            w_sum, v_sum = 0.0, 0.0
            for j in range(n):
                d = math.hypot(x - centroids[j][0], y - centroids[j][1])
                w = 1.0 / max(d * d, 1e-12)
                w_sum += w
                v_sum += w * vals[j]
            energy = -(v_sum / w_sum) if w_sum > 0 else 0.0
            return energy, x, y

        best_energy = float("inf")
        best_x, best_y = 0.0, 0.0

        for _ in range(n_iterations):
            energy, x, y = _evaluate(params)
            if energy < best_energy:
                best_energy = energy
                best_x, best_y = x, y
            # Parameter shift rule gradient estimation
            for p in range(n_params):
                params[p] += math.pi / 2
                e_plus, _, _ = _evaluate(params)
                params[p] -= math.pi
                e_minus, _, _ = _evaluate(params)
                params[p] += math.pi / 2  # restore
                grad = (e_plus - e_minus) / 2
                params[p] -= learning_rate * grad

        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [best_x, best_y]},
            f"optimal_x_{suffix}": round(best_x, 6),
            f"optimal_y_{suffix}": round(best_y, 6),
            f"energy_{suffix}": round(-best_energy, 6),
            f"n_iterations_{suffix}": n_iterations,
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 431: quantum_error_corrected_interpolation
    # ------------------------------------------------------------------
    def quantum_error_corrected_interpolation(
        self,
        value_column: str,
        *,
        grid_resolution: int = 15,
        n_redundancy: int = 3,
        suffix: str = "qec",
    ) -> "GeoPromptFrame":
        """Quantum-error-correction-inspired robust interpolation.

        Uses redundant encoding (repetition code) — each grid prediction is made
        n_redundancy times with different distance kernels, and the majority
        (median) result is kept, reducing interpolation noise.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        n = len(recs)
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        powers = [1.0 + i * 0.5 for i in range(n_redundancy)]  # different IDW powers

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                estimates: list[float] = []
                for power in powers:
                    w_sum, v_sum = 0.0, 0.0
                    for j in range(n):
                        d = math.hypot(px - centroids[j][0], py - centroids[j][1])
                        w = 1.0 / max(d ** power, 1e-12)
                        w_sum += w
                        v_sum += w * vals[j]
                    estimates.append(v_sum / w_sum if w_sum > 0 else 0.0)
                # Majority vote: median
                estimates.sort()
                corrected = estimates[len(estimates) // 2]
                spread = max(estimates) - min(estimates)
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"predicted_{suffix}": round(corrected, 6),
                    f"spread_{suffix}": round(spread, 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 432: quantum_tunneling_optimizer
    # ------------------------------------------------------------------
    def quantum_tunneling_optimizer(
        self,
        value_column: str,
        *,
        n_particles: int = 10,
        n_iterations: int = 100,
        tunnel_probability: float = 0.15,
        seed: int | None = None,
        suffix: str = "qto",
    ) -> "GeoPromptFrame":
        """Quantum-tunnelling-augmented particle swarm for spatial optimisation.

        Particles explore the value surface with occasional tunnelling jumps
        that bypass local optima barriers.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        n = len(recs)
        b = self.bounds()

        def _eval(x: float, y: float) -> float:
            w_sum, v_sum = 0.0, 0.0
            for j in range(n):
                d = math.hypot(x - centroids[j][0], y - centroids[j][1])
                w = 1.0 / max(d * d, 1e-12)
                w_sum += w
                v_sum += w * vals[j]
            return v_sum / w_sum if w_sum > 0 else 0.0

        # Init particles
        particles = [(rng.uniform(b.min_x, b.max_x), rng.uniform(b.min_y, b.max_y)) for _ in range(n_particles)]
        velocities = [(rng.uniform(-1, 1), rng.uniform(-1, 1)) for _ in range(n_particles)]
        p_best = list(particles)
        p_best_val = [_eval(p[0], p[1]) for p in particles]
        g_best = max(range(n_particles), key=lambda i: p_best_val[i])
        g_best_pos = p_best[g_best]
        g_best_val = p_best_val[g_best]

        for _ in range(n_iterations):
            for i in range(n_particles):
                # Quantum tunnelling
                if rng.random() < tunnel_probability:
                    particles[i] = (rng.uniform(b.min_x, b.max_x), rng.uniform(b.min_y, b.max_y))
                    velocities[i] = (rng.uniform(-1, 1), rng.uniform(-1, 1))
                else:
                    # Standard PSO update
                    r1, r2 = rng.random(), rng.random()
                    vx = 0.7 * velocities[i][0] + 1.5 * r1 * (p_best[i][0] - particles[i][0]) + 1.5 * r2 * (g_best_pos[0] - particles[i][0])
                    vy = 0.7 * velocities[i][1] + 1.5 * r1 * (p_best[i][1] - particles[i][1]) + 1.5 * r2 * (g_best_pos[1] - particles[i][1])
                    velocities[i] = (vx, vy)
                    new_x = max(b.min_x, min(b.max_x, particles[i][0] + vx))
                    new_y = max(b.min_y, min(b.max_y, particles[i][1] + vy))
                    particles[i] = (new_x, new_y)
                val = _eval(particles[i][0], particles[i][1])
                if val > p_best_val[i]:
                    p_best_val[i] = val
                    p_best[i] = particles[i]
                    if val > g_best_val:
                        g_best_val = val
                        g_best_pos = particles[i]

        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [g_best_pos[0], g_best_pos[1]]},
            f"optimal_x_{suffix}": round(g_best_pos[0], 6),
            f"optimal_y_{suffix}": round(g_best_pos[1], 6),
            f"optimal_value_{suffix}": round(g_best_val, 6),
            f"n_particles_{suffix}": n_particles,
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 433: quantum_born_rule_density
    # ------------------------------------------------------------------
    def quantum_born_rule_density(
        self,
        *,
        grid_resolution: int = 20,
        bandwidth: float | None = None,
        suffix: str = "qbd",
    ) -> "GeoPromptFrame":
        """Born-rule density estimation: treat feature positions as quantum
        wavefunctions and compute probability density via |psi(x)|^2.

        Each feature contributes a Gaussian wavepacket; the total density
        is the squared modulus of the superposition.
        """
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        n = len(recs)
        b = self.bounds()
        if bandwidth is None:
            bandwidth = max(b.max_x - b.min_x, b.max_y - b.min_y) / (2 * math.sqrt(n)) if n > 0 else 1.0
        bw2 = 2 * bandwidth * bandwidth

        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                # Superposition amplitude
                psi_real, psi_imag = 0.0, 0.0
                for i in range(n):
                    d2 = (px - centroids[i][0]) ** 2 + (py - centroids[i][1]) ** 2
                    amplitude = math.exp(-d2 / bw2) / math.sqrt(n)
                    # Phase based on position
                    phase = centroids[i][0] * px + centroids[i][1] * py
                    psi_real += amplitude * math.cos(phase)
                    psi_imag += amplitude * math.sin(phase)
                density = psi_real ** 2 + psi_imag ** 2
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"density_{suffix}": round(density, 8),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 434: quantum_decoherence_smoothing
    # ------------------------------------------------------------------
    def quantum_decoherence_smoothing(
        self,
        value_column: str,
        *,
        decoherence_rate: float = 0.3,
        n_steps: int = 10,
        suffix: str = "qds",
    ) -> "GeoPromptFrame":
        """Quantum-decoherence-inspired spatial smoothing.

        Feature values evolve under unitary dynamics that gradually decohere,
        blending neighbouring values as quantum states lose coherence.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]

        # k-NN adjacency
        k = min(5, n - 1)
        adj: list[list[tuple[int, float]]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            adj.append([(j, d) for d, j in dists[:k]])

        current = list(vals)
        for step in range(n_steps):
            coherence = math.exp(-decoherence_rate * step)
            new_vals: list[float] = []
            for i in range(n):
                w_sum, v_sum = 0.0, 0.0
                for j, d in adj[i]:
                    w = 1.0 / max(d, 1e-12)
                    w_sum += w
                    v_sum += w * current[j]
                neighbour_avg = v_sum / w_sum if w_sum > 0 else current[i]
                # Coherent: keep original; decoherent: blend with neighbours
                new_vals.append(coherence * current[i] + (1 - coherence) * neighbour_avg)
            current = new_vals

        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"smoothed_{suffix}": round(current[i], 6),
                f"original_{suffix}": vals[i],
                f"delta_{suffix}": round(current[i] - vals[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 435: quantum_interference_pattern
    # ------------------------------------------------------------------
    def quantum_interference_pattern(
        self,
        *,
        grid_resolution: int = 20,
        wavelength: float | None = None,
        suffix: str = "qip",
    ) -> "GeoPromptFrame":
        """Generate a quantum-interference pattern from spatial features.

        Each feature emits a circular wave; the interference pattern on a
        grid shows constructive and destructive superposition — useful for
        identifying reinforcement zones between features.
        """
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        n = len(recs)
        b = self.bounds()
        if wavelength is None:
            wavelength = max(b.max_x - b.min_x, b.max_y - b.min_y) / 10

        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        k_wave = 2 * math.pi / wavelength if wavelength > 0 else 1.0

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                amp_real, amp_imag = 0.0, 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    decay = 1.0 / max(math.sqrt(d), 0.1)
                    phase = k_wave * d
                    amp_real += decay * math.cos(phase)
                    amp_imag += decay * math.sin(phase)
                intensity = amp_real ** 2 + amp_imag ** 2
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"intensity_{suffix}": round(intensity, 6),
                    f"constructive_{suffix}": 1 if amp_real > 0 else 0,
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 436: quantum_boltzmann_sampler
    # ------------------------------------------------------------------
    def quantum_boltzmann_sampler(
        self,
        value_column: str,
        *,
        n_samples: int = 20,
        temperature: float = 1.0,
        seed: int | None = None,
        suffix: str = "qbs",
    ) -> "GeoPromptFrame":
        """Quantum Boltzmann Machine-inspired spatial sampler.

        Samples spatial locations proportional to the Boltzmann distribution
        over the value surface, with quantum fluctuations allowing broader
        exploration than classical Boltzmann sampling.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        n = len(recs)

        # Boltzmann weights with quantum correction
        max_v = max(vals) if vals else 0
        weights = [math.exp((v - max_v) / max(temperature, 1e-12)) for v in vals]
        total_w = sum(weights) or 1e-12
        probs = [w / total_w for w in weights]

        # Cumulative for sampling
        cum = []
        s = 0.0
        for p in probs:
            s += p
            cum.append(s)

        out_rows: list[Record] = []
        for si in range(n_samples):
            r = rng.random()
            idx = n - 1
            for j, c in enumerate(cum):
                if r <= c:
                    idx = j
                    break
            # Quantum fluctuation around sampled point
            qx = centroids[idx][0] + rng.gauss(0, temperature * 0.01)
            qy = centroids[idx][1] + rng.gauss(0, temperature * 0.01)
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": [round(qx, 6), round(qy, 6)]},
                f"sample_id_{suffix}": si,
                f"source_idx_{suffix}": idx,
                f"value_{suffix}": vals[idx],
                f"probability_{suffix}": round(probs[idx], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 437: qubit_spatial_encoder
    # ------------------------------------------------------------------
    def qubit_spatial_encoder(
        self,
        feature_columns: list[str],
        *,
        n_qubits: int | None = None,
        suffix: str = "qse",
    ) -> "GeoPromptFrame":
        """Encode spatial features as qubit states on the Bloch sphere.

        Each feature value is mapped to a (theta, phi) pair on the Bloch
        sphere, producing |0> / |1> measurement probabilities useful as
        binary spatial indicators.
        """
        for c in feature_columns:
            self._require_column(c)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        if n_qubits is None:
            n_qubits = n_feat
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(raw[i][f] for i in range(n)) for f in range(n_feat)]
        col_rng = [max(raw[i][f] for i in range(n)) - col_min[f] or 1.0 for f in range(n_feat)]

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            for q in range(min(n_qubits, n_feat)):
                theta = (raw[i][q] - col_min[q]) / col_rng[q] * math.pi
                phi = (raw[i][q] - col_min[q]) / col_rng[q] * 2 * math.pi
                p0 = math.cos(theta / 2) ** 2
                p1 = math.sin(theta / 2) ** 2
                rec[f"qubit_{q}_p0_{suffix}"] = round(p0, 6)
                rec[f"qubit_{q}_p1_{suffix}"] = round(p1, 6)
                rec[f"qubit_{q}_theta_{suffix}"] = round(theta, 6)
                rec[f"qubit_{q}_phi_{suffix}"] = round(phi, 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 438: quantum_fourier_spatial_analysis
    # ------------------------------------------------------------------
    def quantum_fourier_spatial_analysis(
        self,
        value_column: str,
        *,
        n_harmonics: int = 5,
        suffix: str = "qft",
    ) -> "GeoPromptFrame":
        """Quantum Fourier Transform-inspired spectral analysis of spatial values.

        Decomposes the spatial value distribution into frequency components
        along a 1-D projection, revealing periodic spatial patterns.
        """
        self._require_column(value_column)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]

        # Project onto principal axis (x for simplicity)
        xs = [c[0] for c in centroids]
        min_x, max_x = min(xs), max(xs)
        rx = max_x - min_x or 1.0
        norm_x = [(x - min_x) / rx for x in xs]

        # DFT on the sorted sequence
        order = sorted(range(n), key=lambda i: norm_x[i])
        sorted_vals = [vals[i] for i in order]
        # Precompute inverse index for O(1) lookup
        inv_order = [0] * n
        for pos, orig in enumerate(order):
            inv_order[orig] = pos

        # Compute harmonics
        harmonics: list[tuple[float, float]] = []  # (amplitude, phase) per frequency
        for k in range(n_harmonics):
            real_sum, imag_sum = 0.0, 0.0
            for j in range(n):
                angle = -2 * math.pi * k * j / n
                real_sum += sorted_vals[j] * math.cos(angle)
                imag_sum += sorted_vals[j] * math.sin(angle)
            amp = math.sqrt(real_sum ** 2 + imag_sum ** 2) / n
            phase = math.atan2(imag_sum, real_sum)
            harmonics.append((amp, phase))

        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            rec[f"value_{suffix}"] = vals[i]
            # Reconstruct from harmonics
            idx_in_sorted = inv_order[i]
            reconstructed = 0.0
            for k, (amp, phase) in enumerate(harmonics):
                reconstructed += amp * math.cos(2 * math.pi * k * idx_in_sorted / n + phase)
            rec[f"reconstructed_{suffix}"] = round(reconstructed, 6)
            for k, (amp, phase) in enumerate(harmonics):
                rec[f"harmonic_{k}_amp_{suffix}"] = round(amp, 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 439: quantum_measurement_classifier
    # ------------------------------------------------------------------
    def quantum_measurement_classifier(
        self,
        feature_columns: list[str],
        *,
        n_classes: int = 3,
        n_shots: int = 100,
        seed: int | None = None,
        suffix: str = "qmc",
    ) -> "GeoPromptFrame":
        """Quantum-measurement-based classifier.

        Encodes features as quantum states, applies class-specific measurement
        operators, and classifies by the measurement basis with highest
        shot-frequency — a quantum-native classification paradigm.
        """
        for c in feature_columns:
            self._require_column(c)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(raw[i][f] for i in range(n)) for f in range(n_feat)]
        col_rng = [max(raw[i][f] for i in range(n)) - col_min[f] or 1.0 for f in range(n_feat)]

        # Measurement operators: one rotation angle per class per feature
        operators = [[rng.uniform(0, math.pi) for _ in range(n_feat)] for _ in range(n_classes)]

        out_rows: list[Record] = []
        for i in range(n):
            norm = [(raw[i][f] - col_min[f]) / col_rng[f] for f in range(n_feat)]
            # Compute measurement probabilities once (deterministic given operators)
            probs: list[float] = []
            for c in range(n_classes):
                p = 1.0
                for f in range(n_feat):
                    theta = norm[f] * math.pi
                    p *= math.cos(theta - operators[c][f]) ** 2
                probs.append(max(p, 1e-12))
            total = sum(probs)
            probs = [p / total for p in probs]
            # Build CDF for sampling
            cdf: list[float] = []
            cum = 0.0
            for p in probs:
                cum += p
                cdf.append(cum)
            # Sample n_shots times
            counts: list[int] = [0] * n_classes
            for _ in range(n_shots):
                r_val = rng.random()
                measured = n_classes - 1
                for c in range(n_classes):
                    if r_val <= cdf[c]:
                        measured = c
                        break
                counts[measured] += 1
            best_class = max(range(n_classes), key=lambda c: counts[c])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"class_{suffix}": best_class,
                f"confidence_{suffix}": round(counts[best_class] / n_shots, 4),
                f"n_shots_{suffix}": n_shots,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 440: quantum_adiabatic_solver
    # ------------------------------------------------------------------
    def quantum_adiabatic_solver(
        self,
        value_column: str,
        *,
        n_groups: int = 2,
        n_steps: int = 100,
        seed: int | None = None,
        suffix: str = "qas",
    ) -> "GeoPromptFrame":
        """Quantum adiabatic optimisation for spatial partitioning.

        Slowly evolves a transverse-field Ising model from a trivial ground
        state to one encoding the optimal spatial partition that maximises
        intra-group value similarity and inter-group separation.
        """
        self._require_column(value_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()

        n = len(recs)
        centroids = self._centroids()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]

        # Spin representation: +1 / -1 for 2 groups
        spins = [1 if rng.random() < 0.5 else -1 for _ in range(n)]

        # Problem Hamiltonian: J_ij coupling
        max_dist = 0.0
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                if d > max_dist:
                    max_dist = d
        max_dist = max_dist or 1.0
        v_rng_val = max(vals) - min(vals) or 1.0

        def _problem_energy() -> float:
            e = 0.0
            for i in range(n):
                for j in range(i + 1, n):
                    d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                    proximity = 1 - d / max_dist
                    similarity = 1 - abs(vals[i] - vals[j]) / v_rng_val
                    j_ij = proximity * similarity
                    e -= j_ij * spins[i] * spins[j]
            return e

        best_spins = list(spins)
        best_energy = _problem_energy()
        current_e = best_energy

        for step in range(n_steps):
            s = step / max(n_steps - 1, 1)  # 0 → 1
            # Transverse field strength (decreases)
            gamma = (1 - s) * 5.0
            # Problem strength (increases)
            beta = s

            for i in range(n):
                old = spins[i]
                spins[i] = -spins[i]
                new_e = _problem_energy()
                delta = beta * (new_e - current_e)
                # Transverse-field tunnelling probability
                tunnel = math.exp(-gamma) if gamma < 20 else 0
                if delta < 0 or rng.random() < tunnel:
                    current_e = new_e
                    if current_e < best_energy:
                        best_energy = current_e
                        best_spins = list(spins)
                else:
                    spins[i] = old

        out_rows: list[Record] = []
        for i in range(n):
            group = 0 if best_spins[i] == 1 else 1
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"group_{suffix}": group,
                f"spin_{suffix}": best_spins[i],
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 441: gravity_flow_model
    # ------------------------------------------------------------------
    def gravity_flow_model(
        self,
        mass_column: str,
        *,
        beta: float = 2.0,
        suffix: str = "gfm",
    ) -> "GeoPromptFrame":
        """Spatial interaction model using Newton's law of gravitation.

        Computes pairwise flow potential between features as
        F_ij = m_i * m_j / d_ij^beta, then sums total inflow and outflow
        per feature. Novel enhancement: directional flow bias where
        higher-mass features act as attractors.
        """
        self._require_column(mass_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        masses = [max(float(r.get(mass_column, 0) or 0), 1e-12) for r in recs]
        inflow = [0.0] * n
        outflow = [0.0] * n
        max_partner = [0] * n
        for i in range(n):
            best_f, best_j = 0.0, i
            for j in range(n):
                if i == j:
                    continue
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                if d < 1e-12:
                    d = 1e-12
                f = masses[i] * masses[j] / (d ** beta)
                if masses[j] > masses[i]:
                    inflow[i] += f
                else:
                    outflow[i] += f
                if f > best_f:
                    best_f = f
                    best_j = j
            max_partner[i] = best_j
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"inflow_{suffix}": round(inflow[i], 6),
                f"outflow_{suffix}": round(outflow[i], 6),
                f"net_flow_{suffix}": round(inflow[i] - outflow[i], 6),
                f"max_partner_{suffix}": max_partner[i],
                f"mass_{suffix}": masses[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 442: diffusion_kernel_smooth
    # ------------------------------------------------------------------
    def diffusion_kernel_smooth(
        self,
        value_column: str,
        *,
        time_steps: int = 10,
        diffusion_rate: float = 0.1,
        k_neighbors: int = 5,
        suffix: str = "dks",
    ) -> "GeoPromptFrame":
        """Heat-equation diffusion smoothing on a spatial k-NN graph.

        Simulates the heat equation dv/dt = alpha * Laplacian(v) on a
        graph where edges connect k nearest neighbours. Novel: uses
        adaptive diffusion rate proportional to local feature density.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # Build k-NN graph
        neighbours: list[list[tuple[int, float]]] = []
        for i in range(n):
            dists = []
            for j in range(n):
                if i == j:
                    continue
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                dists.append((d, j))
            dists.sort()
            neighbours.append([(j, d) for d, j in dists[:k_neighbors]])
        # Adaptive diffusion: denser areas diffuse faster
        local_density = [1.0 / (sum(d for _, d in nb) / max(len(nb), 1) + 1e-12) for nb in neighbours]
        max_dens = max(local_density) or 1.0
        norm_density = [d / max_dens for d in local_density]
        current = list(vals)
        for _ in range(time_steps):
            new_vals = list(current)
            for i in range(n):
                laplacian = 0.0
                w_sum = 0.0
                for j, d in neighbours[i]:
                    w = 1.0 / max(d, 1e-12)
                    laplacian += w * (current[j] - current[i])
                    w_sum += w
                if w_sum > 0:
                    alpha = diffusion_rate * (0.5 + 0.5 * norm_density[i])
                    new_vals[i] = current[i] + alpha * laplacian / w_sum
            current = new_vals
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"smoothed_{suffix}": round(current[i], 6),
                f"original_{suffix}": vals[i],
                f"delta_{suffix}": round(current[i] - vals[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 443: topological_persistence
    # ------------------------------------------------------------------
    def topological_persistence(
        self,
        value_column: str,
        *,
        suffix: str = "tph",
    ) -> "GeoPromptFrame":
        """Topological persistence analysis of a spatial scalar field.

        Computes 0-dimensional persistent homology by tracking connected
        components as the value threshold sweeps from maximum to minimum.
        Each feature is assigned the persistence (birth - death) of the
        component it belongs to — high persistence = significant peak.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # Sort indices by descending value (super-level set filtration)
        order = sorted(range(n), key=lambda i: -vals[i])
        # Union-Find
        parent = list(range(n))
        rank = [0] * n
        birth = [0.0] * n
        persistence = [0.0] * n
        active = [False] * n
        # K-NN adjacency (k=6)
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            for _, j in dists[:6]:
                adj[i].append(j)

        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a: int, b: int) -> int:
            ra, rb = find(a), find(b)
            if ra == rb:
                return ra
            if rank[ra] < rank[rb]:
                ra, rb = rb, ra
            parent[rb] = ra
            if rank[ra] == rank[rb]:
                rank[ra] += 1
            return ra

        for idx in order:
            active[idx] = True
            birth[idx] = vals[idx]
            for nb in adj[idx]:
                if active[nb]:
                    ri, rj = find(idx), find(nb)
                    if ri != rj:
                        # Younger component dies
                        if vals[ri] >= vals[rj]:
                            persistence[rj] = birth[rj] - vals[idx]
                            union(ri, rj)
                        else:
                            persistence[ri] = birth[ri] - vals[idx]
                            union(rj, ri)
        # Surviving components get max persistence
        for i in range(n):
            if find(i) == i and persistence[i] == 0.0:
                persistence[i] = birth[i] - min(vals)
        # Propagate persistence to all members
        comp_pers: dict[int, float] = {}
        for i in range(n):
            r = find(i)
            if r not in comp_pers or persistence[r] > comp_pers[r]:
                comp_pers[r] = persistence[r]
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"persistence_{suffix}": round(comp_pers.get(find(i), 0.0), 6),
                f"birth_{suffix}": round(birth[find(i)], 6),
                f"component_{suffix}": find(i),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 444: levy_flight_optimizer
    # ------------------------------------------------------------------
    def levy_flight_optimizer(
        self,
        value_column: str,
        *,
        n_walkers: int = 10,
        n_steps: int = 100,
        alpha: float = 1.5,
        seed: int | None = None,
        suffix: str = "lfo",
    ) -> "GeoPromptFrame":
        """Lévy flight optimiser for spatial maximum finding.

        Walkers explore the spatial field using Lévy-distributed step lengths
        (heavy-tailed distribution), which provably outperforms Brownian motion
        for sparse target search (Viswanathan et al. 1999). The step length
        distribution has PDF ~ |x|^(-1-alpha).
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        rng = random.Random(seed)
        b = self.bounds()

        def _idw_eval(px: float, py: float) -> float:
            num, den = 0.0, 0.0
            for i in range(n):
                d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                w = 1.0 / max(d, 1e-12) ** 2
                num += w * vals[i]
                den += w
            return num / den if den > 0 else 0.0

        def _levy_step() -> float:
            u = rng.gauss(0, 1)
            v = abs(rng.gauss(0, 1))
            return u / max(v ** (1.0 / alpha), 1e-12)

        best_x, best_y = centroids[rng.randint(0, n - 1)]
        best_val = _idw_eval(best_x, best_y)
        scale = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-6) * 0.01
        walkers = [(rng.uniform(b.min_x, b.max_x), rng.uniform(b.min_y, b.max_y))
                   for _ in range(n_walkers)]
        for _ in range(n_steps):
            for w in range(n_walkers):
                wx, wy = walkers[w]
                step_x = _levy_step() * scale
                step_y = _levy_step() * scale
                nx = max(b.min_x, min(b.max_x, wx + step_x))
                ny = max(b.min_y, min(b.max_y, wy + step_y))
                nv = _idw_eval(nx, ny)
                if nv > _idw_eval(wx, wy):
                    walkers[w] = (nx, ny)
                if nv > best_val:
                    best_x, best_y, best_val = nx, ny, nv
        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [best_x, best_y]},
            f"optimal_value_{suffix}": round(best_val, 6),
            f"optimal_x_{suffix}": round(best_x, 6),
            f"optimal_y_{suffix}": round(best_y, 6),
            f"n_walkers_{suffix}": n_walkers,
            f"n_steps_{suffix}": n_steps,
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 445: spectral_graph_partition
    # ------------------------------------------------------------------
    def spectral_graph_partition(
        self,
        *,
        k_neighbors: int = 5,
        n_clusters: int = 2,
        n_iterations: int = 50,
        seed: int | None = None,
        suffix: str = "sgp",
    ) -> "GeoPromptFrame":
        """Spectral graph partitioning via power-iteration Fiedler vector.

        Builds a spatial k-NN graph, computes symmetric normalised Laplacian,
        then uses power iteration to approximate the Fiedler vector (2nd
        smallest eigenvector). Clusters by sign of the Fiedler components.
        For n_clusters > 2, recursively bisects.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        # Build adjacency
        adj: list[list[tuple[int, float]]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            for d, j in dists[:k_neighbors]:
                w = math.exp(-d * d / (2.0 * max(dists[0][0], 1e-12) ** 2))
                adj[i].append((j, w))
        # Degree
        deg = [sum(w for _, w in adj[i]) for i in range(n)]

        def _fiedler(indices: list[int]) -> list[float]:
            m = len(indices)
            if m <= 1:
                return [0.0] * m
            idx_map = {v: k for k, v in enumerate(indices)}
            # Power iteration for smallest non-trivial eigenvector of L_sym
            # Use inverse iteration via L * x = b approach approximation
            v = [rng.gauss(0, 1) for _ in range(m)]
            for _ in range(n_iterations):
                # L_rw * v = D^-1 * L * v = v - D^-1 * W * v
                new_v = [0.0] * m
                for ki, i in enumerate(indices):
                    wv_sum = 0.0
                    for j, w in adj[i]:
                        if j in idx_map:
                            wv_sum += w * v[idx_map[j]]
                    d = deg[i] if deg[i] > 0 else 1.0
                    new_v[ki] = v[ki] - wv_sum / d
                # Remove projection onto constant vector (1st eigenvector)
                mean_v = sum(new_v) / m
                new_v = [x - mean_v for x in new_v]
                norm = math.sqrt(sum(x * x for x in new_v)) or 1.0
                v = [x / norm for x in new_v]
            return v

        labels = [0] * n
        queue = [(list(range(n)), 0)]
        cur_label = 0
        while queue and cur_label < n_clusters - 1:
            indices, _ = queue.pop(0)
            if len(indices) <= 1:
                continue
            fv = _fiedler(indices)
            left = [indices[i] for i in range(len(indices)) if fv[i] <= 0]
            right = [indices[i] for i in range(len(indices)) if fv[i] > 0]
            if not left or not right:
                continue
            cur_label += 1
            for idx in right:
                labels[idx] = cur_label
            queue.append((left, cur_label))
            queue.append((right, cur_label))
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": labels[i],
                f"degree_{suffix}": round(deg[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 446: spatial_wavelet_decompose
    # ------------------------------------------------------------------
    def spatial_wavelet_decompose(
        self,
        value_column: str,
        *,
        n_scales: int = 4,
        grid_resolution: int = 16,
        suffix: str = "swv",
    ) -> "GeoPromptFrame":
        """Spatial wavelet decomposition using Mexican-hat (Ricker) wavelets.

        Evaluates the continuous wavelet transform at multiple scales on a
        regular grid. Each scale captures different spatial frequency content.
        Novel: decomposes the spatial signal into scale-dependent components
        using IDW-interpolated field values.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        base_scale = max(b.max_x - b.min_x, b.max_y - b.min_y) / (grid_resolution * 2)

        def _idw(px: float, py: float) -> float:
            num, den = 0.0, 0.0
            for i in range(n):
                d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                w = 1.0 / max(d, 1e-12) ** 2
                num += w * vals[i]
                den += w
            return num / den if den else 0.0

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                rec: Record = {
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"value_{suffix}": round(_idw(px, py), 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                }
                for s in range(n_scales):
                    sigma = base_scale * (2 ** s)
                    # Mexican hat wavelet transform: integral of f(x')*psi((x-x')/sigma)
                    coeff = 0.0
                    for i in range(n):
                        r2 = ((px - centroids[i][0]) ** 2 + (py - centroids[i][1]) ** 2) / (sigma ** 2)
                        psi = (1.0 - r2) * math.exp(-r2 / 2.0)
                        coeff += vals[i] * psi
                    coeff /= (sigma * max(n, 1))
                    rec[f"scale_{s}_{suffix}"] = round(coeff, 6)
                out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 447: spatial_information_entropy
    # ------------------------------------------------------------------
    def spatial_information_entropy(
        self,
        value_column: str,
        *,
        n_bins: int = 10,
        radius: float | None = None,
        suffix: str = "sie",
    ) -> "GeoPromptFrame":
        """Local spatial information entropy (Shannon entropy of neighbourhood).

        For each feature, computes the Shannon entropy of the value distribution
        within a circular neighbourhood. High entropy = heterogeneous area;
        low entropy = homogeneous. Novel: adaptive radius based on local
        point density when no radius is specified.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        v_min, v_max = min(vals), max(vals)
        v_range = v_max - v_min or 1.0
        if radius is None:
            extent = max(self.bounds().max_x - self.bounds().min_x,
                         self.bounds().max_y - self.bounds().min_y, 1e-6)
            radius = extent / math.sqrt(max(n, 1))
        out_rows: list[Record] = []
        for i in range(n):
            # Gather neighbourhood values
            nb_vals: list[float] = []
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                if d <= radius:
                    nb_vals.append(vals[j])
            # Bin and compute entropy
            if len(nb_vals) < 2:
                entropy = 0.0
            else:
                bins = [0] * n_bins
                for v in nb_vals:
                    b_idx = min(int((v - v_min) / v_range * n_bins), n_bins - 1)
                    bins[b_idx] += 1
                total = len(nb_vals)
                entropy = 0.0
                for count in bins:
                    if count > 0:
                        p = count / total
                        entropy -= p * math.log(p)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"entropy_{suffix}": round(entropy, 6),
                f"n_neighbours_{suffix}": len(nb_vals),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 448: ant_colony_path_finder
    # ------------------------------------------------------------------
    def ant_colony_path_finder(
        self,
        value_column: str,
        *,
        n_ants: int = 10,
        n_iterations: int = 50,
        evaporation: float = 0.3,
        alpha: float = 1.0,
        beta_param: float = 2.0,
        seed: int | None = None,
        suffix: str = "aco",
    ) -> "GeoPromptFrame":
        """Ant Colony Optimisation for spatial touring (visiting all features).

        Ants deposit pheromone on edges; high-value features attract more
        visits. Combines heuristic distance and feature-value attractiveness.
        Returns the best tour found and per-feature visit order.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        vals = [max(float(r.get(value_column, 0) or 0), 0.01) for r in recs]
        # Distance matrix
        dist = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1]) or 1e-12
                dist[i][j] = d
                dist[j][i] = d
        # Pheromone
        tau = [[1.0] * n for _ in range(n)]
        # Heuristic: 1/distance * value_attractiveness
        best_tour: list[int] = list(range(n))
        best_cost = float("inf")
        for _ in range(n_iterations):
            for _ in range(n_ants):
                start = rng.randint(0, n - 1)
                visited = {start}
                tour = [start]
                current = start
                while len(tour) < n:
                    probs: list[tuple[int, float]] = []
                    total = 0.0
                    for j in range(n):
                        if j in visited:
                            continue
                        eta = (1.0 / max(dist[current][j], 1e-12)) * (vals[j] ** 0.5)
                        p = (tau[current][j] ** alpha) * (eta ** beta_param)
                        probs.append((j, p))
                        total += p
                    if not probs:
                        break
                    r = rng.uniform(0, total)
                    cum = 0.0
                    chosen = probs[-1][0]
                    for j, p in probs:
                        cum += p
                        if cum >= r:
                            chosen = j
                            break
                    tour.append(chosen)
                    visited.add(chosen)
                    current = chosen
                cost = sum(dist[tour[i]][tour[i + 1]] for i in range(len(tour) - 1))
                if cost < best_cost:
                    best_cost = cost
                    best_tour = list(tour)
                # Deposit pheromone
                deposit = 1.0 / max(cost, 1e-12)
                for i in range(len(tour) - 1):
                    tau[tour[i]][tour[i + 1]] += deposit
                    tau[tour[i + 1]][tour[i]] += deposit
            # Evaporate
            for i in range(n):
                for j in range(n):
                    tau[i][j] *= (1.0 - evaporation)
        visit_order = [0] * n
        for pos, idx in enumerate(best_tour):
            visit_order[idx] = pos
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"visit_order_{suffix}": visit_order[i],
                f"tour_cost_{suffix}": round(best_cost, 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 449: fractal_dimension_estimator
    # ------------------------------------------------------------------
    def fractal_dimension_estimator(
        self,
        *,
        n_scales: int = 8,
        suffix: str = "fde",
    ) -> "GeoPromptFrame":
        """Estimate fractal (box-counting) dimension of the point pattern.

        Uses the box-counting method: count occupied boxes at decreasing
        scales and fit log(N) vs log(1/epsilon). Returns a single row
        with the estimated dimension and per-scale counts.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not centroids:
            return self.copy()
        n = len(centroids)
        b = self.bounds()
        extent_x = b.max_x - b.min_x or 1.0
        extent_y = b.max_y - b.min_y or 1.0
        extent = max(extent_x, extent_y)
        scales = [extent / (2 ** s) for s in range(1, n_scales + 1)]
        counts: list[int] = []
        for eps in scales:
            boxes: set[tuple[int, int]] = set()
            for cx, cy in centroids:
                bx = int((cx - b.min_x) / eps) if eps > 0 else 0
                by = int((cy - b.min_y) / eps) if eps > 0 else 0
                boxes.add((bx, by))
            counts.append(len(boxes))
        # Linear regression: log(count) = D * log(1/eps) + c
        log_inv_eps = [math.log(1.0 / max(e, 1e-12)) for e in scales]
        log_counts = [math.log(max(c, 1)) for c in counts]
        n_s = len(log_inv_eps)
        mean_x = sum(log_inv_eps) / n_s
        mean_y = sum(log_counts) / n_s
        num = sum((log_inv_eps[i] - mean_x) * (log_counts[i] - mean_y) for i in range(n_s))
        den = sum((log_inv_eps[i] - mean_x) ** 2 for i in range(n_s))
        dimension = num / den if den > 0 else 0.0
        rec: Record = {
            self.geometry_column: {"type": "Point", "coordinates": [
                (b.min_x + b.max_x) / 2, (b.min_y + b.max_y) / 2]},
            f"dimension_{suffix}": round(dimension, 6),
            f"n_points_{suffix}": n,
            f"n_scales_{suffix}": n_scales,
        }
        for i, c in enumerate(counts):
            rec[f"count_scale_{i}_{suffix}"] = c
        out_rows: list[Record] = [rec]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 450: reaction_diffusion_pattern
    # ------------------------------------------------------------------
    def reaction_diffusion_pattern(
        self,
        *,
        grid_resolution: int = 20,
        n_steps: int = 100,
        feed_rate: float = 0.055,
        kill_rate: float = 0.062,
        diffusion_a: float = 1.0,
        diffusion_b: float = 0.5,
        seed: int | None = None,
        suffix: str = "rdp",
    ) -> "GeoPromptFrame":
        """Gray-Scott reaction-diffusion pattern generator on a spatial grid.

        Simulates the Gray-Scott model seeded at feature locations to produce
        Turing-like spatial patterns. Features act as initial seed points
        for the B chemical.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        b_bounds = self.bounds()
        rng = random.Random(seed)
        gr = grid_resolution
        dx = (b_bounds.max_x - b_bounds.min_x) / max(gr - 1, 1)
        dy = (b_bounds.max_y - b_bounds.min_y) / max(gr - 1, 1)
        # Init grids
        A = [[1.0] * gr for _ in range(gr)]
        B = [[0.0] * gr for _ in range(gr)]
        # Seed B at feature locations
        for cx, cy in centroids:
            gi = min(int((cx - b_bounds.min_x) / max(dx, 1e-12)), gr - 1)
            gj = min(int((cy - b_bounds.min_y) / max(dy, 1e-12)), gr - 1)
            for di in range(-1, 2):
                for dj in range(-1, 2):
                    ni, nj = gi + di, gj + dj
                    if 0 <= ni < gr and 0 <= nj < gr:
                        B[nj][ni] = 1.0
        dt = 1.0
        for _ in range(n_steps):
            nA = [[0.0] * gr for _ in range(gr)]
            nB = [[0.0] * gr for _ in range(gr)]
            for gy in range(gr):
                for gx in range(gr):
                    a, b_val = A[gy][gx], B[gy][gx]
                    # Laplacian (5-point stencil)
                    la, lb = -4 * a, -4 * b_val
                    for dgy, dgx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                        ny, nx = gy + dgy, gx + dgx
                        if 0 <= ny < gr and 0 <= nx < gr:
                            la += A[ny][nx]
                            lb += B[ny][nx]
                        else:
                            la += a
                            lb += b_val
                    reaction = a * b_val * b_val
                    nA[gy][gx] = a + dt * (diffusion_a * la - reaction + feed_rate * (1 - a))
                    nB[gy][gx] = b_val + dt * (diffusion_b * lb + reaction - (kill_rate + feed_rate) * b_val)
                    nA[gy][gx] = clamp(nA[gy][gx], 0.0, 1.0)
                    nB[gy][gx] = clamp(nB[gy][gx], 0.0, 1.0)
            A, B = nA, nB
        out_rows: list[Record] = []
        for gy in range(gr):
            py = b_bounds.min_y + gy * dy
            for gx in range(gr):
                px = b_bounds.min_x + gx * dx
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"chemical_a_{suffix}": round(A[gy][gx], 6),
                    f"chemical_b_{suffix}": round(B[gy][gx], 6),
                    f"pattern_{suffix}": round(A[gy][gx] - B[gy][gx], 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 451: spatial_pagerank
    # ------------------------------------------------------------------
    def spatial_pagerank(
        self,
        *,
        k_neighbors: int = 5,
        damping: float = 0.85,
        n_iterations: int = 50,
        suffix: str = "spr",
    ) -> "GeoPromptFrame":
        """PageRank algorithm adapted for spatial proximity networks.

        Builds a spatial k-NN graph and computes PageRank scores. Features
        with many nearby neighbours that are themselves highly connected
        receive higher scores — identifying spatial hubs.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        # Build adjacency
        out_links: list[list[int]] = [[] for _ in range(n)]
        in_links: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            for _, j in dists[:k_neighbors]:
                out_links[i].append(j)
                in_links[j].append(i)
        # PageRank iteration
        pr = [1.0 / n] * n
        for _ in range(n_iterations):
            new_pr = [(1 - damping) / n] * n
            for i in range(n):
                for j in in_links[i]:
                    n_out = len(out_links[j]) or 1
                    new_pr[i] += damping * pr[j] / n_out
            pr = new_pr
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"pagerank_{suffix}": round(pr[i], 8),
                f"out_degree_{suffix}": len(out_links[i]),
                f"in_degree_{suffix}": len(in_links[i]),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 452: cellular_automata_simulator
    # ------------------------------------------------------------------
    def cellular_automata_simulator(
        self,
        value_column: str,
        *,
        grid_resolution: int = 20,
        n_steps: int = 10,
        threshold: float = 0.5,
        seed: int | None = None,
        suffix: str = "cas",
    ) -> "GeoPromptFrame":
        """Cellular automata simulation seeded by spatial feature values.

        Initialises a grid by IDW-interpolating features, then evolves
        using a spatial variant of Conway-like rules where cells transition
        based on the average state of their Moore neighbourhood.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        v_min, v_max = min(vals), max(vals)
        v_rng = v_max - v_min or 1.0
        b = self.bounds()
        gr = grid_resolution
        dxg = (b.max_x - b.min_x) / max(gr - 1, 1)
        dyg = (b.max_y - b.min_y) / max(gr - 1, 1)
        # Init grid via IDW
        grid = [[0.0] * gr for _ in range(gr)]
        for gy in range(gr):
            for gx in range(gr):
                px = b.min_x + gx * dxg
                py = b.min_y + gy * dyg
                num, den = 0.0, 0.0
                for i in range(n):
                    d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                    w = 1.0 / max(d, 1e-12) ** 2
                    num += w * (vals[i] - v_min) / v_rng
                    den += w
                grid[gy][gx] = num / den if den > 0 else 0.0
        # Evolve
        for _ in range(n_steps):
            new_grid = [[0.0] * gr for _ in range(gr)]
            for gy in range(gr):
                for gx in range(gr):
                    total, cnt = 0.0, 0
                    for dy2 in range(-1, 2):
                        for dx2 in range(-1, 2):
                            ny, nx = gy + dy2, gx + dx2
                            if 0 <= ny < gr and 0 <= nx < gr:
                                total += grid[ny][nx]
                                cnt += 1
                    avg = total / cnt if cnt else 0.0
                    cur = grid[gy][gx]
                    # Transition rules
                    if cur < threshold and avg > 0.6:
                        new_grid[gy][gx] = min(cur + 0.2, 1.0)
                    elif cur > threshold and avg < 0.3:
                        new_grid[gy][gx] = max(cur - 0.2, 0.0)
                    else:
                        new_grid[gy][gx] = cur * 0.9 + avg * 0.1
            grid = new_grid
        out_rows: list[Record] = []
        for gy in range(gr):
            py = b.min_y + gy * dyg
            for gx in range(gr):
                px = b.min_x + gx * dxg
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"state_{suffix}": round(grid[gy][gx], 6),
                    f"active_{suffix}": 1 if grid[gy][gx] > threshold else 0,
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 453: spatial_label_propagation
    # ------------------------------------------------------------------
    def spatial_label_propagation(
        self,
        label_column: str,
        *,
        k_neighbors: int = 5,
        n_iterations: int = 20,
        seed: int | None = None,
        suffix: str = "slp",
    ) -> "GeoPromptFrame":
        """Semi-supervised label propagation on a spatial graph.

        Features with known labels propagate them to unlabelled neighbours
        on a distance-weighted k-NN graph. Labelled features keep their
        labels (clamped); unlabelled features adopt the weighted majority
        label from neighbours.
        """
        self._require_column(label_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        raw_labels = [r.get(label_column) for r in recs]
        labelled = [i for i in range(n) if raw_labels[i] is not None and str(raw_labels[i]).strip() != ""]
        unique_labels = sorted(set(raw_labels[i] for i in labelled), key=str)
        if not unique_labels:
            unique_labels = ["unknown"]
        label_map = {lbl: idx for idx, lbl in enumerate(unique_labels)}
        n_labels = len(unique_labels)
        # Init distribution
        dist_matrix = [[0.0] * n_labels for _ in range(n)]
        for i in labelled:
            label_value = raw_labels[i]
            if label_value is None:
                continue
            dist_matrix[i][label_map[label_value]] = 1.0
        for i in range(n):
            if i not in labelled:
                for k in range(n_labels):
                    dist_matrix[i][k] = 1.0 / n_labels
        # Build k-NN
        neighbours: list[list[tuple[int, float]]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([(j, max(d, 1e-12)) for d, j in dists[:k_neighbors]])
        # Propagate
        labelled_set = set(labelled)
        for _ in range(n_iterations):
            new_dist = [list(row) for row in dist_matrix]
            for i in range(n):
                if i in labelled_set:
                    continue
                scores = [0.0] * n_labels
                w_sum = 0.0
                for j, d in neighbours[i]:
                    w = 1.0 / d
                    for k in range(n_labels):
                        scores[k] += w * dist_matrix[j][k]
                    w_sum += w
                if w_sum > 0:
                    new_dist[i] = [s / w_sum for s in scores]
            dist_matrix = new_dist
        out_rows: list[Record] = []
        for i in range(n):
            best = max(range(n_labels), key=lambda k: dist_matrix[i][k])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"label_{suffix}": unique_labels[best],
                f"confidence_{suffix}": round(dist_matrix[i][best], 6),
                f"was_labelled_{suffix}": 1 if i in labelled_set else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 454: optimal_facility_locator
    # ------------------------------------------------------------------
    def optimal_facility_locator(
        self,
        demand_column: str,
        *,
        n_facilities: int = 3,
        n_iterations: int = 50,
        seed: int | None = None,
        suffix: str = "ofl",
    ) -> "GeoPromptFrame":
        """P-median facility location using spatial demand weighting.

        Places n_facilities to minimise demand-weighted total distance.
        Uses alternating assignment + relocation (Maranzana heuristic).
        """
        self._require_column(demand_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        demands = [max(float(r.get(demand_column, 0) or 0), 0.0) for r in recs]
        p = min(n_facilities, n)
        # Init: random facility locations
        fac_indices = rng.sample(range(n), p)
        fac_locs = [list(centroids[i]) for i in fac_indices]
        assignments = [0] * n
        for _ in range(n_iterations):
            # Assign each demand to nearest facility
            for i in range(n):
                best_d, best_f = float("inf"), 0
                for f in range(p):
                    d = math.hypot(centroids[i][0] - fac_locs[f][0],
                                   centroids[i][1] - fac_locs[f][1])
                    if d < best_d:
                        best_d = d
                        best_f = f
                assignments[i] = best_f
            # Relocate: demand-weighted centroid
            for f in range(p):
                wx, wy, tw = 0.0, 0.0, 0.0
                for i in range(n):
                    if assignments[i] == f:
                        wx += demands[i] * centroids[i][0]
                        wy += demands[i] * centroids[i][1]
                        tw += demands[i]
                if tw > 0:
                    fac_locs[f] = [wx / tw, wy / tw]
        total_cost = sum(demands[i] * math.hypot(
            centroids[i][0] - fac_locs[assignments[i]][0],
            centroids[i][1] - fac_locs[assignments[i]][1]) for i in range(n))
        out_rows: list[Record] = []
        for i in range(n):
            d = math.hypot(centroids[i][0] - fac_locs[assignments[i]][0],
                           centroids[i][1] - fac_locs[assignments[i]][1])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"facility_{suffix}": assignments[i],
                f"distance_{suffix}": round(d, 6),
                f"demand_{suffix}": demands[i],
                f"total_cost_{suffix}": round(total_cost, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 455: spatial_outlier_ensemble
    # ------------------------------------------------------------------
    def spatial_outlier_ensemble(
        self,
        value_column: str,
        *,
        k_neighbors: int = 5,
        suffix: str = "soe",
    ) -> "GeoPromptFrame":
        """Multi-method spatial outlier ensemble combining 3 techniques.

        Computes: (1) local Z-score (value vs neighbour mean/std),
        (2) spatial lag ratio (value / lag), (3) Local Outlier Factor.
        Final score = normalised average of all three.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # K-NN
        knn: list[list[tuple[int, float]]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            knn.append([(j, d) for d, j in dists[:k_neighbors]])
        # Method 1: Local Z-score
        z_scores: list[float] = []
        for i in range(n):
            nb_vals = [vals[j] for j, _ in knn[i]]
            if len(nb_vals) < 2:
                z_scores.append(0.0)
                continue
            mean_nb = sum(nb_vals) / len(nb_vals)
            std_nb = math.sqrt(sum((v - mean_nb) ** 2 for v in nb_vals) / len(nb_vals)) or 1e-12
            z_scores.append(abs(vals[i] - mean_nb) / std_nb)
        # Method 2: Spatial lag ratio
        lag_ratios: list[float] = []
        for i in range(n):
            lag = sum(vals[j] / max(d, 1e-12) for j, d in knn[i])
            w = sum(1.0 / max(d, 1e-12) for _, d in knn[i])
            lag_val = lag / w if w > 0 else vals[i]
            lag_ratios.append(abs(vals[i] - lag_val) / max(abs(lag_val), 1e-12))
        # Method 3: LOF (Local Outlier Factor)
        k_dist = [knn[i][-1][1] if knn[i] else 1.0 for i in range(n)]
        reach_dist: list[list[float]] = []
        for i in range(n):
            rd = [max(k_dist[j], d) for j, d in knn[i]]
            reach_dist.append(rd)
        lrd: list[float] = []
        for i in range(n):
            avg_rd = sum(reach_dist[i]) / max(len(reach_dist[i]), 1)
            lrd.append(1.0 / max(avg_rd, 1e-12))
        lof: list[float] = []
        for i in range(n):
            if not knn[i]:
                lof.append(1.0)
                continue
            avg_lrd_nb = sum(lrd[j] for j, _ in knn[i]) / len(knn[i])
            lof.append(avg_lrd_nb / max(lrd[i], 1e-12))
        # Normalise and ensemble
        def _normalise(arr: list[float]) -> list[float]:
            mn, mx = min(arr), max(arr)
            rng_v = mx - mn or 1.0
            return [(v - mn) / rng_v for v in arr]
        nz = _normalise(z_scores)
        nl = _normalise(lag_ratios)
        nf = _normalise(lof)
        ensemble = [(nz[i] + nl[i] + nf[i]) / 3.0 for i in range(n)]
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"outlier_score_{suffix}": round(ensemble[i], 6),
                f"z_score_{suffix}": round(z_scores[i], 6),
                f"lag_ratio_{suffix}": round(lag_ratios[i], 6),
                f"lof_{suffix}": round(lof[i], 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 456: spatial_reservoir_computer
    # ------------------------------------------------------------------
    def spatial_reservoir_computer(
        self,
        value_column: str,
        time_column: str,
        *,
        reservoir_size: int = 20,
        spectral_radius: float = 0.9,
        seed: int | None = None,
        suffix: str = "src",
    ) -> "GeoPromptFrame":
        """Echo State Network (reservoir computing) for spatiotemporal prediction.

        Uses a fixed random reservoir with sparse connectivity. Only the
        output weights are trained (via pseudoinverse), making training O(n).
        Novel spatial twist: reservoir input includes both value and spatial
        coordinates.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        rng = random.Random(seed)
        recs = self.to_records()
        if not recs:
            return self.copy()
        sorted_recs = sorted(recs, key=lambda r: float(r.get(time_column, 0) or 0))
        centroids_sorted = []
        for r in sorted_recs:
            g = r.get(self.geometry_column, {})
            c = g.get("coordinates", [0, 0])
            centroids_sorted.append((c[0], c[1]))
        n = len(sorted_recs)
        vals = [float(r.get(value_column, 0) or 0) for r in sorted_recs]
        v_min, v_max = min(vals), max(vals)
        rv = v_max - v_min or 1.0
        norm_v = [(v - v_min) / rv for v in vals]
        # Init reservoir
        rs = reservoir_size
        W = [[0.0] * rs for _ in range(rs)]
        for i in range(rs):
            for j in range(rs):
                if rng.random() < 0.2:  # sparse
                    W[i][j] = rng.gauss(0, 1)
        # Scale to spectral radius (approximate via max row sum)
        max_row = max(sum(abs(W[i][j]) for j in range(rs)) for i in range(rs)) or 1.0
        for i in range(rs):
            for j in range(rs):
                W[i][j] *= spectral_radius / max_row
        W_in = [[rng.gauss(0, 0.5) for _ in range(3)] for _ in range(rs)]  # 3 inputs: val, x, y

        # Run reservoir
        states: list[list[float]] = []
        h = [0.0] * rs
        xs = [c[0] for c in centroids_sorted]
        ys = [c[1] for c in centroids_sorted]
        xmin, xmax = min(xs), max(xs)
        ymin, ymax = min(ys), max(ys)
        xr = xmax - xmin or 1.0
        yr = ymax - ymin or 1.0
        for t in range(n):
            inp = [norm_v[t], (xs[t] - xmin) / xr, (ys[t] - ymin) / yr]
            new_h = [0.0] * rs
            for i in range(rs):
                s = sum(W[i][j] * h[j] for j in range(rs))
                s += sum(W_in[i][k] * inp[k] for k in range(3))
                new_h[i] = math.tanh(s)
            h = new_h
            states.append(list(h))
        # Train output: linear regression on states[:-1] -> norm_v[1:]
        # Simple: w_out = (S^T S)^-1 S^T y via pseudo inverse approximation
        # Use ridge regression with manual computation
        if n < 3:
            preds = norm_v
        else:
            # S = states[:-1], y = norm_v[1:]
            m = n - 1
            S = states[:m]
            y_target = norm_v[1:]
            # w = (S^T S + λI)^-1 S^T y — approximate with gradient descent
            w_out = [0.0] * rs
            lr = 0.01
            for _ in range(200):
                for t in range(m):
                    pred = sum(w_out[j] * S[t][j] for j in range(rs))
                    err = pred - y_target[t]
                    for j in range(rs):
                        w_out[j] -= lr * err * S[t][j] / m
            preds_raw = [norm_v[0]]
            for t in range(m):
                preds_raw.append(sum(w_out[j] * S[t][j] for j in range(rs)))
            preds = preds_raw
        out_rows: list[Record] = []
        for t in range(n):
            out_rows.append({
                self.geometry_column: sorted_recs[t][self.geometry_column],
                f"predicted_{suffix}": round(preds[t] * rv + v_min, 6),
                f"actual_{suffix}": vals[t],
                f"time_{suffix}": float(sorted_recs[t].get(time_column, 0) or 0),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 457: spatial_contour_tree
    # ------------------------------------------------------------------
    def spatial_contour_tree(
        self,
        value_column: str,
        *,
        suffix: str = "sct",
    ) -> "GeoPromptFrame":
        """Contour tree analysis identifying ridges and valleys.

        Builds a merge tree from the scalar field by processing features
        in value order and tracking component merges. Each feature is
        assigned its contour tree depth and whether it's a local extremum.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # K-NN adjacency
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            for _, j in dists[:6]:
                adj[i].append(j)
                if i not in adj[j]:
                    adj[j].append(i)
        # Process in ascending order (sub-level set)
        order = sorted(range(n), key=lambda i: vals[i])
        parent = list(range(n))
        tree_depth = [0] * n
        is_minimum = [0] * n
        is_maximum = [0] * n
        is_saddle = [0] * n
        active = [False] * n

        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        for idx in order:
            active[idx] = True
            neighbours_active = [j for j in adj[idx] if active[j]]
            if not neighbours_active:
                is_minimum[idx] = 1
                tree_depth[idx] = 0
            else:
                roots = set(find(j) for j in neighbours_active)
                if len(roots) >= 2:
                    is_saddle[idx] = 1
                max_depth = max(tree_depth[find(j)] for j in neighbours_active)
                tree_depth[idx] = max_depth + (1 if len(roots) >= 2 else 0)
                for r in roots:
                    parent[r] = idx
        # Maxima: features with no higher active neighbour
        for i in range(n):
            higher = [j for j in adj[i] if vals[j] > vals[i]]
            if not higher:
                is_maximum[i] = 1
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"tree_depth_{suffix}": tree_depth[i],
                f"is_minimum_{suffix}": is_minimum[i],
                f"is_maximum_{suffix}": is_maximum[i],
                f"is_saddle_{suffix}": is_saddle[i],
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 458: multi_scale_geographically_weighted
    # ------------------------------------------------------------------
    def multi_scale_geographically_weighted(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        bandwidths: list[float] | None = None,
        suffix: str = "mgw",
    ) -> "GeoPromptFrame":
        """Multi-scale geographically weighted regression (MGWR).

        Each feature gets its own optimally-selected bandwidth, allowing
        spatial non-stationarity to vary by variable. Novel pure-Python
        implementation with automatic bandwidth selection via back-fitting.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        # Default bandwidths: geometric sequence
        if bandwidths is None:
            extent = max(self.bounds().max_x - self.bounds().min_x,
                         self.bounds().max_y - self.bounds().min_y, 1e-6)
            bandwidths = [extent * (0.2 + 0.2 * f) for f in range(n_feat)]
        bandwidths = bandwidths[:n_feat]
        while len(bandwidths) < n_feat:
            bandwidths.append(bandwidths[-1] if bandwidths else 1.0)
        # Local regression per feature
        coefficients = [[0.0] * n_feat for _ in range(n)]
        intercepts = [0.0] * n
        predictions = [0.0] * n
        for i in range(n):
            # Weighted least squares with per-feature bandwidth
            # Build weight for this observation
            w = [0.0] * n
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                # Use average band for observation-level weight
                avg_bw = sum(bandwidths) / n_feat
                w[j] = gaussian_kernel(d, max(avg_bw, 1e-12))
            # Solve WLS: minimise sum w_j * (y_j - b0 - sum(b_f * x_jf))^2
            # Normal equations via gradient descent
            betas = [0.0] * (n_feat + 1)  # [intercept, b1, ..., bn_feat]
            lr = 0.001
            for _ in range(100):
                for j in range(n):
                    pred = betas[0] + sum(betas[f + 1] * raw[j][f] for f in range(n_feat))
                    err = w[j] * (pred - targets[j])
                    betas[0] -= lr * err
                    for f in range(n_feat):
                        betas[f + 1] -= lr * err * raw[j][f]
            intercepts[i] = betas[0]
            for f in range(n_feat):
                coefficients[i][f] = betas[f + 1]
            predictions[i] = betas[0] + sum(betas[f + 1] * raw[i][f] for f in range(n_feat))
        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(predictions[i], 6),
                f"residual_{suffix}": round(targets[i] - predictions[i], 6),
                f"intercept_{suffix}": round(intercepts[i], 6),
            }
            for f in range(n_feat):
                rec[f"coef_{feature_columns[f]}_{suffix}"] = round(coefficients[i][f], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 459: spatial_markov_chain
    # ------------------------------------------------------------------
    def spatial_markov_chain(
        self,
        state_column: str,
        *,
        n_steps: int = 10,
        k_neighbors: int = 5,
        seed: int | None = None,
        suffix: str = "smc",
    ) -> "GeoPromptFrame":
        """Spatial Markov chain: transition probabilities conditioned on neighbours.

        Builds a spatial transition matrix where the probability of a feature's
        state change depends on its neighbours' current states. Simulates
        state evolution forward n_steps.
        """
        self._require_column(state_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        states = [str(r.get(state_column, "")) for r in recs]
        unique_states = sorted(set(states))
        state_map = {s: i for i, s in enumerate(unique_states)}
        ns = len(unique_states)
        current = [state_map[s] for s in states]
        # Build k-NN
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([j for _, j in dists[:k_neighbors]])
        # Estimate transition matrix from spatial adjacency
        trans = [[1.0 / ns] * ns for _ in range(ns)]
        for i in range(n):
            for j in neighbours[i]:
                trans[current[i]][current[j]] += 1.0
        for s in range(ns):
            total = sum(trans[s])
            if total > 0:
                trans[s] = [t / total for t in trans[s]]
        # Simulate
        for _ in range(n_steps):
            new_state = list(current)
            for i in range(n):
                # Transition prob conditioned on neighbour distribution
                nb_dist = [0.0] * ns
                for j in neighbours[i]:
                    nb_dist[current[j]] += 1.0 / len(neighbours[i])
                # Blend own transition probability with neighbour influence
                probs = [0.0] * ns
                for s in range(ns):
                    probs[s] = 0.5 * trans[current[i]][s] + 0.5 * nb_dist[s]
                r = rng.random()
                cum = 0.0
                new_s = current[i]
                for s in range(ns):
                    cum += probs[s]
                    if cum >= r:
                        new_s = s
                        break
                new_state[i] = new_s
            current = new_state
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"initial_state_{suffix}": states[i],
                f"final_state_{suffix}": unique_states[current[i]],
                f"changed_{suffix}": 1 if states[i] != unique_states[current[i]] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 460: spatial_homology_betti
    # ------------------------------------------------------------------
    def spatial_homology_betti(
        self,
        *,
        epsilon: float | None = None,
        suffix: str = "shb",
    ) -> "GeoPromptFrame":
        """Compute Betti numbers (β0, β1) of the spatial Rips complex.

        β0 = number of connected components, β1 = number of 1-cycles (holes)
        in the proximity graph at distance threshold epsilon. Novel: if
        epsilon is None, auto-selects the scale that maximises β1.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        # Compute all pairwise distances
        all_dists: list[float] = []
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                all_dists.append(d)
        if not all_dists:
            out_rows: list[Record] = [{
                self.geometry_column: recs[0][self.geometry_column],
                f"beta0_{suffix}": n, f"beta1_{suffix}": 0,
                f"epsilon_{suffix}": 0.0,
            }]
            return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)
        all_dists.sort()
        if epsilon is not None:
            epsilons = [epsilon]
        else:
            # Try multiple scales
            epsilons = [all_dists[int(len(all_dists) * q)] for q in
                        [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5]]
            epsilons = sorted(set(epsilons))

        def _compute_betti(eps: float) -> tuple[int, int]:
            # Build adjacency
            edges: list[tuple[int, int]] = []
            adj_set: list[set[int]] = [set() for _ in range(n)]
            for i in range(n):
                for j in range(i + 1, n):
                    d = math.hypot(centroids[i][0] - centroids[j][0],
                                   centroids[i][1] - centroids[j][1])
                    if d <= eps:
                        edges.append((i, j))
                        adj_set[i].add(j)
                        adj_set[j].add(i)
            # β0 via union-find
            p = list(range(n))
            def find(x: int) -> int:
                while p[x] != x:
                    p[x] = p[p[x]]
                    x = p[x]
                return x
            for a, b in edges:
                ra, rb = find(a), find(b)
                if ra != rb:
                    p[ra] = rb
            beta0 = len(set(find(i) for i in range(n)))
            # β1 = edges - vertices + β0 (Euler characteristic for 1-complex)
            # Count triangles to correct
            triangles = 0
            for i in range(n):
                for j in adj_set[i]:
                    if j > i:
                        for k in adj_set[j]:
                            if k > j and k in adj_set[i]:
                                triangles += 1
            # For simplicial complex: β1 = |E| - |V| + β0 - |T| (... approximation)
            beta1 = max(0, len(edges) - n + beta0)
            return beta0, beta1

        best_b1 = -1
        best_eps = epsilons[0]
        best_b0 = n
        for eps in epsilons:
            b0, b1 = _compute_betti(eps)
            if b1 > best_b1:
                best_b1 = b1
                best_eps = eps
                best_b0 = b0
        if epsilon is not None:
            best_b0, best_b1 = _compute_betti(epsilon)
            best_eps = epsilon
        out_rows_final: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [
                (self.bounds().min_x + self.bounds().max_x) / 2,
                (self.bounds().min_y + self.bounds().max_y) / 2]},
            f"beta0_{suffix}": best_b0,
            f"beta1_{suffix}": best_b1,
            f"epsilon_{suffix}": round(best_eps, 6),
            f"n_points_{suffix}": n,
        }]
        return GeoPromptFrame._from_internal_rows(out_rows_final, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 461: spatial_potential_field
    # ------------------------------------------------------------------
    def spatial_potential_field(
        self,
        value_column: str,
        *,
        grid_resolution: int = 20,
        suffix: str = "spf",
    ) -> "GeoPromptFrame":
        """Spatial potential field with gradient vectors.

        Models features as charged particles with charge proportional to value.
        Computes the electric-potential-like field and its gradient on a grid.
        Gradient vectors indicate the direction toward high-value clusters.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()
        gr = grid_resolution
        dxg = (b.max_x - b.min_x) / max(gr - 1, 1)
        dyg = (b.max_y - b.min_y) / max(gr - 1, 1)
        out_rows: list[Record] = []
        for gy in range(gr):
            py = b.min_y + gy * dyg
            for gx in range(gr):
                px = b.min_x + gx * dxg
                potential = 0.0
                grad_x, grad_y = 0.0, 0.0
                for i in range(n):
                    dx_val = px - centroids[i][0]
                    dy_val = py - centroids[i][1]
                    d = math.hypot(dx_val, dy_val)
                    if d < 1e-12:
                        d = 1e-12
                    potential += vals[i] / d
                    # Gradient: d(V)/dx = -q * (x - xi) / r^3
                    grad_x += -vals[i] * dx_val / (d ** 3)
                    grad_y += -vals[i] * dy_val / (d ** 3)
                magnitude = math.hypot(grad_x, grad_y)
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"potential_{suffix}": round(potential, 6),
                    f"grad_x_{suffix}": round(grad_x, 6),
                    f"grad_y_{suffix}": round(grad_y, 6),
                    f"grad_magnitude_{suffix}": round(magnitude, 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 462: particle_swarm_cluster
    # ------------------------------------------------------------------
    def particle_swarm_cluster(
        self,
        value_column: str,
        *,
        n_clusters: int = 3,
        n_particles: int = 10,
        n_iterations: int = 50,
        seed: int | None = None,
        suffix: str = "psc",
    ) -> "GeoPromptFrame":
        """Particle Swarm Optimisation for spatial clustering.

        PSO particles represent cluster centre positions. The objective
        minimises total intra-cluster distance weighted by values. Novel:
        outperforms k-means on non-convex clusters due to global search.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()
        k = min(n_clusters, n)

        def _objective(centres: list[list[float]]) -> float:
            total = 0.0
            for i in range(n):
                min_d = float("inf")
                for c in centres:
                    d = math.hypot(centroids[i][0] - c[0], centroids[i][1] - c[1])
                    if d < min_d:
                        min_d = d
                total += min_d
            return total

        # Init particles
        particles = [[[rng.uniform(b.min_x, b.max_x), rng.uniform(b.min_y, b.max_y)]
                      for _ in range(k)] for _ in range(n_particles)]
        velocities = [[[rng.gauss(0, 0.1), rng.gauss(0, 0.1)]
                       for _ in range(k)] for _ in range(n_particles)]
        p_best = [list(p) for p in particles]
        p_best_score = [_objective(p) for p in particles]
        g_best_idx = min(range(n_particles), key=lambda i: p_best_score[i])
        g_best = [list(c) for c in p_best[g_best_idx]]
        g_best_score = p_best_score[g_best_idx]
        w, c1, c2 = 0.7, 1.5, 1.5
        for _ in range(n_iterations):
            for p in range(n_particles):
                for ci in range(k):
                    for dim in range(2):
                        r1, r2 = rng.random(), rng.random()
                        velocities[p][ci][dim] = (
                            w * velocities[p][ci][dim] +
                            c1 * r1 * (p_best[p][ci][dim] - particles[p][ci][dim]) +
                            c2 * r2 * (g_best[ci][dim] - particles[p][ci][dim]))
                        particles[p][ci][dim] += velocities[p][ci][dim]
                score = _objective(particles[p])
                if score < p_best_score[p]:
                    p_best_score[p] = score
                    p_best[p] = [list(c) for c in particles[p]]
                if score < g_best_score:
                    g_best_score = score
                    g_best = [list(c) for c in particles[p]]
        # Assign clusters
        out_rows: list[Record] = []
        for i in range(n):
            best_c, best_d = 0, float("inf")
            for ci in range(k):
                d = math.hypot(centroids[i][0] - g_best[ci][0],
                               centroids[i][1] - g_best[ci][1])
                if d < best_d:
                    best_d = d
                    best_c = ci
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": best_c,
                f"distance_{suffix}": round(best_d, 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 463: spatial_elastic_net
    # ------------------------------------------------------------------
    def spatial_elastic_net(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        alpha: float = 1.0,
        l1_ratio: float = 0.5,
        epochs: int = 200,
        learning_rate: float = 0.01,
        suffix: str = "sen",
    ) -> "GeoPromptFrame":
        """Elastic Net regression with coordinate descent.

        Combines L1 (Lasso) and L2 (Ridge) penalties. Pure-Python implementation
        using proximal gradient descent with the soft-threshold operator.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        # Standardise features so the elastic-net penalties act comparably.
        col_mean = [sum(raw[i][f] for i in range(n)) / n for f in range(n_feat)]
        col_scale = [math.sqrt(sum((raw[i][f] - col_mean[f]) ** 2 for i in range(n)) / n) or 1.0 for f in range(n_feat)]
        data = [[(raw[i][f] - col_mean[f]) / col_scale[f] for f in range(n_feat)] for i in range(n)]
        t_mean = sum(targets) / n
        target_c = [t - t_mean for t in targets]
        w = [0.0] * n_feat
        residual = list(target_c)
        l1 = alpha * l1_ratio
        l2 = alpha * (1 - l1_ratio)
        feature_energy = [sum(data[i][f] * data[i][f] for i in range(n)) / n or 1.0 for f in range(n_feat)]
        for _ in range(epochs):
            max_delta = 0.0
            for f in range(n_feat):
                prev_w = w[f]
                rho = sum(data[i][f] * (residual[i] + data[i][f] * prev_w) for i in range(n)) / n
                if rho > l1:
                    new_w = (rho - l1) / (feature_energy[f] + l2)
                elif rho < -l1:
                    new_w = (rho + l1) / (feature_energy[f] + l2)
                else:
                    new_w = 0.0
                delta = new_w - prev_w
                if delta:
                    w[f] = new_w
                    for i in range(n):
                        residual[i] -= data[i][f] * delta
                    max_delta = max(max_delta, abs(delta))
            if max_delta < learning_rate * 1e-3:
                break
        coef = [w[f] / col_scale[f] for f in range(n_feat)]
        intercept = t_mean - sum(coef[f] * col_mean[f] for f in range(n_feat))
        out_rows: list[Record] = []
        for i in range(n):
            pred = intercept + sum(coef[f] * raw[i][f] for f in range(n_feat))
            rec: Record = {
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(pred, 6),
                f"residual_{suffix}": round(targets[i] - pred, 6),
            }
            for f in range(n_feat):
                rec[f"coef_{feature_columns[f]}_{suffix}"] = round(coef[f], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 464: spatial_dbscan_clustering
    # ------------------------------------------------------------------
    def spatial_dbscan_clustering(
        self,
        *,
        epsilon: float | None = None,
        min_points: int = 3,
        suffix: str = "dbs",
    ) -> "GeoPromptFrame":
        """DBSCAN density-based spatial clustering.

        Pure-Python implementation of Ester et al. (1996). Identifies
        arbitrarily-shaped clusters and noise points. Auto-selects epsilon
        from the k-distance graph elbow if not specified.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        dm = self._cached_distance_matrix("euclidean")
        # Auto epsilon from k-distance elbow
        if epsilon is None:
            k_dists: list[float] = []
            for i in range(n):
                dists = sorted(dm[i][j] for j in range(n) if j != i)
                k_dists.append(dists[min(min_points - 1, len(dists) - 1)] if dists else 0.0)
            k_dists.sort()
            # Elbow: point of maximum curvature
            if len(k_dists) > 2:
                max_curv, best_idx = 0.0, len(k_dists) // 2
                for qi in range(1, len(k_dists) - 1):
                    curv = k_dists[qi + 1] - 2 * k_dists[qi] + k_dists[qi - 1]
                    if curv > max_curv:
                        max_curv = curv
                        best_idx = qi
                epsilon = k_dists[best_idx]
            else:
                epsilon = k_dists[-1] if k_dists else 1.0
        neighbour_lists = [[j for j in range(n) if j != i and dm[i][j] <= epsilon] for i in range(n)]
        labels = [-1] * n  # -1 = noise
        cluster_id = 0
        visited = [False] * n

        for i in range(n):
            if visited[i]:
                continue
            visited[i] = True
            neighbours = neighbour_lists[i]
            if len(neighbours) + 1 < min_points:
                continue  # noise
            labels[i] = cluster_id
            queue = list(neighbours)
            queued = [False] * n
            for j in queue:
                queued[j] = True
            queue_index = 0
            while queue_index < len(queue):
                j = queue[queue_index]
                queue_index += 1
                if not visited[j]:
                    visited[j] = True
                    nb = neighbour_lists[j]
                    if len(nb) + 1 >= min_points:
                        for candidate in nb:
                            if not queued[candidate]:
                                queue.append(candidate)
                                queued[candidate] = True
                if labels[j] == -1:
                    labels[j] = cluster_id
            cluster_id += 1
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": labels[i],
                f"is_noise_{suffix}": 1 if labels[i] == -1 else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 465: spatial_mean_shift
    # ------------------------------------------------------------------
    def spatial_mean_shift(
        self,
        *,
        bandwidth: float | None = None,
        n_iterations: int = 50,
        suffix: str = "sms",
    ) -> "GeoPromptFrame":
        """Mean shift clustering in geographic space.

        Each feature point iteratively shifts toward the mean of points within
        its bandwidth kernel until convergence. Cluster centres merge when
        they converge to the same location.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        if bandwidth is None:
            extent = max(self.bounds().max_x - self.bounds().min_x,
                         self.bounds().max_y - self.bounds().min_y, 1e-6)
            bandwidth = extent / math.sqrt(max(n, 1))
        points = [list(c) for c in centroids]
        for _ in range(n_iterations):
            new_points = [list(p) for p in points]
            for i in range(n):
                wx, wy, tw = 0.0, 0.0, 0.0
                for j in range(n):
                    d = math.hypot(points[i][0] - centroids[j][0],
                                   points[i][1] - centroids[j][1])
                    w = gaussian_kernel(d, bandwidth)
                    wx += w * centroids[j][0]
                    wy += w * centroids[j][1]
                    tw += w
                if tw > 0:
                    new_points[i] = [wx / tw, wy / tw]
            points = new_points
        # Assign clusters: merge converged points
        labels = list(range(n))
        merge_dist = bandwidth * 0.1
        for i in range(n):
            for j in range(i):
                if math.hypot(points[i][0] - points[j][0],
                              points[i][1] - points[j][1]) < merge_dist:
                    labels[i] = labels[j]
                    break
        # Renumber clusters
        unique = sorted(set(labels), key=str)
        remap = {v: i for i, v in enumerate(unique)}
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": remap[labels[i]],
                f"shift_x_{suffix}": round(points[i][0] - centroids[i][0], 6),
                f"shift_y_{suffix}": round(points[i][1] - centroids[i][1], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 466: spatial_gradient_field
    # ------------------------------------------------------------------
    def spatial_gradient_field(
        self,
        value_column: str,
        *,
        k_neighbors: int = 5,
        suffix: str = "sgf",
    ) -> "GeoPromptFrame":
        """Compute spatial gradient (slope + aspect) for a scalar field at each feature.

        Uses local least-squares plane fitting on k nearest neighbours to
        estimate dz/dx and dz/dy, then derives slope magnitude and aspect angle.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            # k-NN
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            nb = [j for _, j in dists[:k_neighbors]]
            # Fit plane: z = a*dx + b*dy + c  via normal equations
            # Using deviations from point i
            sxx, syy, sxy, sxz, syz = 0.0, 0.0, 0.0, 0.0, 0.0
            for j in nb:
                dx_val = centroids[j][0] - centroids[i][0]
                dy_val = centroids[j][1] - centroids[i][1]
                dz = vals[j] - vals[i]
                sxx += dx_val * dx_val
                syy += dy_val * dy_val
                sxy += dx_val * dy_val
                sxz += dx_val * dz
                syz += dy_val * dz
            det = sxx * syy - sxy * sxy
            if abs(det) > 1e-12:
                dz_dx = (syy * sxz - sxy * syz) / det
                dz_dy = (sxx * syz - sxy * sxz) / det
            else:
                dz_dx, dz_dy = 0.0, 0.0
            slope = math.hypot(dz_dx, dz_dy)
            aspect = math.degrees(math.atan2(dz_dy, dz_dx)) % 360
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"slope_{suffix}": round(slope, 6),
                f"aspect_{suffix}": round(aspect, 6),
                f"dz_dx_{suffix}": round(dz_dx, 6),
                f"dz_dy_{suffix}": round(dz_dy, 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 467: spatial_hdbscan
    # ------------------------------------------------------------------
    def spatial_hdbscan(
        self,
        *,
        min_cluster_size: int = 3,
        min_samples: int | None = None,
        suffix: str = "hdb",
    ) -> "GeoPromptFrame":
        """Hierarchical DBSCAN for variable-density spatial clustering.

        Pure-Python simplified HDBSCAN: builds mutual reachability graph,
        constructs MST, then extracts stable clusters at varying density
        levels. Outperforms flat DBSCAN on clusters of different densities.
        """
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        if min_samples is None:
            min_samples = min_cluster_size
        dm = self._cached_distance_matrix("euclidean")
        # Core distances
        core_dists: list[float] = []
        for i in range(n):
            dists = sorted(dm[i][j] for j in range(n) if j != i)
            core_dists.append(dists[min(min_samples - 1, len(dists) - 1)] if dists else 0.0)
        # Mutual reachability distance
        def _mrd(i: int, j: int) -> float:
            return max(core_dists[i], core_dists[j], dm[i][j])
        # Prim's MST on mutual reachability
        in_mst = [False] * n
        key = [float("inf")] * n
        parent = [-1] * n
        key[0] = 0.0
        mst_edges: list[tuple[float, int, int]] = []
        for _ in range(n):
            u = -1
            for i in range(n):
                if not in_mst[i] and (u == -1 or key[i] < key[u]):
                    u = i
            if u == -1:
                break
            in_mst[u] = True
            if parent[u] >= 0:
                mst_edges.append((key[u], parent[u], u))
            for v in range(n):
                if not in_mst[v]:
                    d = _mrd(u, v)
                    if d < key[v]:
                        key[v] = d
                        parent[v] = u
        mst_edges.sort()
        if not mst_edges:
            final_labels = [0] * n if n >= min_cluster_size else [-1] * n
        else:
            max_edge = mst_edges[-1][0] or 1.0

            def _labels_for_threshold(threshold: float) -> tuple[list[int], list[int]]:
                uf_parent = list(range(n))
                uf_size = [1] * n

                def find(node: int) -> int:
                    while uf_parent[node] != node:
                        uf_parent[node] = uf_parent[uf_parent[node]]
                        node = uf_parent[node]
                    return node

                for w, left, right in mst_edges:
                    if w > threshold:
                        break
                    root_left = find(left)
                    root_right = find(right)
                    if root_left != root_right:
                        if uf_size[root_left] < uf_size[root_right]:
                            root_left, root_right = root_right, root_left
                        uf_parent[root_right] = root_left
                        uf_size[root_left] += uf_size[root_right]

                components: dict[int, list[int]] = {}
                for idx in range(n):
                    components.setdefault(find(idx), []).append(idx)

                labels = [-1] * n
                component_sizes: list[int] = []
                cluster_id = 0
                for members in components.values():
                    if len(members) >= min_cluster_size:
                        component_sizes.append(len(members))
                        for member in members:
                            labels[member] = cluster_id
                        cluster_id += 1
                return labels, component_sizes

            candidate_thresholds = {mst_edges[-1][0]}
            if len(mst_edges) > 1:
                weights = [w for w, _, _ in mst_edges]
                gaps: list[tuple[float, float]] = []
                for idx in range(len(weights) - 1):
                    left = weights[idx]
                    right = weights[idx + 1]
                    threshold = math.sqrt(left * right) if left > 0 and right > 0 else (left + right) / 2.0
                    gaps.append((((right - left) / max(left, 1e-12)), threshold))
                gaps.sort(reverse=True)
                for _, threshold in gaps[: min(5, len(gaps))]:
                    candidate_thresholds.add(threshold)
                candidate_thresholds.add(weights[len(weights) // 3])
                candidate_thresholds.add(weights[(2 * len(weights)) // 3])

            best_score = -float("inf")
            final_labels = [-1] * n
            for threshold in sorted(candidate_thresholds):
                labels, component_sizes = _labels_for_threshold(threshold)
                clustered_points = sum(component_sizes)
                if clustered_points == 0:
                    continue
                separation = 1.0 - threshold / max(max_edge, 1e-12)
                noise_points = n - clustered_points
                score = clustered_points * (0.4 + 0.6 * separation) - 0.25 * noise_points
                if score > best_score:
                    best_score = score
                    final_labels = labels

            if max(final_labels, default=-1) < 0 and n >= min_cluster_size:
                final_labels = [0] * n
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": final_labels[i],
                f"core_distance_{suffix}": round(core_dists[i], 6),
                f"is_noise_{suffix}": 1 if final_labels[i] == -1 else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 468: spatial_copula_dependence
    # ------------------------------------------------------------------
    def spatial_copula_dependence(
        self,
        column_a: str,
        column_b: str,
        *,
        suffix: str = "scp",
    ) -> "GeoPromptFrame":
        """Spatial copula dependence analysis between two variables.

        Computes rank-based Kendall's tau, Spearman's rho, and a spatial
        concordance measure weighted by proximity. Identifies whether nearby
        features have stronger or weaker dependence than global average.
        """
        self._require_column(column_a)
        self._require_column(column_b)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        a_vals = [float(r.get(column_a, 0) or 0) for r in recs]
        b_vals = [float(r.get(column_b, 0) or 0) for r in recs]
        # Ranks
        def _rank(arr: list[float]) -> list[float]:
            indexed = sorted(range(len(arr)), key=lambda i: arr[i])
            ranks = [0.0] * len(arr)
            for r, i in enumerate(indexed):
                ranks[i] = r + 1.0
            return ranks
        rank_a = _rank(a_vals)
        rank_b = _rank(b_vals)
        # Global Spearman
        d_sq = sum((rank_a[i] - rank_b[i]) ** 2 for i in range(n))
        global_rho = 1 - 6 * d_sq / (n * (n ** 2 - 1)) if n > 1 else 0.0
        # Per-feature local concordance
        extent = max(self.bounds().max_x - self.bounds().min_x,
                     self.bounds().max_y - self.bounds().min_y, 1e-6)
        bw = extent / math.sqrt(max(n, 1))
        out_rows: list[Record] = []
        for i in range(n):
            # Local weighted concordance
            conc, disc, tw = 0.0, 0.0, 0.0
            for j in range(n):
                if j == i:
                    continue
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                w = gaussian_kernel(d, bw)
                sign_a = 1.0 if a_vals[j] > a_vals[i] else (-1.0 if a_vals[j] < a_vals[i] else 0.0)
                sign_b = 1.0 if b_vals[j] > b_vals[i] else (-1.0 if b_vals[j] < b_vals[i] else 0.0)
                if sign_a * sign_b > 0:
                    conc += w
                elif sign_a * sign_b < 0:
                    disc += w
                tw += w
            local_tau = (conc - disc) / tw if tw > 0 else 0.0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"local_tau_{suffix}": round(local_tau, 6),
                f"global_rho_{suffix}": round(global_rho, 6),
                f"dep_ratio_{suffix}": round(local_tau / max(abs(global_rho), 1e-12), 6),
                f"a_val_{suffix}": a_vals[i],
                f"b_val_{suffix}": b_vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 469: spatial_loess_regression
    # ------------------------------------------------------------------
    def spatial_loess_regression(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        span: float = 0.5,
        degree: int = 1,
        suffix: str = "lss",
    ) -> "GeoPromptFrame":
        """LOESS (locally estimated scatterplot smoothing) spatial regression.

        Fits local weighted polynomials at each feature location using a
        tricube distance kernel. span controls the fraction of data used.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        k = max(2, int(span * n))
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            # Find k nearest, compute tricube weights
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n)]
            dists.sort()
            nb = dists[:k]
            max_d = nb[-1][0] if nb else 1.0
            if max_d < 1e-12:
                max_d = 1e-12
            weights: list[float] = []
            indices: list[int] = []
            for d, j in nb:
                u = d / max_d
                w = (1 - u ** 3) ** 3 if u < 1 else 0.0
                weights.append(w)
                indices.append(j)
            # Weighted regression (degree=1: linear)
            if degree >= 1:
                # WLS via gradient descent
                n_feat = len(feature_columns)
                betas = [0.0] * (n_feat + 1)
                lr = 0.001
                for _ in range(100):
                    for wi, j in zip(weights, indices):
                        pred = betas[0] + sum(betas[f + 1] * raw[j][f] for f in range(n_feat))
                        err = wi * (pred - targets[j])
                        betas[0] -= lr * err
                        for f in range(n_feat):
                            betas[f + 1] -= lr * err * raw[j][f]
                prediction = betas[0] + sum(betas[f + 1] * raw[i][f] for f in range(n_feat))
            else:
                # degree=0: weighted mean
                tw = sum(weights)
                prediction = sum(weights[q] * targets[indices[q]] for q in range(len(indices))) / tw if tw > 0 else 0.0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(prediction, 6),
                f"residual_{suffix}": round(targets[i] - prediction, 6),
                f"value_{suffix}": targets[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 470: spatial_flow_accumulation
    # ------------------------------------------------------------------
    def spatial_flow_accumulation(
        self,
        elevation_column: str,
        *,
        suffix: str = "sfa",
    ) -> "GeoPromptFrame":
        """Flow accumulation on a spatial point network.

        Computes drainage direction and flow accumulation by routing flow
        from each feature to its lowest neighbour in a k-NN graph.
        Features that accumulate many upstream flows are valleys/sinks.
        """
        self._require_column(elevation_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        elev = [float(r.get(elevation_column, 0) or 0) for r in recs]
        # K-NN
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            adj[i] = [j for _, j in dists[:8]]
        # Flow direction: steepest descent neighbour
        flow_to: list[int] = []
        for i in range(n):
            best_j, best_drop = i, 0.0
            for j in adj[i]:
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                drop = (elev[i] - elev[j]) / max(d, 1e-12)
                if drop > best_drop:
                    best_drop = drop
                    best_j = j
            flow_to.append(best_j)
        # Accumulation: topological sort from peaks
        accumulation = [1.0] * n
        order = sorted(range(n), key=lambda i: -elev[i])
        for i in order:
            if flow_to[i] != i:
                accumulation[flow_to[i]] += accumulation[i]
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"accumulation_{suffix}": round(accumulation[i], 6),
                f"flow_to_{suffix}": flow_to[i],
                f"elevation_{suffix}": elev[i],
                f"is_sink_{suffix}": 1 if flow_to[i] == i else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 471: spatial_cross_k_function
    # ------------------------------------------------------------------
    def spatial_cross_k_function(
        self,
        label_column: str,
        label_a: str,
        label_b: str,
        *,
        n_bins: int = 20,
        suffix: str = "ckf",
    ) -> "GeoPromptFrame":
        """Bivariate (cross) Ripley's K-function for spatial interaction.

        Measures whether two types of features attract, repel, or are
        independent at various distance scales. K_ab(r) > pi*r^2 indicates
        attraction; K_ab(r) < pi*r^2 indicates repulsion.
        """
        self._require_column(label_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        labels = [str(r.get(label_column, "")) for r in recs]
        idx_a = [i for i in range(n) if labels[i] == label_a]
        idx_b = [i for i in range(n) if labels[i] == label_b]
        n_a, n_b = len(idx_a), len(idx_b)
        b = self.bounds()
        area = max((b.max_x - b.min_x) * (b.max_y - b.min_y), 1e-12)
        max_dist = math.hypot(b.max_x - b.min_x, b.max_y - b.min_y)
        bin_width = max_dist / n_bins
        out_rows: list[Record] = []
        for bi in range(n_bins):
            r_val = (bi + 1) * bin_width
            count = 0
            for i in idx_a:
                for j in idx_b:
                    d = math.hypot(centroids[i][0] - centroids[j][0],
                                   centroids[i][1] - centroids[j][1])
                    if d <= r_val:
                        count += 1
            k_val = area * count / max(n_a * n_b, 1) if n_a > 0 and n_b > 0 else 0.0
            l_val = math.sqrt(k_val / math.pi) - r_val if k_val > 0 else 0.0
            csr = math.pi * r_val ** 2
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": [
                    (b.min_x + b.max_x) / 2, (b.min_y + b.max_y) / 2]},
                f"distance_{suffix}": round(r_val, 6),
                f"k_cross_{suffix}": round(k_val, 6),
                f"l_cross_{suffix}": round(l_val, 6),
                f"csr_{suffix}": round(csr, 6),
                f"interaction_{suffix}": "attraction" if k_val > csr else "repulsion",
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 472: harmonic_regression_spatial
    # ------------------------------------------------------------------
    def harmonic_regression_spatial(
        self,
        value_column: str,
        time_column: str,
        *,
        n_harmonics: int = 3,
        suffix: str = "hrs",
    ) -> "GeoPromptFrame":
        """Harmonic (Fourier) regression for spatiotemporal periodic signals.

        Fits value = a0 + sum(a_k*cos(k*omega*t) + b_k*sin(k*omega*t))
        at each feature. The fundamental frequency is estimated from the
        time range. Returns fitted values and harmonic coefficients.
        """
        self._require_column(value_column)
        self._require_column(time_column)
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        times = [float(r.get(time_column, 0) or 0) for r in recs]
        t_min, t_max = min(times), max(times)
        period = t_max - t_min or 1.0
        omega = 2 * math.pi / period
        # Build design matrix and solve via gradient descent
        n_params = 1 + 2 * n_harmonics
        betas = [0.0] * n_params
        lr = 0.001
        for _ in range(200):
            for i in range(n):
                t = times[i] - t_min
                features = [1.0]
                for h in range(1, n_harmonics + 1):
                    features.append(math.cos(h * omega * t))
                    features.append(math.sin(h * omega * t))
                pred = sum(betas[j] * features[j] for j in range(n_params))
                err = pred - vals[i]
                for j in range(n_params):
                    betas[j] -= lr * err * features[j] / n
        out_rows: list[Record] = []
        for i in range(n):
            t = times[i] - t_min
            features = [1.0]
            for h in range(1, n_harmonics + 1):
                features.append(math.cos(h * omega * t))
                features.append(math.sin(h * omega * t))
            pred = sum(betas[j] * features[j] for j in range(n_params))
            rec: Record = {
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(pred, 6),
                f"residual_{suffix}": round(vals[i] - pred, 6),
                f"actual_{suffix}": vals[i],
                f"time_{suffix}": times[i],
            }
            for h in range(n_harmonics):
                rec[f"cos_coef_{h + 1}_{suffix}"] = round(betas[1 + 2 * h], 6)
                rec[f"sin_coef_{h + 1}_{suffix}"] = round(betas[2 + 2 * h], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 473: spatial_bootstrap_confidence
    # ------------------------------------------------------------------
    def spatial_bootstrap_confidence(
        self,
        value_column: str,
        *,
        n_bootstrap: int = 100,
        confidence: float = 0.95,
        seed: int | None = None,
        suffix: str = "sbc",
    ) -> "GeoPromptFrame":
        """Spatial bootstrap confidence intervals.

        Resamples feature neighbourhoods to compute bootstrap confidence
        intervals for the local mean at each feature location. Captures
        spatial uncertainty by resampling from distance-weighted neighbours.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        k = max(3, int(math.sqrt(n)))
        out_rows: list[Record] = []
        alpha_half = (1 - confidence) / 2
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n)]
            dists.sort()
            nb = [j for _, j in dists[:k]]
            nb_vals = [vals[j] for j in nb]
            means: list[float] = []
            for _ in range(n_bootstrap):
                sample = [rng.choice(nb_vals) for _ in range(len(nb_vals))]
                means.append(sum(sample) / len(sample))
            means.sort()
            lo = means[max(0, int(alpha_half * n_bootstrap))]
            hi = means[min(n_bootstrap - 1, int((1 - alpha_half) * n_bootstrap))]
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"mean_{suffix}": round(sum(nb_vals) / len(nb_vals), 6),
                f"ci_lower_{suffix}": round(lo, 6),
                f"ci_upper_{suffix}": round(hi, 6),
                f"ci_width_{suffix}": round(hi - lo, 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 474: spatial_variogram_cloud
    # ------------------------------------------------------------------
    def spatial_variogram_cloud(
        self,
        value_column: str,
        *,
        n_lags: int = 15,
        suffix: str = "svc",
    ) -> "GeoPromptFrame":
        """Empirical variogram for spatial autocorrelation analysis.

        Computes the semivariogram gamma(h) = 0.5 * E[(Z(x) - Z(x+h))^2]
        at multiple lag distances. Essential for kriging parameterisation.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        max_dist = 0.0
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                if d > max_dist:
                    max_dist = d
        lag_width = max_dist / (2 * n_lags) if max_dist > 0 else 1.0
        out_rows: list[Record] = []
        for lag in range(n_lags):
            lo = lag * lag_width
            hi = (lag + 1) * lag_width
            mid = (lo + hi) / 2
            sv_sum, count = 0.0, 0
            for i in range(n):
                for j in range(i + 1, n):
                    d = math.hypot(centroids[i][0] - centroids[j][0],
                                   centroids[i][1] - centroids[j][1])
                    if lo <= d < hi:
                        sv_sum += (vals[i] - vals[j]) ** 2
                        count += 1
            gamma = 0.5 * sv_sum / count if count > 0 else 0.0
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": [
                    (self.bounds().min_x + self.bounds().max_x) / 2,
                    (self.bounds().min_y + self.bounds().max_y) / 2]},
                f"lag_distance_{suffix}": round(mid, 6),
                f"semivariance_{suffix}": round(gamma, 6),
                f"n_pairs_{suffix}": count,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 475: spatial_permutation_test
    # ------------------------------------------------------------------
    def spatial_permutation_test(
        self,
        value_column: str,
        *,
        n_permutations: int = 199,
        k_neighbors: int = 5,
        seed: int | None = None,
        suffix: str = "spt",
    ) -> "GeoPromptFrame":
        """Spatial permutation test for local autocorrelation significance.

        Tests whether each feature's value is significantly different from
        what would be expected under spatial randomness by permuting values
        across locations and computing a Monte Carlo p-value.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # K-NN
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([j for _, j in dists[:k_neighbors]])
        # Observed local mean
        obs_lag = [sum(vals[j] for j in neighbours[i]) / len(neighbours[i]) if neighbours[i] else 0.0
                   for i in range(n)]
        # Permutation test
        p_values: list[float] = []
        for i in range(n):
            obs = obs_lag[i]
            count_extreme = 0
            for _ in range(n_permutations):
                perm = rng.sample(range(n), len(neighbours[i]))
                perm_lag = sum(vals[j] for j in perm) / len(perm)
                if perm_lag >= obs:
                    count_extreme += 1
            p_val = (count_extreme + 1) / (n_permutations + 1)
            p_values.append(p_val)
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"p_value_{suffix}": round(p_values[i], 6),
                f"significant_{suffix}": 1 if p_values[i] < 0.05 else 0,
                f"local_lag_{suffix}": round(obs_lag[i], 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 476: spatial_random_walk_diffusion
    # ------------------------------------------------------------------
    def spatial_random_walk_diffusion(
        self,
        value_column: str,
        *,
        n_walkers: int = 100,
        n_steps: int = 20,
        k_neighbors: int = 5,
        seed: int | None = None,
        suffix: str = "rwd",
    ) -> "GeoPromptFrame":
        """Random walk diffusion for spatial accessibility scoring.

        Launches random walkers from each feature that propagate through
        the k-NN spatial graph. Features visited by many walkers from
        diverse origins are highly accessible spatial hubs.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        # K-NN
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([j for _, j in dists[:k_neighbors]])
        visit_count = [0] * n
        unique_visitors = [set() for _ in range(n)]
        for origin in range(n):
            for _ in range(n_walkers):
                pos = origin
                for _ in range(n_steps):
                    if neighbours[pos]:
                        pos = rng.choice(neighbours[pos])
                    visit_count[pos] += 1
                    unique_visitors[pos].add(origin)
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"visit_count_{suffix}": visit_count[i],
                f"unique_origins_{suffix}": len(unique_visitors[i]),
                f"accessibility_{suffix}": round(len(unique_visitors[i]) / max(n, 1), 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 477: adaptive_spatial_scan_statistic
    # ------------------------------------------------------------------
    def adaptive_spatial_scan_statistic(
        self,
        value_column: str,
        *,
        max_radius_fraction: float = 0.5,
        n_radius_steps: int = 10,
        suffix: str = "sss",
    ) -> "GeoPromptFrame":
        """Kulldorff spatial scan statistic for cluster detection.

        Scans circular windows centred at each feature, testing whether
        the observed count inside is significantly higher than expected
        under spatial randomness (Poisson likelihood ratio).
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        total = sum(vals)
        if total == 0:
            total = 1e-12
        extent = max(self.bounds().max_x - self.bounds().min_x,
                     self.bounds().max_y - self.bounds().min_y, 1e-6)
        max_radius = extent * max_radius_fraction
        radii = [max_radius * (s + 1) / n_radius_steps for s in range(n_radius_steps)]
        best_llr = [0.0] * n
        best_radius = [0.0] * n
        best_count = [0] * n
        for i in range(n):
            for r in radii:
                inside = 0.0
                n_in = 0
                for j in range(n):
                    d = math.hypot(centroids[i][0] - centroids[j][0],
                                   centroids[i][1] - centroids[j][1])
                    if d <= r:
                        inside += vals[j]
                        n_in += 1
                outside = total - inside
                e_in = total * n_in / n if n > 0 else 1e-12
                e_out = total - e_in
                # Log likelihood ratio
                llr = 0.0
                if inside > e_in and e_in > 0 and e_out > 0:
                    llr = inside * math.log(inside / e_in) + outside * math.log(max(outside, 1e-12) / e_out)
                if llr > best_llr[i]:
                    best_llr[i] = llr
                    best_radius[i] = r
                    best_count[i] = n_in
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"scan_llr_{suffix}": round(best_llr[i], 6),
                f"scan_radius_{suffix}": round(best_radius[i], 6),
                f"n_inside_{suffix}": best_count[i],
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 478: spatial_silhouette_score
    # ------------------------------------------------------------------
    def spatial_silhouette_score(
        self,
        cluster_column: str,
        *,
        suffix: str = "sil",
    ) -> "GeoPromptFrame":
        """Spatial silhouette score for cluster quality assessment.

        For each feature: a(i) = avg distance to same-cluster members,
        b(i) = min avg distance to any other cluster. Silhouette =
        (b - a) / max(a, b). Range [-1, 1]; higher = better clustering.
        """
        self._require_column(cluster_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        labels = [r.get(cluster_column) for r in recs]
        unique = _distinct_values(labels)
        clusters: dict = {c: [i for i in range(n) if labels[i] == c] for c in unique}
        out_rows: list[Record] = []
        for i in range(n):
            # a(i): average distance to same-cluster
            same = clusters[labels[i]]
            if len(same) <= 1:
                ai = 0.0
            else:
                ai = sum(math.hypot(centroids[i][0] - centroids[j][0],
                                    centroids[i][1] - centroids[j][1])
                         for j in same if j != i) / (len(same) - 1)
            # b(i): minimum average distance to other clusters
            bi = float("inf")
            for c in unique:
                if c == labels[i]:
                    continue
                members = clusters[c]
                if not members:
                    continue
                avg_d = sum(math.hypot(centroids[i][0] - centroids[j][0],
                                       centroids[i][1] - centroids[j][1])
                            for j in members) / len(members)
                if avg_d < bi:
                    bi = avg_d
            if bi == float("inf"):
                bi = 0.0
            sil = (bi - ai) / max(ai, bi) if max(ai, bi) > 0 else 0.0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"silhouette_{suffix}": round(sil, 6),
                f"a_dist_{suffix}": round(ai, 6),
                f"b_dist_{suffix}": round(bi, 6),
                f"cluster_{suffix}": labels[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 479: spatial_curvature_estimator
    # ------------------------------------------------------------------
    def spatial_curvature_estimator(
        self,
        value_column: str,
        *,
        k_neighbors: int = 8,
        suffix: str = "sce",
    ) -> "GeoPromptFrame":
        """Estimate surface curvature (profile + plan) at each feature.

        Fits a local quadratic surface z = ax^2 + bxy + cy^2 + dx + ey + f
        to the k nearest neighbours. Profile curvature measures concavity
        along the steepest slope; plan curvature measures across slopes.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0],
                                 centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            nb = [j for _, j in dists[:k_neighbors]]
            # Fit quadratic via 6-param gradient descent
            params = [0.0] * 6  # a, b, c, d, e, f
            lr = 0.0001
            for _ in range(200):
                for j in nb:
                    dx_val = centroids[j][0] - centroids[i][0]
                    dy_val = centroids[j][1] - centroids[i][1]
                    dz = vals[j] - vals[i]
                    pred = (params[0] * dx_val**2 + params[1] * dx_val * dy_val +
                            params[2] * dy_val**2 + params[3] * dx_val +
                            params[4] * dy_val + params[5])
                    err = pred - dz
                    features = [dx_val**2, dx_val * dy_val, dy_val**2, dx_val, dy_val, 1.0]
                    for f_idx in range(6):
                        params[f_idx] -= lr * err * features[f_idx]
            a, b_param, c_param, d_param, e_param = params[0], params[1], params[2], params[3], params[4]
            # Curvatures
            p = d_param**2 + e_param**2
            if p > 1e-12:
                profile_curv = -2 * (a * d_param**2 + b_param * d_param * e_param + c_param * e_param**2) / (p * math.sqrt(1 + p))
                plan_curv = -2 * (a * e_param**2 - b_param * d_param * e_param + c_param * d_param**2) / (p ** 1.5) if p > 1e-12 else 0.0
            else:
                profile_curv = -2 * a
                plan_curv = -2 * c_param
            mean_curv = (2 * a + 2 * c_param)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"profile_curvature_{suffix}": round(profile_curv, 6),
                f"plan_curvature_{suffix}": round(plan_curv, 6),
                f"mean_curvature_{suffix}": round(mean_curv, 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 480: spatial_causal_inference
    # ------------------------------------------------------------------
    def spatial_causal_inference(
        self,
        treatment_column: str,
        outcome_column: str,
        *,
        k_neighbors: int = 5,
        suffix: str = "sci",
    ) -> "GeoPromptFrame":
        """Spatial causal inference via propensity-score matching.

        Estimates the Average Treatment Effect (ATE) by matching treated and
        control features based on spatial proximity and covariate similarity.
        Each treated feature is matched to its nearest spatial control
        neighbour. Novel: incorporates geographic distance into the matching.
        """
        self._require_column(treatment_column)
        self._require_column(outcome_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        treatment = [1 if r.get(treatment_column) else 0 for r in recs]
        outcome = [float(r.get(outcome_column, 0) or 0) for r in recs]
        treated = [i for i in range(n) if treatment[i] == 1]
        control = [i for i in range(n) if treatment[i] == 0]
        # Spatial propensity matching
        match_outcomes: list[float] = []
        matched_to: list[int] = [-1] * n
        for i in treated:
            if not control:
                match_outcomes.append(outcome[i])
                continue
            best_j, best_d = control[0], float("inf")
            for j in control:
                d = math.hypot(centroids[i][0] - centroids[j][0],
                               centroids[i][1] - centroids[j][1])
                if d < best_d:
                    best_d = d
                    best_j = j
            matched_to[i] = best_j
            match_outcomes.append(outcome[i] - outcome[best_j])
        ate = sum(match_outcomes) / len(match_outcomes) if match_outcomes else 0.0
        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {
                self.geometry_column: recs[i][self.geometry_column],
                f"treatment_{suffix}": treatment[i],
                f"outcome_{suffix}": outcome[i],
                f"ate_{suffix}": round(ate, 6),
            }
            if treatment[i] == 1 and matched_to[i] >= 0:
                rec[f"matched_to_{suffix}"] = matched_to[i]
                rec[f"effect_{suffix}"] = round(outcome[i] - outcome[matched_to[i]], 6)
            else:
                rec[f"matched_to_{suffix}"] = -1
                rec[f"effect_{suffix}"] = 0.0
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 481: spatial_optimal_transport
    # ------------------------------------------------------------------
    def spatial_optimal_transport(
        self,
        mass_column: str,
        *,
        reg: float = 0.5,
        n_iterations: int = 40,
        suffix: str = "sot",
    ) -> "GeoPromptFrame":
        """Entropic optimal transport from feature masses to a uniform field.

        Uses a Sinkhorn-style scaling procedure on the pairwise spatial cost
        matrix. Reports each feature's transport burden and transport barycenter.
        """
        self._require_column(mass_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        masses = [max(float(r.get(mass_column, 0) or 0), 0.0) for r in recs]
        total_mass = sum(masses) or 1.0
        source = [m / total_mass for m in masses]
        target = [1.0 / n] * n
        dm = self._cached_distance_matrix("euclidean")
        kernel = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(n):
                kernel[i][j] = exponential_kernel(dm[i][j], scale=max(reg, 1e-6))
        u = [1.0] * n
        v = [1.0] * n
        for _ in range(n_iterations):
            for i in range(n):
                denom = sum(kernel[i][j] * v[j] for j in range(n)) or 1e-12
                u[i] = source[i] / denom
            for j in range(n):
                denom = sum(kernel[i][j] * u[i] for i in range(n)) or 1e-12
                v[j] = target[j] / denom
        out_rows: list[Record] = []
        for i in range(n):
            row_flow = [u[i] * kernel[i][j] * v[j] for j in range(n)]
            flow_sum = sum(row_flow) or 1e-12
            bary_x = sum(row_flow[j] * centroids[j][0] for j in range(n)) / flow_sum
            bary_y = sum(row_flow[j] * centroids[j][1] for j in range(n)) / flow_sum
            cost = sum(row_flow[j] * dm[i][j] for j in range(n))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"transport_cost_{suffix}": round(cost, 6),
                f"transport_mass_{suffix}": round(flow_sum, 6),
                f"barycenter_x_{suffix}": round(bary_x, 6),
                f"barycenter_y_{suffix}": round(bary_y, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 482: graph_neural_spatial_smoother
    # ------------------------------------------------------------------
    def graph_neural_spatial_smoother(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        n_layers: int = 3,
        blend: float = 0.6,
        suffix: str = "gns",
    ) -> "GeoPromptFrame":
        """Graph-neural-style smoothing over a spatial k-NN graph.

        Repeatedly mixes each node value with a weighted neighbourhood
        aggregation, acting as a light GCN message-passing layer.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        k = min(k_neighbors, max(n - 1, 1))
        dm = self._cached_distance_matrix("euclidean")
        neighbours = self._cached_knn(k, "euclidean")
        hidden = list(values)
        for _ in range(n_layers):
            new_hidden = [0.0] * n
            for i in range(n):
                agg = (1.0 - blend) * hidden[i]
                w_sum = (1.0 - blend)
                for j in neighbours[i]:
                    w = blend * inverse_distance_weight(dm[i][j], power=1.0, min_distance=0.0, offset=1.0)
                    agg += w * hidden[j]
                    w_sum += w
                new_hidden[i] = agg / max(w_sum, 1e-12)
            hidden = new_hidden
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"smoothed_{suffix}": round(hidden[i], 6),
                f"original_{suffix}": values[i],
                f"delta_{suffix}": round(hidden[i] - values[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 483: anisotropic_diffusion_surface
    # ------------------------------------------------------------------
    def anisotropic_diffusion_surface(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        n_steps: int = 15,
        conductance: float = 1.0,
        time_step: float = 0.2,
        suffix: str = "ads",
    ) -> "GeoPromptFrame":
        """Perona-Malik anisotropic diffusion on a spatial neighbour graph.

        Smooths within homogeneous neighbourhoods while preserving sharp
        boundaries by downweighting large value gradients.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        k = min(k_neighbors, max(n - 1, 1))
        dm = self._cached_distance_matrix("euclidean")
        neighbours = self._cached_knn(k, "euclidean")
        current = list(vals)
        for _ in range(n_steps):
            updated = list(current)
            for i in range(n):
                flux = 0.0
                weight_sum = 0.0
                for j in neighbours[i]:
                    grad = current[j] - current[i]
                    c = gaussian_kernel(abs(grad), bandwidth=max(conductance, 1e-12))
                    weight = c * inverse_distance_weight(dm[i][j], power=1.0, min_distance=0.0, offset=1.0)
                    flux += weight * grad
                    weight_sum += weight
                if weight_sum > 0:
                    updated[i] = current[i] + time_step * flux / weight_sum
            current = updated
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"diffused_{suffix}": round(current[i], 6),
                f"edge_strength_{suffix}": round(abs(current[i] - vals[i]), 6),
                f"original_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 484: persistent_hotspot_tracker
    # ------------------------------------------------------------------
    def persistent_hotspot_tracker(
        self,
        value_column: str,
        *,
        n_thresholds: int = 12,
        k_neighbors: int = 6,
        suffix: str = "pht",
    ) -> "GeoPromptFrame":
        """Track hotspot persistence across multiple value thresholds.

        Features that remain inside connected hotspot components across many
        thresholds receive higher persistence scores.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        v_min, v_max = min(vals), max(vals)
        thresholds = [v_min + (v_max - v_min) * t / max(n_thresholds - 1, 1) for t in range(n_thresholds)]
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            adj[i] = [j for _, j in dists[:k_neighbors]]
        persistence = [0] * n
        component_size = [0] * n
        for thr in thresholds:
            active = [vals[i] >= thr for i in range(n)]
            seen = [False] * n
            for i in range(n):
                if not active[i] or seen[i]:
                    continue
                stack = [i]
                comp: list[int] = []
                seen[i] = True
                while stack:
                    cur = stack.pop()
                    comp.append(cur)
                    for nb in adj[cur]:
                        if active[nb] and not seen[nb]:
                            seen[nb] = True
                            stack.append(nb)
                for idx in comp:
                    persistence[idx] += 1
                    component_size[idx] = max(component_size[idx], len(comp))
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"persistence_{suffix}": persistence[i],
                f"max_component_size_{suffix}": component_size[i],
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 485: spatial_conformal_predictor
    # ------------------------------------------------------------------
    def spatial_conformal_predictor(
        self,
        feature_columns: list[str],
        target_column: str,
        *,
        calibration_fraction: float = 0.25,
        k_neighbors: int = 7,
        alpha: float = 0.1,
        suffix: str = "scp",
    ) -> "GeoPromptFrame":
        """Conformal prediction intervals using spatial nearest-neighbour residuals.

        Fits local neighbour-weighted predictions, calibrates residuals on a
        held-out spatial subset, and returns finite prediction bands.
        """
        for c in feature_columns:
            self._require_column(c)
        self._require_column(target_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        n_feat = len(feature_columns)
        raw_features = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        feature_mean = [sum(raw_features[i][f] for i in range(n)) / n for f in range(n_feat)]
        feature_scale = [math.sqrt(sum((raw_features[i][f] - feature_mean[f]) ** 2 for i in range(n)) / n) or 1.0 for f in range(n_feat)]
        features = [[(raw_features[i][f] - feature_mean[f]) / feature_scale[f] for f in range(n_feat)] for i in range(n)]
        targets = [float(r.get(target_column, 0) or 0) for r in recs]
        n_cal = max(1, int(n * calibration_fraction))
        ordered_idx = sorted(range(n), key=lambda idx: (centroids[idx][0], centroids[idx][1], idx))
        calibration_idx: set[int] = set()
        for slot in range(n_cal):
            pick = min(n - 1, int((slot + 0.5) * n / n_cal))
            calibration_idx.add(ordered_idx[pick])
        dm = self._cached_distance_matrix("euclidean")
        sorted_neighbours = [sorted((dm[i][j], j) for j in range(n) if j != i) for i in range(n)]

        def _predict(i: int, allowed: list[int]) -> float:
            if not allowed:
                return sum(targets) / max(n, 1)
            allowed_set = set(allowed)
            neighbours: list[tuple[float, int]] = []
            for d, j in sorted_neighbours[i]:
                if j in allowed_set:
                    neighbours.append((d, j))
                    if len(neighbours) >= min(k_neighbors, len(allowed)):
                        break
            if not neighbours:
                return sum(targets[j] for j in allowed) / max(len(allowed), 1)
            num = 0.0
            den = 0.0
            for d, j in neighbours:
                feature_distance = sum((features[i][f] - features[j][f]) ** 2 for f in range(n_feat))
                spatial_weight = prompt_decay(distance_value=d, scale=1.0, power=1.0)
                feature_weight = gaussian_kernel(
                    math.sqrt(feature_distance / max(n_feat, 1)),
                    bandwidth=1.0,
                )
                weight = spatial_weight * feature_weight
                num += weight * targets[j]
                den += weight
            return num / den if den > 0 else sum(targets[j] for j in allowed) / max(len(allowed), 1)

        train_idx = [i for i in range(n) if i not in calibration_idx]
        cal_residuals = []
        for i in calibration_idx:
            pred = _predict(i, train_idx)
            cal_residuals.append(abs(targets[i] - pred))
        cal_residuals.sort()
        q_idx = min(len(cal_residuals) - 1, max(0, math.ceil((len(cal_residuals) + 1) * (1 - alpha)) - 1)) if cal_residuals else 0
        q_hat = cal_residuals[q_idx] if cal_residuals else 0.0
        train_lookup = set(train_idx)
        out_rows: list[Record] = []
        for i in range(n):
            if i in calibration_idx:
                allowed = train_idx
            else:
                allowed = [j for j in train_idx if j != i]
                if not allowed:
                    allowed = [j for j in range(n) if j != i]
            pred = _predict(i, allowed if allowed else list(train_lookup))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(pred, 6),
                f"lower_{suffix}": round(pred - q_hat, 6),
                f"upper_{suffix}": round(pred + q_hat, 6),
                f"interval_width_{suffix}": round(2 * q_hat, 6),
                f"actual_{suffix}": targets[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 486: geodesic_medoid_clustering
    # ------------------------------------------------------------------
    def geodesic_medoid_clustering(
        self,
        *,
        n_clusters: int = 3,
        k_neighbors: int = 6,
        n_iterations: int = 20,
        seed: int | None = None,
        suffix: str = "gmc",
    ) -> "GeoPromptFrame":
        """Cluster features by graph-geodesic medoids on a spatial k-NN graph."""
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        k = min(n_clusters, n)
        graph: list[list[tuple[int, float]]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            graph[i] = [(j, d) for d, j in dists[:k_neighbors]]

        def _shortest_from(src: int) -> list[float]:
            dist = [float("inf")] * n
            used = [False] * n
            dist[src] = 0.0
            for _ in range(n):
                best = -1
                best_d = float("inf")
                for i in range(n):
                    if not used[i] and dist[i] < best_d:
                        best = i
                        best_d = dist[i]
                if best < 0:
                    break
                used[best] = True
                for j, w in graph[best]:
                    if dist[best] + w < dist[j]:
                        dist[j] = dist[best] + w
            return dist

        all_pairs = [_shortest_from(i) for i in range(n)]
        medoids = rng.sample(range(n), k)
        labels = [0] * n
        for _ in range(n_iterations):
            for i in range(n):
                labels[i] = min(range(k), key=lambda c: all_pairs[i][medoids[c]])
            for c in range(k):
                members = [i for i in range(n) if labels[i] == c]
                if members:
                    medoids[c] = min(members, key=lambda m: sum(all_pairs[m][j] for j in members))
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": labels[i],
                f"medoid_{suffix}": medoids[labels[i]],
                f"distance_{suffix}": round(all_pairs[i][medoids[labels[i]]], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 487: spatial_sinkhorn_barycenter
    # ------------------------------------------------------------------
    def spatial_sinkhorn_barycenter(
        self,
        mass_column: str,
        *,
        n_iterations: int = 30,
        suffix: str = "skb",
    ) -> "GeoPromptFrame":
        """Compute a spatial barycenter of masses using iterative transport averaging."""
        self._require_column(mass_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        masses = [max(float(r.get(mass_column, 0) or 0), 0.0) for r in recs]
        total = sum(masses) or 1.0
        weights = [m / total for m in masses]
        bx = sum(weights[i] * centroids[i][0] for i in range(len(recs)))
        by = sum(weights[i] * centroids[i][1] for i in range(len(recs)))
        for _ in range(n_iterations):
            adj_w = []
            for i, (x, y) in enumerate(centroids):
                d = math.hypot(x - bx, y - by)
                adj_w.append(weights[i] / max(1.0 + d, 1e-12))
            norm = sum(adj_w) or 1.0
            adj_w = [w / norm for w in adj_w]
            bx = sum(adj_w[i] * centroids[i][0] for i in range(len(recs)))
            by = sum(adj_w[i] * centroids[i][1] for i in range(len(recs)))
        out_rows: list[Record] = [{
            self.geometry_column: {"type": "Point", "coordinates": [bx, by]},
            f"barycenter_x_{suffix}": round(bx, 6),
            f"barycenter_y_{suffix}": round(by, 6),
            f"total_mass_{suffix}": round(total, 6),
        }]
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 488: multi_agent_coverage
    # ------------------------------------------------------------------
    def multi_agent_coverage(
        self,
        demand_column: str,
        *,
        n_agents: int = 4,
        suffix: str = "mac",
    ) -> "GeoPromptFrame":
        """Greedy multi-agent spatial coverage using weighted farthest-first placement."""
        self._require_column(demand_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        k = min(n_agents, n)
        demand = [max(float(r.get(demand_column, 0) or 0), 0.0) for r in recs]
        centers = [max(range(n), key=lambda i: demand[i])]
        while len(centers) < k:
            best = max(range(n), key=lambda i: min(math.hypot(centroids[i][0] - centroids[c][0], centroids[i][1] - centroids[c][1])
                                                   for c in centers) * (1 + demand[i]))
            if best in centers:
                break
            centers.append(best)
        out_rows: list[Record] = []
        for i in range(n):
            assigned = min(range(len(centers)), key=lambda c: math.hypot(centroids[i][0] - centroids[centers[c]][0],
                                                                          centroids[i][1] - centroids[centers[c]][1]))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"agent_{suffix}": assigned,
                f"is_center_{suffix}": 1 if i in centers else 0,
                f"demand_{suffix}": demand[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 489: manifold_alignment_spatial
    # ------------------------------------------------------------------
    def manifold_alignment_spatial(
        self,
        feature_columns: list[str],
        *,
        n_components: int = 2,
        k_neighbors: int = 6,
        n_iterations: int = 40,
        seed: int | None = None,
        suffix: str = "mas",
    ) -> "GeoPromptFrame":
        """Laplacian-style manifold alignment of attributes with spatial proximity."""
        for c in feature_columns:
            self._require_column(c)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        n_feat = len(feature_columns)
        rng = random.Random(seed)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        embed = [[rng.gauss(0, 0.1) for _ in range(n_components)] for _ in range(n)]
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            neighbours.append([j for _, j in dists[:k_neighbors]])
        for _ in range(n_iterations):
            new_embed = [[0.0] * n_components for _ in range(n)]
            for i in range(n):
                for c_idx in range(n_components):
                    spatial_mean = sum(embed[j][c_idx] for j in neighbours[i]) / max(len(neighbours[i]), 1)
                    attr_term = sum(raw[i][f] * (f + 1 + c_idx) for f in range(n_feat)) / max(n_feat, 1)
                    new_embed[i][c_idx] = 0.7 * spatial_mean + 0.3 * math.tanh(attr_term * 0.1)
            embed = new_embed
        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            for c_idx in range(n_components):
                rec[f"component_{c_idx}_{suffix}"] = round(embed[i][c_idx], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 490: spatial_stochastic_blockmodel
    # ------------------------------------------------------------------
    def spatial_stochastic_blockmodel(
        self,
        *,
        n_blocks: int = 3,
        k_neighbors: int = 6,
        n_iterations: int = 25,
        seed: int | None = None,
        suffix: str = "ssb",
    ) -> "GeoPromptFrame":
        """Infer spatial communities using a simple stochastic block heuristic."""
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        blocks = [rng.randint(0, max(n_blocks - 1, 0)) for _ in range(n)]
        adj: list[list[int]] = [[] for _ in range(n)]
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            adj[i] = [j for _, j in dists[:k_neighbors]]
        for _ in range(n_iterations):
            for i in range(n):
                scores = [0.0] * n_blocks
                for nb in adj[i]:
                    scores[blocks[nb]] += 1.0
                spatial_pull = [0.0] * n_blocks
                for b_idx in range(n_blocks):
                    members = [m for m in range(n) if blocks[m] == b_idx]
                    if members:
                        spatial_pull[b_idx] = -sum(math.hypot(centroids[i][0] - centroids[m][0], centroids[i][1] - centroids[m][1])
                                                   for m in members) / len(members)
                blocks[i] = max(range(n_blocks), key=lambda b_idx: scores[b_idx] + 0.01 * spatial_pull[b_idx])
        out_rows: list[Record] = []
        for i in range(n):
            within = sum(1 for nb in adj[i] if blocks[nb] == blocks[i])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"block_{suffix}": blocks[i],
                f"within_links_{suffix}": within,
                f"cross_links_{suffix}": len(adj[i]) - within,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 491: adaptive_mesh_refinement_surface
    # ------------------------------------------------------------------
    def adaptive_mesh_refinement_surface(
        self,
        value_column: str,
        *,
        base_resolution: int = 6,
        max_level: int = 2,
        error_threshold: float = 0.15,
        suffix: str = "amr",
    ) -> "GeoPromptFrame":
        """Adaptive mesh refinement for interpolated spatial surfaces.

        Starts with a coarse grid and recursively refines cells where local
        corner disagreement indicates unresolved surface complexity.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()

        def _idw(px: float, py: float) -> float:
            num, den = 0.0, 0.0
            for i in range(len(recs)):
                d = math.hypot(px - centroids[i][0], py - centroids[i][1])
                w = 1.0 / max(d, 1e-12) ** 2
                num += w * vals[i]
                den += w
            return num / den if den else 0.0

        cells = [(b.min_x, b.min_y, b.max_x, b.max_y, 0)]
        final_cells: list[tuple[float, float, float, float, int, float]] = []
        while cells:
            x0, y0, x1, y1, level = cells.pop()
            corners = [_idw(x0, y0), _idw(x1, y0), _idw(x0, y1), _idw(x1, y1)]
            center = _idw((x0 + x1) / 2, (y0 + y1) / 2)
            err = max(abs(c - center) for c in corners)
            if level < max_level and err > error_threshold:
                mx = (x0 + x1) / 2
                my = (y0 + y1) / 2
                cells.extend([
                    (x0, y0, mx, my, level + 1),
                    (mx, y0, x1, my, level + 1),
                    (x0, my, mx, y1, level + 1),
                    (mx, my, x1, y1, level + 1),
                ])
            else:
                final_cells.append((x0, y0, x1, y1, level, center))
        out_rows: list[Record] = []
        for x0, y0, x1, y1, level, center in final_cells:
            out_rows.append({
                self.geometry_column: {"type": "Point", "coordinates": [(x0 + x1) / 2, (y0 + y1) / 2]},
                f"value_{suffix}": round(center, 6),
                f"cell_width_{suffix}": round(x1 - x0, 6),
                f"cell_height_{suffix}": round(y1 - y0, 6),
                f"level_{suffix}": level,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 492: spatial_koopman_forecaster
    # ------------------------------------------------------------------
    def spatial_koopman_forecaster(
        self,
        value_column: str,
        time_column: str,
        *,
        suffix: str = "skf",
    ) -> "GeoPromptFrame":
        """Koopman-style linear forecast in lifted observable space."""
        self._require_column(value_column)
        self._require_column(time_column)
        recs = sorted(self.to_records(), key=lambda r: float(r.get(time_column, 0) or 0))
        if not recs:
            return self.copy()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        times = [float(r.get(time_column, 0) or 0) for r in recs]
        n = len(recs)
        if n == 1:
            return self.copy()
        # Lifted basis: [1, x, x^2]
        coeff = [[0.0] * 3 for _ in range(3)]
        lr = 0.001
        for _ in range(300):
            for t in range(n - 1):
                phi = [1.0, vals[t], vals[t] * vals[t]]
                target = [1.0, vals[t + 1], vals[t + 1] * vals[t + 1]]
                pred = [sum(coeff[i][j] * phi[j] for j in range(3)) for i in range(3)]
                for i in range(3):
                    err = pred[i] - target[i]
                    for j in range(3):
                        coeff[i][j] -= lr * err * phi[j]
        out_rows: list[Record] = []
        for t in range(n):
            phi = [1.0, vals[t], vals[t] * vals[t]]
            pred_state = [sum(coeff[i][j] * phi[j] for j in range(3)) for i in range(3)]
            pred_val = pred_state[1]
            out_rows.append({
                self.geometry_column: recs[t][self.geometry_column],
                f"predicted_next_{suffix}": round(pred_val, 6),
                f"value_{suffix}": vals[t],
                f"time_{suffix}": times[t],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 493: barycentric_spatial_interpolator
    # ------------------------------------------------------------------
    def barycentric_spatial_interpolator(
        self,
        value_column: str,
        *,
        grid_resolution: int = 16,
        suffix: str = "bsi",
    ) -> "GeoPromptFrame":
        """Interpolate using barycentric coordinates of the 3 nearest points."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        b = self.bounds()
        dxg = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dyg = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        def _interp(px: float, py: float) -> float:
            dists = sorted((math.hypot(px - x, py - y), idx) for idx, (x, y) in enumerate(centroids))[:3]
            pts = [centroids[idx] for _, idx in dists]
            idxs = [idx for _, idx in dists]
            (x1, y1), (x2, y2), (x3, y3) = pts
            det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3)
            if abs(det) < 1e-12:
                num = sum((1.0 / max(d, 1e-12)) * vals[idx] for d, idx in dists)
                den = sum(1.0 / max(d, 1e-12) for d, _ in dists)
                return num / den if den else 0.0
            l1 = ((y2 - y3) * (px - x3) + (x3 - x2) * (py - y3)) / det
            l2 = ((y3 - y1) * (px - x3) + (x1 - x3) * (py - y3)) / det
            l3 = 1.0 - l1 - l2
            l1, l2, l3 = max(l1, 0.0), max(l2, 0.0), max(l3, 0.0)
            s = l1 + l2 + l3 or 1.0
            l1, l2, l3 = l1 / s, l2 / s, l3 / s
            return l1 * vals[idxs[0]] + l2 * vals[idxs[1]] + l3 * vals[idxs[2]]

        out_rows: list[Record] = []
        for gy in range(grid_resolution):
            py = b.min_y + gy * dyg
            for gx in range(grid_resolution):
                px = b.min_x + gx * dxg
                out_rows.append({
                    self.geometry_column: {"type": "Point", "coordinates": [px, py]},
                    f"predicted_{suffix}": round(_interp(px, py), 6),
                    f"grid_row_{suffix}": gy,
                    f"grid_col_{suffix}": gx,
                })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 494: topological_skeleton_extractor
    # ------------------------------------------------------------------
    def topological_skeleton_extractor(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        suffix: str = "tsk",
    ) -> "GeoPromptFrame":
        """Extract a ridge-like skeleton from a scalar field on points."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            dists = [(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                     for j in range(n) if j != i]
            dists.sort()
            nb = [j for _, j in dists[:k_neighbors]]
            higher = sum(1 for j in nb if vals[j] > vals[i])
            lower = sum(1 for j in nb if vals[j] < vals[i])
            skeleton = 1 if lower >= max(1, k_neighbors // 2) and higher <= 1 else 0
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"is_skeleton_{suffix}": skeleton,
                f"ridge_score_{suffix}": round((lower - higher) / max(k_neighbors, 1), 6),
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 495: spatial_contrastive_embedding
    # ------------------------------------------------------------------
    def spatial_contrastive_embedding(
        self,
        feature_columns: list[str],
        *,
        n_components: int = 2,
        n_iterations: int = 50,
        seed: int | None = None,
        suffix: str = "sce",
    ) -> "GeoPromptFrame":
        """Contrastive embedding where nearby points attract and far points repel."""
        for c in feature_columns:
            self._require_column(c)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        embed = [[rng.gauss(0, 0.1) for _ in range(n_components)] for _ in range(n)]
        for _ in range(n_iterations):
            for i in range(n):
                near = sorted(((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                               for j in range(n) if j != i))[:5]
                far = sorted(((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                              for j in range(n) if j != i), reverse=True)[:5]
                for c_idx in range(n_components):
                    near_pull = sum(embed[j][c_idx] for _, j in near) / max(len(near), 1)
                    far_push = sum(embed[j][c_idx] for _, j in far) / max(len(far), 1)
                    embed[i][c_idx] += 0.05 * (near_pull - far_push)
        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            for c_idx in range(n_components):
                rec[f"embed_{c_idx}_{suffix}"] = round(embed[i][c_idx], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 496: uncertainty_aware_idw
    # ------------------------------------------------------------------
    def uncertainty_aware_idw(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        suffix: str = "uidw",
    ) -> "GeoPromptFrame":
        """Neighbour-based IDW with predictive uncertainty from weighted variance."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            weights = [1.0 / max(d, 1e-12) ** 2 for d, _ in dists]
            w_sum = sum(weights) or 1.0
            pred = sum(weights[k] * vals[dists[k][1]] for k in range(len(dists))) / w_sum
            var = sum(weights[k] * (vals[dists[k][1]] - pred) ** 2 for k in range(len(dists))) / w_sum
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": round(pred, 6),
                f"uncertainty_{suffix}": round(math.sqrt(max(var, 0.0)), 6),
                f"actual_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 497: spatial_federated_clusterer
    # ------------------------------------------------------------------
    def spatial_federated_clusterer(
        self,
        feature_columns: list[str],
        *,
        n_clusters: int = 3,
        suffix: str = "sfc",
    ) -> "GeoPromptFrame":
        """Cluster by training local regional centroids and merging them globally."""
        for c in feature_columns:
            self._require_column(c)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        xs = [c[0] for c in centroids]
        ys = [c[1] for c in centroids]
        mx = sum(xs) / n
        my = sum(ys) / n
        regions = [0 if x <= mx and y <= my else 1 if x > mx and y <= my else 2 if x <= mx and y > my else 3
                   for x, y in centroids]
        local_centers: list[list[float]] = []
        for reg in range(4):
            members = [i for i in range(n) if regions[i] == reg]
            if members:
                center = [sum(raw[i][f] for i in members) / len(members) for f in range(len(feature_columns))]
                local_centers.append(center)
        if not local_centers:
            local_centers = [[0.0] * len(feature_columns)]
        while len(local_centers) > n_clusters:
            best_i, best_j, best_d = 0, 1, float("inf")
            for i in range(len(local_centers)):
                for j in range(i + 1, len(local_centers)):
                    d = math.sqrt(sum((local_centers[i][f] - local_centers[j][f]) ** 2 for f in range(len(feature_columns))))
                    if d < best_d:
                        best_i, best_j, best_d = i, j, d
            merged = [(local_centers[best_i][f] + local_centers[best_j][f]) / 2 for f in range(len(feature_columns))]
            keep = [local_centers[i] for i in range(len(local_centers)) if i not in (best_i, best_j)]
            local_centers = keep + [merged]
        out_rows: list[Record] = []
        for i in range(n):
            cluster = min(range(len(local_centers)), key=lambda c_idx: math.sqrt(sum((raw[i][f] - local_centers[c_idx][f]) ** 2 for f in range(len(feature_columns)))))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": cluster,
                f"region_{suffix}": regions[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 498: graph_wave_propagation
    # ------------------------------------------------------------------
    def graph_wave_propagation(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        n_steps: int = 20,
        damping: float = 0.05,
        suffix: str = "gwp",
    ) -> "GeoPromptFrame":
        """Discrete wave equation on a spatial graph."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            neighbours.append([j for _, j in dists])
        prev_state = list(vals)
        state = list(vals)
        for _ in range(n_steps):
            new_state = [0.0] * n
            for i in range(n):
                lap = sum(state[j] - state[i] for j in neighbours[i]) / max(len(neighbours[i]), 1)
                new_state[i] = 2 * state[i] - prev_state[i] + lap - damping * (state[i] - prev_state[i])
            prev_state, state = state, new_state
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"amplitude_{suffix}": round(state[i], 6),
                f"velocity_{suffix}": round(state[i] - prev_state[i], 6),
                f"initial_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 499: spatial_morse_smale_partition
    # ------------------------------------------------------------------
    def spatial_morse_smale_partition(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        suffix: str = "sms",
    ) -> "GeoPromptFrame":
        """Approximate Morse-Smale partition by gradient ascent to local maxima."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            neighbours.append([j for _, j in dists])

        def _ascend(i: int) -> int:
            cur = i
            seen: set[int] = set()
            while cur not in seen:
                seen.add(cur)
                best = max([cur] + neighbours[cur], key=lambda j: vals[j])
                if best == cur:
                    return cur
                cur = best
            return cur

        maxima = [_ascend(i) for i in range(n)]
        basin_ids: dict[int, int] = {}
        next_id = 0
        out_rows: list[Record] = []
        for i in range(n):
            if maxima[i] not in basin_ids:
                basin_ids[maxima[i]] = next_id
                next_id += 1
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"basin_{suffix}": basin_ids[maxima[i]],
                f"maximum_{suffix}": maxima[i],
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 500: optimal_transport_clustering
    # ------------------------------------------------------------------
    def optimal_transport_clustering(
        self,
        mass_column: str,
        *,
        n_clusters: int = 3,
        n_iterations: int = 20,
        seed: int | None = None,
        suffix: str = "otc",
    ) -> "GeoPromptFrame":
        """Cluster by alternating transport assignment and barycenter updates."""
        self._require_column(mass_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        masses = [max(float(r.get(mass_column, 0) or 0), 0.0) for r in recs]
        k = min(n_clusters, n)
        centers = [list(centroids[i]) for i in rng.sample(range(n), k)]
        labels = [0] * n
        for _ in range(n_iterations):
            for i in range(n):
                labels[i] = min(range(k), key=lambda c: math.hypot(centroids[i][0] - centers[c][0], centroids[i][1] - centers[c][1]))
            for c in range(k):
                members = [i for i in range(n) if labels[i] == c]
                if not members:
                    continue
                w_sum = sum(masses[i] for i in members) or 1.0
                centers[c][0] = sum(masses[i] * centroids[i][0] for i in members) / w_sum
                centers[c][1] = sum(masses[i] * centroids[i][1] for i in members) / w_sum
        out_rows: list[Record] = []
        for i in range(n):
            c = labels[i]
            dist = math.hypot(centroids[i][0] - centers[c][0], centroids[i][1] - centers[c][1])
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": c,
                f"transport_distance_{suffix}": round(dist, 6),
                f"mass_{suffix}": masses[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 501: game_theoretic_location_equilibrium
    # ------------------------------------------------------------------
    def game_theoretic_location_equilibrium(
        self,
        demand_column: str,
        *,
        n_players: int = 3,
        n_iterations: int = 25,
        seed: int | None = None,
        suffix: str = "gle",
    ) -> "GeoPromptFrame":
        """Competing-facility best-response equilibrium on a spatial demand field."""
        self._require_column(demand_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        rng = random.Random(seed)
        demand = [max(float(r.get(demand_column, 0) or 0), 0.0) for r in recs]
        players = [list(centroids[i]) for i in rng.sample(range(n), min(n_players, n))]

        def _payoff(player_idx: int, px: float, py: float, positions: list[list[float]]) -> float:
            payoff = 0.0
            for i in range(n):
                own_d = math.hypot(centroids[i][0] - px, centroids[i][1] - py)
                best_other = min(math.hypot(centroids[i][0] - positions[j][0], centroids[i][1] - positions[j][1])
                                 for j in range(len(positions)) if j != player_idx) if len(positions) > 1 else float("inf")
                if own_d <= best_other:
                    payoff += demand[i] / max(own_d + 1.0, 1e-12)
            return payoff

        for _ in range(n_iterations):
            for player_idx in range(len(players)):
                best_pos = list(players[player_idx])
                best_pay = _payoff(player_idx, best_pos[0], best_pos[1], players)
                candidates = random.sample(centroids, min(len(centroids), 12))
                for cand_x, cand_y in candidates:
                    pay = _payoff(player_idx, cand_x, cand_y, players)
                    if pay > best_pay:
                        best_pay = pay
                        best_pos = [cand_x, cand_y]
                players[player_idx] = best_pos
        out_rows: list[Record] = []
        for i in range(n):
            assigned = min(range(len(players)), key=lambda p_idx: math.hypot(centroids[i][0] - players[p_idx][0], centroids[i][1] - players[p_idx][1]))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"player_{suffix}": assigned,
                f"demand_{suffix}": demand[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 502: spatial_ordinal_regression
    # ------------------------------------------------------------------
    def spatial_ordinal_regression(
        self,
        feature_columns: list[str],
        ordinal_column: str,
        *,
        epochs: int = 150,
        learning_rate: float = 0.01,
        suffix: str = "sor",
    ) -> "GeoPromptFrame":
        """Ordinal regression via latent-score fitting and threshold decoding."""
        for c in feature_columns:
            self._require_column(c)
        self._require_column(ordinal_column)
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        labels = [r.get(ordinal_column) for r in recs]
        ordered = sorted(set(labels), key=str)
        label_to_score = {lbl: idx for idx, lbl in enumerate(ordered)}
        target = [float(label_to_score[lbl]) for lbl in labels]
        n_feat = len(feature_columns)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        col_min = [min(row[f] for row in raw) for f in range(n_feat)]
        col_rng = [max(row[f] for row in raw) - col_min[f] or 1.0 for f in range(n_feat)]
        data = [[(row[f] - col_min[f]) / col_rng[f] for f in range(n_feat)] for row in raw]
        weights = [0.0] * n_feat
        bias = 0.0
        for _ in range(epochs):
            for i in range(n):
                pred = bias + sum(weights[f] * data[i][f] for f in range(n_feat))
                err = pred - target[i]
                bias -= learning_rate * err
                for f in range(n_feat):
                    weights[f] -= learning_rate * err * data[i][f]
        thresholds = [0.5 * (idx + idx + 1) for idx in range(len(ordered) - 1)]
        out_rows: list[Record] = []
        for i in range(n):
            latent = bias + sum(weights[f] * data[i][f] for f in range(n_feat))
            pred_idx = 0
            while pred_idx < len(thresholds) and latent > thresholds[pred_idx]:
                pred_idx += 1
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"predicted_{suffix}": ordered[pred_idx],
                f"latent_{suffix}": round(latent, 6),
                f"actual_{suffix}": labels[i],
                f"correct_{suffix}": 1 if ordered[pred_idx] == labels[i] else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 503: graph_total_variation_denoise
    # ------------------------------------------------------------------
    def graph_total_variation_denoise(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        lambda_reg: float = 0.2,
        n_steps: int = 40,
        suffix: str = "gtv",
    ) -> "GeoPromptFrame":
        """Graph total-variation denoising for edge-preserving smoothing."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        neighbours: list[list[int]] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            neighbours.append([j for _, j in dists])
        current = list(values)
        for _ in range(n_steps):
            updated = list(current)
            for i in range(n):
                grad = sum((current[i] - current[j]) / max(abs(current[i] - current[j]), 1e-6) for j in neighbours[i])
                updated[i] = (values[i] + lambda_reg * sum(current[j] for j in neighbours[i]) / max(len(neighbours[i]), 1) - 0.05 * grad) / (1 + lambda_reg)
            current = updated
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"denoised_{suffix}": round(current[i], 6),
                f"original_{suffix}": values[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 504: spatial_nystrom_kernel_map
    # ------------------------------------------------------------------
    def spatial_nystrom_kernel_map(
        self,
        feature_columns: list[str],
        *,
        n_components: int = 2,
        n_landmarks: int = 12,
        seed: int | None = None,
        suffix: str = "nyk",
    ) -> "GeoPromptFrame":
        """Nyström approximation of an RBF kernel map for spatial attributes."""
        for c in feature_columns:
            self._require_column(c)
        recs = self.to_records()
        if not recs:
            return self.copy()
        rng = random.Random(seed)
        n = len(recs)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        landmarks = rng.sample(range(n), min(n_landmarks, n))
        sigma = math.sqrt(sum(sum((raw[i][f] - raw[j][f]) ** 2 for f in range(len(feature_columns))) for i in range(n) for j in landmarks) /
                          max(n * len(landmarks), 1)) or 1.0
        out_rows: list[Record] = []
        for i in range(n):
            rec: Record = {self.geometry_column: recs[i][self.geometry_column]}
            kernel_vals = []
            for j in landmarks:
                d2 = sum((raw[i][f] - raw[j][f]) ** 2 for f in range(len(feature_columns)))
                kernel_vals.append(math.exp(-d2 / (2 * sigma * sigma)))
            for c_idx in range(min(n_components, len(kernel_vals))):
                rec[f"component_{c_idx}_{suffix}"] = round(kernel_vals[c_idx], 6)
            out_rows.append(rec)
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 505: terrain_ridge_detector
    # ------------------------------------------------------------------
    def terrain_ridge_detector(
        self,
        value_column: str,
        *,
        k_neighbors: int = 8,
        suffix: str = "trd",
    ) -> "GeoPromptFrame":
        """Detect ridge candidates from directional neighbour contrasts."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        vals = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            higher = sum(1 for _, j in dists if vals[i] > vals[j])
            contrast = sum(abs(vals[i] - vals[j]) for _, j in dists) / max(len(dists), 1)
            ridge_score = (higher / max(len(dists), 1)) * contrast
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"ridge_score_{suffix}": round(ridge_score, 6),
                f"is_ridge_{suffix}": 1 if higher >= max(2, len(dists) // 2) else 0,
                f"value_{suffix}": vals[i],
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 506: spatial_conformal_outlier
    # ------------------------------------------------------------------
    def spatial_conformal_outlier(
        self,
        value_column: str,
        *,
        calibration_fraction: float = 0.25,
        k_neighbors: int = 6,
        alpha: float = 0.1,
        suffix: str = "sco",
    ) -> "GeoPromptFrame":
        """Outlier scoring using conformal residual thresholds on local predictors."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        n_cal = max(1, int(n * calibration_fraction))
        calibration = set(range(max(0, n - n_cal), n))

        def _local_pred(i: int) -> float:
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            num, den = 0.0, 0.0
            for d, j in dists:
                w = 1.0 / max(d, 1e-12)
                num += w * values[j]
                den += w
            return num / den if den else values[i]

        calibration_scores = sorted(abs(values[i] - _local_pred(i)) for i in calibration)
        q_idx = min(len(calibration_scores) - 1, max(0, int((1 - alpha) * len(calibration_scores)))) if calibration_scores else 0
        threshold = calibration_scores[q_idx] if calibration_scores else 0.0
        out_rows: list[Record] = []
        for i in range(n):
            pred = _local_pred(i)
            score = abs(values[i] - pred)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"score_{suffix}": round(score, 6),
                f"threshold_{suffix}": round(threshold, 6),
                f"is_outlier_{suffix}": 1 if score > threshold else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 507: hyperbolic_spatial_embedding
    # ------------------------------------------------------------------
    def hyperbolic_spatial_embedding(
        self,
        feature_columns: list[str],
        *,
        suffix: str = "hse",
    ) -> "GeoPromptFrame":
        """Embed features into a 2D Poincare disk using centrality-based radius."""
        for c in feature_columns:
            self._require_column(c)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        mean_val = [sum(row[f] for row in raw) / n for f in range(len(feature_columns))]
        out_rows: list[Record] = []
        for i in range(n):
            centrality = 1.0 / (1.0 + sum(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for j in range(n)) / max(n - 1, 1))
            radius = clamp(1.0 - centrality, 0.0, 0.99)
            angle = math.atan2(sum((raw[i][f] - mean_val[f]) * (f + 1) for f in range(len(feature_columns))),
                               1.0 + sum(abs(raw[i][f] - mean_val[f]) for f in range(len(feature_columns))))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"x_{suffix}": round(radius * math.cos(angle), 6),
                f"y_{suffix}": round(radius * math.sin(angle), 6),
                f"radius_{suffix}": round(radius, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 508: spatial_energy_distance_test
    # ------------------------------------------------------------------
    def spatial_energy_distance_test(
        self,
        value_column: str,
        group_column: str,
        *,
        suffix: str = "sed",
    ) -> "GeoPromptFrame":
        """Energy-distance-style separation test between two spatial groups."""
        self._require_column(value_column)
        self._require_column(group_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        groups = [r.get(group_column) for r in recs]
        unique = sorted(set(groups), key=str)
        if len(unique) < 2:
            return self.copy()
        first = [i for i in range(len(recs)) if groups[i] == unique[0]]
        second = [i for i in range(len(recs)) if groups[i] == unique[1]]
        within_a = sum(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for i in first for j in first if i < j)
        within_b = sum(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for i in second for j in second if i < j)
        between = sum(math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) for i in first for j in second)
        na = max(len(first), 1)
        nb = max(len(second), 1)
        energy = 2 * between / (na * nb) - 2 * within_a / max(na * na, 1) - 2 * within_b / max(nb * nb, 1)
        out_rows: list[Record] = []
        for i in range(len(recs)):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"group_{suffix}": groups[i],
                f"energy_distance_{suffix}": round(energy, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 509: adaptive_radius_hotspot
    # ------------------------------------------------------------------
    def adaptive_radius_hotspot(
        self,
        value_column: str,
        *,
        n_radii: int = 8,
        max_radius: float | None = None,
        suffix: str = "arh",
    ) -> "GeoPromptFrame":
        """Choose the neighbourhood radius that maximises each point's hotspot score."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        extent = max(self.bounds().max_x - self.bounds().min_x, self.bounds().max_y - self.bounds().min_y, 1e-6)
        max_radius = max_radius or extent / 2
        radii = [max_radius * (r_idx + 1) / n_radii for r_idx in range(n_radii)]
        global_mean = sum(values) / n
        global_std = math.sqrt(sum((v - global_mean) ** 2 for v in values) / n) or 1e-12
        out_rows: list[Record] = []
        for i in range(n):
            best_score = -float("inf")
            best_radius = radii[0]
            for radius in radii:
                nb = [j for j in range(n) if math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]) <= radius]
                local_mean = sum(values[j] for j in nb) / max(len(nb), 1)
                score = (local_mean - global_mean) / global_std
                if score > best_score:
                    best_score = score
                    best_radius = radius
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"hotspot_score_{suffix}": round(best_score, 6),
                f"best_radius_{suffix}": round(best_radius, 6),
                f"is_hotspot_{suffix}": 1 if best_score > 1.0 else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 510: spatial_transport_accessibility
    # ------------------------------------------------------------------
    def spatial_transport_accessibility(
        self,
        supply_column: str,
        demand_column: str,
        *,
        beta: float = 1.5,
        suffix: str = "sta",
    ) -> "GeoPromptFrame":
        """Hansen-style accessibility index balancing supply, demand, and distance."""
        self._require_column(supply_column)
        self._require_column(demand_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        supply = [max(float(r.get(supply_column, 0) or 0), 0.0) for r in recs]
        demand = [max(float(r.get(demand_column, 0) or 0), 0.0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            access = 0.0
            for j in range(n):
                d = math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1])
                access += supply[j] / (1.0 + d) ** beta
            balance = access / max(demand[i] + 1.0, 1e-12)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"accessibility_{suffix}": round(access, 6),
                f"balance_{suffix}": round(balance, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 511: graph_heat_centrality
    # ------------------------------------------------------------------
    def graph_heat_centrality(
        self,
        *,
        k_neighbors: int = 6,
        n_steps: int = 20,
        suffix: str = "ghc",
    ) -> "GeoPromptFrame":
        """Heat-kernel-inspired centrality on a spatial graph."""
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        graph: list[list[int]] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            graph.append([j for _, j in dists])
        heat = [1.0] * n
        for _ in range(n_steps):
            new_heat = [0.0] * n
            for i in range(n):
                share = heat[i] / max(len(graph[i]), 1)
                new_heat[i] += 0.5 * heat[i]
                for j in graph[i]:
                    new_heat[j] += 0.5 * share
            heat = new_heat
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"heat_centrality_{suffix}": round(heat[i], 6),
                f"degree_{suffix}": len(graph[i]),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 512: spatial_bayes_blend_interpolator
    # ------------------------------------------------------------------
    def spatial_bayes_blend_interpolator(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        prior_strength: float = 1.0,
        suffix: str = "sbb",
    ) -> "GeoPromptFrame":
        """Bayesian blend of global prior mean and local inverse-distance evidence."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        prior_mean = sum(values) / n
        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            weights = [1.0 / max(d, 1e-12) for d, _ in dists]
            local_mean = sum(weights[k] * values[dists[k][1]] for k in range(len(dists))) / max(sum(weights), 1e-12) if dists else prior_mean
            precision = prior_strength + sum(weights)
            posterior = (prior_strength * prior_mean + sum(weights[k] * values[dists[k][1]] for k in range(len(dists)))) / max(precision, 1e-12)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"posterior_{suffix}": round(posterior, 6),
                f"local_mean_{suffix}": round(local_mean, 6),
                f"prior_mean_{suffix}": round(prior_mean, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 513: spatial_jensen_shannon_scan
    # ------------------------------------------------------------------
    def spatial_jensen_shannon_scan(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        n_bins: int = 8,
        suffix: str = "sjs",
    ) -> "GeoPromptFrame":
        """Local Jensen-Shannon divergence between neighbourhood and global distributions."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        v_min, v_max = min(values), max(values)
        v_rng = v_max - v_min or 1.0

        def _hist(indices: list[int]) -> list[float]:
            hist = [0.0] * n_bins
            for idx in indices:
                b_idx = min(n_bins - 1, int((values[idx] - v_min) / v_rng * n_bins))
                hist[b_idx] += 1.0
            total = sum(hist) or 1.0
            return [h / total for h in hist]

        global_hist = _hist(list(range(n)))

        def _kl(p: list[float], q: list[float]) -> float:
            s = 0.0
            for idx in range(len(p)):
                if p[idx] > 0 and q[idx] > 0:
                    s += p[idx] * math.log(p[idx] / q[idx])
            return s

        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            nb_hist = _hist([i] + [j for _, j in dists])
            mix = [(nb_hist[b_idx] + global_hist[b_idx]) / 2 for b_idx in range(n_bins)]
            js = 0.5 * _kl(nb_hist, mix) + 0.5 * _kl(global_hist, mix)
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"js_divergence_{suffix}": round(js, 6),
                f"is_anomalous_{suffix}": 1 if js > 0.1 else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 514: front_propagation_distance
    # ------------------------------------------------------------------
    def front_propagation_distance(
        self,
        source_column: str,
        *,
        k_neighbors: int = 6,
        speed_column: str | None = None,
        suffix: str = "fpd",
    ) -> "GeoPromptFrame":
        """Approximate eikonal front-propagation distance from source features."""
        self._require_column(source_column)
        if speed_column is not None:
            self._require_column(speed_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        speed = [max(float(r.get(speed_column, 1) or 1), 1e-6) for r in recs] if speed_column else [1.0] * n
        graph: list[list[tuple[int, float]]] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            graph.append([(j, d) for d, j in dists])
        dist = [float("inf")] * n
        visited = [False] * n
        for i in range(n):
            if recs[i].get(source_column):
                dist[i] = 0.0
        for _ in range(n):
            best = -1
            best_d = float("inf")
            for i in range(n):
                if not visited[i] and dist[i] < best_d:
                    best = i
                    best_d = dist[i]
            if best < 0:
                break
            visited[best] = True
            for j, w in graph[best]:
                cost = w / ((speed[best] + speed[j]) / 2)
                if dist[best] + cost < dist[j]:
                    dist[j] = dist[best] + cost
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"distance_{suffix}": round(dist[i], 6) if dist[i] < float("inf") else None,
                f"is_source_{suffix}": 1 if recs[i].get(source_column) else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 515: spatial_kernel_herding
    # ------------------------------------------------------------------
    def spatial_kernel_herding(
        self,
        *,
        n_select: int = 10,
        suffix: str = "skh",
    ) -> "GeoPromptFrame":
        """Select representative features using kernel herding on spatial locations."""
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        n_select = min(n_select, n)
        extent = max(self.bounds().max_x - self.bounds().min_x, self.bounds().max_y - self.bounds().min_y, 1e-6)
        sigma = extent / 4

        def _kernel(i: int, j: int) -> float:
            d2 = (centroids[i][0] - centroids[j][0]) ** 2 + (centroids[i][1] - centroids[j][1]) ** 2
            return math.exp(-d2 / (2 * sigma * sigma))

        mean_embed = [sum(_kernel(i, j) for j in range(n)) / n for i in range(n)]
        selected: list[int] = []
        scores = [0.0] * n
        for _ in range(n_select):
            best = 0
            best_score = -float("inf")
            for i in range(n):
                if i in selected:
                    continue
                herd = sum(_kernel(i, j) for j in selected) / max(len(selected), 1) if selected else 0.0
                score = mean_embed[i] - herd
                if score > best_score:
                    best_score = score
                    best = i
            selected.append(best)
            scores[best] = best_score
        out_rows: list[Record] = []
        for i in range(n):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"selected_{suffix}": 1 if i in selected else 0,
                f"selection_rank_{suffix}": selected.index(i) if i in selected else -1,
                f"score_{suffix}": round(scores[i], 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 516: spatial_mutual_information_map
    # ------------------------------------------------------------------
    def spatial_mutual_information_map(
        self,
        value_column: str,
        other_column: str,
        *,
        k_neighbors: int = 8,
        n_bins: int = 6,
        suffix: str = "smi",
    ) -> "GeoPromptFrame":
        """Local mutual information between two attributes over spatial neighbourhoods."""
        self._require_column(value_column)
        self._require_column(other_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        first = [float(r.get(value_column, 0) or 0) for r in recs]
        second = [float(r.get(other_column, 0) or 0) for r in recs]
        f_min, f_max = min(first), max(first)
        s_min, s_max = min(second), max(second)
        f_rng = f_max - f_min or 1.0
        s_rng = s_max - s_min or 1.0
        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            idxs = [i] + [j for _, j in dists]
            joint = [[0.0] * n_bins for _ in range(n_bins)]
            for idx in idxs:
                a = min(n_bins - 1, int((first[idx] - f_min) / f_rng * n_bins))
                b = min(n_bins - 1, int((second[idx] - s_min) / s_rng * n_bins))
                joint[a][b] += 1.0
            total = sum(sum(row) for row in joint) or 1.0
            px = [sum(joint[a][b] for b in range(n_bins)) / total for a in range(n_bins)]
            py = [sum(joint[a][b] for a in range(n_bins)) / total for b in range(n_bins)]
            mi = 0.0
            for a in range(n_bins):
                for b in range(n_bins):
                    p = joint[a][b] / total
                    if p > 0 and px[a] > 0 and py[b] > 0:
                        mi += p * math.log(p / (px[a] * py[b]))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"mi_{suffix}": round(mi, 6),
                f"n_neighbours_{suffix}": len(idxs),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 517: robust_spatial_median_field
    # ------------------------------------------------------------------
    def robust_spatial_median_field(
        self,
        value_column: str,
        *,
        k_neighbors: int = 7,
        suffix: str = "rmf",
    ) -> "GeoPromptFrame":
        """Robust local field using neighbour medians instead of means."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        out_rows: list[Record] = []
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            nb_vals = sorted([values[j] for _, j in dists] + [values[i]])
            mid = len(nb_vals) // 2
            median_val = nb_vals[mid] if len(nb_vals) % 2 == 1 else (nb_vals[mid - 1] + nb_vals[mid]) / 2
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"median_{suffix}": round(median_val, 6),
                f"residual_{suffix}": round(values[i] - median_val, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 518: spatial_bifurcation_detector
    # ------------------------------------------------------------------
    def spatial_bifurcation_detector(
        self,
        value_column: str,
        *,
        axis: str = "x",
        window: int = 7,
        suffix: str = "sbd",
    ) -> "GeoPromptFrame":
        """Detect locations where the ordered spatial signal splits into two regimes."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        coords = [c[0] if axis.lower() == "x" else c[1] for c in centroids]
        order = sorted(range(len(recs)), key=lambda idx: coords[idx])
        out_score = [0.0] * len(recs)
        for pos in range(len(order)):
            left = order[max(0, pos - window):pos]
            right = order[pos + 1:min(len(order), pos + 1 + window)]
            if not left or not right:
                continue
            left_mean = sum(values[idx] for idx in left) / len(left)
            right_mean = sum(values[idx] for idx in right) / len(right)
            out_score[order[pos]] = abs(left_mean - right_mean)
        out_rows: list[Record] = []
        for i in range(len(recs)):
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"bifurcation_score_{suffix}": round(out_score[i], 6),
                f"is_bifurcation_{suffix}": 1 if out_score[i] > 0 else 0,
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 519: spatial_adversarial_validator
    # ------------------------------------------------------------------
    def spatial_adversarial_validator(
        self,
        feature_columns: list[str],
        *,
        split_axis: str = "x",
        epochs: int = 120,
        learning_rate: float = 0.05,
        suffix: str = "sav",
    ) -> "GeoPromptFrame":
        """Domain-shift validator by classifying one side of space from the other."""
        for c in feature_columns:
            self._require_column(c)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        raw = [[float(r.get(c, 0) or 0) for c in feature_columns] for r in recs]
        coords = [c[0] if split_axis.lower() == "x" else c[1] for c in centroids]
        median_coord = sorted(coords)[n // 2]
        labels = [1.0 if coord > median_coord else 0.0 for coord in coords]
        n_feat = len(feature_columns)
        weights = [0.0] * n_feat
        bias = 0.0

        for _ in range(epochs):
            for i in range(n):
                score = bias + sum(weights[f] * raw[i][f] for f in range(n_feat))
                pred = sigmoid(score)
                err = pred - labels[i]
                bias -= learning_rate * err
                for f in range(n_feat):
                    weights[f] -= learning_rate * err * raw[i][f]
        out_rows: list[Record] = []
        for i in range(n):
            prob = sigmoid(bias + sum(weights[f] * raw[i][f] for f in range(n_feat)))
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"domain_prob_{suffix}": round(prob, 6),
                f"domain_label_{suffix}": int(labels[i]),
                f"shift_score_{suffix}": round(abs(prob - 0.5) * 2, 6),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 520: spatial_consensus_partition
    # ------------------------------------------------------------------
    def spatial_consensus_partition(
        self,
        value_column: str,
        *,
        k_neighbors: int = 6,
        suffix: str = "scpn",
    ) -> "GeoPromptFrame":
        """Consensus partition by combining value, geometry, and graph neighbourhood cues."""
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        median_value = sorted(values)[n // 2]
        x_coords = [c[0] for c in centroids]
        median_x = sorted(x_coords)[n // 2]
        graph_label = [0] * n
        for i in range(n):
            dists = sorted((math.hypot(centroids[i][0] - centroids[j][0], centroids[i][1] - centroids[j][1]), j)
                           for j in range(n) if j != i)[:k_neighbors]
            nb_mean = sum(values[j] for _, j in dists) / max(len(dists), 1)
            graph_label[i] = 1 if nb_mean > median_value else 0
        label_map: dict[tuple[int, int, int], int] = {}
        next_id = 0
        out_rows: list[Record] = []
        for i in range(n):
            signature = (1 if values[i] > median_value else 0,
                         1 if centroids[i][0] > median_x else 0,
                         graph_label[i])
            if signature not in label_map:
                label_map[signature] = next_id
                next_id += 1
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"cluster_{suffix}": label_map[signature],
                f"signature_{suffix}": "".join(str(part) for part in signature),
            })
        return GeoPromptFrame._from_internal_rows(out_rows, geometry_column=self.geometry_column, crs=self.crs)

    # ------------------------------------------------------------------
    # Tool 521: transport_aware_hotspot
    # ------------------------------------------------------------------
    def transport_aware_hotspot(
        self,
        value_column: str,
        supply_column: str | None = None,
        *,
        beta: float = 1.5,
        n_radii: int = 8,
        max_radius: float | None = None,
        suffix: str = "tah",
    ) -> "GeoPromptFrame":
        """Hotspot detection weighted by transport-style accessibility.

        Combines adaptive-radius neighbourhood scoring (Tool 509) with
        Hansen-style distance-decay accessibility (Tool 510) so that
        hotspot significance is modulated by how reachable each location
        is within the network of surrounding features.
        """
        self._require_column(value_column)
        centroids = self._centroids()
        recs = self.to_records()
        if not recs:
            return self.copy()
        n = len(recs)
        values = [float(r.get(value_column, 0) or 0) for r in recs]
        supply = (
            [max(float(r.get(supply_column, 0) or 0), 0.0) for r in recs]
            if supply_column
            else values
        )
        extent = max(
            self.bounds().max_x - self.bounds().min_x,
            self.bounds().max_y - self.bounds().min_y,
            1e-6,
        )
        max_r = max_radius or extent / 2
        radii = [max_r * (r_idx + 1) / n_radii for r_idx in range(n_radii)]
        global_mean = sum(values) / n
        global_std = math.sqrt(sum((v - global_mean) ** 2 for v in values) / n) or 1e-12

        # Pre-compute pairwise distances once
        dists = [[0.0] * n for _ in range(n)]
        for i in range(n):
            for j in range(i + 1, n):
                d = math.hypot(
                    centroids[i][0] - centroids[j][0],
                    centroids[i][1] - centroids[j][1],
                )
                dists[i][j] = d
                dists[j][i] = d

        # Accessibility score per location (Hansen-style)
        accessibility = [0.0] * n
        for i in range(n):
            acc = 0.0
            for j in range(n):
                acc += supply[j] / (1.0 + dists[i][j]) ** beta
            accessibility[i] = acc
        max_acc = max(accessibility) or 1e-12

        out_rows: list[Record] = []
        for i in range(n):
            best_score = -float("inf")
            best_radius = radii[0]
            for radius in radii:
                nb = [j for j in range(n) if dists[i][j] <= radius]
                local_mean = sum(values[j] for j in nb) / max(len(nb), 1)
                score = (local_mean - global_mean) / global_std
                if score > best_score:
                    best_score = score
                    best_radius = radius
            # Transport-weighted hotspot score
            norm_acc = accessibility[i] / max_acc
            weighted_score = best_score * norm_acc
            out_rows.append({
                self.geometry_column: recs[i][self.geometry_column],
                f"hotspot_score_{suffix}": round(best_score, 6),
                f"weighted_score_{suffix}": round(weighted_score, 6),
                f"accessibility_{suffix}": round(accessibility[i], 6),
                f"best_radius_{suffix}": round(best_radius, 6),
                f"is_hotspot_{suffix}": 1 if weighted_score > 1.0 else 0,
            })
        return GeoPromptFrame._from_internal_rows(
            out_rows, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 522: counterfactual_gwr
    # ------------------------------------------------------------------
    def counterfactual_gwr(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        scenario: dict[str, float],
        *,
        bandwidth: float | None = None,
        auto_bandwidth: bool = False,
        distance_method: str = "euclidean",
        suffix: str = "cfgwr",
    ) -> "GeoPromptFrame":
        """Geographically weighted regression with counterfactual scenario analysis.

        Fits a local GWR model at each location, then re-predicts under a
        counterfactual *scenario* where one or more independent variables
        are shifted by a fixed amount.  The output includes the baseline
        prediction, the counterfactual prediction, and the spatially
        varying marginal effect (difference) — enabling causal-style
        "what-if" sensitivity analysis across geography.

        Parameters
        ----------
        scenario : dict[str, float]
            Mapping of column name → additive perturbation.  For example
            ``{"income": 1000}`` predicts what happens if income is
            raised by 1 000 at every location, using local coefficients.
        """
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        p = len(independent_columns) + 1
        y_vals = [float(row[dependent_column]) for row in self._rows]
        x_matrix = [
            [1.0] + [float(row.get(col, 0) or 0) for col in independent_columns]
            for row in self._rows
        ]

        # Build the counterfactual X matrix
        delta = [0.0] + [scenario.get(col, 0.0) for col in independent_columns]
        x_cf = [[x_matrix[i][j] + delta[j] for j in range(p)] for i in range(n)]

        # Bandwidth selection (golden-section CV, same as Tool 22)
        def _cv(h: float) -> float:
            sse = 0.0
            for i in range(n):
                wts = [0.0] * n
                for j in range(n):
                    if i == j:
                        continue
                    u = dist_matrix[i][j] / max(h, 1e-12)
                    wts[j] = math.exp(-0.5 * u * u) if u < 3.0 else 0.0
                wxtx = [
                    [sum(wts[k] * x_matrix[k][a] * x_matrix[k][b_] for k in range(n)) for b_ in range(p)]
                    for a in range(p)
                ]
                wxty = [sum(wts[k] * x_matrix[k][a] * y_vals[k] for k in range(n)) for a in range(p)]
                coeffs = _solve_linear_system(wxtx, wxty)
                pred = (
                    sum(coeffs[j] * x_matrix[i][j] for j in range(p))
                    if coeffs is not None
                    else sum(y_vals) / n
                )
                sse += (y_vals[i] - pred) ** 2
            return sse

        if bandwidth is not None:
            h = bandwidth
        elif auto_bandwidth and n > 2:
            h_lo, h_hi = extent * 0.05, extent * 2.0
            gr = (math.sqrt(5) + 1) / 2
            for _ in range(15):
                h1 = h_hi - (h_hi - h_lo) / gr
                h2 = h_lo + (h_hi - h_lo) / gr
                if _cv(h1) < _cv(h2):
                    h_hi = h2
                else:
                    h_lo = h1
            h = (h_lo + h_hi) / 2.0
        else:
            h = extent / 3.0

        rows: list[Record] = []
        for i in range(n):
            # Local weights
            wts = [0.0] * n
            for j in range(n):
                u = dist_matrix[i][j] / max(h, 1e-12)
                wts[j] = math.exp(-0.5 * u * u) if u < 3.0 else 0.0
            wxtx = [
                [sum(wts[k] * x_matrix[k][a] * x_matrix[k][b_] for k in range(n)) for b_ in range(p)]
                for a in range(p)
            ]
            wxty = [sum(wts[k] * x_matrix[k][a] * y_vals[k] for k in range(n)) for a in range(p)]
            coeffs = _solve_linear_system(wxtx, wxty)
            if coeffs is None:
                coeffs = [0.0] * p

            baseline_pred = sum(coeffs[j] * x_matrix[i][j] for j in range(p))
            cf_pred = sum(coeffs[j] * x_cf[i][j] for j in range(p))
            effect = cf_pred - baseline_pred

            resolved = dict(self._rows[i])
            resolved[f"predicted_{suffix}"] = round(baseline_pred, 6)
            resolved[f"counterfactual_{suffix}"] = round(cf_pred, 6)
            resolved[f"effect_{suffix}"] = round(effect, 6)
            resolved[f"bandwidth_{suffix}"] = round(h, 6)
            resolved[f"intercept_{suffix}"] = round(coeffs[0], 6)
            for ci, col in enumerate(independent_columns):
                resolved[f"coeff_{col}_{suffix}"] = round(coeffs[ci + 1], 6)
            rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            rows, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 523: topologically_regularized_spatial_scan
    # ------------------------------------------------------------------
    def topologically_regularized_spatial_scan(
        self,
        case_column: str,
        population_column: str | None = None,
        max_radius_fraction: float = 0.5,
        distance_method: str = "euclidean",
        n_simulations: int = 99,
        seed: int | None = None,
        adjacency_k: int = 6,
        topo_penalty: float = 0.1,
        suffix: str = "trscan",
    ) -> "GeoPromptFrame":
        """Kulldorff spatial scan with topological regularization.

        Extends the classic scan statistic by penalizing candidate
        clusters whose members violate spatial contiguity.  A cluster
        that forms a connected sub-graph in the k-nearest-neighbour
        adjacency receives the full LLR; disconnected clusters are
        penalized proportionally to the number of connected components.

        Parameters
        ----------
        adjacency_k : int
            Number of nearest neighbours for the contiguity graph.
        topo_penalty : float
            Multiplicative discount per additional component beyond 1.
            Effective LLR = LLR / (1 + penalty * (n_components - 1)).
        """
        self._require_column(case_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        cases = [float(row.get(case_column, 0) or 0) for row in self._rows]
        pops = (
            [float(row.get(population_column, 1) or 1) for row in self._rows]
            if population_column
            else [1.0] * n
        )
        total_c = sum(cases)
        total_p = sum(pops)
        if total_c <= 0 or total_p <= 0:
            rows = [
                dict(r) | {
                    f"in_cluster_{suffix}": False,
                    f"llr_{suffix}": 0.0,
                    f"p_value_{suffix}": 1.0,
                }
                for r in self._rows
            ]
            return GeoPromptFrame._from_internal_rows(
                rows, geometry_column=self.geometry_column, crs=self.crs,
            )
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        max_radius = extent * max_radius_fraction

        # Build k-NN adjacency sets
        adj_sets: list[set[int]] = [set() for _ in range(n)]
        for i in range(n):
            sorted_j = sorted(range(n), key=lambda j: dist_matrix[i][j])
            for j in sorted_j[1: adjacency_k + 1]:
                adj_sets[i].add(j)
                adj_sets[j].add(i)

        def _llr(c_in: float, n_in: float) -> float:
            c_out = total_c - c_in
            n_out = total_p - n_in
            if c_in <= 0 or n_in <= 0 or n_out <= 0:
                return 0.0
            rate_in = c_in / n_in
            rate_out = c_out / n_out if n_out > 0 else 0.0
            if rate_in <= rate_out:
                return 0.0
            val = 0.0
            exp_in = n_in * total_c / total_p
            exp_out = n_out * total_c / total_p
            if c_in > 0 and exp_in > 0:
                val += c_in * math.log(c_in / max(exp_in, 1e-12))
            if c_out > 0 and exp_out > 0:
                val += c_out * math.log(c_out / max(exp_out, 1e-12))
            return max(val, 0.0)

        def _count_components(members: set[int]) -> int:
            if not members:
                return 0
            visited: set[int] = set()
            comps = 0
            for start in members:
                if start in visited:
                    continue
                comps += 1
                stack = [start]
                while stack:
                    node = stack.pop()
                    if node in visited:
                        continue
                    visited.add(node)
                    for nb in adj_sets[node]:
                        if nb in members and nb not in visited:
                            stack.append(nb)
            return comps

        best_score = 0.0
        best_center = 0
        best_radius = 0.0
        best_members: set[int] = set()
        best_raw_llr = 0.0
        for i in range(n):
            sorted_nb = sorted(range(n), key=lambda j: dist_matrix[i][j])
            c_in = 0.0
            n_in = 0.0
            members: set[int] = set()
            for j in sorted_nb:
                if dist_matrix[i][j] > max_radius and j != i:
                    break
                c_in += cases[j]
                n_in += pops[j]
                members.add(j)
                raw = _llr(c_in, n_in)
                if raw > 0:
                    nc = _count_components(members)
                    score = raw / (1.0 + topo_penalty * max(nc - 1, 0))
                    if score > best_score:
                        best_score = score
                        best_raw_llr = raw
                        best_center = i
                        best_radius = dist_matrix[i][j]
                        best_members = set(members)

        # Monte Carlo significance
        rng = random.Random(seed)
        exceed = 0
        for _ in range(n_simulations):
            sim_cases = list(cases)
            rng.shuffle(sim_cases)
            sim_best = 0.0
            for i in range(n):
                sorted_nb = sorted(range(n), key=lambda j: dist_matrix[i][j])
                c_in = 0.0
                n_in = 0.0
                members_sim: set[int] = set()
                for j in sorted_nb:
                    if dist_matrix[i][j] > max_radius and j != i:
                        break
                    c_in += sim_cases[j]
                    n_in += pops[j]
                    members_sim.add(j)
                    raw = _llr(c_in, n_in)
                    if raw > 0:
                        nc = _count_components(members_sim)
                        score = raw / (1.0 + topo_penalty * max(nc - 1, 0))
                        if score > sim_best:
                            sim_best = score
            if sim_best >= best_score:
                exceed += 1
        p_value = (exceed + 1) / (n_simulations + 1)

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"in_cluster_{suffix}"] = i in best_members
            resolved[f"llr_{suffix}"] = round(best_raw_llr, 6) if i in best_members else 0.0
            resolved[f"topo_score_{suffix}"] = round(best_score, 6) if i in best_members else 0.0
            resolved[f"p_value_{suffix}"] = round(p_value, 6)
            resolved[f"cluster_center_{suffix}"] = i == best_center
            resolved[f"cluster_radius_{suffix}"] = round(best_radius, 6)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 524: graph_coupled_space_time_regionalization
    # ------------------------------------------------------------------
    def graph_coupled_space_time_regionalization(
        self,
        value_columns: Sequence[str],
        time_column: str,
        n_regions: int = 5,
        k: int = 6,
        alpha: float = 0.5,
        max_iterations: int = 50,
        seed: int | None = None,
        distance_method: str = "euclidean",
        suffix: str = "gstr",
    ) -> "GeoPromptFrame":
        """Graph-coupled space-time regionalization.

        Partitions spatial features into *n_regions* contiguous regions
        that minimize within-region dissimilarity in both attribute space
        and temporal trajectory.  A k-NN spatial adjacency graph enforces
        contiguity; *alpha* balances attribute distance (1-alpha) vs
        temporal distance (alpha).
        """
        for col in value_columns:
            self._require_column(col)
        self._require_column(time_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)

        # Build k-NN adjacency
        adj: list[set[int]] = [set() for _ in range(n)]
        for i in range(n):
            sorted_j = sorted(range(n), key=lambda j: dist_matrix[i][j])
            for j in sorted_j[1: k + 1]:
                adj[i].add(j)
                adj[j].add(i)

        # Attribute vectors
        attr_vals = [
            [float(self._rows[i].get(col, 0) or 0) for col in value_columns]
            for i in range(n)
        ]
        time_vals = [float(self._rows[i].get(time_column, 0) or 0) for i in range(n)]

        def _combined_dist(i: int, j: int) -> float:
            a_d = math.sqrt(
                sum((attr_vals[i][d] - attr_vals[j][d]) ** 2 for d in range(len(value_columns)))
            )
            t_d = abs(time_vals[i] - time_vals[j])
            return (1.0 - alpha) * a_d + alpha * t_d

        # Initialize: random contiguous seed growth
        rng = random.Random(seed)
        labels = [-1] * n
        seeds = rng.sample(range(n), min(n_regions, n))
        for r, s in enumerate(seeds):
            labels[s] = r
        # BFS grow from seeds
        frontier: list[tuple[int, int]] = [(s, r) for r, s in enumerate(seeds)]
        rng.shuffle(frontier)
        while frontier:
            node, region = frontier.pop(0)
            for nb in adj[node]:
                if labels[nb] == -1:
                    labels[nb] = region
                    frontier.append((nb, region))
        # Assign any remaining unlabeled to nearest labeled
        for i in range(n):
            if labels[i] == -1:
                best_d = float("inf")
                best_r = 0
                for j in range(n):
                    if labels[j] >= 0:
                        d = dist_matrix[i][j]
                        if d < best_d:
                            best_d = d
                            best_r = labels[j]
                labels[i] = best_r

        # Iterative improvement: reassign boundary nodes
        for _ in range(max_iterations):
            changed = False
            for i in range(n):
                neighbor_regions = {labels[nb] for nb in adj[i]}
                if len(neighbor_regions) <= 1:
                    continue
                # Compute cost for each candidate region
                best_cost = float("inf")
                best_label = labels[i]
                for cand_r in neighbor_regions:
                    cost = sum(
                        _combined_dist(i, j) for j in range(n) if labels[j] == cand_r
                    )
                    if cost < best_cost:
                        best_cost = cost
                        best_label = cand_r
                if best_label != labels[i]:
                    labels[i] = best_label
                    changed = True
            if not changed:
                break

        # Compute region centroids for summary
        region_sizes: dict[int, int] = {}
        for lb in labels:
            region_sizes[lb] = region_sizes.get(lb, 0) + 1

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"region_{suffix}"] = labels[i]
            resolved[f"region_size_{suffix}"] = region_sizes.get(labels[i], 0)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 525: anisotropic_kernel_density
    # ------------------------------------------------------------------
    def anisotropic_kernel_density(
        self,
        value_column: str | None = None,
        bandwidth: float | None = None,
        angle_column: str | None = None,
        ratio: float = 2.0,
        grid_resolution: int = 20,
        suffix: str = "akde",
    ) -> "GeoPromptFrame":
        """Anisotropic kernel density estimation.

        Fits an oriented Gaussian kernel at each point.  The kernel's
        major axis follows the bearing given in *angle_column* (degrees
        east of north) or, when not supplied, the principal axis of the
        local point cloud.  The *ratio* controls the eccentricity
        (major / minor bandwidth ratio).
        """
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        h = bandwidth if bandwidth is not None else extent / max(n ** 0.2, 1.0)
        h_major = h * ratio
        h_minor = h

        weights = [
            float(self._rows[i].get(value_column, 1) or 1) if value_column else 1.0
            for i in range(n)
        ]

        # Per-point rotation angle (radians from east, counter-clockwise)
        angles: list[float] = []
        if angle_column:
            for i in range(n):
                deg = float(self._rows[i].get(angle_column, 0) or 0)
                angles.append(math.radians(90.0 - deg))  # bearing → math angle
        else:
            # PCA principal angle from local 6-NN
            for i in range(n):
                dists = sorted(
                    ((j, math.hypot(centroids[i][0] - centroids[j][0],
                                    centroids[i][1] - centroids[j][1]))
                     for j in range(n) if j != i),
                    key=lambda t: t[1],
                )
                neighbors = [centroids[t[0]] for t in dists[:6]]
                cx = sum(p[0] for p in neighbors) / max(len(neighbors), 1)
                cy = sum(p[1] for p in neighbors) / max(len(neighbors), 1)
                cov_xx = sum((p[0] - cx) ** 2 for p in neighbors)
                cov_yy = sum((p[1] - cy) ** 2 for p in neighbors)
                cov_xy = sum((p[0] - cx) * (p[1] - cy) for p in neighbors)
                theta = 0.5 * math.atan2(2 * cov_xy, cov_xx - cov_yy + 1e-15)
                angles.append(theta)

        # Build a grid and evaluate the kernel density
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)

        grid_pts: list[tuple[float, float]] = []
        for gy in range(grid_resolution):
            for gx in range(grid_resolution):
                grid_pts.append((b.min_x + gx * dx, b.min_y + gy * dy))

        grid_vals = [0.0] * len(grid_pts)
        for i in range(n):
            cos_a = math.cos(angles[i])
            sin_a = math.sin(angles[i])
            for gi, (gx, gy) in enumerate(grid_pts):
                rx = (gx - centroids[i][0]) * cos_a + (gy - centroids[i][1]) * sin_a
                ry = -(gx - centroids[i][0]) * sin_a + (gy - centroids[i][1]) * cos_a
                u = (rx / max(h_major, 1e-12)) ** 2 + (ry / max(h_minor, 1e-12)) ** 2
                if u < 9.0:
                    grid_vals[gi] += weights[i] * math.exp(-0.5 * u)

        # For each input feature, sample from the closest grid cell
        out: list[Record] = []
        for i in range(n):
            gx_idx = max(0, min(grid_resolution - 1,
                                round((centroids[i][0] - b.min_x) / max(dx, 1e-12))))
            gy_idx = max(0, min(grid_resolution - 1,
                                round((centroids[i][1] - b.min_y) / max(dy, 1e-12))))
            density = grid_vals[gy_idx * grid_resolution + gx_idx]
            resolved = dict(self._rows[i])
            resolved[f"density_{suffix}"] = round(density, 6)
            resolved[f"angle_deg_{suffix}"] = round(math.degrees(angles[i]), 2)
            resolved[f"h_major_{suffix}"] = round(h_major, 6)
            resolved[f"h_minor_{suffix}"] = round(h_minor, 6)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 526: spatial_durbin_error_model
    # ------------------------------------------------------------------
    def spatial_durbin_error_model(
        self,
        dependent_column: str,
        independent_columns: Sequence[str],
        mode: str = "k_nearest",
        k: int = 6,
        max_distance: float | None = None,
        distance_method: str = "euclidean",
        lambda_init: float = 0.5,
        max_iter: int = 30,
        suffix: str = "sdem",
    ) -> "GeoPromptFrame":
        """Spatial Durbin Error Model (SDEM).

        Extends the classical spatial error model by including spatial
        lags of the independent variables.  This captures both error
        dependence and spillover effects from neighbouring covariates:

            y = Xβ + WXθ + u,  where u = λWu + ε

        The spatial parameter *lambda* is estimated via iterative
        generalised least squares (IGLS).
        """
        self._require_column(dependent_column)
        for col in independent_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)

        # Build row-normalised spatial weight matrix W
        W: list[list[float]] = [[0.0] * n for _ in range(n)]
        for i in range(n):
            if mode == "k_nearest":
                sorted_j = sorted(range(n), key=lambda j: dist_matrix[i][j])
                for j in sorted_j[1: k + 1]:
                    W[i][j] = 1.0
            else:
                md = max_distance if max_distance else (max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9) * 0.3 for b in [self.bounds()]).__next__()
                for j in range(n):
                    if j != i and dist_matrix[i][j] <= md:
                        W[i][j] = 1.0
            row_sum = sum(W[i])
            if row_sum > 0:
                for j in range(n):
                    W[i][j] /= row_sum

        p = len(independent_columns)
        y = [float(self._rows[i].get(dependent_column, 0) or 0) for i in range(n)]
        X_raw = [[float(self._rows[i].get(col, 0) or 0) for col in independent_columns] for i in range(n)]
        # WX — spatial lag of each independent
        WX = [[sum(W[i][j] * X_raw[j][c] for j in range(n)) for c in range(p)] for i in range(n)]
        # Augmented X = [1, X, WX]
        p_aug = 1 + p + p
        X = [[1.0] + X_raw[i] + WX[i] for i in range(n)]

        lam = lambda_init
        coeffs: list[float] | None = None

        for _ in range(max_iter):
            # Filter: y* = y - lambda*Wy, X* = X - lambda*WX_aug
            Wy = [sum(W[i][j] * y[j] for j in range(n)) for i in range(n)]
            y_star = [y[i] - lam * Wy[i] for i in range(n)]
            WX_aug = [
                [sum(W[i][j] * X[j][c] for j in range(n)) for c in range(p_aug)]
                for i in range(n)
            ]
            X_star = [[X[i][c] - lam * WX_aug[i][c] for c in range(p_aug)] for i in range(n)]
            # OLS on filtered
            xtx = [
                [sum(X_star[i][a] * X_star[i][b_] for i in range(n)) for b_ in range(p_aug)]
                for a in range(p_aug)
            ]
            xty = [sum(X_star[i][a] * y_star[i] for i in range(n)) for a in range(p_aug)]
            coeffs = _solve_linear_system(xtx, xty)
            if coeffs is None:
                coeffs = [0.0] * p_aug
                break
            # Update residuals and lambda
            resid = [y[i] - sum(coeffs[c] * X[i][c] for c in range(p_aug)) for i in range(n)]
            W_resid = [sum(W[i][j] * resid[j] for j in range(n)) for i in range(n)]
            num = sum(resid[i] * W_resid[i] for i in range(n))
            den = sum(W_resid[i] * W_resid[i] for i in range(n))
            lam_new = num / max(den, 1e-12)
            lam_new = max(-0.99, min(0.99, lam_new))
            if abs(lam_new - lam) < 1e-6:
                lam = lam_new
                break
            lam = lam_new

        if coeffs is None:
            coeffs = [0.0] * p_aug

        # Predicted and residuals
        out: list[Record] = []
        for i in range(n):
            pred = sum(coeffs[c] * X[i][c] for c in range(p_aug))
            resolved = dict(self._rows[i])
            resolved[f"predicted_{suffix}"] = round(pred, 6)
            resolved[f"residual_{suffix}"] = round(y[i] - pred, 6)
            resolved[f"lambda_{suffix}"] = round(lam, 6)
            resolved[f"intercept_{suffix}"] = round(coeffs[0], 6)
            for ci, col in enumerate(independent_columns):
                resolved[f"coeff_{col}_{suffix}"] = round(coeffs[1 + ci], 6)
                resolved[f"lag_coeff_{col}_{suffix}"] = round(coeffs[1 + p + ci], 6)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 527: wavelet_spatial_autocorrelation
    # ------------------------------------------------------------------
    def wavelet_spatial_autocorrelation(
        self,
        value_column: str,
        n_scales: int = 5,
        base_bandwidth: float | None = None,
        distance_method: str = "euclidean",
        suffix: str = "wsa",
    ) -> "GeoPromptFrame":
        """Wavelet-based spatial autocorrelation across multiple scales.

        Decomposes the spatial signal into scale-specific components
        using difference-of-Gaussians wavelets, then computes a local
        autocorrelation measure at each scale.  Reveals at which
        spatial scales clustering is strongest for each feature.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        vals = [float(self._rows[i].get(value_column, 0) or 0) for i in range(n)]
        mean_val = sum(vals) / n if n > 0 else 0.0
        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        h0 = base_bandwidth if base_bandwidth else extent / (n ** 0.5 + 1)

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            best_scale = -1
            best_corr = -2.0
            for s in range(n_scales):
                h_s = h0 * (2.0 ** s)
                h_s2 = h_s * 2.0
                # DoG wavelet coefficient at feature i
                smooth_narrow = 0.0
                w_narrow = 0.0
                smooth_wide = 0.0
                w_wide = 0.0
                for j in range(n):
                    d = dist_matrix[i][j]
                    g_n = math.exp(-0.5 * (d / max(h_s, 1e-12)) ** 2)
                    g_w = math.exp(-0.5 * (d / max(h_s2, 1e-12)) ** 2)
                    smooth_narrow += g_n * vals[j]
                    w_narrow += g_n
                    smooth_wide += g_w * vals[j]
                    w_wide += g_w
                s_n = smooth_narrow / max(w_narrow, 1e-12)
                s_w = smooth_wide / max(w_wide, 1e-12)
                wavelet_coeff = s_n - s_w

                # Local autocorrelation: correlation of wavelet coeff with neighbours
                nb_coeffs: list[float] = []
                for j in range(n):
                    if j == i:
                        continue
                    if dist_matrix[i][j] <= h_s:
                        sn_j = 0.0
                        wn_j = 0.0
                        sw_j = 0.0
                        ww_j = 0.0
                        for jj in range(n):
                            d2 = dist_matrix[j][jj]
                            g_n2 = math.exp(-0.5 * (d2 / max(h_s, 1e-12)) ** 2)
                            g_w2 = math.exp(-0.5 * (d2 / max(h_s2, 1e-12)) ** 2)
                            sn_j += g_n2 * vals[jj]
                            wn_j += g_n2
                            sw_j += g_w2 * vals[jj]
                            ww_j += g_w2
                        nb_coeffs.append(sn_j / max(wn_j, 1e-12) - sw_j / max(ww_j, 1e-12))
                if nb_coeffs:
                    mean_nb = sum(nb_coeffs) / len(nb_coeffs)
                    cov = sum((c - mean_nb) * wavelet_coeff for c in nb_coeffs) / len(nb_coeffs)
                    var_nb = sum((c - mean_nb) ** 2 for c in nb_coeffs) / len(nb_coeffs)
                    corr = cov / max(math.sqrt(var_nb) * max(abs(wavelet_coeff), 1e-12), 1e-12)
                else:
                    corr = 0.0

                resolved[f"wavelet_s{s}_{suffix}"] = round(wavelet_coeff, 6)
                resolved[f"autocorr_s{s}_{suffix}"] = round(corr, 6)
                if corr > best_corr:
                    best_corr = corr
                    best_scale = s
            resolved[f"dominant_scale_{suffix}"] = best_scale
            resolved[f"dominant_autocorr_{suffix}"] = round(best_corr, 6)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 528: multiscale_getis_ord
    # ------------------------------------------------------------------
    def multiscale_getis_ord(
        self,
        value_column: str,
        scales: Sequence[float] | None = None,
        n_scales: int = 5,
        distance_method: str = "euclidean",
        suffix: str = "msgi",
    ) -> "GeoPromptFrame":
        """Multi-scale Getis-Ord Gi* statistic.

        Evaluates the Gi* hotspot statistic across several distance
        bands simultaneously, storing the z-score at each scale and
        identifying the optimal scale (highest |z|) for every feature.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        vals = [float(self._rows[i].get(value_column, 0) or 0) for i in range(n)]
        mean_v = sum(vals) / n
        var_v = sum((v - mean_v) ** 2 for v in vals) / n
        sd_v = math.sqrt(var_v) if var_v > 0 else 1e-12

        b = self.bounds()
        extent = max(b.max_x - b.min_x, b.max_y - b.min_y, 1e-9)
        if scales is not None:
            scale_list = list(scales)
        else:
            step = extent / max(n_scales, 1)
            scale_list = [step * (s + 1) for s in range(n_scales)]

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            best_abs_z = 0.0
            best_scale_idx = 0
            for si, sc in enumerate(scale_list):
                w_sum = 0.0
                wx_sum = 0.0
                w2_sum = 0.0
                for j in range(n):
                    if dist_matrix[i][j] <= sc:
                        w = 1.0
                    else:
                        w = 0.0
                    w_sum += w
                    wx_sum += w * vals[j]
                    w2_sum += w * w
                if w_sum > 0 and n > 1:
                    numerator = wx_sum - mean_v * w_sum
                    denominator = sd_v * math.sqrt((n * w2_sum - w_sum * w_sum) / max(n - 1, 1))
                    z = numerator / max(denominator, 1e-12)
                else:
                    z = 0.0
                resolved[f"gi_z_s{si}_{suffix}"] = round(z, 6)
                if abs(z) > best_abs_z:
                    best_abs_z = abs(z)
                    best_scale_idx = si
            resolved[f"optimal_scale_idx_{suffix}"] = best_scale_idx
            resolved[f"optimal_scale_dist_{suffix}"] = round(scale_list[best_scale_idx], 6)
            resolved[f"optimal_gi_z_{suffix}"] = round(
                resolved.get(f"gi_z_s{best_scale_idx}_{suffix}", 0.0), 6  # type: ignore[arg-type]
            )
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 529: network_constrained_clustering
    # ------------------------------------------------------------------
    def network_constrained_clustering(
        self,
        value_column: str,
        k: int = 6,
        n_clusters: int = 5,
        max_iterations: int = 50,
        distance_method: str = "euclidean",
        seed: int | None = None,
        suffix: str = "ncc",
    ) -> "GeoPromptFrame":
        """Network-constrained k-medoids clustering.

        Builds a k-NN spatial graph, then runs a modified k-medoids
        algorithm where reassignment is only allowed along graph edges,
        guaranteeing that each cluster is a connected subgraph.
        """
        self._require_column(value_column)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        vals = [float(self._rows[i].get(value_column, 0) or 0) for i in range(n)]

        # Build adjacency
        adj: list[set[int]] = [set() for _ in range(n)]
        for i in range(n):
            sorted_j = sorted(range(n), key=lambda j: dist_matrix[i][j])
            for j in sorted_j[1: k + 1]:
                adj[i].add(j)
                adj[j].add(i)

        # Graph shortest-path distance (BFS with uniform weight)
        graph_dist: list[list[float]] = [[float("inf")] * n for _ in range(n)]
        for s in range(n):
            graph_dist[s][s] = 0.0
            queue = [s]
            head = 0
            while head < len(queue):
                u = queue[head]
                head += 1
                for v in adj[u]:
                    alt = graph_dist[s][u] + dist_matrix[u][v]
                    if alt < graph_dist[s][v]:
                        graph_dist[s][v] = alt
                        queue.append(v)

        # k-medoids PAM-style
        rng = random.Random(seed)
        nc = min(n_clusters, n)
        medoids = rng.sample(range(n), nc)
        labels = [0] * n
        for _ in range(max_iterations):
            # Assign
            for i in range(n):
                best_d = float("inf")
                best_m = 0
                for mi, m in enumerate(medoids):
                    d = graph_dist[i][m] + abs(vals[i] - vals[m])
                    if d < best_d:
                        best_d = d
                        best_m = mi
                labels[i] = best_m
            # Update medoids
            changed = False
            for mi in range(nc):
                members = [j for j in range(n) if labels[j] == mi]
                if not members:
                    continue
                best_cost = float("inf")
                best_med = medoids[mi]
                for cand in members:
                    cost = sum(graph_dist[cand][j] + abs(vals[cand] - vals[j]) for j in members)
                    if cost < best_cost:
                        best_cost = cost
                        best_med = cand
                if best_med != medoids[mi]:
                    medoids[mi] = best_med
                    changed = True
            if not changed:
                break

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"cluster_{suffix}"] = labels[i]
            resolved[f"medoid_{suffix}"] = medoids[labels[i]]
            resolved[f"dist_to_medoid_{suffix}"] = round(graph_dist[i][medoids[labels[i]]], 6)
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Tool 530: spatial_envelope_anomaly
    # ------------------------------------------------------------------
    def spatial_envelope_anomaly(
        self,
        value_columns: Sequence[str],
        k: int = 6,
        contamination: float = 0.1,
        distance_method: str = "euclidean",
        suffix: str = "sea",
    ) -> "GeoPromptFrame":
        """Spatial envelope anomaly detection.

        Computes a local multivariate envelope (bounding hyper-rectangle
        of neighbor attribute values) for each feature, then scores
        how far the feature's own attributes fall outside the local
        envelope.  Features with high exceedance across many dimensions
        are flagged as spatial anomalies.
        """
        for col in value_columns:
            self._require_column(col)
        n = len(self._rows)
        if n == 0:
            return GeoPromptFrame._from_internal_rows(
                [], geometry_column=self.geometry_column, crs=self.crs,
            )
        centroids = self._centroids()
        dist_matrix = _pairwise_distance_matrix(centroids, distance_method)
        p = len(value_columns)
        attr_vals = [
            [float(self._rows[i].get(col, 0) or 0) for col in value_columns]
            for i in range(n)
        ]

        scores: list[float] = []
        for i in range(n):
            # k nearest neighbours
            sorted_j = sorted(range(n), key=lambda j: dist_matrix[i][j])
            neighbors = sorted_j[1: k + 1]
            if not neighbors:
                scores.append(0.0)
                continue
            # Envelope: min/max per dimension
            exceedance = 0.0
            for d in range(p):
                nb_vals = [attr_vals[j][d] for j in neighbors]
                lo = min(nb_vals)
                hi = max(nb_vals)
                rng_d = hi - lo
                if rng_d < 1e-12:
                    rng_d = 1.0
                v = attr_vals[i][d]
                if v < lo:
                    exceedance += (lo - v) / rng_d
                elif v > hi:
                    exceedance += (v - hi) / rng_d
            scores.append(exceedance)

        # Threshold
        sorted_scores = sorted(scores, reverse=True)
        top_n = max(1, int(contamination * n))
        threshold = sorted_scores[min(top_n - 1, n - 1)] if n > 0 else 0.0

        out: list[Record] = []
        for i in range(n):
            resolved = dict(self._rows[i])
            resolved[f"anomaly_score_{suffix}"] = round(scores[i], 6)
            resolved[f"is_anomaly_{suffix}"] = 1 if scores[i] >= threshold and scores[i] > 0 else 0
            out.append(resolved)
        return GeoPromptFrame._from_internal_rows(
            out, geometry_column=self.geometry_column, crs=self.crs,
        )

    # ------------------------------------------------------------------
    # Internal helper: IDW grid generation
    # ------------------------------------------------------------------
    def _idw_grid(
        self,
        value_column: str,
        grid_resolution: int,
        distance_method: str,
    ) -> tuple[list[list[float]], Bounds, float, float]:
        centroids = self._centroids()
        b = self.bounds()
        dx = (b.max_x - b.min_x) / max(grid_resolution - 1, 1)
        dy = (b.max_y - b.min_y) / max(grid_resolution - 1, 1)
        use_euclidean = distance_method == "euclidean"
        grid: list[list[float]] = []
        for gy in range(grid_resolution):
            row_vals: list[float] = []
            py = b.min_y + gy * dy
            for gx in range(grid_resolution):
                px = b.min_x + gx * dx
                weight_sum = 0.0
                value_sum = 0.0
                for ci, centroid in enumerate(centroids):
                    if use_euclidean:
                        d = math.hypot(px - centroid[0], py - centroid[1])
                    else:
                        d = coordinate_distance((px, py), centroid, method=distance_method)
                    w = 1.0 / max(d * d, 1e-12)
                    weight_sum += w
                    value_sum += w * float(self._rows[ci].get(value_column, 0) or 0)
                row_vals.append(value_sum / weight_sum if weight_sum > 0 else 0.0)
            grid.append(row_vals)
        return grid, b, dx, dy

    # ==================================================================
    # File I/O: Universal Dispatchers & Additional Formats
    # ==================================================================

    # ------------------------------------------------------------------
    # read_file — universal format dispatcher (like geopandas.read_file)
    # ------------------------------------------------------------------
    @classmethod
    def read_file(
        cls,
        path: str,
        layer: str | None = None,
        geometry: str = "geometry",
        crs: str | None = None,
        **kwargs: Any,
    ) -> "GeoPromptFrame":
        """Read spatial data from any supported file format.

        Auto-detects format from file extension. Supported formats:

        * ``.geojson`` / ``.json`` — GeoJSON
        * ``.shp`` — ESRI Shapefile
        * ``.gpkg`` — OGC GeoPackage
        * ``.gdb`` — ESRI File Geodatabase (requires fiona or geopandas)
        * ``.parquet`` / ``.geoparquet`` — GeoParquet
        * ``.fgb`` — FlatGeobuf
        * ``.kml`` — KML
        * ``.csv`` — CSV with WKT geometry column
        * ``.geojsonl`` / ``.ndjson`` / ``.jsonl`` — Newline-delimited GeoJSON
        * ``.tif`` / ``.tiff`` / ``.img`` / ``.asc`` / ``.nc`` — Raster (returns point grid)

        Parameters
        ----------
        path : str
            Path to the input file.
        layer : str | None
            Layer name for multi-layer formats (GeoPackage, FGDB).
        geometry : str
            Name of the geometry column in the resulting frame.
        crs : str | None
            Override CRS. If *None*, read from file metadata when available.
        **kwargs
            Additional keyword arguments forwarded to the format-specific reader.
        """
        import os
        ext = os.path.splitext(path)[1].lower()

        # Handle .gdb directory
        if os.path.isdir(path) and path.lower().endswith(".gdb"):
            ext = ".gdb"

        dispatch: dict[str, str] = {
            ".geojson": "geojson",
            ".json": "geojson",
            ".shp": "shapefile",
            ".gpkg": "geopackage",
            ".gdb": "fgdb",
            ".parquet": "geoparquet",
            ".geoparquet": "geoparquet",
            ".fgb": "flatgeobuf",
            ".kml": "kml",
            ".csv": "csv_wkt",
            ".geojsonl": "geojson_seq",
            ".ndjson": "geojson_seq",
            ".jsonl": "geojson_seq",
            ".tif": "raster",
            ".tiff": "raster",
            ".img": "raster",
            ".asc": "raster",
            ".nc": "raster",
        }

        fmt = dispatch.get(ext)
        if fmt is None:
            raise ValueError(f"Unsupported file extension '{ext}'. Supported: {', '.join(sorted(dispatch))}")

        if fmt == "geojson":
            from .io import read_geojson
            return read_geojson(path, geometry=geometry, crs=crs)
        if fmt == "shapefile":
            return cls.read_shapefile(path, geometry=geometry, crs=crs)
        if fmt == "geopackage":
            return cls.read_geopackage(path, layer=layer, geometry=geometry, crs=crs)
        if fmt == "fgdb":
            return cls.read_fgdb(path, layer=layer, geometry=geometry, crs=crs)
        if fmt == "geoparquet":
            return cls.read_geoparquet(path, geometry=geometry, crs=crs)
        if fmt == "flatgeobuf":
            return cls.read_flatgeobuf(path, geometry=geometry, crs=crs)
        if fmt == "kml":
            return cls.read_kml(path, geometry=geometry, crs=crs)
        if fmt == "csv_wkt":
            geom_col = kwargs.get("geometry_column", "wkt")
            return cls.read_csv_wkt(path, geometry_column=geom_col, output_geometry=geometry, crs=crs)
        if fmt == "geojson_seq":
            return cls.read_geojson_seq(path, geometry=geometry, crs=crs)
        if fmt == "raster":
            return cls.read_raster(path, geometry=geometry, crs=crs, **kwargs)
        raise ValueError(f"Unsupported format: {fmt}")

    # ------------------------------------------------------------------
    # to_file — universal write dispatcher
    # ------------------------------------------------------------------
    def to_file(self, path: str, driver: str | None = None, layer: str | None = None, **kwargs: Any) -> None:
        """Write the frame to any supported file format.

        Auto-detects format from file extension. Supported formats:

        * ``.geojson`` / ``.json`` — GeoJSON
        * ``.shp`` — ESRI Shapefile
        * ``.gpkg`` — OGC GeoPackage
        * ``.parquet`` / ``.geoparquet`` — GeoParquet
        * ``.fgb`` — FlatGeobuf
        * ``.csv`` — Flat CSV
        * ``.geojsonl`` / ``.ndjson`` / ``.jsonl`` — Newline-delimited GeoJSON
        * ``.tif`` / ``.tiff`` — GeoTIFF raster (requires value_column kwarg)

        Parameters
        ----------
        path : str
            Path to the output file.
        driver : str | None
            Override the driver/format name instead of auto-detecting.
        layer : str | None
            Layer name for multi-layer formats (GeoPackage).
        **kwargs
            Additional keyword arguments forwarded to the format-specific writer.
        """
        import os
        ext = os.path.splitext(path)[1].lower()

        dispatch: dict[str, str] = {
            ".geojson": "geojson",
            ".json": "geojson",
            ".shp": "shapefile",
            ".gpkg": "geopackage",
            ".parquet": "geoparquet",
            ".geoparquet": "geoparquet",
            ".fgb": "flatgeobuf",
            ".csv": "csv",
            ".geojsonl": "geojson_seq",
            ".ndjson": "geojson_seq",
            ".jsonl": "geojson_seq",
            ".tif": "raster",
            ".tiff": "raster",
        }

        fmt = driver or dispatch.get(ext)
        if fmt is None:
            raise ValueError(f"Unsupported file extension '{ext}'. Supported: {', '.join(sorted(dispatch))}")

        if fmt == "geojson":
            from .io import write_geojson
            write_geojson(path, self, geometry=self.geometry_column)
        elif fmt == "shapefile":
            self.to_shapefile(path)
        elif fmt == "geopackage":
            self.to_geopackage(path, layer=layer)
        elif fmt == "geoparquet":
            self.to_geoparquet(path)
        elif fmt == "flatgeobuf":
            self.to_flatgeobuf(path)
        elif fmt == "csv":
            from .io import write_flat_csv
            write_flat_csv(path, self, geometry=self.geometry_column)
        elif fmt == "geojson_seq":
            self.to_geojson_seq(path)
        elif fmt == "raster":
            self.to_raster(path, **kwargs)
        else:
            raise ValueError(f"Unsupported output format: {fmt}")

    # ------------------------------------------------------------------
    # read_fgdb — ESRI File Geodatabase
    # ------------------------------------------------------------------
    @classmethod
    def read_fgdb(
        cls,
        path: str,
        layer: str | None = None,
        geometry: str = "geometry",
        crs: str | None = None,
    ) -> "GeoPromptFrame":
        """Read an ESRI File Geodatabase (.gdb).

        Requires ``fiona`` (with GDAL OpenFileGDB driver) or ``geopandas``.

        Parameters
        ----------
        path : str
            Path to the ``.gdb`` directory.
        layer : str | None
            Layer name. If *None*, reads the first available layer.
        geometry : str
            Geometry column name in returned frame.
        crs : str | None
            Override CRS.
        """
        # Try fiona first
        try:
            fiona = importlib.import_module("fiona")
            layers = fiona.listlayers(path)
            target = layer or (layers[0] if layers else None)
            if target is None:
                return cls.from_records([], geometry=geometry, crs=crs)
            with fiona.open(path, layer=target) as src:
                frame_crs = crs or str(src.crs)
                records: list[Record] = []
                for feat in src:
                    rec: Record = dict(feat.get("properties") or {})
                    geom = feat.get("geometry")
                    rec[geometry] = dict(geom) if geom else None
                    records.append(rec)
                return cls.from_records(records, geometry=geometry, crs=frame_crs)
        except ImportError:
            pass

        # Fall back to geopandas
        try:
            import geopandas as gpd
        except ImportError as exc:
            raise ImportError("Install fiona or geopandas to read File Geodatabases") from exc

        gdf = gpd.read_file(path, layer=layer)
        active_geometry = getattr(gdf, "geometry", None)
        geometry_name = getattr(active_geometry, "name", geometry)
        records_list: list[Record] = []
        for _, source_row in gdf.iterrows():
            rec = dict(source_row)
            geom = rec.pop(geometry_name, None)
            rec[geometry] = geometry_from_shapely(geom) if geom is not None else None
            records_list.append(rec)
        frame_crs = crs or (str(getattr(gdf, "crs", "")) if getattr(gdf, "crs", None) else None)
        return cls.from_records(records_list, geometry=geometry, crs=frame_crs)

    # ------------------------------------------------------------------
    # to_geopackage — OGC GeoPackage write
    # ------------------------------------------------------------------
    def to_geopackage(self, path: str, layer: str | None = None, geometry: str | None = None) -> None:
        """Write the frame to OGC GeoPackage.

        Requires ``fiona`` or ``geopandas``.

        Parameters
        ----------
        path : str
            Output ``.gpkg`` file path.
        layer : str | None
            Layer name. Defaults to ``"data"``.
        geometry : str | None
            Geometry column name in the frame (defaults to self.geometry_column).
        """
        geom_col = geometry or self.geometry_column
        layer_name = layer or "data"

        # Try geopandas first (most reliable for GeoPackage)
        try:
            import geopandas as gpd
            from .overlay import geometry_to_shapely

            rows: list[Record] = []
            for row in self._rows:
                resolved = {k: v for k, v in row.items() if k != geom_col}
                geom = row.get(geom_col)
                resolved[geom_col] = geometry_to_shapely(geom) if geom is not None else None
                rows.append(resolved)
            gdf = gpd.GeoDataFrame(rows, geometry=geom_col, crs=self.crs)
            gdf.to_file(path, layer=layer_name, driver="GPKG")
            return
        except ImportError:
            pass

        # Fallback: fiona
        try:
            fiona = importlib.import_module("fiona")
            from_epsg = importlib.import_module("fiona.crs").from_epsg
            schema_props: dict[str, str] = {}
            geom_type = "Point"
            for row in self._rows:
                geom = row.get(geom_col)
                if geom:
                    geom_type = geometry_type(geom)
                    break
            for row in self._rows:
                for k, v in row.items():
                    if k != geom_col and k not in schema_props:
                        schema_props[k] = "str"
            schema = {"geometry": geom_type, "properties": schema_props}
            crs_dict = None
            if self.crs and self.crs.upper().startswith("EPSG:"):
                try:
                    crs_dict = from_epsg(int(self.crs.split(":")[1]))
                except Exception:
                    crs_dict = None
            with fiona.open(path, "w", driver="GPKG", layer=layer_name, schema=schema, crs=crs_dict) as dst:
                for row in self._rows:
                    geom = row.get(geom_col)
                    props = {k: str(v) for k, v in row.items() if k != geom_col}
                    dst.write({"geometry": geom, "properties": props})
            return
        except ImportError:
            pass

        raise ImportError("Install geopandas or fiona to write GeoPackage files")

    # ------------------------------------------------------------------
    # read_raster — raster file to point grid
    # ------------------------------------------------------------------
    @classmethod
    def read_raster(
        cls,
        path: str,
        band: int = 1,
        geometry: str = "geometry",
        crs: str | None = None,
        sample_step: int = 1,
        value_column: str = "value",
        nodata_skip: bool = True,
    ) -> "GeoPromptFrame":
        """Read a raster file and convert to a point grid.

        Each raster cell becomes a point feature with the cell value as an
        attribute. Supports GeoTIFF (``.tif``), Erdas Imagine (``.img``),
        ASCII Grid (``.asc``), and NetCDF (``.nc``).

        Requires ``rasterio`` for most formats. ASCII Grid (``.asc``) has a
        pure-Python fallback.

        Parameters
        ----------
        path : str
            Path to the raster file.
        band : int
            Band number to read (1-indexed).
        geometry : str
            Geometry column name.
        crs : str | None
            Override CRS.
        sample_step : int
            Read every *n*-th pixel (default 1 = all pixels). Increase to
            subsample large rasters.
        value_column : str
            Column name for pixel values.
        nodata_skip : bool
            If True, skip pixels with nodata value.
        """
        import os
        ext = os.path.splitext(path)[1].lower()

        # Pure-Python ASCII Grid fallback
        if ext == ".asc":
            return cls._read_asc_grid(path, geometry=geometry, crs=crs, sample_step=sample_step, value_column=value_column, nodata_skip=nodata_skip)

        # rasterio path
        try:
            rasterio = importlib.import_module("rasterio")
        except ImportError as exc:
            raise ImportError("Install rasterio to read raster files (pip install rasterio)") from exc

        records: list[Record] = []
        with rasterio.open(path) as src:
            frame_crs = crs or (str(src.crs) if src.crs else None)
            data = src.read(band)
            transform = src.transform
            nodata_val = src.nodata

            nrows, ncols = data.shape
            for r in range(0, nrows, sample_step):
                for c in range(0, ncols, sample_step):
                    val = float(data[r, c])
                    if nodata_skip and nodata_val is not None and val == float(nodata_val):
                        continue
                    x, y = rasterio.transform.xy(transform, r, c)
                    records.append({
                        geometry: {"type": "Point", "coordinates": (x, y)},
                        value_column: val,
                        "row": r,
                        "col": c,
                    })
        return cls.from_records(records, geometry=geometry, crs=frame_crs)

    @classmethod
    def _read_asc_grid(
        cls,
        path: str,
        geometry: str = "geometry",
        crs: str | None = None,
        sample_step: int = 1,
        value_column: str = "value",
        nodata_skip: bool = True,
    ) -> "GeoPromptFrame":
        """Pure-Python reader for ESRI ASCII Grid (.asc)."""
        headers: dict[str, str] = {}
        data_lines: list[str] = []
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                stripped = line.strip()
                if not stripped:
                    continue
                parts = stripped.split()
                if len(parts) == 2 and parts[0].lower() in (
                    "ncols", "nrows", "xllcorner", "yllcorner", "xllcenter",
                    "yllcenter", "cellsize", "nodata_value",
                ):
                    headers[parts[0].lower()] = parts[1]
                else:
                    data_lines.append(stripped)

        ncols = int(headers.get("ncols", "0"))
        nrows_hdr = int(headers.get("nrows", "0"))
        xll = float(headers.get("xllcorner", headers.get("xllcenter", "0")))
        yll = float(headers.get("yllcorner", headers.get("yllcenter", "0")))
        cellsize = float(headers.get("cellsize", "1"))
        nodata_str = headers.get("nodata_value", "-9999")
        nodata_val = float(nodata_str)

        records: list[Record] = []
        for r, line in enumerate(data_lines[:nrows_hdr]):
            if r % sample_step != 0:
                continue
            vals = line.split()
            for c, vs in enumerate(vals[:ncols]):
                if c % sample_step != 0:
                    continue
                val = float(vs)
                if nodata_skip and val == nodata_val:
                    continue
                x = xll + c * cellsize + cellsize / 2
                y = yll + (nrows_hdr - 1 - r) * cellsize + cellsize / 2
                records.append({
                    geometry: {"type": "Point", "coordinates": (x, y)},
                    value_column: val,
                    "row": r,
                    "col": c,
                })
        return cls.from_records(records, geometry=geometry, crs=crs)

    # ------------------------------------------------------------------
    # to_raster — write point values to raster
    # ------------------------------------------------------------------
    def to_raster(
        self,
        path: str,
        value_column: str = "value",
        resolution: float | None = None,
        nodata: float = -9999.0,
        driver: str | None = None,
    ) -> None:
        """Write point values to a raster file.

        Each point's value is burned into the nearest grid cell. Requires
        ``rasterio`` for GeoTIFF output; ASCII Grid (``.asc``) has a
        pure-Python fallback.

        Parameters
        ----------
        path : str
            Output file path.
        value_column : str
            Column containing cell values.
        resolution : float | None
            Cell size. If *None*, auto-computed from point spacing.
        nodata : float
            Nodata fill value.
        driver : str | None
            Rasterio driver name. Auto-detected from extension if *None*.
        """
        import os
        ext = os.path.splitext(path)[1].lower()

        # Collect points + values
        points: list[tuple[float, float, float]] = []
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom:
                continue
            coords = geom.get("coordinates", (0, 0))
            val = float(row.get(value_column, 0) or 0)
            points.append((float(coords[0]), float(coords[1]), val))

        if not points:
            return

        xs = [p[0] for p in points]
        ys = [p[1] for p in points]
        min_x, max_x = min(xs), max(xs)
        min_y, max_y = min(ys), max(ys)

        # Auto resolution
        if resolution is None:
            n = len(points)
            if n > 1:
                extent = max(max_x - min_x, max_y - min_y)
                resolution = extent / max(math.isqrt(n), 1)
            else:
                resolution = 1.0

        ncols = max(int((max_x - min_x) / resolution) + 1, 1)
        nrows = max(int((max_y - min_y) / resolution) + 1, 1)

        # Build grid
        grid = [[nodata] * ncols for _ in range(nrows)]
        for px, py, pv in points:
            c = min(int((px - min_x) / resolution), ncols - 1)
            r = min(int((max_y - py) / resolution), nrows - 1)
            grid[r][c] = pv

        # ASCII Grid fallback
        if ext == ".asc":
            with open(path, "w", encoding="utf-8") as f:
                f.write(f"ncols {ncols}\n")
                f.write(f"nrows {nrows}\n")
                f.write(f"xllcorner {min_x}\n")
                f.write(f"yllcorner {min_y}\n")
                f.write(f"cellsize {resolution}\n")
                f.write(f"nodata_value {nodata}\n")
                for row_vals in grid:
                    f.write(" ".join(str(v) for v in row_vals) + "\n")
            return

        # rasterio for GeoTIFF and other formats
        try:
            rasterio = importlib.import_module("rasterio")
            from_bounds = importlib.import_module("rasterio.transform").from_bounds
            import numpy as np
        except ImportError as exc:
            raise ImportError("Install rasterio to write raster files (pip install rasterio)") from exc

        transform = from_bounds(min_x, min_y, max_x + resolution, max_y + resolution, ncols, nrows)
        raster_driver = driver or {"tif": "GTiff", "tiff": "GTiff", "img": "HFA"}.get(ext.lstrip("."), "GTiff")
        crs_str = self.crs or None

        with rasterio.open(
            path,
            "w",
            driver=raster_driver,
            height=nrows,
            width=ncols,
            count=1,
            dtype="float64",
            crs=crs_str,
            transform=transform,
            nodata=nodata,
        ) as dst:
            arr = np.array(grid, dtype="float64")
            dst.write(arr, 1)

    # ------------------------------------------------------------------
    # read_excel — read tabular data with coordinates from Excel
    # ------------------------------------------------------------------
    @classmethod
    def read_excel(
        cls,
        path: str,
        x_column: str = "x",
        y_column: str = "y",
        geometry: str = "geometry",
        crs: str | None = None,
        sheet_name: str | int = 0,
    ) -> "GeoPromptFrame":
        """Read an Excel file (.xlsx/.xls) with coordinate columns.

        Requires ``openpyxl`` (for .xlsx) or ``xlrd`` (for .xls).

        Parameters
        ----------
        path : str
            Path to the Excel file.
        x_column : str
            Column name containing X/longitude values.
        y_column : str
            Column name containing Y/latitude values.
        geometry : str
            Geometry column name in returned frame.
        crs : str | None
            Coordinate reference system.
        sheet_name : str | int
            Sheet name or 0-based index.
        """
        import os
        ext = os.path.splitext(path)[1].lower()

        if ext == ".xlsx":
            try:
                openpyxl = importlib.import_module("openpyxl")
            except ImportError as exc:
                raise ImportError("Install openpyxl to read .xlsx files") from exc
            wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
            if isinstance(sheet_name, int):
                ws = wb.worksheets[sheet_name]
            else:
                ws = wb[sheet_name]
            rows_iter = ws.iter_rows(values_only=True)
            headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(next(rows_iter))]
            records: list[Record] = []
            for data_row in rows_iter:
                rec: Record = dict(zip(headers, data_row))
                xv = rec.pop(x_column, None)
                yv = rec.pop(y_column, None)
                if xv is not None and yv is not None:
                    rec[geometry] = {"type": "Point", "coordinates": (float(xv), float(yv))}
                records.append(rec)
            wb.close()
            return cls.from_records(records, geometry=geometry, crs=crs)

        # .xls or fallback: try pandas
        try:
            import pandas as pd
        except ImportError as exc:
            raise ImportError("Install pandas (and openpyxl or xlrd) to read Excel files") from exc
        df = pd.read_excel(path, sheet_name=sheet_name)
        records = []
        for _, pandas_row in df.iterrows():
            rec = dict(pandas_row)
            xv = rec.pop(x_column, None)
            yv = rec.pop(y_column, None)
            if xv is not None and yv is not None:
                rec[geometry] = {"type": "Point", "coordinates": (float(xv), float(yv))}
            records.append(rec)
        return cls.from_records(records, geometry=geometry, crs=crs)

    # ------------------------------------------------------------------
    # read_csv_xy — CSV with X/Y coordinate columns
    # ------------------------------------------------------------------
    @classmethod
    def read_csv_xy(
        cls,
        path: str,
        x_column: str = "x",
        y_column: str = "y",
        geometry: str = "geometry",
        crs: str | None = None,
        delimiter: str = ",",
    ) -> "GeoPromptFrame":
        """Read a CSV file with separate X and Y coordinate columns.

        Parameters
        ----------
        path : str
            Path to the CSV file.
        x_column : str
            Column containing X / longitude values.
        y_column : str
            Column containing Y / latitude values.
        geometry : str
            Geometry column name in the returned frame.
        crs : str | None
            Coordinate reference system.
        delimiter : str
            Column delimiter (default ``","``).
        """
        import csv
        records: list[Record] = []
        with open(path, "r", encoding="utf-8") as f:
            reader = csv.DictReader(f, delimiter=delimiter)
            for csv_row in reader:
                rec: Record = dict(csv_row)
                xv = rec.pop(x_column, None)
                yv = rec.pop(y_column, None)
                if xv is not None and yv is not None:
                    try:
                        rec[geometry] = {"type": "Point", "coordinates": (float(xv), float(yv))}
                    except (ValueError, TypeError):
                        pass
                records.append(rec)
        return cls.from_records(records, geometry=geometry, crs=crs)

    # ------------------------------------------------------------------
    # to_csv_wkt — CSV with WKT geometry column
    # ------------------------------------------------------------------
    def to_csv_wkt(self, path: str, geometry_column: str | None = None) -> None:
        """Write frame to CSV with geometry as WKT strings.

        Parameters
        ----------
        path : str
            Output CSV path.
        geometry_column : str | None
            Name of the WKT column in output. Defaults to ``"wkt"``.
        """
        import csv
        geom_col = self.geometry_column
        wkt_col = geometry_column or "wkt"
        cols: set[str] = set()
        for row in self._rows:
            cols.update(k for k in row if k != geom_col)
        col_list = sorted(cols)

        with open(path, "w", encoding="utf-8", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=[wkt_col] + col_list)
            writer.writeheader()
            for row in self._rows:
                out: dict[str, Any] = {k: row.get(k, "") for k in col_list}
                geom = row.get(geom_col)
                out[wkt_col] = _geometry_to_wkt(geom) if geom else ""
                writer.writerow(out)

    # ------------------------------------------------------------------
    # to_kml — write KML
    # ------------------------------------------------------------------
    def to_kml(self, path: str, name_column: str | None = None) -> None:
        """Write the frame to a KML file.

        Parameters
        ----------
        path : str
            Output KML file path.
        name_column : str | None
            Column to use for Placemark names.
        """
        lines: list[str] = [
            '<?xml version="1.0" encoding="UTF-8"?>',
            '<kml xmlns="http://www.opengis.net/kml/2.2">',
            "<Document>",
        ]
        for row in self._rows:
            geom = row.get(self.geometry_column)
            if not geom:
                continue
            gt = geometry_type(geom)
            name = str(row.get(name_column, "")) if name_column else ""
            lines.append("  <Placemark>")
            if name:
                lines.append(f"    <name>{name}</name>")
            if gt == "Point":
                coords = geom.get("coordinates", (0, 0))
                lines.append("    <Point>")
                lines.append(f"      <coordinates>{coords[0]},{coords[1]},0</coordinates>")
                lines.append("    </Point>")
            elif gt == "LineString":
                coords_list = geom.get("coordinates", [])
                coord_str = " ".join(f"{c[0]},{c[1]},0" for c in coords_list)
                lines.append("    <LineString>")
                lines.append(f"      <coordinates>{coord_str}</coordinates>")
                lines.append("    </LineString>")
            elif gt == "Polygon":
                raw = geom.get("coordinates", [])
                ring = raw[0] if raw and isinstance(raw[0], list) else raw
                coord_str = " ".join(f"{c[0]},{c[1]},0" for c in ring)
                lines.append("    <Polygon>")
                lines.append("      <outerBoundaryIs><LinearRing>")
                lines.append(f"        <coordinates>{coord_str}</coordinates>")
                lines.append("      </LinearRing></outerBoundaryIs>")
                lines.append("    </Polygon>")
            lines.append("  </Placemark>")
        lines.append("</Document>")
        lines.append("</kml>")
        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))

    # ------------------------------------------------------------------
    # list_layers — list layers in multi-layer files
    # ------------------------------------------------------------------
    @classmethod
    def list_layers(cls, path: str) -> list[str]:
        """List available layers in a multi-layer spatial file.

        Supports GeoPackage, File Geodatabase, and other GDAL-supported
        formats. Requires ``fiona`` or ``geopandas``.

        Parameters
        ----------
        path : str
            Path to the spatial file.
        """
        try:
            fiona = importlib.import_module("fiona")
            return list(fiona.listlayers(path))
        except ImportError:
            pass
        # sqlite3 fallback for GeoPackage (before geopandas, which may
        # use pyogrio with stricter validation on minimal files)
        import os
        if os.path.splitext(path)[1].lower() == ".gpkg":
            import sqlite3
            conn = sqlite3.connect(path)
            cursor = conn.cursor()
            try:
                cursor.execute("SELECT table_name FROM gpkg_contents")
                layers = [r[0] for r in cursor.fetchall()]
                conn.close()
                return layers
            except Exception:
                conn.close()
        try:
            import geopandas as gpd
            return list(gpd.list_layers(path)["name"])
        except (ImportError, AttributeError, Exception):
            pass
        raise ImportError("Install fiona or geopandas to list layers")

    # ------------------------------------------------------------------
    # raster_stats — zonal statistics from raster
    # ------------------------------------------------------------------
    def raster_stats(
        self,
        raster_path: str,
        band: int = 1,
        stats: list[str] | None = None,
        prefix: str = "rast",
    ) -> "GeoPromptFrame":
        """Extract raster statistics for each feature (zonal statistics).

        For Point features, samples the raster value at the point location.
        For Polygon features, computes aggregate statistics of all pixels
        within the polygon's bounding box.

        Requires ``rasterio``.

        Parameters
        ----------
        raster_path : str
            Path to the raster file.
        band : int
            Band number (1-indexed).
        stats : list[str] | None
            Statistics to compute: ``"min"``, ``"max"``, ``"mean"``, ``"sum"``,
            ``"count"``, ``"std"``. Default is all.
        prefix : str
            Column name prefix for results.
        """
        try:
            rasterio = importlib.import_module("rasterio")
        except ImportError as exc:
            raise ImportError("Install rasterio for raster_stats") from exc

        stat_list = stats or ["min", "max", "mean", "sum", "count", "std"]
        rows: list[Record] = []

        with rasterio.open(raster_path) as src:
            data = src.read(band)
            nodata_val = src.nodata

            for row in self._rows:
                resolved = dict(row)
                geom = row.get(self.geometry_column)
                if not geom:
                    for s in stat_list:
                        resolved[f"{prefix}_{s}"] = None
                    rows.append(resolved)
                    continue

                gt = geometry_type(geom)
                if gt == "Point":
                    coords = geom.get("coordinates", (0, 0))
                    try:
                        r_idx, c_idx = src.index(coords[0], coords[1])
                        if 0 <= r_idx < data.shape[0] and 0 <= c_idx < data.shape[1]:
                            val = float(data[r_idx, c_idx])
                            if nodata_val is not None and val == float(nodata_val):
                                val = None
                        else:
                            val = None
                    except Exception:
                        val = None
                    for s in stat_list:
                        resolved[f"{prefix}_{s}"] = val
                else:
                    # Bounding box approach for non-point features
                    b = geometry_bounds(geom)
                    try:
                        r1, c1 = src.index(b[0], b[3])
                        r2, c2 = src.index(b[2], b[1])
                        r_min, r_max = max(min(r1, r2), 0), min(max(r1, r2) + 1, data.shape[0])
                        c_min, c_max = max(min(c1, c2), 0), min(max(c1, c2) + 1, data.shape[1])
                        window = data[r_min:r_max, c_min:c_max].flatten()
                        if nodata_val is not None:
                            window = [v for v in window if float(v) != float(nodata_val)]
                        else:
                            window = [float(v) for v in window]
                    except Exception:
                        window = []

                    if window:
                        vals_f = [float(v) for v in window]
                        n = len(vals_f)
                        s_sum = sum(vals_f)
                        s_mean = s_sum / n
                        if "min" in stat_list:
                            resolved[f"{prefix}_min"] = min(vals_f)
                        if "max" in stat_list:
                            resolved[f"{prefix}_max"] = max(vals_f)
                        if "mean" in stat_list:
                            resolved[f"{prefix}_mean"] = s_mean
                        if "sum" in stat_list:
                            resolved[f"{prefix}_sum"] = s_sum
                        if "count" in stat_list:
                            resolved[f"{prefix}_count"] = n
                        if "std" in stat_list:
                            variance = sum((v - s_mean) ** 2 for v in vals_f) / max(n, 1)
                            resolved[f"{prefix}_std"] = math.sqrt(variance)
                    else:
                        for s in stat_list:
                            resolved[f"{prefix}_{s}"] = None
                rows.append(resolved)
        return GeoPromptFrame._from_internal_rows(rows, geometry_column=self.geometry_column, crs=self.crs)


# ------------------------------------------------------------------
# Helper: segment crossing test for polygon validity
# ------------------------------------------------------------------
def _segments_cross(
    a1x: float, a1y: float, a2x: float, a2y: float,
    b1x: float, b1y: float, b2x: float, b2y: float,
) -> bool:
    """Return True if segment (a1,a2) properly crosses segment (b1,b2)."""
    d1 = (b2x - b1x) * (a1y - b1y) - (b2y - b1y) * (a1x - b1x)
    d2 = (b2x - b1x) * (a2y - b1y) - (b2y - b1y) * (a2x - b1x)
    d3 = (a2x - a1x) * (b1y - a1y) - (a2y - a1y) * (b1x - a1x)
    d4 = (a2x - a1x) * (b2y - a1y) - (a2y - a1y) * (b2x - a1x)
    if d1 * d2 < 0 and d3 * d4 < 0:
        return True
    return False


# ------------------------------------------------------------------
# Helper: clip polygon to bounding box (Sutherland-Hodgman)
# ------------------------------------------------------------------
def _clip_polygon_to_box(
    polygon: list[tuple[float, float]],
    min_x: float, min_y: float, max_x: float, max_y: float,
) -> list[tuple[float, float]]:
    """Clip a convex or simple polygon to an axis-aligned bounding box."""

    def _clip_edge(pts: list[tuple[float, float]], inside_fn, intersect_fn) -> list[tuple[float, float]]:
        if not pts:
            return []
        out: list[tuple[float, float]] = []
        prev = pts[-1]
        prev_in = inside_fn(prev)
        for cur in pts:
            cur_in = inside_fn(cur)
            if cur_in:
                if not prev_in:
                    out.append(intersect_fn(prev, cur))
                out.append(cur)
            elif prev_in:
                out.append(intersect_fn(prev, cur))
            prev = cur
            prev_in = cur_in
        return out

    def _lerp(a: tuple[float, float], b: tuple[float, float], t: float) -> tuple[float, float]:
        return (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))

    pts = list(polygon)
    # Clip left
    pts = _clip_edge(pts, lambda p: p[0] >= min_x,
                     lambda a, b: _lerp(a, b, (min_x - a[0]) / (b[0] - a[0]) if abs(b[0] - a[0]) > 1e-15 else 0.5))
    # Clip right
    pts = _clip_edge(pts, lambda p: p[0] <= max_x,
                     lambda a, b: _lerp(a, b, (max_x - a[0]) / (b[0] - a[0]) if abs(b[0] - a[0]) > 1e-15 else 0.5))
    # Clip bottom
    pts = _clip_edge(pts, lambda p: p[1] >= min_y,
                     lambda a, b: _lerp(a, b, (min_y - a[1]) / (b[1] - a[1]) if abs(b[1] - a[1]) > 1e-15 else 0.5))
    # Clip top
    pts = _clip_edge(pts, lambda p: p[1] <= max_y,
                     lambda a, b: _lerp(a, b, (max_y - a[1]) / (b[1] - a[1]) if abs(b[1] - a[1]) > 1e-15 else 0.5))
    return pts


# ------------------------------------------------------------------
# Helper: stitch edge pairs into connected chains
# ------------------------------------------------------------------
def _stitch_edge_chains(
    edges: set[tuple[tuple[float, float], tuple[float, float]]],
) -> list[list[tuple[float, float]]]:
    """Given a set of undirected edges, stitch them into connected chains."""
    if not edges:
        return []
    adj: dict[tuple[float, float], list[tuple[float, float]]] = {}
    for a, b in edges:
        adj.setdefault(a, []).append(b)
        adj.setdefault(b, []).append(a)
    visited_edges: set[tuple[tuple[float, float], tuple[float, float]]] = set()
    chains: list[list[tuple[float, float]]] = []
    for start in adj:
        for neighbor in adj[start]:
            edge_key = (min(start, neighbor), max(start, neighbor))
            if edge_key in visited_edges:
                continue
            chain = [start, neighbor]
            visited_edges.add(edge_key)
            # Extend forward
            cur = neighbor
            while True:
                extended = False
                for nxt in adj.get(cur, []):
                    ek = (min(cur, nxt), max(cur, nxt))
                    if ek not in visited_edges:
                        visited_edges.add(ek)
                        chain.append(nxt)
                        cur = nxt
                        extended = True
                        break
                if not extended:
                    break
            chains.append(chain)
    return chains if chains else [list(next(iter(edges))[0:2])]


# ------------------------------------------------------------------
# Helper: safe raster algebra expression evaluator
# ------------------------------------------------------------------
def _safe_raster_eval(expression: str, x: float, funcs: dict) -> float:
    """Evaluate a simple math expression with 'x' as the variable.
    Only allows basic arithmetic and whitelisted functions."""
    import ast
    import operator

    allowed_ops = {
        ast.Add: operator.add, ast.Sub: operator.sub,
        ast.Mult: operator.mul, ast.Div: operator.truediv,
        ast.Pow: operator.pow, ast.USub: operator.neg,
        ast.UAdd: operator.pos, ast.Mod: operator.mod,
    }

    def _eval_node(node):
        if isinstance(node, ast.Expression):
            return _eval_node(node.body)
        if isinstance(node, ast.Constant):
            if isinstance(node.value, (int, float)):
                return float(node.value)
            raise ValueError("Only numeric constants allowed")
        if isinstance(node, ast.Name):
            if node.id == "x":
                return x
            raise ValueError(f"Unknown variable: {node.id}")
        if isinstance(node, ast.BinOp):
            op = allowed_ops.get(type(node.op))
            if op is None:
                raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
            left = _eval_node(node.left)
            right = _eval_node(node.right)
            return float(op(left, right))
        if isinstance(node, ast.UnaryOp):
            op = allowed_ops.get(type(node.op))
            if op is None:
                raise ValueError(f"Unsupported unary: {type(node.op).__name__}")
            return float(op(_eval_node(node.operand)))
        if isinstance(node, ast.Call):
            if not isinstance(node.func, ast.Name):
                raise ValueError("Only simple function calls allowed")
            fn = funcs.get(node.func.id)
            if fn is None:
                raise ValueError(f"Unknown function: {node.func.id}")
            args = [_eval_node(a) for a in node.args]
            return float(fn(*args))
        raise ValueError(f"Unsupported expression: {type(node).__name__}")

    tree = ast.parse(expression, mode="eval")
    return _eval_node(tree)


# ------------------------------------------------------------------
# Helper: Bowyer-Watson Delaunay triangulation
# ------------------------------------------------------------------
def _bowyer_watson(points: list[tuple[float, float]]) -> list[tuple[int, int, int]]:
    """Incremental Bowyer-Watson Delaunay triangulation returning index triples."""
    n = len(points)
    if n < 3:
        return []
    # Super-triangle encompassing all points
    xs = [p[0] for p in points]
    ys = [p[1] for p in points]
    dx = max(xs) - min(xs) + 1.0
    dy = max(ys) - min(ys) + 1.0
    mx = (max(xs) + min(xs)) / 2
    my = (max(ys) + min(ys)) / 2
    d = max(dx, dy) * 20
    # Super-triangle vertices are at indices n, n+1, n+2
    sp = [(mx - d, my - d), (mx + d, my - d), (mx, my + d)]
    all_pts = list(points) + sp
    triangles: list[tuple[int, int, int]] = [(n, n + 1, n + 2)]

    def circumcircle_contains(tri: tuple[int, int, int], px: float, py: float) -> bool:
        ax, ay = all_pts[tri[0]]
        bx, by = all_pts[tri[1]]
        cx, cy = all_pts[tri[2]]
        Dx = ax - px; Dy = ay - py
        Ex = bx - px; Ey = by - py
        Fx = cx - px; Fy = cy - py
        det = (Dx * (Ey * (Fx * Fx + Fy * Fy) - Fy * (Ex * Ex + Ey * Ey))
             - Dy * (Ex * (Fx * Fx + Fy * Fy) - Fx * (Ex * Ex + Ey * Ey))
             + (Dx * Dx + Dy * Dy) * (Ex * Fy - Ey * Fx))
        # Ensure consistent orientation
        cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
        return (det > 0) == (cross > 0) if abs(cross) > 1e-15 else False

    for i in range(n):
        px, py = all_pts[i]
        bad: list[tuple[int, int, int]] = []
        for t in triangles:
            if circumcircle_contains(t, px, py):
                bad.append(t)
        boundary: list[tuple[int, int]] = []
        for t in bad:
            for edge in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])]:
                shared = False
                for t2 in bad:
                    if t2 is t:
                        continue
                    e2s = [(t2[0], t2[1]), (t2[1], t2[2]), (t2[2], t2[0])]
                    for e2 in e2s:
                        if (edge[0] == e2[1] and edge[1] == e2[0]) or (edge[0] == e2[0] and edge[1] == e2[1]):
                            shared = True
                            break
                    if shared:
                        break
                if not shared:
                    boundary.append(edge)
        for t in bad:
            triangles.remove(t)
        for e in boundary:
            triangles.append((e[0], e[1], i))
    # Remove triangles referencing super-triangle vertices
    result = [t for t in triangles if all(v < n for v in t)]
    return result


# ------------------------------------------------------------------
# Helper: ear-clipping polygon triangulation
# ------------------------------------------------------------------
def _ear_clip_triangulate(
    polygon: list[tuple[float, float]],
) -> list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]]:
    """Triangulate a simple polygon using the ear-clipping algorithm."""
    n = len(polygon)
    if n < 3:
        return []
    if n == 3:
        return [(polygon[0], polygon[1], polygon[2])]

    # Ensure CCW winding
    pts = list(polygon)
    area = sum(pts[i][0] * pts[(i + 1) % n][1] - pts[(i + 1) % n][0] * pts[i][1] for i in range(n)) / 2
    if area > 0:
        pts.reverse()

    indices = list(range(len(pts)))
    triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]] = []

    def _cross_2d(o, a, b):
        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

    def _point_in_triangle(p, a, b, c):
        d1 = _cross_2d(p, a, b)
        d2 = _cross_2d(p, b, c)
        d3 = _cross_2d(p, c, a)
        has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0)
        has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0)
        return not (has_neg and has_pos)

    max_iter = len(indices) * 3
    itr = 0
    while len(indices) > 3 and itr < max_iter:
        itr += 1
        found_ear = False
        for ei in range(len(indices)):
            prev_i = indices[(ei - 1) % len(indices)]
            curr_i = indices[ei]
            next_i = indices[(ei + 1) % len(indices)]
            a, b, c = pts[prev_i], pts[curr_i], pts[next_i]
            # Check if ear (convex and no points inside)
            if _cross_2d(a, b, c) >= 0:
                continue  # reflex vertex
            is_ear = True
            for oi in indices:
                if oi in (prev_i, curr_i, next_i):
                    continue
                if _point_in_triangle(pts[oi], a, b, c):
                    is_ear = False
                    break
            if is_ear:
                triangles.append((a, b, c))
                indices.pop(ei)
                found_ear = True
                break
        if not found_ear:
            break

    if len(indices) == 3:
        triangles.append((pts[indices[0]], pts[indices[1]], pts[indices[2]]))
    return triangles


def _resolve_zone_fit_weights(score_weights: dict[str, float] | None) -> dict[str, float]:
    default_weights = {
        "containment": 0.4,
        "overlap": 0.3,
        "size": 0.3,
        "access": 0.0,
        "alignment": 0.0,
    }
    if score_weights is None:
        return default_weights

    resolved = dict(default_weights)
    for name, value in score_weights.items():
        if name not in resolved:
            raise ValueError(f"unsupported zone fit weight: {name}")
        if value < 0:
            raise ValueError("zone fit weights must be zero or greater")
        resolved[name] = float(value)

    if sum(resolved.values()) <= 0:
        raise ValueError("at least one zone fit weight must be greater than zero")
    return resolved


def _cluster_sort_key(row: Record, index: int, id_column: str) -> tuple[str, int]:
    if id_column in row:
        return (str(row[id_column]), index)
    return (str(index), index)


def _cluster_silhouette_scores(
    centroids: Sequence[Coordinate],
    assignments: Sequence[int],
    distance_method: str,
) -> list[float]:
    unique_clusters = sorted(set(assignments))
    if len(unique_clusters) <= 1:
        return [0.0 for _ in centroids]

    members_by_cluster = {
        cluster_id: [index for index, assignment in enumerate(assignments) if assignment == cluster_id]
        for cluster_id in unique_clusters
    }
    scores: list[float] = []
    for index, point in enumerate(centroids):
        own_cluster = assignments[index]
        own_members = [member for member in members_by_cluster[own_cluster] if member != index]
        if own_members:
            intra_distance = sum(
                coordinate_distance(point, centroids[member], method=distance_method)
                for member in own_members
            ) / len(own_members)
        else:
            intra_distance = 0.0

        nearest_other_distance = min(
            sum(coordinate_distance(point, centroids[member], method=distance_method) for member in members_by_cluster[cluster_id]) / len(members_by_cluster[cluster_id])
            for cluster_id in unique_clusters
            if cluster_id != own_cluster and members_by_cluster[cluster_id]
        )
        denominator = max(intra_distance, nearest_other_distance)
        if denominator == 0.0:
            scores.append(0.0)
        else:
            scores.append((nearest_other_distance - intra_distance) / denominator)
    return scores


def _polyline_length(vertices: Sequence[Coordinate], method: str = "euclidean") -> float:
    if len(vertices) < 2:
        return 0.0
    return sum(coordinate_distance(vertices[index - 1], vertices[index], method=method) for index in range(1, len(vertices)))


def _point_to_polyline_distance_details(point: Coordinate, vertices: Sequence[Coordinate], method: str = "euclidean") -> tuple[float, float]:
    if not vertices:
        return (float("inf"), float("inf"))
    if len(vertices) == 1:
        return (coordinate_distance(point, vertices[0], method=method), 0.0)

    if method == "haversine":
        projected_point, projected_vertices = _project_to_local_tangent_plane(point, vertices)
        return _point_to_polyline_distance_details(projected_point, projected_vertices, method="euclidean")

    best_offset = float("inf")
    best_along = float("inf")
    cumulative_length = 0.0
    for index in range(1, len(vertices)):
        segment_start = vertices[index - 1]
        segment_end = vertices[index]
        offset_distance, projection, t_value = _point_to_segment_projection(point, segment_start, segment_end)
        segment_length = coordinate_distance(segment_start, segment_end, method="euclidean")
        along_distance = cumulative_length + (segment_length * t_value)
        if offset_distance < best_offset:
            best_offset = offset_distance
            best_along = along_distance
        cumulative_length += segment_length
    return (best_offset, best_along)


def _point_to_segment_projection(point: Coordinate, seg_start: Coordinate, seg_end: Coordinate) -> tuple[float, Coordinate, float]:
    sx, sy = seg_start
    ex, ey = seg_end
    px, py = point
    dx, dy = ex - sx, ey - sy
    length_sq = dx * dx + dy * dy
    if length_sq == 0.0:
        return (coordinate_distance(point, seg_start, method="euclidean"), seg_start, 0.0)
    t_value = clamp(((px - sx) * dx + (py - sy) * dy) / length_sq, 0.0, 1.0)
    projection = (sx + (t_value * dx), sy + (t_value * dy))
    return (coordinate_distance(point, projection, method="euclidean"), projection, t_value)


def _project_to_local_tangent_plane(point: Coordinate, vertices: Sequence[Coordinate]) -> tuple[Coordinate, list[Coordinate]]:
    all_coordinates = [point, *vertices]
    reference_lat = math.radians(sum(coordinate[1] for coordinate in all_coordinates) / len(all_coordinates))

    def project(coordinate: Coordinate) -> Coordinate:
        lon_radians = math.radians(coordinate[0])
        lat_radians = math.radians(coordinate[1])
        x_value = 6371.0088 * lon_radians * math.cos(reference_lat)
        y_value = 6371.0088 * lat_radians
        return (x_value, y_value)

    return (project(point), [project(vertex) for vertex in vertices])


def _resolve_anchor_distance(along_distance: float, total_length: float, path_anchor: CorridorPathAnchor) -> float:
    if path_anchor == "start":
        return along_distance
    if path_anchor == "end":
        return max(0.0, total_length - along_distance)
    return 0.0


def _rectangle_polygon(min_x: float, min_y: float, max_x: float, max_y: float) -> Geometry:
    return {
        "type": "Polygon",
        "coordinates": (
            (min_x, min_y),
            (max_x, min_y),
            (max_x, max_y),
            (min_x, max_y),
            (min_x, min_y),
        ),
    }


def _to_geojson(geom: Geometry) -> dict:
    """Convert normalized geometry to standard GeoJSON dict for Shapely interop."""
    gt = geom["type"]
    if gt == "Polygon":
        coordinates = cast(Sequence[Coordinate], geom["coordinates"])
        return {"type": "Polygon", "coordinates": [list(list(c) for c in coordinates)]}
    return dict(geom)


def _hexagon_polygon(center_x: float, center_y: float, size: float) -> Geometry:
    vertices = []
    for angle_degrees in (90, 150, 210, 270, 330, 30):
        angle_radians = math.radians(angle_degrees)
        vertices.append((center_x + (size * math.cos(angle_radians)), center_y + (size * math.sin(angle_radians))))
    vertices.append(vertices[0])
    return {"type": "Polygon", "coordinates": tuple(vertices)}


def _as_coordinate(value: Any) -> Coordinate:
    return (float(value[0]), float(value[1]))


def _coordinate_key(point: Coordinate) -> str:
    return f"{point[0]:.12f},{point[1]:.12f}"


def _same_coordinate(left: Coordinate, right: Coordinate, tolerance: float = 1e-9) -> bool:
    return abs(left[0] - right[0]) <= tolerance and abs(left[1] - right[1]) <= tolerance


def _build_snap_coordinate_lookup(
    coordinates: Sequence[Coordinate],
    coordinate_index: SpatialIndex,
    tolerance: float,
) -> dict[str, Coordinate]:
    sorted_coordinates = sorted(coordinates)
    visited: set[str] = set()
    lookup: dict[str, Coordinate] = {}

    for coordinate in sorted_coordinates:
        coordinate_key = _coordinate_key(coordinate)
        if coordinate_key in visited:
            continue
        component: dict[str, Coordinate] = {}
        stack = [coordinate]
        visited.add(coordinate_key)
        while stack:
            current = stack.pop()
            current_key = _coordinate_key(current)
            component[current_key] = current
            candidate_indexes = coordinate_index.query(
                (
                    current[0] - tolerance,
                    current[1] - tolerance,
                    current[0] + tolerance,
                    current[1] + tolerance,
                )
            )
            for candidate_index in candidate_indexes:
                candidate = coordinates[candidate_index]
                candidate_key = _coordinate_key(candidate)
                if candidate_key in visited:
                    continue
                if coordinate_distance(current, candidate, method="euclidean") > tolerance:
                    continue
                visited.add(candidate_key)
                stack.append(candidate)
        anchor = min(component.values())
        for component_key in component:
            lookup[component_key] = anchor
    return lookup


def _snap_geometry(
    geometry: Geometry,
    snap_lookup: dict[str, Coordinate],
) -> tuple[Geometry, int, bool]:
    geometry_kind = geometry_type(geometry)
    if geometry_kind == "Point":
        coordinate = _as_coordinate(geometry["coordinates"])
        snapped = snap_lookup.get(_coordinate_key(coordinate), coordinate)
        changed = 0 if _same_coordinate(snapped, coordinate) else 1
        return {"type": "Point", "coordinates": snapped}, changed, False

    raw_coordinates = cast(Sequence[Any], geometry["coordinates"])
    coordinates = [_as_coordinate(value) for value in raw_coordinates]
    if geometry_kind == "Polygon":
        coordinates = coordinates[:-1]
    snapped_coordinates = [snap_lookup.get(_coordinate_key(coordinate), coordinate) for coordinate in coordinates]
    changed_vertex_count = sum(
        0 if _same_coordinate(original, snapped) else 1
        for original, snapped in zip(coordinates, snapped_coordinates, strict=True)
    )
    deduped_coordinates: list[Coordinate] = []
    for coordinate in snapped_coordinates:
        if deduped_coordinates and _same_coordinate(deduped_coordinates[-1], coordinate):
            continue
        deduped_coordinates.append(coordinate)

    if geometry_kind == "LineString":
        if len(deduped_coordinates) < 2:
            return geometry, changed_vertex_count, True
        return {"type": "LineString", "coordinates": tuple(deduped_coordinates)}, changed_vertex_count, False

    while len(deduped_coordinates) > 1 and _same_coordinate(deduped_coordinates[0], deduped_coordinates[-1]):
        deduped_coordinates.pop()
    if len(deduped_coordinates) < 3:
        return geometry, changed_vertex_count, True
    polygon_ring = tuple(deduped_coordinates + [deduped_coordinates[0]])
    return {"type": "Polygon", "coordinates": polygon_ring}, changed_vertex_count, False


def _build_linestring_info(geometry: Geometry) -> dict[str, Any]:
    raw_coordinates = cast(Sequence[Any], geometry["coordinates"])
    coordinates = tuple(_as_coordinate(value) for value in raw_coordinates)
    cumulative_lengths = [0.0]
    for start, end in zip(coordinates, coordinates[1:]):
        cumulative_lengths.append(cumulative_lengths[-1] + coordinate_distance(start, end, method="euclidean"))
    return {
        "coordinates": coordinates,
        "cumulative_lengths": tuple(cumulative_lengths),
        "total_length": cumulative_lengths[-1],
    }


def _locate_point_fraction_on_linestring(line_info: dict[str, Any], point: Coordinate) -> float:
    coordinates: Sequence[Coordinate] = line_info["coordinates"]
    cumulative_lengths: Sequence[float] = line_info["cumulative_lengths"]
    total_length = float(line_info["total_length"])
    if total_length <= 0.0:
        return 0.0
    for segment_index, (start, end) in enumerate(zip(coordinates, coordinates[1:])):
        if not _point_on_segment(point, start, end):
            continue
        segment_length = coordinate_distance(start, end, method="euclidean")
        if segment_length <= 0.0:
            return cumulative_lengths[segment_index] / total_length
        distance_to_point = coordinate_distance(start, point, method="euclidean")
        return (cumulative_lengths[segment_index] + distance_to_point) / total_length
    raise ValueError("point does not lie on the input linestring")


def _line_segment_records(rows: Sequence[Record], geometry_column: str) -> list[dict[str, Any]]:
    segment_records: list[dict[str, Any]] = []
    for row_index, row in enumerate(rows):
        geometry = cast(dict[str, Any], row[geometry_column])
        coordinates = tuple(_as_coordinate(value) for value in cast(Sequence[Any], geometry["coordinates"]))
        for segment_index, (start, end) in enumerate(zip(coordinates, coordinates[1:])):
            segment_records.append(
                {
                    "source_index": row_index,
                    "segment_index": segment_index,
                    "start": start,
                    "end": end,
                    "bounds": (
                        min(start[0], end[0]),
                        min(start[1], end[1]),
                        max(start[0], end[0]),
                        max(start[1], end[1]),
                    ),
                }
            )
    return segment_records


def _register_cut_point(points: dict[str, Coordinate], point: Coordinate) -> bool:
    point_key = _coordinate_key(point)
    if point_key in points:
        return False
    points[point_key] = point
    return True


def _clean_geometry(
    original_geometry: Geometry,
    working_geometry: Geometry,
    min_segment_length: float,
) -> tuple[Geometry, dict[str, Any]]:
    geometry_kind = geometry_type(working_geometry)
    input_vertex_count = len(geometry_vertices(original_geometry))
    if geometry_kind == "Point":
        changed = not _same_coordinate(_as_coordinate(original_geometry["coordinates"]), _as_coordinate(working_geometry["coordinates"]))
        return working_geometry, {
            "changed": changed,
            "removed_vertex_count": 0,
            "removed_short_segment_count": 0,
            "input_vertex_count": 1,
            "output_vertex_count": 1,
            "collapsed": False,
        }

    raw_coordinates = cast(Sequence[Any], working_geometry["coordinates"])
    coordinates = [_as_coordinate(value) for value in raw_coordinates]
    is_polygon = geometry_kind == "Polygon"
    if is_polygon:
        coordinates = coordinates[:-1]
        input_vertex_count -= 1

    deduped_coordinates: list[Coordinate] = []
    for coordinate in coordinates:
        if deduped_coordinates and _same_coordinate(deduped_coordinates[-1], coordinate):
            continue
        deduped_coordinates.append(coordinate)

    cleaned_coordinates = [deduped_coordinates[0]] if deduped_coordinates else []
    removed_short_segment_count = 0
    for coordinate in deduped_coordinates[1:]:
        if coordinate_distance(cleaned_coordinates[-1], coordinate, method="euclidean") < min_segment_length:
            removed_short_segment_count += 1
            continue
        cleaned_coordinates.append(coordinate)

    collapsed = False
    if geometry_kind == "LineString":
        if len(cleaned_coordinates) < 2:
            collapsed = True
            cleaned_geometry = original_geometry
            output_vertex_count = len(geometry_vertices(original_geometry))
        else:
            cleaned_geometry = {"type": "LineString", "coordinates": tuple(cleaned_coordinates)}
            output_vertex_count = len(cleaned_coordinates)
    else:
        while len(cleaned_coordinates) > 1 and _same_coordinate(cleaned_coordinates[0], cleaned_coordinates[-1]):
            cleaned_coordinates.pop()
        if len(cleaned_coordinates) < 3:
            collapsed = True
            cleaned_geometry = original_geometry
            output_vertex_count = len(geometry_vertices(original_geometry)) - 1
        else:
            cleaned_geometry = {"type": "Polygon", "coordinates": tuple(cleaned_coordinates + [cleaned_coordinates[0]])}
            output_vertex_count = len(cleaned_coordinates)

    return cleaned_geometry, {
        "changed": collapsed or cleaned_geometry != original_geometry,
        "removed_vertex_count": max(0, input_vertex_count - output_vertex_count),
        "removed_short_segment_count": removed_short_segment_count,
        "input_vertex_count": input_vertex_count,
        "output_vertex_count": output_vertex_count,
        "collapsed": collapsed,
    }


def _is_coordinate_value(value: Any) -> bool:
    return (
        isinstance(value, Sequence)
        and not isinstance(value, (str, bytes, dict))
        and len(value) == 2
        and all(isinstance(item, (int, float)) for item in value)
    )


def _point_parameter(point: Coordinate, start: Coordinate, end: Coordinate) -> float:
    dx = end[0] - start[0]
    dy = end[1] - start[1]
    length_sq = dx * dx + dy * dy
    if length_sq == 0.0:
        return 0.0
    return ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_sq


def _segment_intersection_points(first_start: Coordinate, first_end: Coordinate, second_start: Coordinate, second_end: Coordinate) -> list[Coordinate]:
    points: dict[str, Coordinate] = {}
    denominator = ((first_start[0] - first_end[0]) * (second_start[1] - second_end[1])) - ((first_start[1] - first_end[1]) * (second_start[0] - second_end[0]))
    if abs(denominator) <= 1e-12:
        for point in (first_start, first_end, second_start, second_end):
            if _point_on_segment(point, first_start, first_end) and _point_on_segment(point, second_start, second_end):
                points[_coordinate_key(point)] = point
        return list(points.values())

    first_det = (first_start[0] * first_end[1]) - (first_start[1] * first_end[0])
    second_det = (second_start[0] * second_end[1]) - (second_start[1] * second_end[0])
    x_value = ((first_det * (second_start[0] - second_end[0])) - ((first_start[0] - first_end[0]) * second_det)) / denominator
    y_value = ((first_det * (second_start[1] - second_end[1])) - ((first_start[1] - first_end[1]) * second_det)) / denominator
    intersection = (float(x_value), float(y_value))
    if _point_on_segment(intersection, first_start, first_end) and _point_on_segment(intersection, second_start, second_end):
        points[_coordinate_key(intersection)] = intersection
    return list(points.values())


def _point_on_segment(point: Coordinate, start: Coordinate, end: Coordinate, tolerance: float = 1e-9) -> bool:
    cross = ((point[1] - start[1]) * (end[0] - start[0])) - ((point[0] - start[0]) * (end[1] - start[1]))
    if abs(cross) > tolerance:
        return False
    return (
        min(start[0], end[0]) - tolerance <= point[0] <= max(start[0], end[0]) + tolerance
        and min(start[1], end[1]) - tolerance <= point[1] <= max(start[1], end[1]) + tolerance
    )


def _expand_bounds(bounds: tuple[float, float, float, float], distance: float) -> tuple[float, float, float, float]:
    return (
        bounds[0] - distance,
        bounds[1] - distance,
        bounds[2] + distance,
        bounds[3] + distance,
    )


def _geometry_size_metric(geometry: Geometry) -> float:
    geometry_kind = geometry_type(geometry)
    if geometry_kind == "Polygon":
        return geometry_area(geometry)
    if geometry_kind == "LineString":
        return geometry_length(geometry)
    return 1.0


def _size_ratio(left: float, right: float) -> float:
    if left <= 0.0 and right <= 0.0:
        return 1.0
    if left <= 0.0 or right <= 0.0:
        return 0.0
    return min(left, right) / max(left, right)


def _coverage_share(overlap_size: float, geometry_size: float, intersects: bool) -> float:
    if geometry_size <= 0.0:
        return 1.0 if intersects else 0.0
    return clamp(overlap_size / geometry_size, 0.0, 1.0)


def _geometry_overlap_size(left_geometry: Geometry, right_geometry: Geometry) -> float:
    if not geometry_intersects(left_geometry, right_geometry):
        return 0.0
    try:
        intersections = overlay_intersections([left_geometry], [right_geometry])
    except RuntimeError:
        intersections = []
    if intersections:
        return sum(_geometry_size_metric(geometry) for _left_index, _right_index, geometries in intersections for geometry in geometries)

    left_bounds = geometry_bounds(left_geometry)
    right_bounds = geometry_bounds(right_geometry)
    overlap_width = max(0.0, min(left_bounds[2], right_bounds[2]) - max(left_bounds[0], right_bounds[0]))
    overlap_height = max(0.0, min(left_bounds[3], right_bounds[3]) - max(left_bounds[1], right_bounds[1]))
    geometry_kind = geometry_type(left_geometry)
    if geometry_kind == "Polygon" and geometry_type(right_geometry) == "Polygon":
        return overlap_width * overlap_height
    if geometry_kind == "LineString" and geometry_type(right_geometry) == "LineString":
        return max(overlap_width, overlap_height)
    return 1.0


def _autocorrelation_statistics(
    values: Sequence[float],
    neighbor_indexes: Sequence[Sequence[int]],
    neighbor_weights: Sequence[Sequence[float]],
    row_standardize: bool = True,
) -> dict[str, Any]:
    row_count = len(values)
    mean_value = (sum(values) / row_count) if row_count else 0.0
    centered_values = [value - mean_value for value in values]
    variance_sum = sum(value * value for value in centered_values)
    m2 = (variance_sum / row_count) if row_count else 0.0

    # Row-standardize weights when requested (PySAL default transform='R')
    if row_standardize:
        std_weights: list[list[float]] = []
        for weights in neighbor_weights:
            row_sum = sum(weights)
            if row_sum > 0:
                std_weights.append([w / row_sum for w in weights])
            else:
                std_weights.append(list(weights))
        effective_weights: Sequence[Sequence[float]] = std_weights
    else:
        effective_weights = neighbor_weights

    weighted_cross_sum = 0.0
    weighted_difference_sum = 0.0
    total_weight = 0.0
    local_moran_values: list[float | None] = []
    local_geary_values: list[float | None] = []
    for origin_index, (indexes, weights) in enumerate(zip(neighbor_indexes, effective_weights, strict=True)):
        local_cross_sum = 0.0
        local_difference_sum = 0.0
        for neighbor_index, weight in zip(indexes, weights, strict=True):
            weighted_cross_sum += weight * centered_values[origin_index] * centered_values[neighbor_index]
            weighted_difference_sum += weight * (values[origin_index] - values[neighbor_index]) ** 2
            total_weight += weight
            local_cross_sum += weight * centered_values[neighbor_index]
            local_difference_sum += weight * (values[origin_index] - values[neighbor_index]) ** 2
        if m2 > 0.0:
            local_moran_values.append((centered_values[origin_index] / m2) * local_cross_sum)
            local_geary_values.append(local_difference_sum / m2)
        else:
            local_moran_values.append(None)
            local_geary_values.append(None)

    global_moran = None
    global_geary = None
    if row_count > 0 and variance_sum > 0.0 and total_weight > 0.0:
        global_moran = (row_count / total_weight) * (weighted_cross_sum / variance_sum)
        global_geary = ((row_count - 1) / (2.0 * total_weight)) * (weighted_difference_sum / variance_sum) if row_count > 1 else None
    return {
        "mean_value": mean_value,
        "centered_values": centered_values,
        "variance_sum": variance_sum,
        "m2": m2,
        "total_weight": total_weight,
        "global_moran": global_moran,
        "global_geary": global_geary,
        "local_moran_values": local_moran_values,
        "local_geary_values": local_geary_values,
    }


def _local_cluster_label(
    centered_value: float,
    lag_centered_value: float | None,
    local_moran: float | None,
    local_p_value: float | None,
    significance_level: float,
) -> str | None:
    if local_moran is None or lag_centered_value is None:
        return None
    if local_p_value is not None and local_p_value > significance_level:
        return "not_significant"
    if centered_value > 0.0 and lag_centered_value > 0.0:
        return "high-high"
    if centered_value < 0.0 and lag_centered_value < 0.0:
        return "low-low"
    if centered_value > 0.0 and lag_centered_value < 0.0:
        return "high-low"
    if centered_value < 0.0 and lag_centered_value > 0.0:
        return "low-high"
    return "mixed"


def _local_cluster_code(cluster_label: str | None) -> str | None:
    if cluster_label is None:
        return None
    return {
        "high-high": "HH",
        "low-low": "LL",
        "high-low": "HL",
        "low-high": "LH",
        "not_significant": "NS",
        "mixed": "MX",
    }.get(cluster_label, cluster_label.upper())


def _local_cluster_family(cluster_label: str | None) -> str | None:
    if cluster_label == "high-high":
        return "hotspot"
    if cluster_label == "low-low":
        return "coldspot"
    if cluster_label in {"high-low", "low-high"}:
        return "outlier"
    if cluster_label == "not_significant":
        return "not_significant"
    if cluster_label is None:
        return None
    return "mixed"


def _autocorr_report_label(family: str) -> str:
    return {
        "hotspot": "hotspot cluster",
        "coldspot": "coldspot cluster",
        "outlier": "spatial outlier cluster",
        "mixed": "mixed local pattern",
        "not_significant": "not significant",
    }.get(family, family.replace("_", " "))


def _autocorr_report_priority(family: str, intensity_score: float) -> str:
    if family in {"hotspot", "coldspot"} and intensity_score >= 0.2:
        return "primary"
    if family == "outlier" or intensity_score >= 0.05:
        return "secondary"
    return "background"


def _primary_change_matches(candidate_pairs: Sequence[dict[str, Any]], key_name: str = "right_index") -> list[dict[str, Any]]:
    if not candidate_pairs:
        return []
    best_score = float(candidate_pairs[0]["similarity_score"])
    threshold = max(best_score - 0.1, best_score * 0.9)
    matches = [pair for pair in candidate_pairs if float(pair["similarity_score"]) >= threshold]
    return sorted(matches, key=lambda item: (-float(item["similarity_score"]), float(item["centroid_distance"]), int(item[key_name])))


def _attribute_change_summary(
    left_rows: Sequence[Record],
    right_rows: Sequence[Record],
    attribute_columns: Sequence[str],
) -> dict[str, Any]:
    summaries: dict[str, Any] = {}
    single_left = len(left_rows) == 1
    single_right = len(right_rows) == 1
    for column in attribute_columns:
        left_values = _distinct_values(row.get(column) for row in left_rows)
        right_values = _distinct_values(row.get(column) for row in right_rows)
        if left_values == right_values:
            continue
        if single_left and single_right:
            summaries[column] = {"left": left_values[0], "right": right_values[0]}
        else:
            summaries[column] = {"left_values": left_values, "right_values": right_values}
    return summaries


def _distinct_values(values: Iterable[Any]) -> list[Any]:
    distinct: list[Any] = []
    for value in values:
        if value not in distinct:
            distinct.append(value)
    return distinct


def _consecutive_distinct_values(values: Iterable[Any]) -> list[Any]:
    distinct: list[Any] = []
    previous = object()
    for value in values:
        if value != previous:
            distinct.append(value)
            previous = value
    return distinct


def _mean_centroid_geometry(rows: Sequence[Record], geometry_column: str) -> Geometry:
    centroids = [geometry_centroid(row[geometry_column]) for row in rows]
    mean_x = sum(centroid[0] for centroid in centroids) / len(centroids)
    mean_y = sum(centroid[1] for centroid in centroids) / len(centroids)
    return {"type": "Point", "coordinates": [mean_x, mean_y]}


def _trajectory_anomaly_level(confidence_score: float, anomaly_count: int) -> str:
    if confidence_score < 0.35 or anomaly_count >= 3:
        return "high"
    if confidence_score < 0.7 or anomaly_count >= 1:
        return "moderate"
    return "low"


def _annotate_change_event_groups(rows: list[Record], change_suffix: str) -> None:
    grouped_rows: dict[tuple[Any, ...], list[Record]] = {}
    for row in rows:
        group_key = (
            row.get(f"change_class_{change_suffix}"),
            row.get(f"event_side_{change_suffix}"),
            tuple(row.get(f"left_ids_{change_suffix}", [])),
            tuple(row.get(f"right_ids_{change_suffix}", [])),
        )
        grouped_rows.setdefault(group_key, []).append(row)

    for group_index, (group_key, grouped) in enumerate(sorted(grouped_rows.items(), key=lambda item: item[0]), start=1):
        left_ids = sorted({value for row in grouped for value in row.get(f"left_ids_{change_suffix}", [])})
        right_ids = sorted({value for row in grouped for value in row.get(f"right_ids_{change_suffix}", [])})
        attribute_columns = sorted(
            {
                column
                for row in grouped
                for column in (row.get(f"attribute_changes_{change_suffix}") or {}).keys()
            }
        )
        similarity_scores = [
            float(row[f"similarity_score_{change_suffix}"])
            for row in grouped
            if row.get(f"similarity_score_{change_suffix}") is not None
        ]
        area_share_scores = [
            float(row[f"area_share_score_{change_suffix}"])
            for row in grouped
            if row.get(f"area_share_score_{change_suffix}") is not None
        ]
        summary = {
            "change_class": group_key[0],
            "event_side": group_key[1],
            "left_ids": left_ids,
            "right_ids": right_ids,
            "left_count": len(left_ids),
            "right_count": len(right_ids),
            "row_count": len(grouped),
            "feature_count": len(left_ids) + len(right_ids),
            "attribute_columns": attribute_columns,
            "mean_similarity_score": (sum(similarity_scores) / len(similarity_scores)) if similarity_scores else None,
            "mean_area_share_score": (sum(area_share_scores) / len(area_share_scores)) if area_share_scores else None,
        }
        event_group_id = f"event-{group_index:05d}"
        for row in grouped:
            row[f"event_group_id_{change_suffix}"] = event_group_id
            row[f"event_row_count_{change_suffix}"] = len(grouped)
            row[f"event_feature_count_{change_suffix}"] = len(left_ids) + len(right_ids)
            row[f"event_summary_{change_suffix}"] = summary


def _change_event_signature(row: Record, change_suffix: str) -> tuple[str, tuple[str, ...], tuple[str, ...]]:
    return (
        str(row[f"change_class_{change_suffix}"]),
        tuple(str(value) for value in row.get(f"left_ids_{change_suffix}", [])),
        tuple(str(value) for value in row.get(f"right_ids_{change_suffix}", [])),
    )


def _format_change_event_signature(signature: tuple[str, tuple[str, ...], tuple[str, ...]]) -> str:
    change_class, left_ids, right_ids = signature
    left_part = ",".join(left_ids) if left_ids else "none"
    right_part = ",".join(right_ids) if right_ids else "none"
    return f"{change_class}|{left_part}|{right_part}"


def _numeric_delta(current: Any, baseline: Any) -> float | None:
    if current is None or baseline is None:
        return None
    return float(current) - float(baseline)


def _change_event_status_rank(event_status: str) -> int:
    return {
        "emerged": 0,
        "resolved": 1,
        "persisted": 2,
    }.get(event_status, 3)


def _match_equivalent_change_events(
    baseline_rows: Sequence[Record],
    current_rows: Sequence[Record],
    change_suffix: str,
    geometry_column: str,
    min_similarity: float,
) -> list[tuple[tuple[str, tuple[str, ...], tuple[str, ...]], Record | None, Record | None, float | None]]:
    baseline_events = [_prepare_change_event_match(row, change_suffix, geometry_column) for row in baseline_rows]
    current_events = [_prepare_change_event_match(row, change_suffix, geometry_column) for row in current_rows]
    current_events_by_class: dict[str, list[tuple[int, dict[str, Any]]]] = {}
    for current_index, current_event in enumerate(current_events):
        current_events_by_class.setdefault(current_event["change_class"], []).append((current_index, current_event))

    candidate_pairs: list[tuple[float, int, int]] = []
    for baseline_index, baseline_event in enumerate(baseline_events):
        for current_index, current_event in current_events_by_class.get(baseline_event["change_class"], []):
            similarity = _equivalent_change_event_similarity_prepared(baseline_event, current_event)
            if similarity >= min_similarity:
                candidate_pairs.append((similarity, baseline_index, current_index))

    candidate_pairs.sort(key=lambda item: (-float(item[0]), item[1], item[2]))
    matched_baseline_indexes: set[int] = set()
    matched_current_indexes: set[int] = set()
    results: list[tuple[tuple[str, tuple[str, ...], tuple[str, ...]], Record | None, Record | None, float | None]] = []

    for similarity, baseline_index, current_index in candidate_pairs:
        if baseline_index in matched_baseline_indexes or current_index in matched_current_indexes:
            continue
        baseline_row = baseline_rows[baseline_index]
        current_row = current_rows[current_index]
        matched_baseline_indexes.add(baseline_index)
        matched_current_indexes.add(current_index)
        signature = _change_event_pair_signature(baseline_row, current_row, change_suffix)
        results.append((signature, baseline_row, current_row, similarity))

    for baseline_index, baseline_row in enumerate(baseline_rows):
        if baseline_index in matched_baseline_indexes:
            continue
        signature = _change_event_signature(baseline_row, change_suffix)
        results.append((signature, baseline_row, None, None))

    for current_index, current_row in enumerate(current_rows):
        if current_index in matched_current_indexes:
            continue
        signature = _change_event_signature(current_row, change_suffix)
        results.append((signature, None, current_row, None))

    return sorted(
        results,
        key=lambda item: (
            _change_event_status_rank("persisted" if item[1] is not None and item[2] is not None else ("resolved" if item[1] is not None else "emerged")),
            str(item[0][0]),
            item[0][1],
            item[0][2],
        ),
    )


def _equivalent_change_event_similarity(
    baseline_row: Record,
    current_row: Record,
    change_suffix: str,
    geometry_column: str,
) -> float:
    return _equivalent_change_event_similarity_prepared(
        _prepare_change_event_match(baseline_row, change_suffix, geometry_column),
        _prepare_change_event_match(current_row, change_suffix, geometry_column),
    )


def _prepare_change_event_match(row: Record, change_suffix: str, geometry_column: str) -> dict[str, Any]:
    summary = dict(row.get(f"event_summary_{change_suffix}") or {})
    geometry = row.get(geometry_column)
    return {
        "row": row,
        "change_class": str(row.get(f"change_class_{change_suffix}")),
        "event_side": str(row.get(f"event_side_{change_suffix}")),
        "left_id_set": {str(value) for value in row.get(f"left_ids_{change_suffix}", [])},
        "right_id_set": {str(value) for value in row.get(f"right_ids_{change_suffix}", [])},
        "feature_count": summary.get("feature_count"),
        "row_count": summary.get("row_count"),
        "attribute_columns_set": {str(value) for value in summary.get("attribute_columns", [])},
        "geometry": geometry,
        "centroid": geometry_centroid(geometry) if isinstance(geometry, dict) else None,
    }


def _equivalent_change_event_similarity_prepared(
    baseline_event: dict[str, Any],
    current_event: dict[str, Any],
) -> float:
    if baseline_event["change_class"] != current_event["change_class"]:
        return 0.0

    left_similarity = _prepared_set_similarity(baseline_event["left_id_set"], current_event["left_id_set"])
    right_similarity = _prepared_set_similarity(baseline_event["right_id_set"], current_event["right_id_set"])
    feature_count_similarity = _count_similarity(baseline_event["feature_count"], current_event["feature_count"])
    row_count_similarity = _count_similarity(baseline_event["row_count"], current_event["row_count"])
    attribute_similarity = _prepared_set_similarity(
        baseline_event["attribute_columns_set"],
        current_event["attribute_columns_set"],
    )
    side_similarity = 1.0 if baseline_event["event_side"] == current_event["event_side"] else 0.0
    partial_similarity = (
        (0.1 * side_similarity)
        + (0.2 * left_similarity)
        + (0.2 * right_similarity)
        + (0.15 * feature_count_similarity)
        + (0.1 * row_count_similarity)
        + (0.1 * attribute_similarity)
    )
    if partial_similarity >= 0.85:
        geometry_similarity = 1.0
    else:
        geometry_similarity = _geometry_similarity_prepared(
            baseline_event["geometry"],
            current_event["geometry"],
            baseline_event.get("centroid"),
            current_event.get("centroid"),
        )
    return partial_similarity + (0.15 * geometry_similarity)


def _prepared_set_similarity(left_values: set[str], right_values: set[str]) -> float:
    if not left_values and not right_values:
        return 1.0
    union = left_values | right_values
    if not union:
        return 0.0
    return len(left_values & right_values) / len(union)


def _geometry_similarity_prepared(
    left_geometry: Any,
    right_geometry: Any,
    left_centroid: Coordinate | None,
    right_centroid: Coordinate | None,
) -> float:
    if left_centroid is not None and right_centroid is not None:
        distance_value = coordinate_distance(left_centroid, right_centroid)
        return 1.0 / (1.0 + float(distance_value))
    return _geometry_similarity(left_geometry, right_geometry)


def _set_similarity(left_values: Iterable[Any], right_values: Iterable[Any]) -> float:
    left_set = {str(value) for value in left_values}
    right_set = {str(value) for value in right_values}
    if not left_set and not right_set:
        return 1.0
    union = left_set | right_set
    if not union:
        return 0.0
    return len(left_set & right_set) / len(union)


def _count_similarity(left_value: Any, right_value: Any) -> float:
    if left_value is None or right_value is None:
        return 0.0
    left_count = float(left_value)
    right_count = float(right_value)
    if left_count <= 0.0 and right_count <= 0.0:
        return 1.0
    denominator = max(left_count, right_count)
    if denominator <= 0.0:
        return 0.0
    return min(left_count, right_count) / denominator


def _geometry_similarity(left_geometry: Any, right_geometry: Any) -> float:
    if not isinstance(left_geometry, dict) or not isinstance(right_geometry, dict):
        return 0.0
    distance_value = geometry_distance(left_geometry, right_geometry)
    return 1.0 / (1.0 + float(distance_value))


def _change_event_pair_signature(
    baseline_row: Record,
    current_row: Record,
    change_suffix: str,
) -> tuple[str, tuple[str, ...], tuple[str, ...]]:
    return (
        str(current_row.get(f"change_class_{change_suffix}") or baseline_row.get(f"change_class_{change_suffix}")),
        tuple(str(value) for value in (current_row.get(f"left_ids_{change_suffix}") or baseline_row.get(f"left_ids_{change_suffix}") or [])),
        tuple(str(value) for value in (current_row.get(f"right_ids_{change_suffix}") or baseline_row.get(f"right_ids_{change_suffix}") or [])),
    )


def _gap_transition_penalty(previous_gap_state: bool, current_gap_state: bool, gap_penalty: float) -> float:
    if previous_gap_state and current_gap_state:
        return gap_penalty * 0.25
    if previous_gap_state or current_gap_state:
        return gap_penalty
    return 0.0


def _edge_transition_cost(
    previous_edge_index: int,
    current_edge_index: int,
    previous_along_distance: float,
    current_along_distance: float,
    edge_rows: Sequence[Record],
    adjacency: dict[str, list[tuple[str, float, int]]],
    from_node_id_column: str,
    to_node_id_column: str,
    cost_column: str,
    node_distance_cache: dict[str, dict[str, float]],
    transition_cache: dict[tuple[int, int, float, float], float],
) -> float:
    cache_key = (
        previous_edge_index,
        current_edge_index,
        round(previous_along_distance, 6),
        round(current_along_distance, 6),
    )
    if cache_key in transition_cache:
        return transition_cache[cache_key]

    previous_row = edge_rows[previous_edge_index]
    current_row = edge_rows[current_edge_index]
    previous_from_node = str(previous_row[from_node_id_column])
    previous_to_node = str(previous_row[to_node_id_column])
    current_from_node = str(current_row[from_node_id_column])
    current_to_node = str(current_row[to_node_id_column])
    previous_edge_cost = max(float(previous_row.get(cost_column, 0.0)), 0.0)
    current_edge_cost = max(float(current_row.get(cost_column, 0.0)), 0.0)

    if previous_edge_index == current_edge_index:
        best_cost = abs(current_along_distance - previous_along_distance)
        transition_cache[cache_key] = best_cost
        return best_cost

    previous_nodes = [
        (previous_from_node, max(0.0, previous_along_distance)),
        (previous_to_node, max(0.0, previous_edge_cost - previous_along_distance)),
    ]
    current_nodes = [
        (current_from_node, max(0.0, current_along_distance)),
        (current_to_node, max(0.0, current_edge_cost - current_along_distance)),
    ]
    target_nodes = {node_id for node_id, _node_cost in current_nodes}

    best_cost = float("inf")
    for node_id, source_edge_cost in previous_nodes:
        if node_id not in node_distance_cache:
            node_distance_cache[node_id] = _dijkstra_distances(adjacency, [node_id], stop_nodes=target_nodes)
        distances = node_distance_cache[node_id]
        for target_node, target_edge_cost in current_nodes:
            best_cost = min(best_cost, source_edge_cost + distances.get(target_node, float("inf")) + target_edge_cost)

    if best_cost == float("inf"):
        best_cost = max(previous_edge_cost, current_edge_cost, 0.0)
    transition_cache[cache_key] = best_cost
    return best_cost


def _dijkstra_distances(
    adjacency: dict[str, list[tuple[str, float, int]]],
    origin_node_ids: Sequence[str],
    stop_node: str | None = None,
    stop_nodes: set[str] | None = None,
) -> dict[str, float]:
    distances: dict[str, float] = {node_id: 0.0 for node_id in origin_node_ids}
    queue: list[tuple[float, str]] = [(0.0, node_id) for node_id in origin_node_ids]
    visited: set[str] = set()
    remaining_stop_nodes = set(stop_nodes) if stop_nodes is not None else None

    while queue:
        current_cost, current_node = heappop(queue)
        if current_node in visited:
            continue
        visited.add(current_node)
        if stop_node is not None and current_node == stop_node:
            break
        if remaining_stop_nodes is not None and current_node in remaining_stop_nodes:
            remaining_stop_nodes.remove(current_node)
            if not remaining_stop_nodes:
                break
        for next_node, edge_cost, _edge_index in adjacency.get(current_node, []):
            path_cost = current_cost + edge_cost
            if path_cost < distances.get(next_node, float("inf")):
                distances[next_node] = path_cost
                heappush(queue, (path_cost, next_node))
    return distances


def _reachable_edge_intervals(
    from_cost: float | None,
    to_cost: float | None,
    edge_cost: float,
    max_cost: float,
    directed: bool,
) -> list[tuple[float, float]]:
    intervals: list[tuple[float, float]] = []
    if edge_cost <= 0.0:
        if from_cost is not None and from_cost <= max_cost:
            return [(0.0, 1.0)]
        if not directed and to_cost is not None and to_cost <= max_cost:
            return [(0.0, 1.0)]
        return []

    if from_cost is not None and from_cost <= max_cost:
        intervals.append((0.0, min(1.0, (max_cost - from_cost) / edge_cost)))
    if not directed and to_cost is not None and to_cost <= max_cost:
        intervals.append((max(0.0, 1.0 - ((max_cost - to_cost) / edge_cost)), 1.0))
    if not intervals:
        return []

    merged: list[tuple[float, float]] = []
    for start_value, end_value in sorted(intervals):
        if end_value <= start_value + 1e-9:
            continue
        if not merged or start_value > merged[-1][1] + 1e-9:
            merged.append((start_value, end_value))
            continue
        merged[-1] = (merged[-1][0], max(merged[-1][1], end_value))
    return merged


def _linestring_subgeometry(geometry: Geometry, start_fraction: float, end_fraction: float) -> Geometry | None:
    coordinates_obj: Any = geometry["coordinates"]
    raw_coordinates = cast(Sequence[Any], coordinates_obj)
    coordinates = [_as_coordinate(value) for value in raw_coordinates]
    if len(coordinates) < 2:
        return None

    clamped_start = max(0.0, min(1.0, float(start_fraction)))
    clamped_end = max(0.0, min(1.0, float(end_fraction)))
    if clamped_end <= clamped_start + 1e-9:
        return None

    cumulative_lengths = [0.0]
    for start_point, end_point in zip(coordinates, coordinates[1:]):
        cumulative_lengths.append(
            cumulative_lengths[-1] + coordinate_distance(start_point, end_point, method="euclidean")
        )
    total_length = cumulative_lengths[-1]
    if total_length <= 0.0:
        return None

    start_distance = total_length * clamped_start
    end_distance = total_length * clamped_end
    clipped_coordinates = [_interpolate_linestring_coordinate(coordinates, cumulative_lengths, start_distance)]
    for vertex_index in range(1, len(coordinates) - 1):
        vertex_distance = cumulative_lengths[vertex_index]
        if start_distance < vertex_distance < end_distance:
            clipped_coordinates.append(coordinates[vertex_index])
    clipped_coordinates.append(_interpolate_linestring_coordinate(coordinates, cumulative_lengths, end_distance))

    deduped_coordinates: list[Coordinate] = []
    for coordinate in clipped_coordinates:
        if deduped_coordinates and _same_coordinate(deduped_coordinates[-1], coordinate):
            continue
        deduped_coordinates.append(coordinate)
    if len(deduped_coordinates) < 2:
        return None
    return {"type": "LineString", "coordinates": tuple(deduped_coordinates)}


def _interpolate_linestring_coordinate(
    coordinates: Sequence[Coordinate],
    cumulative_lengths: Sequence[float],
    target_distance: float,
) -> Coordinate:
    if target_distance <= 0.0:
        return coordinates[0]
    if target_distance >= cumulative_lengths[-1]:
        return coordinates[-1]

    for index in range(1, len(coordinates)):
        segment_start_distance = cumulative_lengths[index - 1]
        segment_end_distance = cumulative_lengths[index]
        if target_distance > segment_end_distance + 1e-9:
            continue
        segment_length = segment_end_distance - segment_start_distance
        if segment_length <= 0.0:
            return coordinates[index]
        ratio = (target_distance - segment_start_distance) / segment_length
        start_point = coordinates[index - 1]
        end_point = coordinates[index]
        return (
            start_point[0] + ((end_point[0] - start_point[0]) * ratio),
            start_point[1] + ((end_point[1] - start_point[1]) * ratio),
        )
    return coordinates[-1]


def _point_to_segment_distance(point: Coordinate, seg_start: Any, seg_end: Any, method: str = "euclidean") -> float:
    sx, sy = float(seg_start[0]), float(seg_start[1])
    ex, ey = float(seg_end[0]), float(seg_end[1])
    px, py = point
    if method == "haversine":
        return _point_to_segment_distance_haversine((px, py), (sx, sy), (ex, ey))

    dx, dy = ex - sx, ey - sy
    length_sq = dx * dx + dy * dy
    if length_sq == 0:
        return coordinate_distance(point, (sx, sy), method=method)
    t = max(0.0, min(1.0, ((px - sx) * dx + (py - sy) * dy) / length_sq))
    proj_x = sx + t * dx
    proj_y = sy + t * dy
    return coordinate_distance(point, (proj_x, proj_y), method=method)


def _point_to_segment_distance_haversine(point: Coordinate, seg_start: Coordinate, seg_end: Coordinate) -> float:
    reference_lat = math.radians((point[1] + seg_start[1] + seg_end[1]) / 3.0)

    def project(coordinate: Coordinate) -> Coordinate:
        lon_radians = math.radians(coordinate[0])
        lat_radians = math.radians(coordinate[1])
        x_value = 6371.0088 * lon_radians * math.cos(reference_lat)
        y_value = 6371.0088 * lat_radians
        return (x_value, y_value)

    return _point_to_segment_distance(project(point), project(seg_start), project(seg_end), method="euclidean")


# ------------------------------------------------------------------
# Helper functions for new tools (40-tool expansion)
# ------------------------------------------------------------------

def _marching_square_edges(
    cell_values: list[float],
    cell_coords: list[Coordinate],
    level: float,
) -> list[Coordinate]:
    edges: list[Coordinate] = []
    sides = [
        (cell_values[0], cell_values[1], cell_coords[0], cell_coords[1]),
        (cell_values[1], cell_values[2], cell_coords[1], cell_coords[2]),
        (cell_values[2], cell_values[3], cell_coords[2], cell_coords[3]),
        (cell_values[3], cell_values[0], cell_coords[3], cell_coords[0]),
    ]
    crossing_points: list[Coordinate] = []
    for v0, v1, c0, c1 in sides:
        if (v0 < level) != (v1 < level):
            t = (level - v0) / (v1 - v0) if abs(v1 - v0) > 1e-12 else 0.5
            crossing_points.append((c0[0] + t * (c1[0] - c0[0]), c0[1] + t * (c1[1] - c0[1])))
    if len(crossing_points) == 2:
        edges.extend(crossing_points)
    elif len(crossing_points) == 4:
        edges.extend(crossing_points[:2])
        edges.extend(crossing_points[2:])
    return edges


def _variogram_value(
    variogram_model: str,
    h: float,
    range_val: float,
    sill: float,
    nugget: float,
    *,
    include_nugget_at_zero: bool = False,
) -> float:
    if variogram_model == "spherical":
        return _spherical_variogram(
            h,
            range_val,
            sill,
            nugget,
            include_nugget_at_zero=include_nugget_at_zero,
        )
    if variogram_model == "exponential":
        return _exponential_variogram(
            h,
            range_val,
            sill,
            nugget,
            include_nugget_at_zero=include_nugget_at_zero,
        )
    if variogram_model == "gaussian":
        return _gaussian_variogram(
            h,
            range_val,
            sill,
            nugget,
            include_nugget_at_zero=include_nugget_at_zero,
        )
    if variogram_model == "hole_effect":
        return _hole_effect_variogram(
            h,
            range_val,
            sill,
            nugget,
            include_nugget_at_zero=include_nugget_at_zero,
        )
    raise ValueError(f"unsupported variogram_model: {variogram_model}")


def _spherical_variogram(
    h: float,
    range_val: float,
    sill: float,
    nugget: float,
    *,
    include_nugget_at_zero: bool = False,
) -> float:
    partial_sill = max(sill - nugget, 0.0)
    if h <= 0:
        return nugget if include_nugget_at_zero else 0.0
    if h >= range_val:
        return nugget + partial_sill
    ratio = h / range_val
    return nugget + partial_sill * (1.5 * ratio - 0.5 * ratio ** 3)


def _exponential_variogram(
    h: float,
    range_val: float,
    sill: float,
    nugget: float,
    *,
    include_nugget_at_zero: bool = False,
) -> float:
    partial_sill = max(sill - nugget, 0.0)
    if h <= 0:
        return nugget if include_nugget_at_zero else 0.0
    return partial_sill * (1.0 - math.exp(-h / max(range_val / 3.0, 1e-12))) + nugget


def _gaussian_variogram(
    h: float,
    range_val: float,
    sill: float,
    nugget: float,
    *,
    include_nugget_at_zero: bool = False,
) -> float:
    partial_sill = max(sill - nugget, 0.0)
    if h <= 0:
        return nugget if include_nugget_at_zero else 0.0
    effective_scale = max(range_val * 4.0 / 7.0, 1e-12)
    return partial_sill * (1.0 - math.exp(-(h ** 2.0) / (effective_scale ** 2.0))) + nugget


def _hole_effect_variogram(
    h: float,
    range_val: float,
    sill: float,
    nugget: float,
    *,
    include_nugget_at_zero: bool = False,
) -> float:
    partial_sill = max(sill - nugget, 0.0)
    if h <= 0:
        return nugget if include_nugget_at_zero else 0.0
    scaled = h / max(range_val / 3.0, 1e-12)
    return partial_sill * (1.0 - (1.0 - scaled) * math.exp(-scaled)) + nugget


def _normal_cdf(x: float) -> float:
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def _silverman_bandwidth(coordinates: Sequence[Coordinate]) -> float:
    """Silverman's rule of thumb for 2D KDE bandwidth.

    h = 0.9 * min(σ, IQR/1.34) * n^(-1/5)  (per dimension, averaged).
    """
    n = len(coordinates)
    if n < 2:
        return 1.0
    xs = sorted(c[0] for c in coordinates)
    ys = sorted(c[1] for c in coordinates)
    mx = sum(xs) / n
    my = sum(ys) / n
    sx = math.sqrt(sum((x - mx) ** 2 for x in xs) / n)
    sy = math.sqrt(sum((y - my) ** 2 for y in ys) / n)
    q1x = xs[n // 4]
    q3x = xs[3 * n // 4]
    q1y = ys[n // 4]
    q3y = ys[3 * n // 4]
    iqrx = (q3x - q1x) / 1.34
    iqry = (q3y - q1y) / 1.34
    hx = 0.9 * min(sx, iqrx) * n ** (-0.2) if min(sx, iqrx) > 0 else 0.9 * max(sx, iqrx, 1e-9) * n ** (-0.2)
    hy = 0.9 * min(sy, iqry) * n ** (-0.2) if min(sy, iqry) > 0 else 0.9 * max(sy, iqry, 1e-9) * n ** (-0.2)
    return max((hx + hy) / 2.0, 1e-9)


def _fdr_correction(p_values: list[float], alpha: float = 0.05) -> list[bool]:
    """Benjamini-Hochberg FDR correction.  Returns list of booleans indicating significance."""
    n = len(p_values)
    if n == 0:
        return []
    indexed = sorted(enumerate(p_values), key=lambda x: x[1])
    significant = [False] * n
    max_k = -1
    for rank, (orig_idx, p) in enumerate(indexed, 1):
        if p <= (rank / n) * alpha:
            max_k = rank
    if max_k > 0:
        for rank, (orig_idx, _) in enumerate(indexed, 1):
            if rank <= max_k:
                significant[orig_idx] = True
    return significant


def _fit_empirical_variogram(
    coordinates: Sequence[Coordinate],
    values: Sequence[float],
    distance_method: str = "euclidean",
    n_lags: int = 15,
) -> tuple[str, float, float, float]:
    """Fit an empirical variogram and return best (model, range, sill, nugget).

    Computes experimental variogram from sample pairs, then fits spherical,
    exponential, and gaussian models by minimising weighted SSE.
    """
    n = len(coordinates)
    if n < 3:
        extent = max(
            max(c[0] for c in coordinates) - min(c[0] for c in coordinates),
            max(c[1] for c in coordinates) - min(c[1] for c in coordinates),
            1e-9,
        )
        var_val = sum((v - sum(values) / n) ** 2 for v in values) / n if n > 0 else 1.0
        return ("spherical", extent / 3.0, max(var_val, 1e-9), 0.0)
    # Compute all pairwise distances and semivariances
    pair_dists: list[float] = []
    pair_semivars: list[float] = []
    for i in range(n):
        for j in range(i + 1, n):
            d = coordinate_distance(coordinates[i], coordinates[j], method=distance_method)
            sv = semivariance(values[i], values[j])
            pair_dists.append(d)
            pair_semivars.append(sv)
    if not pair_dists:
        return ("spherical", 1.0, 1.0, 0.0)
    max_dist = max(pair_dists)
    lag_width = max_dist / n_lags
    lags: list[float] = []
    lag_semivars: list[float] = []
    lag_counts: list[int] = []
    for li in range(n_lags):
        lo = li * lag_width
        hi = (li + 1) * lag_width
        bin_vals = [pair_semivars[k] for k in range(len(pair_dists)) if lo <= pair_dists[k] < hi]
        if bin_vals:
            lags.append((lo + hi) / 2.0)
            lag_semivars.append(sum(bin_vals) / len(bin_vals))
            lag_counts.append(len(bin_vals))
    if len(lags) < 2:
        var_val = sum((v - sum(values) / n) ** 2 for v in values) / n
        return ("spherical", max_dist / 3.0, max(var_val, 1e-9), 0.0)
    # Estimate initial parameters
    est_sill = max(lag_semivars)
    est_nugget = max(lag_semivars[0] * 0.5, 0.0)
    est_range = lags[len(lags) // 2]
    models = ["spherical", "exponential", "gaussian"]
    best_model = "spherical"
    best_sse = float("inf")
    best_params = (est_range, est_sill, est_nugget)
    for model in models:
        # Grid search over range
        for rf in [0.2, 0.3, 0.5, 0.7, 1.0, 1.3, 1.5, 2.0]:
            r = est_range * rf
            if r <= 0:
                continue
            for nf in [0.0, 0.05, 0.1, 0.2, 0.3]:
                nug = est_sill * nf
                for sf in [0.8, 1.0, 1.2]:
                    sill = est_sill * sf
                    if sill <= nug:
                        continue
                    sse = 0.0
                    for li in range(len(lags)):
                        pred = _variogram_value(model, lags[li], r, sill, nug)
                        w = lag_counts[li]
                        sse += w * (lag_semivars[li] - pred) ** 2
                    if sse < best_sse:
                        best_sse = sse
                        best_model = model
                        best_params = (r, sill, nug)
    # Nelder-Mead refinement of best grid-search result
    best_params = _nelder_mead_variogram(
        best_model, lags, lag_semivars, lag_counts, best_params, best_sse,
    )
    return (best_model, best_params[0], max(best_params[1], 1e-9), best_params[2])


def _chi_square_p_value(chi_sq: float, df: int) -> float:
    """Return the upper-tail p-value for a chi-square statistic."""
    try:
        scipy_stats = importlib.import_module("scipy.stats")
        return float(scipy_stats.chi2.sf(chi_sq, df))
    except ImportError:
        pass
    # Fallback: Wilson–Hilferty normal approximation
    if df <= 0:
        return 1.0
    z = ((chi_sq / df) ** (1.0 / 3.0) - (1.0 - 2.0 / (9.0 * df))) / math.sqrt(2.0 / (9.0 * df))
    return 1.0 - _normal_cdf(z)


def _two_sided_t_probability(t_statistic_abs: float, degrees_of_freedom: int) -> float:
    if degrees_of_freedom <= 0:
        return 2.0 * (1.0 - _normal_cdf(t_statistic_abs))
    try:
        scipy_stats = importlib.import_module("scipy.stats")
    except ImportError:
        return 2.0 * (1.0 - _normal_cdf(t_statistic_abs))
    return 2.0 * float(scipy_stats.t.sf(t_statistic_abs, degrees_of_freedom))


def _reference_getis_ord_statistics(
    values: Sequence[float],
    neighbor_indexes: Sequence[Sequence[int]],
    include_self: bool,
) -> list[tuple[float, float]] | None:
    try:
        esda = importlib.import_module("esda")
        libpysal_weights = importlib.import_module("libpysal.weights")
    except ImportError:
        return None
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            weights = libpysal_weights.W(
                {index: list(neighbors) for index, neighbors in enumerate(neighbor_indexes)},
                {index: [1.0] * len(neighbors) for index, neighbors in enumerate(neighbor_indexes)},
            )
            statistic = esda.G_Local(
                values,
                weights,
                transform="B",
                permutations=0,
                star=include_self,
                keep_simulations=False,
                island_weight=0,
            )
    except Exception:
        return None
    z_scores = getattr(statistic, "Zs", None)
    p_values = getattr(statistic, "p_norm", None)
    if z_scores is None or p_values is None:
        return None
    statistics: list[tuple[float, float]] = []
    for z_score, p_value in zip(z_scores, p_values, strict=True):
        z_numeric = float(z_score) if z_score is not None and math.isfinite(float(z_score)) else 0.0
        p_numeric = float(p_value) if p_value is not None and math.isfinite(float(p_value)) else 1.0
        statistics.append((z_numeric, p_numeric))
    return statistics


def _pairwise_distance_matrix(
    coordinates: Sequence[Coordinate],
    distance_method: str,
) -> list[list[float]]:
    n = len(coordinates)
    if n == 0:
        return []
    # Use scipy KDTree for faster O(n log n) euclidean distances when available
    if distance_method == "euclidean" and n >= 10:
        try:
            scipy_spatial = importlib.import_module("scipy.spatial")
            np = importlib.import_module("numpy")
            pts = np.array(coordinates, dtype=float)
            dm = scipy_spatial.distance.squareform(scipy_spatial.distance.pdist(pts, "euclidean"))
            return dm.tolist()
        except ImportError:
            pass
    matrix = [[0.0] * n for _ in range(n)]
    if distance_method == "euclidean":
        for i in range(n):
            x_i, y_i = coordinates[i]
            for j in range(i + 1, n):
                x_j, y_j = coordinates[j]
                distance = math.hypot(x_j - x_i, y_j - y_i)
                matrix[i][j] = distance
                matrix[j][i] = distance
        return matrix
    for i in range(n):
        for j in range(i + 1, n):
            distance = coordinate_distance(coordinates[i], coordinates[j], method=distance_method)
            matrix[i][j] = distance
            matrix[j][i] = distance
    return matrix


def _matrix_vector_product(matrix: Sequence[Sequence[float]], vector: Sequence[float]) -> list[float]:
    try:
        numpy = importlib.import_module("numpy")
    except ImportError:
        pass
    else:
        return [float(value) for value in (numpy.asarray(matrix, dtype=float) @ numpy.asarray(vector, dtype=float)).tolist()]
    return [sum(float(value) * float(vector[col_index]) for col_index, value in enumerate(row)) for row in matrix]


def _transpose_matrix(matrix: Sequence[Sequence[float]]) -> list[list[float]]:
    if not matrix:
        return []
    return [
        [float(matrix[row_index][col_index]) for row_index in range(len(matrix))]
        for col_index in range(len(matrix[0]))
    ]


def _matrix_product(left: Sequence[Sequence[float]], right: Sequence[Sequence[float]]) -> list[list[float]]:
    try:
        numpy = importlib.import_module("numpy")
    except ImportError:
        pass
    else:
        return [
            [float(value) for value in row]
            for row in (numpy.asarray(left, dtype=float) @ numpy.asarray(right, dtype=float)).tolist()
        ]
    if not left:
        return []
    shared_width = len(left[0])
    right_columns = _transpose_matrix(right)
    if shared_width == 0 or not right_columns:
        return [[0.0] * (len(right[0]) if right else 0) for _ in left]
    return [
        [
            sum(float(left_row[index]) * float(right_column[index]) for index in range(shared_width))
            for right_column in right_columns
        ]
        for left_row in left
    ]


def _path_sequence_cost(sequence: Sequence[int], cost_matrix: Sequence[Sequence[float]]) -> float:
    if len(sequence) <= 1:
        return 0.0
    total = 0.0
    for current_index, next_index in zip(sequence[:-1], sequence[1:]):
        step_cost = float(cost_matrix[current_index][next_index])
        if not math.isfinite(step_cost):
            return float("inf")
        total += step_cost
    return total


def _two_opt_open_path(sequence: Sequence[int], cost_matrix: Sequence[Sequence[float]]) -> list[int]:
    if len(sequence) < 4:
        return list(sequence)
    best_sequence = list(sequence)
    best_cost = _path_sequence_cost(best_sequence, cost_matrix)
    improved = True
    while improved:
        improved = False
        candidate_best_sequence = best_sequence
        candidate_best_cost = best_cost
        for start_index in range(1, len(best_sequence) - 2):
            for end_index in range(start_index + 1, len(best_sequence)):
                candidate_sequence = (
                    best_sequence[:start_index]
                    + list(reversed(best_sequence[start_index:end_index + 1]))
                    + best_sequence[end_index + 1:]
                )
                candidate_cost = _path_sequence_cost(candidate_sequence, cost_matrix)
                if candidate_cost + 1e-12 < candidate_best_cost:
                    candidate_best_sequence = candidate_sequence
                    candidate_best_cost = candidate_cost
                    improved = True
        best_sequence = candidate_best_sequence
        best_cost = candidate_best_cost
    return best_sequence


def _solve_linear_system(a: Sequence[Sequence[float | int]], b: Sequence[float | int]) -> list[float] | None:
    n = len(b)
    aug = [[float(value) for value in a[i]] + [float(b[i])] for i in range(n)]
    for col in range(n):
        max_row = col
        for row in range(col + 1, n):
            if abs(aug[row][col]) > abs(aug[max_row][col]):
                max_row = row
        aug[col], aug[max_row] = aug[max_row], aug[col]
        if abs(aug[col][col]) < 1e-12:
            return None
        for row in range(col + 1, n):
            factor = aug[row][col] / aug[col][col]
            for j in range(col, n + 1):
                aug[row][j] -= factor * aug[col][j]
    result = [0.0] * n
    for i in range(n - 1, -1, -1):
        result[i] = aug[i][n]
        for j in range(i + 1, n):
            result[i] -= aug[i][j] * result[j]
        result[i] /= aug[i][i] if abs(aug[i][i]) > 1e-12 else 1.0
    return result


def _solve_linear_system_with_regularization(
    a: Sequence[Sequence[float | int]],
    b: Sequence[float | int],
    diagonal_jitter: float = 1e-8,
) -> list[float] | None:
    solution = _solve_linear_system(a, b)
    if solution is not None:
        return solution
    regularized = [[float(value) for value in row] for row in a]
    for index in range(min(len(regularized), len(b))):
        regularized[index][index] += diagonal_jitter
    return _solve_linear_system(regularized, [float(value) for value in b])


def _invert_matrix(matrix: list[list[float]]) -> list[list[float]] | None:
    size = len(matrix)
    if size == 0:
        return []
    augmented = [
        list(matrix[row_index]) + [1.0 if row_index == col_index else 0.0 for col_index in range(size)]
        for row_index in range(size)
    ]
    for pivot_index in range(size):
        best_row = max(range(pivot_index, size), key=lambda row_index: abs(augmented[row_index][pivot_index]))
        if abs(augmented[best_row][pivot_index]) < 1e-12:
            return None
        augmented[pivot_index], augmented[best_row] = augmented[best_row], augmented[pivot_index]
        pivot_value = augmented[pivot_index][pivot_index]
        for col_index in range(2 * size):
            augmented[pivot_index][col_index] /= pivot_value
        for row_index in range(size):
            if row_index == pivot_index:
                continue
            factor = augmented[row_index][pivot_index]
            if abs(factor) < 1e-12:
                continue
            for col_index in range(2 * size):
                augmented[row_index][col_index] -= factor * augmented[pivot_index][col_index]
    return [row[size:] for row in augmented]


def _invert_matrix_with_regularization(
    matrix: list[list[float]],
    diagonal_jitter: float = 1e-8,
) -> list[list[float]] | None:
    inverse = _invert_matrix(matrix)
    if inverse is not None:
        return inverse
    regularized = [list(row) for row in matrix]
    for index in range(len(regularized)):
        regularized[index][index] += diagonal_jitter
    return _invert_matrix(regularized)


def _matrix_rank(matrix: list[list[float]], tolerance: float = 1e-10) -> int:
    try:
        numpy = importlib.import_module("numpy")
    except ImportError:
        working = [list(row) for row in matrix]
        rank = 0
        row_count = len(working)
        col_count = len(working[0]) if working else 0
        pivot_row = 0
        for pivot_col in range(col_count):
            best_row = max(range(pivot_row, row_count), key=lambda row_index: abs(working[row_index][pivot_col]), default=pivot_row)
            if pivot_row >= row_count or abs(working[best_row][pivot_col]) <= tolerance:
                continue
            working[pivot_row], working[best_row] = working[best_row], working[pivot_row]
            pivot_value = working[pivot_row][pivot_col]
            for row_index in range(pivot_row + 1, row_count):
                factor = working[row_index][pivot_col] / pivot_value
                for col_index in range(pivot_col, col_count):
                    working[row_index][col_index] -= factor * working[pivot_row][col_index]
            rank += 1
            pivot_row += 1
            if pivot_row >= row_count:
                break
        return rank
    return int(numpy.linalg.matrix_rank(numpy.asarray(matrix, dtype=float), tol=tolerance))


def _matrix_pseudoinverse(matrix: list[list[float]], tolerance: float = 1e-15) -> list[list[float]] | None:
    try:
        numpy = importlib.import_module("numpy")
    except ImportError:
        return None
    array = numpy.asarray(matrix, dtype=float)
    pseudoinverse = numpy.linalg.pinv(array, rcond=tolerance)
    return [[float(value) for value in row] for row in pseudoinverse.tolist()]


def _moran_on_residuals(
    residuals: list[float],
    distance_matrix: list[list[float]],
    k: int,
) -> float:
    n = len(residuals)
    if n < 2:
        return 0.0
    mean_r = sum(residuals) / n
    centered = [r - mean_r for r in residuals]
    var_sum = sum(c * c for c in centered)
    if var_sum < 1e-12:
        return 0.0
    w_sum = 0.0
    cross_sum = 0.0
    for i in range(n):
        dists = [(j, distance_matrix[i][j]) for j in range(n) if j != i]
        neighbors = nsmallest(k, dists, key=lambda x: x[1])
        for j, _ in neighbors:
            w_sum += 1.0
            cross_sum += centered[i] * centered[j]
    return (n / w_sum) * (cross_sum / var_sum) if w_sum > 0 else 0.0


def _douglas_peucker(coords: list[Coordinate], tolerance: float) -> list[Coordinate]:
    if len(coords) <= 2:
        return list(coords)
    max_dist = 0.0
    max_idx = 0
    start, end = coords[0], coords[-1]
    for i in range(1, len(coords) - 1):
        d = _perpendicular_distance(coords[i], start, end)
        if d > max_dist:
            max_dist = d
            max_idx = i
    if max_dist > tolerance:
        left = _douglas_peucker(coords[:max_idx + 1], tolerance)
        right = _douglas_peucker(coords[max_idx:], tolerance)
        return left[:-1] + right
    return [coords[0], coords[-1]]


def _douglas_peucker_indices(coords: list[Coordinate], tolerance: float) -> list[int]:
    if len(coords) <= 2:
        return list(range(len(coords)))
    indices = [0, len(coords) - 1]
    _dp_recurse(coords, 0, len(coords) - 1, tolerance, indices)
    return sorted(indices)


def _dp_recurse(coords: list[Coordinate], start: int, end: int, tolerance: float, indices: list[int]) -> None:
    if end - start <= 1:
        return
    max_dist = 0.0
    max_idx = start
    for i in range(start + 1, end):
        d = _perpendicular_distance(coords[i], coords[start], coords[end])
        if d > max_dist:
            max_dist = d
            max_idx = i
    if max_dist > tolerance:
        indices.append(max_idx)
        _dp_recurse(coords, start, max_idx, tolerance, indices)
        _dp_recurse(coords, max_idx, end, tolerance, indices)


def _perpendicular_distance(point: Coordinate, line_start: Coordinate, line_end: Coordinate) -> float:
    dx = line_end[0] - line_start[0]
    dy = line_end[1] - line_start[1]
    length_sq = dx * dx + dy * dy
    if length_sq < 1e-24:
        return math.hypot(point[0] - line_start[0], point[1] - line_start[1])
    t = max(0.0, min(1.0, ((point[0] - line_start[0]) * dx + (point[1] - line_start[1]) * dy) / length_sq))
    proj_x = line_start[0] + t * dx
    proj_y = line_start[1] + t * dy
    return math.hypot(point[0] - proj_x, point[1] - proj_y)


def _densify_coordinates(coords: list[Coordinate], max_length: float) -> list[Coordinate]:
    if len(coords) < 2:
        return list(coords)
    result: list[Coordinate] = [coords[0]]
    for i in range(1, len(coords)):
        start = coords[i - 1]
        end = coords[i]
        seg_len = math.hypot(end[0] - start[0], end[1] - start[1])
        if seg_len > max_length:
            n_segments = math.ceil(seg_len / max_length)
            for s in range(1, n_segments):
                t = s / n_segments
                result.append((start[0] + t * (end[0] - start[0]), start[1] + t * (end[1] - start[1])))
        result.append(end)
    return result


def _chaikin_smooth(coords: list[Coordinate], is_closed: bool = False) -> list[Coordinate]:
    if len(coords) < 3:
        return list(coords)
    result: list[Coordinate] = []
    n = len(coords)
    start = 0 if is_closed else 0
    end = n if is_closed else n - 1
    if not is_closed:
        result.append(coords[0])
    for i in range(start, end):
        p0 = coords[i]
        p1 = coords[(i + 1) % n]
        q = (0.75 * p0[0] + 0.25 * p1[0], 0.75 * p0[1] + 0.25 * p1[1])
        r = (0.25 * p0[0] + 0.75 * p1[0], 0.25 * p0[1] + 0.75 * p1[1])
        result.append(q)
        result.append(r)
    if not is_closed:
        result.append(coords[-1])
    if is_closed and result:
        result.append(result[0])
    return result


_GEOHASH_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"


def _geohash_encode(latitude: float, longitude: float, precision: int) -> str:
    lat_range = [-90.0, 90.0]
    lon_range = [-180.0, 180.0]
    is_longitude = True
    bits = 0
    char_index = 0
    result: list[str] = []
    while len(result) < precision:
        if is_longitude:
            mid = (lon_range[0] + lon_range[1]) / 2.0
            if longitude >= mid:
                char_index = char_index * 2 + 1
                lon_range[0] = mid
            else:
                char_index = char_index * 2
                lon_range[1] = mid
        else:
            mid = (lat_range[0] + lat_range[1]) / 2.0
            if latitude >= mid:
                char_index = char_index * 2 + 1
                lat_range[0] = mid
            else:
                char_index = char_index * 2
                lat_range[1] = mid
        is_longitude = not is_longitude
        bits += 1
        if bits == 5:
            result.append(_GEOHASH_BASE32[char_index])
            bits = 0
            char_index = 0
    return "".join(result)


def _rbf_value(function: str, r: float, epsilon: float) -> float:
    """Evaluate a radial basis function."""
    re = r / max(epsilon, 1e-12)
    if function == "multiquadric":
        return math.sqrt(1.0 + re * re)
    elif function == "inverse":
        return 1.0 / math.sqrt(1.0 + re * re)
    elif function == "gaussian":
        return math.exp(-re * re)
    elif function == "thin_plate" or function == "thin-plate":
        return thin_plate_spline_basis(re)
    return math.sqrt(1.0 + re * re)


def _polynomial_terms(coords: Sequence[Coordinate], order: int) -> list[list[float]]:
    """Generate polynomial design matrix terms for coordinates."""
    terms = []
    for x, y in coords:
        row = [1.0]
        if order >= 1:
            row.extend([x, y])
        if order >= 2:
            row.extend([x * x, x * y, y * y])
        if order >= 3:
            row.extend([x * x * x, x * x * y, x * y * y, y * y * y])
        terms.append(row)
    return terms


def _polynomial_terms_single(coord: Coordinate, order: int) -> list[float]:
    """Generate polynomial terms for a single coordinate."""
    x, y = coord
    row = [1.0]
    if order >= 1:
        row.extend([x, y])
    if order >= 2:
        row.extend([x * x, x * y, y * y])
    if order >= 3:
        row.extend([x * x * x, x * x * y, x * y * y, y * y * y])
    return row


def _welzl_mbc(points: list[Coordinate]) -> tuple[float, float, float]:
    """Welzl's algorithm for minimum bounding circle. Returns (cx, cy, radius)."""
    import random as _rng
    pts = list(points)
    _rng.shuffle(pts)

    def _circle_from_1(p: Coordinate) -> tuple[float, float, float]:
        return (p[0], p[1], 0.0)

    def _circle_from_2(p1: Coordinate, p2: Coordinate) -> tuple[float, float, float]:
        cx = (p1[0] + p2[0]) / 2.0
        cy = (p1[1] + p2[1]) / 2.0
        return (cx, cy, math.hypot(p1[0] - p2[0], p1[1] - p2[1]) / 2.0)

    def _circle_from_3(p1: Coordinate, p2: Coordinate, p3: Coordinate) -> tuple[float, float, float]:
        ax, ay = p1
        bx, by = p2
        cx, cy = p3
        d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
        if abs(d) < 1e-12:
            d1 = _circle_from_2(p1, p2)
            d2 = _circle_from_2(p1, p3)
            d3 = _circle_from_2(p2, p3)
            return max([d1, d2, d3], key=lambda c: c[2])
        ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
        uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
        return (ux, uy, math.hypot(ax - ux, ay - uy))

    def _in_circle(c: tuple[float, float, float], p: Coordinate) -> bool:
        return math.hypot(p[0] - c[0], p[1] - c[1]) <= c[2] + 1e-10

    def _welzl_rec(P: list[Coordinate], R: list[Coordinate]) -> tuple[float, float, float]:
        if len(P) == 0 or len(R) == 3:
            if len(R) == 0:
                return (0.0, 0.0, 0.0)
            elif len(R) == 1:
                return _circle_from_1(R[0])
            elif len(R) == 2:
                return _circle_from_2(R[0], R[1])
            else:
                return _circle_from_3(R[0], R[1], R[2])
        p = P[0]
        d = _welzl_rec(P[1:], R)
        if _in_circle(d, p):
            return d
        return _welzl_rec(P[1:], R + [p])

    if len(pts) <= 1:
        if pts:
            return (pts[0][0], pts[0][1], 0.0)
        return (0.0, 0.0, 0.0)

    # Iterative version to avoid stack overflow for large inputs
    if len(pts) > 100:
        # Approximate: use centroid + max distance
        cx = sum(p[0] for p in pts) / len(pts)
        cy = sum(p[1] for p in pts) / len(pts)
        r = max(math.hypot(p[0] - cx, p[1] - cy) for p in pts)
        return (cx, cy, r)

    return _welzl_rec(pts, [])


def _convex_hull_2d(points: list[Coordinate]) -> list[Coordinate]:
    """Andrew's monotone chain convex hull."""
    pts = sorted(set(points))
    if len(pts) <= 2:
        return pts
    lower: list[Coordinate] = []
    for p in pts:
        while len(lower) >= 2 and _cross_2d(lower[-2], lower[-1], p) <= 0:
            lower.pop()
        lower.append(p)
    upper: list[Coordinate] = []
    for p in reversed(pts):
        while len(upper) >= 2 and _cross_2d(upper[-2], upper[-1], p) <= 0:
            upper.pop()
        upper.append(p)
    return lower[:-1] + upper[:-1]


def _cross_2d(o: Coordinate, a: Coordinate, b: Coordinate) -> float:
    return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])


def _snap_geometry_to_grid(geom: Geometry, cell_size: float) -> Geometry:
    """Snap all coordinates in a geometry to a grid."""
    def snap_coord(c: tuple[float, ...]) -> tuple[float, ...]:
        return tuple(round(v / cell_size) * cell_size for v in c)
    gtype = geometry_type(geom)
    coords = geom.get("coordinates")
    if gtype == "Point" and coords:
        return {"type": "Point", "coordinates": snap_coord(cast(tuple[float, ...], coords))}
    elif gtype == "LineString" and coords:
        return {"type": "LineString", "coordinates": [snap_coord(cast(tuple[float, ...], c)) for c in cast(Sequence[Any], coords)]}
    elif gtype == "Polygon" and coords:
        return {"type": "Polygon", "coordinates": [[snap_coord(cast(tuple[float, ...], c)) for c in cast(Sequence[Any], ring)] for ring in cast(Sequence[Any], coords)]}
    elif gtype == "MultiPoint" and coords:
        return {"type": "MultiPoint", "coordinates": [snap_coord(cast(tuple[float, ...], c)) for c in cast(Sequence[Any], coords)]}
    elif gtype == "MultiLineString" and coords:
        return {"type": "MultiLineString", "coordinates": [[snap_coord(cast(tuple[float, ...], c)) for c in cast(Sequence[Any], line)] for line in cast(Sequence[Any], coords)]}
    elif gtype == "MultiPolygon" and coords:
        return {"type": "MultiPolygon", "coordinates": [[[snap_coord(cast(tuple[float, ...], c)) for c in cast(Sequence[Any], ring)] for ring in cast(Sequence[Any], poly)] for poly in cast(Sequence[Any], coords)]}
    return geom


def _parse_wkt(wkt: str) -> Geometry | None:
    """Parse simple WKT to GeoJSON geometry."""
    wkt = wkt.strip()
    upper = wkt.upper()
    if upper.startswith("POINT"):
        coords_str = wkt[wkt.index("(") + 1:wkt.rindex(")")].strip()
        parts = coords_str.split()
        if len(parts) >= 2:
            return {"type": "Point", "coordinates": (float(parts[0]), float(parts[1]))}
    elif upper.startswith("LINESTRING"):
        coords_str = wkt[wkt.index("(") + 1:wkt.rindex(")")].strip()
        coords = []
        for pair in coords_str.split(","):
            parts = pair.strip().split()
            if len(parts) >= 2:
                coords.append((float(parts[0]), float(parts[1])))
        return {"type": "LineString", "coordinates": coords}
    elif upper.startswith("POLYGON"):
        # Extract rings
        rings = []
        inner = wkt[wkt.index("(") + 1:wkt.rindex(")")].strip()
        if inner.startswith("("):
            # Multiple rings
            depth = 0
            ring_start = 0
            for ci, ch in enumerate(inner):
                if ch == "(":
                    if depth == 0:
                        ring_start = ci + 1
                    depth += 1
                elif ch == ")":
                    depth -= 1
                    if depth == 0:
                        ring_str = inner[ring_start:ci]
                        ring_coords = []
                        for pair in ring_str.split(","):
                            parts = pair.strip().split()
                            if len(parts) >= 2:
                                ring_coords.append((float(parts[0]), float(parts[1])))
                        rings.append(ring_coords)
        return {"type": "Polygon", "coordinates": rings}
    # Try shapely as fallback
    try:
        shapely_wkt = importlib.import_module("shapely.wkt")
        geom = shapely_wkt.loads(wkt)
        shapely_geom = importlib.import_module("shapely.geometry")
        return shapely_geom.mapping(geom)
    except Exception:
        pass
    return None


def _geometry_to_wkt(geom: Geometry) -> str:
    """Convert GeoJSON geometry to WKT string."""
    gtype = geometry_type(geom)
    coords = geom.get("coordinates")
    if gtype == "Point" and coords:
        point = cast(Sequence[Any], coords)
        return f"POINT ({point[0]} {point[1]})"
    elif gtype == "LineString" and coords:
        pts = ", ".join(f"{c[0]} {c[1]}" for c in cast(Sequence[Sequence[Any]], coords))
        return f"LINESTRING ({pts})"
    elif gtype == "Polygon" and coords:
        rings = ", ".join(
            "(" + ", ".join(f"{c[0]} {c[1]}" for c in ring) + ")"
            for ring in cast(Sequence[Sequence[Sequence[Any]]], coords)
        )
        return f"POLYGON ({rings})"
    elif gtype == "MultiPoint" and coords:
        pts = ", ".join(f"({c[0]} {c[1]})" for c in cast(Sequence[Sequence[Any]], coords))
        return f"MULTIPOINT ({pts})"
    return f"{gtype.upper()} EMPTY"


def _nelder_mead_variogram(
    model: str,
    lags: list[float],
    lag_semivars: list[float],
    lag_counts: list[int],
    initial_params: tuple[float, float, float],
    initial_sse: float,
    max_iter: int = 200,
) -> tuple[float, float, float]:
    """Refine variogram parameters using Nelder-Mead simplex optimization."""
    def objective(params: list[float]) -> float:
        r, s, ng = params
        if r <= 0 or s <= ng or ng < 0:
            return 1e30
        sse = 0.0
        for li in range(len(lags)):
            pred = _variogram_value(model, lags[li], r, s, ng)
            sse += lag_counts[li] * (lag_semivars[li] - pred) ** 2
        return sse

    r0, s0, n0 = initial_params
    # Build initial simplex around the best grid-search solution
    simplex = [
        [r0, s0, n0],
        [r0 * 1.1, s0, n0],
        [r0, s0 * 1.1, n0],
        [r0, s0, max(n0 * 1.2, s0 * 0.01)],
    ]
    f_vals = [objective(v) for v in simplex]
    alpha, gamma, rho, sigma = 1.0, 2.0, 0.5, 0.5
    for _ in range(max_iter):
        order = sorted(range(4), key=lambda i: f_vals[i])
        simplex = [simplex[i] for i in order]
        f_vals = [f_vals[i] for i in order]
        # Centroid of all but worst
        centroid = [sum(simplex[i][j] for i in range(3)) / 3.0 for j in range(3)]
        # Reflection
        xr = [centroid[j] + alpha * (centroid[j] - simplex[3][j]) for j in range(3)]
        fr = objective(xr)
        if f_vals[0] <= fr < f_vals[2]:
            simplex[3] = xr
            f_vals[3] = fr
            continue
        if fr < f_vals[0]:
            xe = [centroid[j] + gamma * (xr[j] - centroid[j]) for j in range(3)]
            fe = objective(xe)
            if fe < fr:
                simplex[3] = xe
                f_vals[3] = fe
            else:
                simplex[3] = xr
                f_vals[3] = fr
            continue
        xc = [centroid[j] + rho * (simplex[3][j] - centroid[j]) for j in range(3)]
        fc = objective(xc)
        if fc < f_vals[3]:
            simplex[3] = xc
            f_vals[3] = fc
            continue
        # Shrink
        for i in range(1, 4):
            simplex[i] = [simplex[0][j] + sigma * (simplex[i][j] - simplex[0][j]) for j in range(3)]
            f_vals[i] = objective(simplex[i])
    best_idx = min(range(4), key=lambda i: f_vals[i])
    best = simplex[best_idx]
    if f_vals[best_idx] < initial_sse:
        return (max(best[0], 1e-9), max(best[1], 1e-9), max(best[2], 0.0))
    return initial_params


def _natural_neighbor_voronoi(
    centroids: Sequence[Coordinate],
    values: Sequence[float],
    b: "Bounds",
    grid_resolution: int,
    dx: float,
    dy: float,
    geometry_column: str,
    suffix: str,
) -> list[Record] | None:
    """True Voronoi area-stealing natural neighbor interpolation via scipy + shapely."""
    try:
        scipy_spatial = importlib.import_module("scipy.spatial")
        shapely_geom = importlib.import_module("shapely.geometry")
        shapely_ops = importlib.import_module("shapely.ops")
        np = importlib.import_module("numpy")
    except ImportError:
        return None
    n = len(centroids)
    if n < 4:
        return None
    pts = np.array(centroids, dtype=float)
    # Compute original Voronoi
    try:
        vor_orig = scipy_spatial.Voronoi(pts)
    except Exception:
        return None
    # Bounding box for clipping Voronoi cells
    pad = max(b.max_x - b.min_x, b.max_y - b.min_y) * 0.5
    clip_box = shapely_geom.box(b.min_x - pad, b.min_y - pad, b.max_x + pad, b.max_y + pad)

    def _voronoi_cells(vor: Any) -> list[Any]:
        cells = [None] * len(vor.point_region)
        for pi, ri in enumerate(vor.point_region):
            region = vor.regions[ri]
            if -1 in region or not region:
                cells[pi] = None
            else:
                verts = [vor.vertices[v] for v in region]
                try:
                    poly = shapely_geom.Polygon(verts)
                    poly = poly.intersection(clip_box)
                    if poly.is_empty or poly.area < 1e-20:
                        cells[pi] = None
                    else:
                        cells[pi] = poly
                except Exception:
                    cells[pi] = None
        return cells

    orig_cells = _voronoi_cells(vor_orig)
    if all(c is None for c in orig_cells):
        return None
    rows: list[Record] = []
    cell_id = 0
    for gy in range(grid_resolution):
        py = b.min_y + gy * dy
        for gx in range(grid_resolution):
            px = b.min_x + gx * dx
            # Check exact match
            dists = [math.hypot(px - c[0], py - c[1]) for c in centroids]
            min_d = min(dists)
            if min_d < 1e-12:
                predicted = values[dists.index(min_d)]
            else:
                # Insert query point and compute new Voronoi
                aug_pts = np.vstack([pts, [[px, py]]])
                try:
                    vor_new = scipy_spatial.Voronoi(aug_pts)
                    new_cells = _voronoi_cells(vor_new)
                except Exception:
                    # Fallback IDW
                    inv_d2 = [(i, 1.0 / (d * d)) for i, d in enumerate(dists) if d > 1e-12]
                    ws = sum(w for _, w in inv_d2)
                    predicted = sum(values[i] * w for i, w in inv_d2) / ws if ws > 0 else 0.0
                    cell_id += 1
                    rows.append({
                        f"cell_id_{suffix}": f"nni-{cell_id:04d}",
                        f"value_{suffix}": predicted,
                        geometry_column: {"type": "Point", "coordinates": (px, py)},
                    })
                    continue
                # Compute weights = stolen area from each original cell
                weights: list[tuple[int, float]] = []
                for i in range(n):
                    if orig_cells[i] is None or new_cells[i] is None:
                        continue
                    try:
                        stolen = orig_cells[i].area - new_cells[i].area
                        if stolen > 1e-20:
                            weights.append((i, stolen))
                    except Exception:
                        continue
                if weights:
                    ws = sum(w for _, w in weights)
                    predicted = sum(values[i] * w for i, w in weights) / ws
                else:
                    inv_d2 = [(i, 1.0 / (d * d)) for i, d in enumerate(dists) if d > 1e-12]
                    ws = sum(w for _, w in inv_d2)
                    predicted = sum(values[i] * w for i, w in inv_d2) / ws if ws > 0 else 0.0
            cell_id += 1
            rows.append({
                f"cell_id_{suffix}": f"nni-{cell_id:04d}",
                f"value_{suffix}": predicted,
                geometry_column: {"type": "Point", "coordinates": (px, py)},
            })
    return rows


def _parse_wkb_hex(wkb_hex: str) -> Geometry | None:
    """Minimal WKB hex parser for Point, LineString, Polygon (little-endian)."""
    import struct
    try:
        data = bytes.fromhex(wkb_hex)
    except ValueError:
        return None
    if len(data) < 5:
        return None
    byte_order = data[0]
    fmt = "<" if byte_order == 1 else ">"
    wkb_type = struct.unpack_from(f"{fmt}I", data, 1)[0]
    offset = 5
    if wkb_type == 1:  # Point
        x, y = struct.unpack_from(f"{fmt}dd", data, offset)
        return {"type": "Point", "coordinates": (x, y)}
    elif wkb_type == 2:  # LineString
        n_pts = struct.unpack_from(f"{fmt}I", data, offset)[0]
        offset += 4
        coords = []
        for _ in range(n_pts):
            x, y = struct.unpack_from(f"{fmt}dd", data, offset)
            coords.append((x, y))
            offset += 16
        return {"type": "LineString", "coordinates": coords}
    elif wkb_type == 3:  # Polygon
        n_rings = struct.unpack_from(f"{fmt}I", data, offset)[0]
        offset += 4
        rings = []
        for _ in range(n_rings):
            n_pts = struct.unpack_from(f"{fmt}I", data, offset)[0]
            offset += 4
            ring = []
            for _ in range(n_pts):
                x, y = struct.unpack_from(f"{fmt}dd", data, offset)
                ring.append((x, y))
                offset += 16
            rings.append(ring)
        return {"type": "Polygon", "coordinates": rings}
    return None


def _geometry_to_wkb_hex(geom: Geometry) -> str:
    """Minimal geometry to WKB hex encoder (little-endian)."""
    import struct
    gt = geometry_type(geom)
    parts: list[bytes] = [struct.pack("<B", 1)]  # little-endian
    coords_obj: Any = geom.get("coordinates", ())
    if gt == "Point":
        point = _as_coordinate(coords_obj)
        parts.append(struct.pack("<I", 1))
        parts.append(struct.pack("<dd", float(point[0]), float(point[1])))
    elif gt == "LineString":
        line = [_as_coordinate(value) for value in cast(Sequence[Any], coords_obj)]
        parts.append(struct.pack("<I", 2))
        parts.append(struct.pack("<I", len(line)))
        for c in line:
            parts.append(struct.pack("<dd", float(c[0]), float(c[1])))
    elif gt == "Polygon":
        rings = [
            [_as_coordinate(value) for value in cast(Sequence[Any], ring)]
            for ring in cast(Sequence[Any], coords_obj)
        ]
        parts.append(struct.pack("<I", 3))
        parts.append(struct.pack("<I", len(rings)))
        for ring in rings:
            parts.append(struct.pack("<I", len(ring)))
            for c in ring:
                parts.append(struct.pack("<dd", float(c[0]), float(c[1])))
    else:
        return ""
    return b"".join(parts).hex()


__all__ = ["Bounds", "GeoPromptFrame"]