Skip to content

AOI perturbation API

The AOI perturbation surface is vendor-neutral and exported from the eyeprocesspy package front door.

aoi_perturbation

AOI perturbation and uncertainty analysis public API.

AMBIGUOUS module-attribute

AMBIGUOUS = '__ambiguous__'

OUTSIDE module-attribute

OUTSIDE = '__outside__'

__all__ module-attribute

__all__ = ['OUTSIDE', 'AMBIGUOUS', 'aoi_perturbation_spec', 'validate_aoi_geometry', 'convert_aoi_margin_to_degrees', 'convert_aoi_margin_to_pixels', 'dilate_aoi', 'erode_aoi', 'translate_aoi', 'jitter_aoi', 'perturb_aoi_geometry', 'create_aoi_perturbation_grid', 'apply_aoi_perturbation_grid', 'compare_aoi_assignments', 'estimate_aoi_assignment_stability', 'estimate_fixation_assignment_probability', 'recompute_aoi_features', 'run_aoi_sensitivity_analysis', 'summarise_aoi_sensitivity', 'assess_aoi_inference_stability', 'report_aoi_sensitivity', 'plot_aoi_perturbations', 'plot_aoi_assignment_stability', 'plot_aoi_coefficient_stability', 'plot_aoi_robustness_surface']

EyeProcessValidationError

Bases: EyeProcessError

Raised when a canonical dataset violates validation contracts.

Source code in src/eyeprocesspy/exceptions.py
class EyeProcessValidationError(EyeProcessError):
    """Raised when a canonical dataset violates validation contracts."""

EyeResult

Bases: dict

Small R-list-like result object with stable class metadata.

Source code in src/eyeprocesspy/irt.py
class EyeResult(dict):
    """Small R-list-like result object with stable class metadata."""

    def __init__(self, *args: Any, eyeprocess_class: str | None = None, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.eyeprocess_class = eyeprocess_class

    def __getattr__(self, name: str) -> Any:
        try:
            return self[name]
        except KeyError as exc:
            raise AttributeError(name) from exc

_AssignmentVector

Bases: list

List-compatible AOI assignment vector.

Source code in src/eyeprocesspy/aoi_perturbation.py
class _AssignmentVector(list):
    """List-compatible AOI assignment vector."""

    def tolist(self) -> list[Any]:
        return list(self)

_assign_points

_assign_points(data: DataFrame, geometry: DataFrame, *, x_col: str, y_col: str, overlap_policy: str = 'ambiguous') -> pd.Series
Source code in src/eyeprocesspy/_aoi_assignment_core.py
def _assign_points(
    data: pd.DataFrame,
    geometry: pd.DataFrame,
    *,
    x_col: str,
    y_col: str,
    overlap_policy: str = "ambiguous",
) -> pd.Series:
    frame = _frame(data, "data")
    if x_col not in frame.columns or y_col not in frame.columns:
        raise EyeProcessValidationError(f"`data` must contain `{x_col}` and `{y_col}`.")
    overlap_policy = str(overlap_policy).lower()
    if overlap_policy not in {"ambiguous", "all", "error"}:
        raise EyeProcessValidationError("`overlap_policy` must be ambiguous, all, or error.")
    geom = validate_aoi_geometry(geometry)["geometry"]
    x = pd.to_numeric(frame[x_col], errors="coerce").to_numpy(dtype=float)
    y = pd.to_numeric(frame[y_col], errors="coerce").to_numpy(dtype=float)
    missing = ~np.isfinite(x) | ~np.isfinite(y)
    memberships = np.zeros((len(frame), len(geom)), dtype=bool)
    for j, (_, row) in enumerate(geom.iterrows()):
        memberships[:, j] = _aoi_contains(row, x, y) & ~missing
    counts = memberships.sum(axis=1)
    labels = np.full(len(frame), OUTSIDE, dtype=object)
    labels[missing] = None
    for i in np.where(counts == 1)[0]:
        labels[i] = str(geom.iloc[np.flatnonzero(memberships[i])[0]]["aoi_id"])
    ambiguous = np.where(counts > 1)[0]
    if len(ambiguous):
        if overlap_policy == "error":
            raise EyeProcessValidationError(
                f"{len(ambiguous)} observations have ambiguous overlapping AOI membership."
            )
        if overlap_policy == "ambiguous":
            labels[ambiguous] = AMBIGUOUS
        else:
            for i in ambiguous:
                labels[i] = "|".join(
                    geom.iloc[np.flatnonzero(memberships[i])]["aoi_id"].astype(str).tolist()
                )
    return pd.Series(labels, index=frame.index, dtype="object", name="aoi_assignment")

_validate_model_table

_validate_model_table(table: Any, perturbation_id: str) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_assignment_core.py
def _validate_model_table(table: Any, perturbation_id: str) -> pd.DataFrame:
    frame = _frame(table, "model callback result")
    required = ["term", "estimate", "SE", "CI_low", "CI_high", "p_value", "model_converged", "N"]
    missing = [c for c in required if c not in frame.columns]
    if missing:
        raise EyeProcessValidationError(
            "Model callback result is missing required column(s): " + ", ".join(missing)
        )
    out = frame.copy()
    terms = out["term"].astype("string")
    if terms.isna().any() or (terms.str.len() == 0).any():
        raise EyeProcessValidationError(
            "Model callback term values must be non-missing and non-empty."
        )
    out["term"] = terms.astype(str)

    raw_convergence = out["model_converged"]
    valid_boolean = raw_convergence.map(
        lambda v: pd.isna(v) or isinstance(v, (bool, np.bool_)) or v in (0, 1)
    )
    if not bool(valid_boolean.all()):
        raise EyeProcessValidationError(
            "`model_converged` must contain booleans, 0/1, or missing values; strings are not accepted."
        )
    out["model_converged"] = raw_convergence.astype("boolean")

    for column in ("estimate", "SE", "CI_low", "CI_high", "p_value", "N"):
        original = out[column]
        converted = pd.to_numeric(original, errors="coerce")
        bad_conversion = original.notna() & converted.isna()
        if bad_conversion.any():
            raise EyeProcessValidationError(
                f"Model callback `{column}` must be numeric or missing."
            )
        out[column] = converted
    if (out["N"].dropna() < 0).any():
        raise EyeProcessValidationError("Model callback `N` must be non-negative when supplied.")
    n_values = out["N"].dropna().to_numpy(dtype=float)
    if len(n_values) and not np.allclose(n_values, np.round(n_values), atol=1e-9, rtol=0.0):
        raise EyeProcessValidationError("Model callback `N` must be integer-valued when supplied.")

    converged = out["model_converged"].fillna(False).astype(bool)
    if converged.any():
        required_finite = ["estimate", "SE", "CI_low", "CI_high", "N"]
        bad = ~np.isfinite(out.loc[converged, required_finite].to_numpy(dtype=float))
        if bad.any():
            raise EyeProcessValidationError(
                "Converged model rows must contain finite estimate, SE, CI_low, CI_high, and N values."
            )
        if (out.loc[converged, "N"] <= 0).any():
            raise EyeProcessValidationError("Converged model rows must report N > 0.")

    out["perturbation_id"] = perturbation_id
    est = out["estimate"].to_numpy(dtype=float)
    out["direction"] = np.where(
        ~np.isfinite(est),
        "missing",
        np.where(est > 0, "positive", np.where(est < 0, "negative", "zero")),
    )
    ordered = ["perturbation_id"] + required + ["direction"]
    extras = [c for c in out.columns if c not in set(ordered)]
    return out[ordered + extras]

compare_aoi_assignments

compare_aoi_assignments(baseline: Sequence[Any], perturbed: Sequence[Any], *, ids: Sequence[Any] | None = None) -> EyeResult
Source code in src/eyeprocesspy/_aoi_assignment_core.py
def compare_aoi_assignments(
    baseline: Sequence[Any],
    perturbed: Sequence[Any],
    *,
    ids: Sequence[Any] | None = None,
) -> EyeResult:
    base = pd.Series(list(baseline), dtype="object")
    alt = pd.Series(list(perturbed), dtype="object")
    if len(base) != len(alt):
        raise EyeProcessValidationError(
            "Baseline and perturbed assignments must have equal length."
        )
    resolved_ids = list(range(1, len(base) + 1)) if ids is None else list(ids)
    if len(resolved_ids) != len(base):
        raise EyeProcessValidationError("`ids` must have the same length as assignments.")
    missing = base.isna() | alt.isna()
    unchanged = (~missing) & base.eq(alt)
    newly = (~missing) & base.eq(OUTSIDE) & ~alt.eq(OUTSIDE)
    lost = (~missing) & ~base.eq(OUTSIDE) & alt.eq(OUTSIDE)
    reassigned = (~missing) & ~unchanged & ~newly & ~lost
    detail = pd.DataFrame(
        {
            "observation_id": resolved_ids,
            "baseline_aoi": base,
            "perturbed_aoi": alt,
            "unchanged": unchanged,
            "newly_assigned": newly,
            "lost_assignment": lost,
            "reassigned": reassigned,
            "missing_comparison": missing,
        }
    )
    comparable = ~missing
    n = int(comparable.sum())
    summary = pd.DataFrame(
        [
            {
                "n_total": len(base),
                "n_comparable": n,
                "proportion_unchanged": float(unchanged[comparable].mean()) if n else np.nan,
                "proportion_newly_assigned": float(newly[comparable].mean()) if n else np.nan,
                "proportion_lost": float(lost[comparable].mean()) if n else np.nan,
                "proportion_reassigned": float(reassigned[comparable].mean()) if n else np.nan,
                "proportion_missing_comparison": float(missing.mean()) if len(base) else np.nan,
            }
        ]
    )
    valid_pairs = detail.loc[comparable, ["baseline_aoi", "perturbed_aoi"]]
    matrix = pd.crosstab(valid_pairs["baseline_aoi"], valid_pairs["perturbed_aoi"], dropna=False)
    return _result(
        "eye_aoi_assignment_comparison", detail=detail, summary=summary, reassignment_matrix=matrix
    )

estimate_aoi_assignment_stability

estimate_aoi_assignment_stability(comparisons: Mapping[str, Any] | DataFrame, *, metadata: DataFrame | None = None, group_cols: Sequence[str] | None = None) -> EyeResult
Source code in src/eyeprocesspy/_aoi_assignment_core.py
def estimate_aoi_assignment_stability(
    comparisons: Mapping[str, Any] | pd.DataFrame,
    *,
    metadata: pd.DataFrame | None = None,
    group_cols: Sequence[str] | None = None,
) -> EyeResult:
    rows: list[pd.DataFrame] = []
    if isinstance(comparisons, pd.DataFrame):
        required = {"perturbation_id", "baseline_aoi", "perturbed_aoi"}
        if not required.issubset(comparisons.columns):
            raise EyeProcessValidationError(
                "Comparison data must contain perturbation_id, baseline_aoi, and perturbed_aoi."
            )
        source = comparisons.copy()
    else:
        for pid, comparison in comparisons.items():
            detail = comparison["detail"].copy()
            detail["perturbation_id"] = str(pid)
            rows.append(detail)
        source = pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
    if source.empty:
        raise EyeProcessValidationError("No assignment comparisons are available.")
    if "observation_id" not in source.columns:
        source["observation_id"] = source.groupby("perturbation_id").cumcount() + 1
    source_ids = source["observation_id"].drop_duplicates().tolist()
    if pd.Series(source_ids).isna().any():
        raise EyeProcessValidationError("Comparison observation IDs must be non-missing.")
    if metadata is not None:
        meta = _frame(metadata, "metadata")
        if len(meta) != len(source_ids):
            raise EyeProcessValidationError("`metadata` must contain one row per observation.")
        if "observation_id" not in meta.columns:
            meta["observation_id"] = source_ids
        else:
            if meta["observation_id"].isna().any() or meta["observation_id"].duplicated().any():
                raise EyeProcessValidationError(
                    "Metadata observation IDs must be unique and non-missing."
                )
            if set(meta["observation_id"].tolist()) != set(source_ids):
                raise EyeProcessValidationError(
                    "Metadata observation IDs must match the comparison observation IDs exactly."
                )
        source = source.merge(meta, on="observation_id", how="left", validate="many_to_one")
    source["comparable"] = ~(source["baseline_aoi"].isna() | source["perturbed_aoi"].isna())
    source["unchanged"] = source["comparable"] & source["baseline_aoi"].eq(source["perturbed_aoi"])
    source["newly_assigned"] = (
        source["comparable"]
        & source["baseline_aoi"].eq(OUTSIDE)
        & ~source["perturbed_aoi"].eq(OUTSIDE)
    )
    source["lost_assignment"] = (
        source["comparable"]
        & ~source["baseline_aoi"].eq(OUTSIDE)
        & source["perturbed_aoi"].eq(OUTSIDE)
    )
    source["reassigned"] = source["comparable"] & ~(
        source["unchanged"] | source["newly_assigned"] | source["lost_assignment"]
    )

    def summarise(df: pd.DataFrame, keys: list[str]) -> pd.DataFrame:
        out_rows = []
        grouped = [((), df)] if not keys else df.groupby(keys, dropna=False, sort=True)
        for key, z in grouped:
            key = (key,) if keys and not isinstance(key, tuple) else key
            comparable = z["comparable"]
            n = int(comparable.sum())
            row = {k: v for k, v in zip(keys, key)}
            row.update(
                {
                    "n_total": len(z),
                    "n_comparable": n,
                    "proportion_unchanged": float(z.loc[comparable, "unchanged"].mean())
                    if n
                    else np.nan,
                    "proportion_newly_assigned": float(z.loc[comparable, "newly_assigned"].mean())
                    if n
                    else np.nan,
                    "proportion_lost": float(z.loc[comparable, "lost_assignment"].mean())
                    if n
                    else np.nan,
                    "proportion_reassigned": float(z.loc[comparable, "reassigned"].mean())
                    if n
                    else np.nan,
                }
            )
            out_rows.append(row)
        return pd.DataFrame(out_rows)

    overall = summarise(source, ["perturbation_id"])
    by_group: dict[str, pd.DataFrame] = {}
    for col in group_cols or []:
        if col not in source.columns:
            raise EyeProcessValidationError(
                f"Grouping column `{col}` is not available in comparison metadata."
            )
        by_group[col] = summarise(source, ["perturbation_id", col])
    aoi_level = summarise(source.assign(aoi=source["baseline_aoi"]), ["perturbation_id", "aoi"])
    return _result(
        "eye_aoi_assignment_stability",
        detail=source,
        overall=overall,
        group_summaries=by_group,
        aoi_level=aoi_level,
        caveat="Stability frequencies describe sensitivity to the specified perturbations; they are not probabilities that AOI assignments are scientifically true.",
    )

estimate_fixation_assignment_probability

estimate_fixation_assignment_probability(assignments: Mapping[str, Sequence[Any]] | DataFrame, *, ids: Sequence[Any] | None = None, include_baseline: bool = True) -> pd.DataFrame

Estimate empirical assignment frequencies across a declared perturbation set.

Source code in src/eyeprocesspy/_aoi_assignment_core.py
def estimate_fixation_assignment_probability(
    assignments: Mapping[str, Sequence[Any]] | pd.DataFrame,
    *,
    ids: Sequence[Any] | None = None,
    include_baseline: bool = True,
) -> pd.DataFrame:
    """Estimate empirical assignment frequencies across a declared perturbation set."""
    if isinstance(assignments, pd.DataFrame):
        required = {"perturbation_id", "observation_id", "aoi_assignment"}
        if not required.issubset(assignments.columns):
            raise EyeProcessValidationError(
                "Assignment table must contain perturbation_id, observation_id, and aoi_assignment."
            )
        long = assignments.loc[:, list(required)].copy()
    else:
        rows = []
        lengths = {len(v) for v in assignments.values()}
        if len(lengths) != 1:
            raise EyeProcessValidationError(
                "All perturbation assignment vectors must have the same length."
            )
        n = next(iter(lengths), 0)
        obs = list(ids) if ids is not None else list(range(1, n + 1))
        if len(obs) != n:
            raise EyeProcessValidationError("`ids` must match the assignment vector length.")
        for pid, labels in assignments.items():
            if not include_baseline and str(pid) == "baseline":
                continue
            rows.append(
                pd.DataFrame(
                    {
                        "perturbation_id": str(pid),
                        "observation_id": obs,
                        "aoi_assignment": list(labels),
                    }
                )
            )
        long = (
            pd.concat(rows, ignore_index=True)
            if rows
            else pd.DataFrame(columns=["perturbation_id", "observation_id", "aoi_assignment"])
        )
    long = long.dropna(subset=["aoi_assignment"])
    if long.empty:
        return pd.DataFrame(
            columns=[
                "observation_id",
                "aoi",
                "assignment_count",
                "n_perturbations",
                "assignment_frequency",
            ]
        )
    counts = (
        long.groupby(["observation_id", "aoi_assignment"], dropna=False)
        .size()
        .rename("assignment_count")
        .reset_index()
    )
    denominators = (
        long.groupby("observation_id")["perturbation_id"]
        .nunique()
        .rename("n_perturbations")
        .reset_index()
    )
    out = counts.merge(denominators, on="observation_id", how="left")
    out["assignment_frequency"] = out["assignment_count"] / out["n_perturbations"]
    out = out.rename(columns={"aoi_assignment": "aoi"})
    out.attrs["caveat"] = (
        "Assignment frequency is a descriptive perturbation frequency, not a posterior probability of true AOI membership."
    )
    return out

recompute_aoi_features

recompute_aoi_features(data: DataFrame, assignments: Sequence[Any], *, participant_col: str | None = None, trial_col: str | None = None, duration_col: str | None = None, time_col: str | None = None, perturbation_id: str | None = None, aoi_levels: Sequence[str] | None = None, observation_level: str = 'fixation') -> pd.DataFrame

Recompute AOI features without dropping observed zero-assignment cells.

A participant/trial × AOI cell receives a structural zero count only when the group contains at least one non-missing AOI assignment opportunity. Groups whose assignments are all missing retain NA counts/inspection. Dwell is NA when an assigned observation has missing duration, and first-fixation timing is never inferred from row order.

Source code in src/eyeprocesspy/_aoi_assignment_core.py
def recompute_aoi_features(
    data: pd.DataFrame,
    assignments: Sequence[Any],
    *,
    participant_col: str | None = None,
    trial_col: str | None = None,
    duration_col: str | None = None,
    time_col: str | None = None,
    perturbation_id: str | None = None,
    aoi_levels: Sequence[str] | None = None,
    observation_level: str = "fixation",
) -> pd.DataFrame:
    """Recompute AOI features without dropping observed zero-assignment cells.

    A participant/trial × AOI cell receives a structural zero count only when
    the group contains at least one non-missing AOI assignment opportunity.
    Groups whose assignments are all missing retain NA counts/inspection.
    Dwell is NA when an assigned observation has missing duration, and
    first-fixation timing is never inferred from row order.
    """
    frame = _frame(data, "data")
    observation_level = str(observation_level).lower()
    if observation_level not in {"fixation", "sample"}:
        raise EyeProcessValidationError("`observation_level` must be 'fixation' or 'sample'.")
    if len(assignments) != len(frame):
        raise EyeProcessValidationError("`assignments` must contain one value per data row.")
    frame = frame.copy()
    frame["aoi_assignment"] = list(assignments)
    group_cols = [c for c in (participant_col, trial_col) if c is not None]
    missing_groups = [c for c in group_cols if c not in frame.columns]
    if missing_groups:
        raise EyeProcessValidationError("Missing grouping column(s): " + ", ".join(missing_groups))
    if duration_col is not None and duration_col not in frame.columns:
        raise EyeProcessValidationError(f"`duration_col` `{duration_col}` is absent.")
    if time_col is not None and time_col not in frame.columns:
        raise EyeProcessValidationError(f"`time_col` `{time_col}` is absent.")
    if duration_col is None:
        warnings.warn(
            "No `duration_col` supplied; dwell is returned as NA rather than inferred.",
            RuntimeWarning,
            stacklevel=2,
        )
    if time_col is None:
        warnings.warn(
            "No `time_col` supplied; first_fixation is returned as NA rather than inferred from row order.",
            RuntimeWarning,
            stacklevel=2,
        )

    if aoi_levels is None:
        inferred = (
            frame.loc[
                frame["aoi_assignment"].notna()
                & ~frame["aoi_assignment"].isin([OUTSIDE, AMBIGUOUS]),
                "aoi_assignment",
            ]
            .astype(str)
            .drop_duplicates()
            .tolist()
        )
        levels = inferred
    else:
        levels = [str(v) for v in aoi_levels]
        if any(not v for v in levels) or len(set(levels)) != len(levels):
            raise EyeProcessValidationError(
                "`aoi_levels` must contain unique non-empty AOI identifiers."
            )

    columns = group_cols + [
        "aoi",
        "observation_level",
        "observation_count",
        "fixation_count",
        "sample_count",
        "dwell",
        "first_observation",
        "first_fixation",
        "inspected",
        "n_valid_observations",
        "n_missing_observations",
        "duration_complete",
        "time_complete",
        "perturbation_id",
    ]
    if not levels:
        return pd.DataFrame(columns=columns)

    grouped: Any
    if group_cols:
        grouper = group_cols[0] if len(group_cols) == 1 else group_cols
        grouped = frame.groupby(grouper, dropna=False, sort=True)
    else:
        grouped = [((), frame)]

    rows: list[dict[str, Any]] = []
    for key, group in grouped:
        if len(group_cols) == 1:
            key = (key,)
        elif not group_cols:
            key = ()
        group_values = {k: v for k, v in zip(group_cols, key)}
        valid_assignment = group["aoi_assignment"].notna()
        n_valid = int(valid_assignment.sum())
        n_missing = int((~valid_assignment).sum())

        for aoi in levels:
            row: dict[str, Any] = dict(group_values)
            row["aoi"] = aoi
            selected = group.loc[group["aoi_assignment"].astype("string").eq(aoi).fillna(False)]
            count = int(len(selected))

            row["observation_level"] = observation_level
            if n_valid == 0:
                row["observation_count"] = pd.NA
                row["fixation_count"] = pd.NA
                row["sample_count"] = pd.NA
                row["dwell"] = np.nan
                row["first_observation"] = np.nan
                row["first_fixation"] = np.nan
                row["inspected"] = pd.NA
                row["duration_complete"] = pd.NA
                row["time_complete"] = pd.NA
            else:
                row["observation_count"] = count
                row["fixation_count"] = count if observation_level == "fixation" else pd.NA
                row["sample_count"] = count if observation_level == "sample" else pd.NA
                row["inspected"] = bool(count > 0)
                if duration_col is None:
                    row["dwell"] = np.nan
                    row["duration_complete"] = pd.NA
                elif count == 0:
                    row["dwell"] = 0.0
                    row["duration_complete"] = True
                else:
                    durations = pd.to_numeric(selected[duration_col], errors="coerce")
                    duration_complete = bool(durations.notna().all())
                    row["duration_complete"] = duration_complete
                    row["dwell"] = float(durations.sum()) if duration_complete else np.nan

                if time_col is None:
                    row["first_observation"] = np.nan
                    row["first_fixation"] = np.nan
                    row["time_complete"] = pd.NA
                elif count == 0:
                    row["first_observation"] = np.nan
                    row["first_fixation"] = np.nan
                    row["time_complete"] = True
                else:
                    times = pd.to_numeric(selected[time_col], errors="coerce")
                    time_complete = bool(times.notna().all())
                    first_observation = float(times.min()) if times.notna().any() else np.nan
                    row["time_complete"] = time_complete
                    row["first_observation"] = first_observation
                    row["first_fixation"] = (
                        first_observation if observation_level == "fixation" else np.nan
                    )

            row["n_valid_observations"] = n_valid
            row["n_missing_observations"] = n_missing
            row["perturbation_id"] = perturbation_id
            rows.append(row)

    out = pd.DataFrame(rows, columns=columns)
    for count_col in ("observation_count", "fixation_count", "sample_count"):
        out[count_col] = out[count_col].astype("Int64")
    out["inspected"] = out["inspected"].astype("boolean")
    out["duration_complete"] = out["duration_complete"].astype("boolean")
    out["time_complete"] = out["time_complete"].astype("boolean")
    return out

_frame

_frame(x: Any, name: str) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_geometry_primitives.py
def _frame(x: Any, name: str) -> pd.DataFrame:
    if not isinstance(x, pd.DataFrame):
        raise EyeProcessValidationError(f"`{name}` must be a pandas DataFrame.")
    return x.copy()

_software_provenance

_software_provenance() -> dict[str, Any]
Source code in src/eyeprocesspy/_aoi_geometry_primitives.py
def _software_provenance() -> dict[str, Any]:
    package_version: str | None

    try:
        from . import __version__ as package_version
    except Exception:  # pragma: no cover - defensive when module is executed standalone
        package_version = None

    return {
        "package": "eyeprocesspy",
        "version": package_version,
        "module": __name__,
    }

_stable_frame_hash

_stable_frame_hash(frame: DataFrame) -> str
Source code in src/eyeprocesspy/_aoi_geometry_primitives.py
def _stable_frame_hash(frame: pd.DataFrame) -> str:
    def normalise(v: Any) -> Any:
        if isinstance(v, np.ndarray):
            return v.tolist()
        if isinstance(v, (list, tuple)):
            return [normalise(z) for z in v]
        if isinstance(v, Mapping):
            return {str(k): normalise(val) for k, val in sorted(v.items(), key=lambda z: str(z[0]))}
        if pd.isna(v) if np.isscalar(v) else False:
            return None
        if isinstance(v, (np.integer, np.floating)):
            return v.item()
        return v

    records = []
    for row in frame.to_dict(orient="records"):
        records.append({str(k): normalise(v) for k, v in sorted(row.items())})
    blob = json.dumps(records, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(
        "utf-8"
    )
    return hashlib.sha256(blob).hexdigest()

_spec_value

_spec_value(spec: Any, key: str) -> Any
Source code in src/eyeprocesspy/_aoi_geometry_units.py
def _spec_value(spec: Any, key: str) -> Any:
    if isinstance(spec, Mapping):
        return spec[key]
    try:
        return spec[key]
    except Exception as exc:
        raise EyeProcessValidationError(
            "Perturbation specifications must be mapping-like objects created by `aoi_perturbation_spec()`."
        ) from exc

aoi_perturbation_spec

aoi_perturbation_spec(perturbation_id: str, operation: str = 'baseline', *, margin_x: float = 0.0, margin_y: float | None = None, translation_x: float = 0.0, translation_y: float = 0.0, unit: str = 'px', screen_width_px: int | None = None, screen_height_px: int | None = None, viewing_distance: float | None = None, physical_screen_size: Sequence[float] | None = None, degrees_per_pixel: Sequence[float] | None = None, seed: int | None = None, boundary_policy: str = 'warn') -> EyeResult

Create a validated, reproducible AOI perturbation specification.

Source code in src/eyeprocesspy/_aoi_geometry_units.py
def aoi_perturbation_spec(
    perturbation_id: str,
    operation: str = "baseline",
    *,
    margin_x: float = 0.0,
    margin_y: float | None = None,
    translation_x: float = 0.0,
    translation_y: float = 0.0,
    unit: str = "px",
    screen_width_px: int | None = None,
    screen_height_px: int | None = None,
    viewing_distance: float | None = None,
    physical_screen_size: Sequence[float] | None = None,
    degrees_per_pixel: Sequence[float] | None = None,
    seed: int | None = None,
    boundary_policy: str = "warn",
) -> EyeResult:
    """Create a validated, reproducible AOI perturbation specification."""
    if not isinstance(perturbation_id, str) or not perturbation_id.strip():
        raise EyeProcessValidationError("`perturbation_id` must be a non-empty string.")
    operation = str(operation).lower()
    operations = {"baseline", "dilation", "erosion", "translate", "jitter", "anisotropic_expansion"}
    if operation not in operations:
        raise EyeProcessValidationError(f"Unsupported perturbation operation `{operation}`.")
    unit = str(unit).lower()
    if unit not in {"px", "deg"}:
        raise EyeProcessValidationError("`unit` must be 'px' or 'deg'.")
    boundary_policy = str(boundary_policy).lower()
    if boundary_policy not in {"warn", "clip", "error", "allow"}:
        raise EyeProcessValidationError("`boundary_policy` must be warn, clip, error, or allow.")
    mx = _finite_scalar(margin_x, "margin_x")
    my = mx if margin_y is None else _finite_scalar(margin_y, "margin_y")
    tx = _finite_scalar(translation_x, "translation_x")
    ty = _finite_scalar(translation_y, "translation_y")
    if operation == "dilation" and (mx < 0 or my < 0):
        raise EyeProcessValidationError("Dilation margins must be non-negative.")
    if operation == "erosion" and (mx < 0 or my < 0):
        raise EyeProcessValidationError(
            "Erosion margins must be non-negative; erosion direction is implied by the operation."
        )
    if operation in {"dilation", "erosion"} and not math.isclose(mx, my, rel_tol=0, abs_tol=1e-12):
        raise EyeProcessValidationError(
            "Polygon-safe dilation/erosion uses a uniform margin; use anisotropic_expansion for different x/y margins."
        )
    resolved_dpp: tuple[float, float] | None = None
    if degrees_per_pixel is not None:
        resolved_dpp = _normalise_pair(degrees_per_pixel, "degrees_per_pixel", nonnegative=True)
        if min(resolved_dpp) <= 0:
            raise EyeProcessValidationError("`degrees_per_pixel` values must be positive.")
    elif all(
        v is not None
        for v in (screen_width_px, screen_height_px, viewing_distance, physical_screen_size)
    ):
        assert screen_width_px is not None
        assert screen_height_px is not None
        assert viewing_distance is not None
        assert physical_screen_size is not None

        _, _, dx, dy = _screen_geometry(
            screen_width_px=int(screen_width_px),
            screen_height_px=int(screen_height_px),
            viewing_distance=float(viewing_distance),
            physical_screen_size=physical_screen_size,
        )
        resolved_dpp = (dx, dy)
    if unit == "deg" and resolved_dpp is None:
        raise EyeProcessValidationError(
            "Degree-based perturbations require `degrees_per_pixel` or complete screen geometry and viewing distance."
        )
    if seed is not None:
        if isinstance(seed, bool) or int(seed) != seed or int(seed) < 0:
            raise EyeProcessValidationError("`seed` must be a non-negative integer or None.")
        seed = int(seed)
    return _result(
        "eye_aoi_perturbation_spec",
        perturbation_id=perturbation_id,
        operation=operation,
        margin_x=mx,
        margin_y=my,
        translation_x=tx,
        translation_y=ty,
        unit=unit,
        screen_width_px=None if screen_width_px is None else int(screen_width_px),
        screen_height_px=None if screen_height_px is None else int(screen_height_px),
        viewing_distance=viewing_distance,
        physical_screen_size=None
        if physical_screen_size is None
        else tuple(float(v) for v in physical_screen_size),
        degrees_per_pixel=resolved_dpp,
        seed=seed,
        boundary_policy=boundary_policy,
        software=_software_provenance(),
    )

convert_aoi_margin_to_degrees

convert_aoi_margin_to_degrees(margin_pixels: Any, *, screen_width_px: int, screen_height_px: int, viewing_distance: float, physical_screen_size: Sequence[float]) -> tuple[float, float]

Convert pixel margins to degrees of visual angle on x and y axes.

Source code in src/eyeprocesspy/_aoi_geometry_units.py
def convert_aoi_margin_to_degrees(
    margin_pixels: Any,
    *,
    screen_width_px: int,
    screen_height_px: int,
    viewing_distance: float,
    physical_screen_size: Sequence[float],
) -> tuple[float, float]:
    """Convert pixel margins to degrees of visual angle on x and y axes."""
    mx, my = _normalise_pair(margin_pixels, "margin_pixels", nonnegative=False)
    _, _, dx, dy = _screen_geometry(
        screen_width_px=screen_width_px,
        screen_height_px=screen_height_px,
        viewing_distance=viewing_distance,
        physical_screen_size=physical_screen_size,
    )
    return (mx * dx, my * dy)

convert_aoi_margin_to_pixels

convert_aoi_margin_to_pixels(margin_degrees: Any, *, screen_width_px: int, screen_height_px: int, viewing_distance: float, physical_screen_size: Sequence[float]) -> tuple[float, float]

Convert visual-angle margins to pixels on x and y axes.

Source code in src/eyeprocesspy/_aoi_geometry_units.py
def convert_aoi_margin_to_pixels(
    margin_degrees: Any,
    *,
    screen_width_px: int,
    screen_height_px: int,
    viewing_distance: float,
    physical_screen_size: Sequence[float],
) -> tuple[float, float]:
    """Convert visual-angle margins to pixels on x and y axes."""
    mx, my = _normalise_pair(margin_degrees, "margin_degrees", nonnegative=False)
    _, _, dx, dy = _screen_geometry(
        screen_width_px=screen_width_px,
        screen_height_px=screen_height_px,
        viewing_distance=viewing_distance,
        physical_screen_size=physical_screen_size,
    )
    return (mx / dx, my / dy)

validate_aoi_geometry

validate_aoi_geometry(aois: DataFrame, *, allow_overlap: bool = True) -> EyeResult

Validate rectangular and polygonal AOI geometry without resolving overlap.

Source code in src/eyeprocesspy/_aoi_geometry_validation.py
def validate_aoi_geometry(aois: pd.DataFrame, *, allow_overlap: bool = True) -> EyeResult:
    """Validate rectangular and polygonal AOI geometry without resolving overlap."""
    frame = _frame(aois, "aois")
    if frame.empty:
        raise EyeProcessValidationError("`aois` must contain at least one AOI.")
    id_col = next((c for c in ("aoi_id", "aoi", "name", "label") if c in frame.columns), None)
    if id_col is None:
        raise EyeProcessValidationError(
            "`aois` must include an AOI identifier column such as `aoi_id`."
        )
    ids = frame[id_col].astype("string")
    if ids.isna().any() or (ids.str.len() == 0).any():
        raise EyeProcessValidationError("AOI identifiers must be non-missing and non-empty.")
    if ids.duplicated().any():
        raise EyeProcessValidationError(
            "AOI identifiers must be unique within an AOI specification."
        )

    rows: list[dict[str, Any]] = []
    normalized = frame.copy()
    normalized["aoi_id"] = ids.astype(str)
    for coordinate_column in ("xmin", "xmax", "ymin", "ymax"):
        if coordinate_column in normalized.columns:
            normalized[coordinate_column] = pd.to_numeric(
                normalized[coordinate_column], errors="coerce"
            ).astype(float)
    normalized["shape_type"] = [_infer_shape(row) for _, row in normalized.iterrows()]
    if "polygon" not in normalized.columns:
        normalized["polygon"] = [None] * len(normalized)

    for i, row in normalized.iterrows():
        aoi_id = row["aoi_id"]
        shape = row["shape_type"]
        if shape == "rectangle":
            required = ["xmin", "xmax", "ymin", "ymax"]
            missing = [c for c in required if c not in normalized.columns]
            if missing:
                raise EyeProcessValidationError(
                    "Rectangular AOIs require xmin, xmax, ymin, and ymax columns."
                )
            vals = {c: _finite_scalar(row[c], f"{aoi_id}.{c}") for c in required}
            if not vals["xmin"] < vals["xmax"] or not vals["ymin"] < vals["ymax"]:
                raise EyeProcessValidationError(
                    f"AOI `{aoi_id}` has zero or negative rectangle area."
                )
            area = (vals["xmax"] - vals["xmin"]) * (vals["ymax"] - vals["ymin"])
            normalized.loc[i, required] = [vals[c] for c in required]
            rows.append({"aoi_id": aoi_id, "shape_type": shape, "area": area, "valid": True})
        else:
            poly = _polygon_array(row["polygon"])
            if _polygon_self_intersects(poly):
                raise EyeProcessValidationError(f"AOI `{aoi_id}` polygon self-intersects.")
            area = abs(_signed_polygon_area(poly))
            if area <= 1e-12:
                raise EyeProcessValidationError(f"AOI `{aoi_id}` polygon has zero area.")
            normalized.at[i, "polygon"] = poly
            rows.append({"aoi_id": aoi_id, "shape_type": shape, "area": area, "valid": True})

    overlap = _pairwise_overlap(normalized)
    overlapping = overlap.loc[overlap["overlap"]]
    if len(overlapping) and not allow_overlap:
        pairs = ", ".join(f"{r.aoi_1}/{r.aoi_2}" for r in overlapping.itertuples())
        raise EyeProcessValidationError(f"AOIs overlap but `allow_overlap=False`: {pairs}.")

    return _result(
        "eye_aoi_geometry_validation",
        geometry=normalized.reset_index(drop=True),
        audit=pd.DataFrame(rows),
        overlap=overlap,
        overlap_present=bool(len(overlapping)),
        source_hash=_stable_frame_hash(normalized),
        software=_software_provenance(),
        status="valid_with_overlap" if len(overlapping) else "valid",
    )

apply_aoi_perturbation_grid

apply_aoi_perturbation_grid(aois: DataFrame, grid: Any) -> EyeResult
Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def apply_aoi_perturbation_grid(aois: pd.DataFrame, grid: Any) -> EyeResult:
    specs = (
        grid["specifications"]
        if isinstance(grid, Mapping)
        else getattr(grid, "specifications", None)
    )
    if specs is None:
        try:
            specs = grid["specifications"]
        except Exception as exc:
            raise EyeProcessValidationError(
                "`grid` must be created by `create_aoi_perturbation_grid()`."
            ) from exc
    outputs: dict[str, pd.DataFrame] = {}
    audits: list[dict[str, Any]] = []
    for spec in specs:
        pid = str(_spec_value(spec, "perturbation_id"))
        try:
            out = _transform_geometry(aois, spec)
            outputs[pid] = out
            audits.append(
                {
                    "perturbation_id": pid,
                    "status": "completed",
                    "message": None,
                    "geometry_hash": _stable_frame_hash(out),
                }
            )
        except Exception as exc:
            audits.append(
                {
                    "perturbation_id": pid,
                    "status": "failed",
                    "message": str(exc),
                    "geometry_hash": None,
                }
            )
    return _result(
        "eye_aoi_perturbation_grid_result",
        geometries=outputs,
        audit=pd.DataFrame(audits),
        grid=grid,
        nominal_hash=_stable_frame_hash(validate_aoi_geometry(aois)["geometry"]),
        software=_software_provenance(),
    )

create_aoi_perturbation_grid

create_aoi_perturbation_grid(*, dilations: Iterable[float] | None = None, erosions: Iterable[float] | None = None, translations_x: Iterable[float] | None = None, translations_y: Iterable[float] | None = None, translations_xy: Iterable[Sequence[float]] | None = None, jitters: Iterable[float] | None = None, anisotropic: Iterable[Sequence[float]] | None = None, unit: str = 'px', include_baseline: bool = True, seed: int = 20260918, **geometry_kwargs: Any) -> EyeResult

Create a deterministic perturbation grid without silently crossing choices.

Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def create_aoi_perturbation_grid(
    *,
    dilations: Iterable[float] | None = None,
    erosions: Iterable[float] | None = None,
    translations_x: Iterable[float] | None = None,
    translations_y: Iterable[float] | None = None,
    translations_xy: Iterable[Sequence[float]] | None = None,
    jitters: Iterable[float] | None = None,
    anisotropic: Iterable[Sequence[float]] | None = None,
    unit: str = "px",
    include_baseline: bool = True,
    seed: int = 20260918,
    **geometry_kwargs: Any,
) -> EyeResult:
    """Create a deterministic perturbation grid without silently crossing choices."""
    specs: list[EyeResult] = []
    if include_baseline:
        specs.append(aoi_perturbation_spec("baseline", "baseline", unit=unit, **geometry_kwargs))

    def values_or_empty(values: Iterable[Any] | None) -> Iterable[Any]:
        return () if values is None else values

    def pairs_or_empty(values: Any, name: str) -> Iterable[Sequence[float]]:
        if values is None:
            return ()
        if isinstance(values, pd.DataFrame):
            array = values.to_numpy()
            if array.ndim != 2 or array.shape[1] != 2:
                raise EyeProcessValidationError(
                    f"`{name}` data frames must have exactly two columns."
                )
            return [row.tolist() for row in array]
        if isinstance(values, np.ndarray):
            array = np.asarray(values)
            if array.ndim == 1:
                if array.shape[0] != 2:
                    raise EyeProcessValidationError(
                        f"`{name}` one-dimensional arrays must have length two."
                    )
                return [array.tolist()]
            if array.ndim == 2 and array.shape[1] == 2:
                return [row.tolist() for row in array]
            raise EyeProcessValidationError(
                f"`{name}` arrays must be length two or have exactly two columns."
            )
        if isinstance(values, Sequence) and not isinstance(values, (str, bytes)):
            seq = list(values)
            if len(seq) == 2 and all(np.isscalar(v) for v in seq):
                return [seq]
            return seq
        return cast(
            Iterable[Sequence[float]],
            values,
        )

    for value in values_or_empty(dilations):
        specs.append(
            aoi_perturbation_spec(
                f"dilate_{float(value):g}_{unit}",
                "dilation",
                margin_x=value,
                unit=unit,
                **geometry_kwargs,
            )
        )
    for value in values_or_empty(erosions):
        specs.append(
            aoi_perturbation_spec(
                f"erode_{float(value):g}_{unit}",
                "erosion",
                margin_x=value,
                unit=unit,
                **geometry_kwargs,
            )
        )
    for value in values_or_empty(translations_x):
        specs.append(
            aoi_perturbation_spec(
                f"shift_x_{float(value):g}_{unit}",
                "translate",
                translation_x=value,
                unit=unit,
                **geometry_kwargs,
            )
        )
    for value in values_or_empty(translations_y):
        specs.append(
            aoi_perturbation_spec(
                f"shift_y_{float(value):g}_{unit}",
                "translate",
                translation_y=value,
                unit=unit,
                **geometry_kwargs,
            )
        )
    for values in pairs_or_empty(translations_xy, "translations_xy"):
        tx, ty = _normalise_pair(values, "translations_xy")
        specs.append(
            aoi_perturbation_spec(
                f"shift_xy_{tx:g}_{ty:g}_{unit}",
                "translate",
                translation_x=tx,
                translation_y=ty,
                unit=unit,
                **geometry_kwargs,
            )
        )
    for idx, value in enumerate(values_or_empty(jitters)):
        specs.append(
            aoi_perturbation_spec(
                f"jitter_{float(value):g}_{unit}_{idx + 1}",
                "jitter",
                translation_x=value,
                translation_y=value,
                unit=unit,
                seed=seed + idx,
                **geometry_kwargs,
            )
        )
    for values in pairs_or_empty(anisotropic, "anisotropic"):
        mx, my = _normalise_pair(values, "anisotropic")
        specs.append(
            aoi_perturbation_spec(
                f"anisotropic_{mx:g}_{my:g}_{unit}",
                "anisotropic_expansion",
                margin_x=mx,
                margin_y=my,
                unit=unit,
                **geometry_kwargs,
            )
        )
    if not specs:
        raise EyeProcessValidationError("The perturbation grid is empty.")
    ids = [str(s["perturbation_id"]) for s in specs]
    if len(set(ids)) != len(ids):
        raise EyeProcessValidationError(
            "Generated perturbation IDs are not unique; use distinct values."
        )
    table = pd.DataFrame(
        [
            {
                k: s[k]
                for k in (
                    "perturbation_id",
                    "operation",
                    "margin_x",
                    "margin_y",
                    "translation_x",
                    "translation_y",
                    "unit",
                    "seed",
                    "boundary_policy",
                )
            }
            for s in specs
        ]
    )
    return _result(
        "eye_aoi_perturbation_grid",
        specifications=specs,
        table=table,
        software=_software_provenance(),
    )

dilate_aoi

dilate_aoi(aois: DataFrame, margin: Any, **kwargs: Any) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def dilate_aoi(aois: pd.DataFrame, margin: Any, **kwargs: Any) -> pd.DataFrame:
    mx, my = _normalise_pair(margin, "margin", nonnegative=True)
    spec = aoi_perturbation_spec("dilation", "dilation", margin_x=mx, margin_y=my, **kwargs)
    return _transform_geometry(aois, spec)

erode_aoi

erode_aoi(aois: DataFrame, margin: Any, **kwargs: Any) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def erode_aoi(aois: pd.DataFrame, margin: Any, **kwargs: Any) -> pd.DataFrame:
    mx, my = _normalise_pair(margin, "margin", nonnegative=True)
    spec = aoi_perturbation_spec("erosion", "erosion", margin_x=mx, margin_y=my, **kwargs)
    return _transform_geometry(aois, spec)

jitter_aoi

jitter_aoi(aois: DataFrame, x: float, y: float | None = None, *, seed: int = 20260918, **kwargs: Any) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def jitter_aoi(
    aois: pd.DataFrame, x: float, y: float | None = None, *, seed: int = 20260918, **kwargs: Any
) -> pd.DataFrame:
    yy = x if y is None else y
    spec = aoi_perturbation_spec(
        "jitter", "jitter", translation_x=x, translation_y=yy, seed=seed, **kwargs
    )
    return _transform_geometry(aois, spec)

perturb_aoi_geometry

perturb_aoi_geometry(aois: DataFrame, spec: Any) -> EyeResult

Apply one perturbation and preserve nominal and transformed provenance.

Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def perturb_aoi_geometry(aois: pd.DataFrame, spec: Any) -> EyeResult:
    """Apply one perturbation and preserve nominal and transformed provenance."""
    nominal = validate_aoi_geometry(aois)
    transformed = _transform_geometry(nominal["geometry"], spec)
    return _result(
        "eye_aoi_perturbation",
        perturbation_id=_spec_value(spec, "perturbation_id"),
        specification=dict(spec),
        nominal_geometry=nominal["geometry"],
        perturbed_geometry=transformed,
        reference_geometry_hash=nominal["source_hash"],
        perturbed_geometry_hash=_stable_frame_hash(transformed),
        software=_software_provenance(),
    )

translate_aoi

translate_aoi(aois: DataFrame, x: float = 0.0, y: float = 0.0, **kwargs: Any) -> pd.DataFrame
Source code in src/eyeprocesspy/_aoi_perturb_ops.py
def translate_aoi(
    aois: pd.DataFrame, x: float = 0.0, y: float = 0.0, **kwargs: Any
) -> pd.DataFrame:
    spec = aoi_perturbation_spec(
        "translation", "translate", translation_x=x, translation_y=y, **kwargs
    )
    return _transform_geometry(aois, spec)

plot_aoi_assignment_stability

plot_aoi_assignment_stability(x: Any, *, ax: Any = None)
Source code in src/eyeprocesspy/plots_aoi_perturbation.py
def plot_aoi_assignment_stability(x: Any, *, ax: Any = None):
    plt, _, _ = _mpl()
    axis = ax or plt.subplots()[1]
    data = x["stability"]["overall"].copy() if "stability" in x else x["overall"].copy()
    axis.plot(np.arange(len(data)), data["proportion_unchanged"].to_numpy(dtype=float), marker="o")
    axis.set_xticks(
        np.arange(len(data)),
        labels=data["perturbation_id"].astype(str).tolist(),
        rotation=45,
        ha="right",
    )
    axis.set_ylim(0, 1.02)
    axis.set_ylabel("Proportion unchanged")
    axis.set_title("AOI assignment stability")
    return axis

plot_aoi_coefficient_stability

plot_aoi_coefficient_stability(x: Any, *, term: str, ax: Any = None)
Source code in src/eyeprocesspy/plots_aoi_perturbation.py
def plot_aoi_coefficient_stability(x: Any, *, term: str, ax: Any = None):
    plt, _, _ = _mpl()
    axis = ax or plt.subplots()[1]
    data = x["models"].loc[x["models"]["term"].astype(str).eq(str(term))].copy()
    if data.empty:
        raise EyeProcessValidationError(f"No coefficient rows found for term `{term}`.")
    data = data.reset_index(drop=True)
    y = pd.to_numeric(data["estimate"], errors="coerce").to_numpy(dtype=float)
    lo = pd.to_numeric(data["CI_low"], errors="coerce").to_numpy(dtype=float)
    hi = pd.to_numeric(data["CI_high"], errors="coerce").to_numpy(dtype=float)
    axis.errorbar(np.arange(len(data)), y, yerr=np.vstack([y - lo, hi - y]), fmt="o-")
    failed = ~data["model_converged"].fillna(False).astype(bool).to_numpy()
    if failed.any():
        axis.scatter(np.where(failed)[0], y[failed], marker="x", s=80, label="not converged")
        axis.legend()
    axis.axhline(0, linewidth=1)
    axis.set_xticks(
        np.arange(len(data)),
        labels=data["perturbation_id"].astype(str).tolist(),
        rotation=45,
        ha="right",
    )
    axis.set_ylabel("Coefficient estimate")
    axis.set_title(f"Coefficient stability: {term}")
    return axis

plot_aoi_perturbations

plot_aoi_perturbations(x: Any, *, perturbation_id: str | None = None, data: DataFrame | None = None, x_col: str | None = None, y_col: str | None = None, ax: Any = None)
Source code in src/eyeprocesspy/plots_aoi_perturbation.py
def plot_aoi_perturbations(
    x: Any,
    *,
    perturbation_id: str | None = None,
    data: pd.DataFrame | None = None,
    x_col: str | None = None,
    y_col: str | None = None,
    ax: Any = None,
):
    plt, _, _ = _mpl()
    axis = ax or plt.subplots()[1]
    nominal = x["nominal_aois"] if "nominal_aois" in x else x["nominal_geometry"]
    _draw_geometry(axis, nominal, alpha=0.6, linestyle="--")
    if "grid_result" in x:
        if perturbation_id is None:
            candidates = [p for p in x["grid_result"]["geometries"] if p != "baseline"]
            perturbation_id = candidates[0] if candidates else "baseline"
        geom = x["grid_result"]["geometries"][perturbation_id]
    else:
        geom = x["perturbed_geometry"]
        perturbation_id = x["perturbation_id"]
    _draw_geometry(axis, geom, alpha=1.0, linestyle="-")
    if data is not None:
        if not x_col or not y_col:
            raise EyeProcessValidationError(
                "`x_col` and `y_col` are required when plotting reassigned observations."
            )
        frame = _frame(data, "data")
        bx = pd.to_numeric(frame[x_col], errors="coerce").to_numpy(dtype=float)
        by = pd.to_numeric(frame[y_col], errors="coerce").to_numpy(dtype=float)
        if "assignments" in x and perturbation_id in x["assignments"]:
            changed = (
                ~pd.Series(x["assignments"]["baseline"])
                .reset_index(drop=True)
                .eq(pd.Series(x["assignments"][perturbation_id]).reset_index(drop=True))
                .to_numpy()
            )
            axis.scatter(bx[~changed], by[~changed], s=12, alpha=0.35)
            axis.scatter(bx[changed], by[changed], s=32, marker="x", label="reassigned")
            axis.legend()
        else:
            axis.scatter(bx, by, s=12, alpha=0.35)
    axis.set_title(f"AOI perturbation: {perturbation_id}")
    axis.set_xlabel(x_col or "x")
    axis.set_ylabel(y_col or "y")
    axis.set_aspect("equal", adjustable="datalim")
    return axis

plot_aoi_robustness_surface

plot_aoi_robustness_surface(x: Any, *, value_col: str = 'proportion_unchanged', x_col: str = 'margin_x', y_col: str = 'margin_y', ax: Any = None)
Source code in src/eyeprocesspy/plots_aoi_perturbation.py
def plot_aoi_robustness_surface(
    x: Any,
    *,
    value_col: str = "proportion_unchanged",
    x_col: str = "margin_x",
    y_col: str = "margin_y",
    ax: Any = None,
):
    plt, _, _ = _mpl()
    axis = ax or plt.subplots()[1]
    stability = x["stability"]["overall"].copy()
    grid_table = x["grid"]["table"].copy()
    data = stability.merge(grid_table, on="perturbation_id", how="left")
    if x_col not in data.columns or y_col not in data.columns or value_col not in data.columns:
        raise EyeProcessValidationError("Requested robustness-surface columns are unavailable.")
    pivot = data.pivot_table(index=y_col, columns=x_col, values=value_col, aggfunc="mean")
    if pivot.empty:
        raise EyeProcessValidationError(
            "Robustness surface requires at least one finite x/y/value combination."
        )
    im = axis.imshow(
        pivot.to_numpy(dtype=float),
        origin="lower",
        aspect="auto",
        extent=[pivot.columns.min(), pivot.columns.max(), pivot.index.min(), pivot.index.max()],
    )
    axis.set_xlabel(x_col)
    axis.set_ylabel(y_col)
    axis.set_title("AOI robustness surface")
    plt.colorbar(im, ax=axis, label=value_col)
    return axis

_result

_result(cls: str, **kwargs: Any) -> EyeResult
Source code in src/eyeprocesspy/aoi_perturbation.py
def _result(cls: str, **kwargs: Any) -> EyeResult:
    return EyeResult(kwargs, eyeprocess_class=cls)

run_aoi_sensitivity_analysis

run_aoi_sensitivity_analysis(data: DataFrame, aois: DataFrame, grid: Any, *, x_col: str, y_col: str, observation_id_col: str | None = None, participant_col: str | None = None, trial_col: str | None = None, duration_col: str | None = None, time_col: str | None = None, observation_level: str = 'fixation', overlap_policy: str = 'ambiguous', model_callback: Callable[[DataFrame, DataFrame, Any], DataFrame] | None = None, preprocessing_specification: Any = None, event_detector: Any = None, quality_rules: Any = None, model_specification: Any = None) -> EyeResult

Run geometry perturbation, remapping, feature recomputation, and optional models.

Source code in src/eyeprocesspy/aoi_perturbation.py
def run_aoi_sensitivity_analysis(
    data: pd.DataFrame,
    aois: pd.DataFrame,
    grid: Any,
    *,
    x_col: str,
    y_col: str,
    observation_id_col: str | None = None,
    participant_col: str | None = None,
    trial_col: str | None = None,
    duration_col: str | None = None,
    time_col: str | None = None,
    observation_level: str = "fixation",
    overlap_policy: str = "ambiguous",
    model_callback: Callable[[pd.DataFrame, pd.DataFrame, Any], pd.DataFrame] | None = None,
    preprocessing_specification: Any = None,
    event_detector: Any = None,
    quality_rules: Any = None,
    model_specification: Any = None,
) -> EyeResult:
    """Run geometry perturbation, remapping, feature recomputation, and optional models."""
    frame = _frame(data, "data")
    observation_level = str(observation_level).lower()
    if observation_level not in {"fixation", "sample"}:
        raise EyeProcessValidationError("`observation_level` must be 'fixation' or 'sample'.")
    geometry = validate_aoi_geometry(aois)["geometry"]
    if observation_id_col is not None:
        if observation_id_col not in frame.columns:
            raise EyeProcessValidationError(
                f"Observation id column `{observation_id_col}` is absent."
            )
        ids = frame[observation_id_col].tolist()
        id_series = pd.Series(ids)
        if id_series.isna().any() or id_series.duplicated().any():
            raise EyeProcessValidationError(
                "Observation IDs must be unique and non-missing for perturbation tracking."
            )
    else:
        ids = list(range(1, len(frame) + 1))
    grid_result = apply_aoi_perturbation_grid(geometry, grid)
    completed = (
        grid_result["audit"]
        .loc[grid_result["audit"]["status"].eq("completed"), "perturbation_id"]
        .astype(str)
        .tolist()
    )
    if "baseline" not in completed:
        raise EyeProcessValidationError(
            "Sensitivity analysis requires a successful `baseline` perturbation in the grid."
        )

    assignments: dict[str, pd.Series] = {}
    features: dict[str, pd.DataFrame] = {}
    model_rows: list[pd.DataFrame] = []
    failure_rows = (
        grid_result["audit"]
        .loc[grid_result["audit"]["status"].eq("failed"), ["perturbation_id", "message"]]
        .copy()
    )
    if len(failure_rows):
        failure_rows["stage"] = "geometry"
    else:
        failure_rows = pd.DataFrame(columns=["perturbation_id", "message", "stage"])
    specs = {str(_spec_value(s, "perturbation_id")): s for s in grid["specifications"]}

    for pid in completed:
        geom = grid_result["geometries"][pid]
        assigned = _assign_points(
            frame, geom, x_col=x_col, y_col=y_col, overlap_policy=overlap_policy
        )
        assignments[pid] = assigned
        feat = recompute_aoi_features(
            frame,
            assigned,
            participant_col=participant_col,
            trial_col=trial_col,
            duration_col=duration_col,
            time_col=time_col,
            perturbation_id=pid,
            aoi_levels=geometry["aoi_id"].astype(str).tolist(),
            observation_level=observation_level,
        )
        features[pid] = feat
        if model_callback is not None:
            assigned_data = frame.copy()
            assigned_data["aoi_assignment"] = assigned
            assigned_data["perturbation_id"] = pid
            try:
                model_table = _validate_model_table(
                    model_callback(feat.copy(), assigned_data, specs[pid]), pid
                )
                model_rows.append(model_table)
            except Exception as exc:
                failure_rows = pd.concat(
                    [
                        failure_rows,
                        pd.DataFrame(
                            [{"perturbation_id": pid, "message": str(exc), "stage": "model"}]
                        ),
                    ],
                    ignore_index=True,
                )

    baseline = assignments["baseline"]
    comparisons: dict[str, Any] = {}
    for pid, assigned in assignments.items():
        comparisons[pid] = compare_aoi_assignments(baseline, assigned, ids=ids)
    metadata_cols = [c for c in (participant_col, trial_col) if c is not None]
    metadata = (
        frame.loc[:, metadata_cols].copy() if metadata_cols else pd.DataFrame(index=frame.index)
    )
    metadata.insert(0, "observation_id", ids)
    stability = estimate_aoi_assignment_stability(
        comparisons, metadata=metadata, group_cols=metadata_cols
    )
    assignment_long = pd.concat(
        [
            pd.DataFrame(
                {"perturbation_id": pid, "observation_id": ids, "aoi_assignment": labels.tolist()}
            )
            for pid, labels in assignments.items()
        ],
        ignore_index=True,
    )
    assignment_probability = estimate_fixation_assignment_probability(assignment_long)
    public_assignments = {
        pid: _AssignmentVector(labels.tolist()) for pid, labels in assignments.items()
    }
    models = (
        pd.concat(model_rows, ignore_index=True)
        if model_rows
        else pd.DataFrame(
            columns=[
                "perturbation_id",
                "term",
                "estimate",
                "SE",
                "CI_low",
                "CI_high",
                "p_value",
                "model_converged",
                "N",
                "direction",
            ]
        )
    )
    provenance = {
        "source_data_hash": _stable_frame_hash(frame),
        "aoi_specification_hash": _stable_frame_hash(geometry),
        "preprocessing_specification": preprocessing_specification,
        "event_detector": event_detector,
        "quality_rules": quality_rules,
        "model_specification": model_specification,
        "overlap_policy": overlap_policy,
        "observation_level": observation_level,
        "software": _software_provenance(),
    }
    return _result(
        "eye_aoi_sensitivity",
        nominal_aois=geometry,
        grid=grid,
        grid_result=grid_result,
        assignments=public_assignments,
        assignment_table=assignment_long,
        comparisons=comparisons,
        stability=stability,
        assignment_probability=assignment_probability,
        features=features,
        models=models,
        failures=failure_rows.reset_index(drop=True),
        provenance=provenance,
        caveat="Robustness proportions summarize the declared perturbation set; they are not probabilities that the substantive conclusion is true.",
    )

assess_aoi_inference_stability

assess_aoi_inference_stability(x: Any, *, term: str | None = None) -> pd.DataFrame
Source code in src/eyeprocesspy/aoi_perturbation.py
def assess_aoi_inference_stability(x: Any, *, term: str | None = None) -> pd.DataFrame:
    models = x["models"].copy()
    if models.empty:
        return pd.DataFrame(
            columns=[
                "term",
                "n_models",
                "n_converged",
                "convergence_proportion",
                "same_sign_proportion",
                "median_estimate",
                "min_estimate",
                "max_estimate",
                "median_CI_width",
                "median_N",
                "min_N",
                "max_N",
            ]
        )
    if term is not None:
        models = models.loc[models["term"].astype(str).eq(str(term))].copy()
        if models.empty:
            raise EyeProcessValidationError(f"No model rows found for term `{term}`.")
    rows = []
    for current_term, z in models.groupby("term", sort=True):
        converged = z["model_converged"].fillna(False).astype(bool)
        usable = z.loc[converged].copy()
        baseline = usable.loc[usable["perturbation_id"].eq("baseline")]
        if baseline.empty:
            baseline_sign = np.nan
        else:
            baseline_est = float(pd.to_numeric(baseline.iloc[0]["estimate"], errors="coerce"))
            baseline_sign = np.sign(baseline_est)
        estimates = pd.to_numeric(usable["estimate"], errors="coerce")
        ci_width = pd.to_numeric(usable["CI_high"], errors="coerce") - pd.to_numeric(
            usable["CI_low"], errors="coerce"
        )
        sample_sizes = pd.to_numeric(usable["N"], errors="coerce")
        signs = np.sign(estimates)
        same = (
            np.nan
            if not np.isfinite(baseline_sign) or not len(signs)
            else float(np.mean(signs == baseline_sign))
        )
        rows.append(
            {
                "term": current_term,
                "n_models": len(z),
                "n_converged": int(converged.sum()),
                "convergence_proportion": float(converged.mean()) if len(z) else np.nan,
                "same_sign_proportion": same,
                "median_estimate": float(estimates.median()) if estimates.notna().any() else np.nan,
                "min_estimate": float(estimates.min()) if estimates.notna().any() else np.nan,
                "max_estimate": float(estimates.max()) if estimates.notna().any() else np.nan,
                "median_CI_width": float(ci_width.median()) if ci_width.notna().any() else np.nan,
                "median_N": float(sample_sizes.median()) if sample_sizes.notna().any() else np.nan,
                "min_N": float(sample_sizes.min()) if sample_sizes.notna().any() else np.nan,
                "max_N": float(sample_sizes.max()) if sample_sizes.notna().any() else np.nan,
            }
        )
    out = pd.DataFrame(rows)
    out.attrs["caveat"] = (
        "Same-sign and convergence proportions are descriptive sensitivity summaries, not probabilities that an effect is true."
    )
    return out

summarise_aoi_sensitivity

summarise_aoi_sensitivity(x: Any) -> EyeResult
Source code in src/eyeprocesspy/aoi_perturbation.py
def summarise_aoi_sensitivity(x: Any) -> EyeResult:
    stability = x["stability"]["overall"].copy()
    inference = assess_aoi_inference_stability(x)
    failures = x["failures"].copy()
    grid_audit = x["grid_result"]["audit"].copy()
    return _result(
        "eye_aoi_sensitivity_summary",
        assignment_stability=stability,
        inference_stability=inference,
        perturbation_audit=grid_audit,
        failures=failures,
        n_planned=len(grid_audit),
        n_completed=int(grid_audit["status"].eq("completed").sum()),
        n_geometry_failed=int(grid_audit["status"].eq("failed").sum()),
        n_model_failures=int((failures["stage"] == "model").sum()) if len(failures) else 0,
        caveat=x["caveat"],
    )

report_aoi_sensitivity

report_aoi_sensitivity(x: Any) -> str

Return a compact manuscript-oriented Markdown report.

Source code in src/eyeprocesspy/aoi_perturbation.py
def report_aoi_sensitivity(x: Any) -> str:
    """Return a compact manuscript-oriented Markdown report."""
    summary = summarise_aoi_sensitivity(x)
    stability = summary["assignment_stability"]
    median_unchanged = (
        float(stability["proportion_unchanged"].median()) if len(stability) else np.nan
    )
    lines = [
        "## AOI perturbation sensitivity analysis",
        "",
        f"Planned perturbations: {summary['n_planned']}; completed geometry branches: {summary['n_completed']}; geometry failures: {summary['n_geometry_failed']}; model callback failures: {summary['n_model_failures']}.",
        f"Median unchanged AOI assignment across completed perturbations: {median_unchanged:.3f}."
        if np.isfinite(median_unchanged)
        else "Assignment stability could not be summarized.",
        "",
    ]
    inference = summary["inference_stability"]
    if len(inference):
        lines.append(
            "Model-level sensitivity was summarized using coefficient direction, magnitude, interval width, and convergence rather than significance alone."
        )
        for row in inference.itertuples(index=False):
            lines.append(
                f"- `{row.term}`: {row.n_converged}/{row.n_models} converged; same-sign frequency={row.same_sign_proportion:.3f}; median estimate={row.median_estimate:.4g}; range=[{row.min_estimate:.4g}, {row.max_estimate:.4g}]; N range=[{row.min_N:.0f}, {row.max_N:.0f}]."
            )
        lines.append("")
    lines.append(
        "Interpretation: these quantities describe robustness to the declared AOI perturbations. They are not probabilities that the scientific conclusion is true."
    )
    if len(summary["failures"]):
        lines.append(
            "Failed or non-evaluable branches remain in the audit trail and should be reported rather than silently excluded."
        )
    return "\n".join(lines)

Core contract

Use aoi_perturbation_spec() for one validated branch or create_aoi_perturbation_grid() for a declared sensitivity set.

run_aoi_sensitivity_analysis() performs geometry perturbation → AOI remapping → feature recomputation → optional fixed model callback → assignment/model stability summaries while retaining failures and provenance.

See AOI perturbation and uncertainty analysis for design, interpretation, limitations, and reporting guidance.

Practical guidance

Use the AOI robustness decision clinic to interpret assignment/model stability patterns, the fixation-level worked example for end-to-end model propagation, and the sample-level worked example when geometry sensitivity is evaluated directly on gaze samples.

Failure handling

Use AOI perturbation troubleshooting for geometry failures, overlap ambiguity, zero-versus-missing denominators, callback exceptions, non-convergence, and model-N changes. The worked failure clinic executes these states on synthetic data.

Planning

Use the AOI sensitivity analysis plan before running branch-specific models. The worked filled plan maps each planning decision to the public API.

Evidence bundle

Use the AOI robustness reporting bundle to package the analysis plan, branch audit, assignment/model summaries, failures, provenance, report, and figures. The worked example provides a deterministic helper and SHA-256 manifest without adding a new public API.