Source code for cellucid.prepare_data

#!/usr/bin/env python3
"""
Export raw dataframes/arrays to files used by the WebGL viewer.

Includes memory/disk optimization features:
- Quantization for continuous data (var/gene expression and obs continuous)
- Auto dtype selection for categorical obs based on category count
- Gzip compression for all binary files

Instead of AnnData, accepts:
- latent_space: (n_cells, n_dims) numpy/sparse array for outlier quantile calculation
- X_umap_1d / X_umap_2d / X_umap_3d: explicit embeddings (at least one required)
- vector_fields: dict[str, array] of per-cell displacement vectors (optional)
- obs: pandas DataFrame with cell metadata columns
- var: pandas DataFrame with gene/feature metadata
- gene_expression: (n_cells, n_genes) numpy/sparse array for gene expression matrix
- var_gene_id_column: exact column name in var, or None to use var.index
- connectivities: sparse matrix with KNN connectivities
"""

import json
import math
import os
import re
import shutil
import tempfile
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from datetime import UTC, datetime
from numbers import Integral, Real
from pathlib import Path
from typing import Any, BinaryIO, Literal, cast

import numpy as np
import pandas as pd
import tqdm
from scipy import sparse

from ._compression import open_deterministic_gzip_writer
from .connectivity_contract import (
    CONNECTIVITY_BINARY_DIRNAME,
    CONNECTIVITY_MANIFEST_FILENAME,
    ConnectivityEdgePairs,
    build_connectivity_manifest,
    validate_connectivity_edges,
)
from .vector_fields import scale_vector_field, validate_vector_fields

DEFAULT_EXPORT_DIR = Path.cwd() / "exports"
DEFAULT_OBS_DIRNAME = "obs"
DEFAULT_VAR_DIRNAME = "var"

# Manifest format version for compact format
MANIFEST_FORMAT_VERSION = "compact_v1"
JsonScalar = str | bool | int | float
_PORTABLE_COMPONENT_PATTERN = re.compile(
    r"^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$",
    flags=re.ASCII,
)
_CANONICAL_UTC_TIMESTAMP_PATTERN = re.compile(
    r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
    flags=re.ASCII,
)
_WINDOWS_RESERVED_COMPONENTS = {
    "CON",
    "PRN",
    "AUX",
    "NUL",
    *(f"COM{index}" for index in range(1, 10)),
    *(f"LPT{index}" for index in range(1, 10)),
}


def _require_portable_filename_component(
    name: object,
    *,
    label: str = "Field key",
) -> str:
    """Require one exact cross-platform ASCII filename component."""
    if not isinstance(name, str):
        raise TypeError(f"{label} must be a native string.")
    if not _PORTABLE_COMPONENT_PATTERN.fullmatch(name):
        raise ValueError(
            f"{label} {name!r} must be 1-180 ASCII bytes, start with an "
            "ASCII letter or digit, and otherwise contain only ASCII "
            "letters, digits, '.', '_', or '-'."
        )
    if name.endswith("."):
        raise ValueError(f"{label} {name!r} must not end with '.'.")
    windows_stem = name.split(".", 1)[0].upper()
    if windows_stem in _WINDOWS_RESERVED_COMPONENTS:
        raise ValueError(f"{label} {name!r} is a reserved Windows filename.")
    return name


def _assert_unique_filename_components(
    keys: list[str],
    *,
    label: str,
) -> list[str]:
    raw_keys: set[str] = set()
    portable_to_raw: dict[str, str] = {}
    portable_keys: list[str] = []
    for key in keys:
        portable_key = _require_portable_filename_component(
            key,
            label=f"{label} key",
        )
        if key in raw_keys:
            raise ValueError(f"{label} key {key!r} is duplicated.")
        collision_key = portable_key.casefold()
        if collision_key in portable_to_raw:
            raise ValueError(
                f"{label} keys {portable_to_raw[collision_key]!r} and "
                f"{key!r} collide case-insensitively at one payload path."
            )
        raw_keys.add(key)
        portable_to_raw[collision_key] = key
        portable_keys.append(portable_key)
    return portable_keys


def _require_string_identifiers(
    values: Sequence[object],
    *,
    label: str,
) -> list[str]:
    identifiers: list[str] = []
    for index, value in enumerate(values):
        if not isinstance(value, str):
            raise TypeError(
                f"{label} identifier at position {index} must be a string, "
                f"got {type(value).__name__}."
            )
        if not value:
            raise ValueError(f"{label} identifier at position {index} must be non-empty.")
        identifiers.append(value)
    return identifiers


def _require_nonempty_string(value: object, *, label: str) -> str:
    """Require explicit text without deriving or coercing an identity."""
    if not isinstance(value, str):
        raise TypeError(f"{label} must be a non-empty string.")
    if not value:
        raise ValueError(f"{label} must be a non-empty string.")
    return value


def _require_native_boolean(value: object, *, label: str) -> bool:
    """Require one native boolean without truth-value coercion."""
    if type(value) is not bool:
        raise TypeError(f"{label} must be exactly True or False.")
    return value


def _require_positive_native_integer(value: object, *, label: str) -> int:
    """Require one positive native integer without numeric coercion."""
    if type(value) is not int:
        raise TypeError(f"{label} must be a native integer.")
    if value < 1:
        raise ValueError(f"{label} must be a positive integer.")
    return value


def _require_optional_native_string(
    value: object,
    *,
    label: str,
    allow_empty: bool,
) -> str | None:
    """Validate optional identity text without omission or string coercion."""
    if value is None:
        return None
    if type(value) is not str:
        raise TypeError(f"{label} must be None or a native string.")
    if not allow_empty and (not value or not value.strip()):
        raise ValueError(f"{label} must be None or a non-empty string.")
    return value


def _resolve_created_at(value: object) -> str:
    """Return one exact UTC-seconds timestamp for dataset identity metadata."""
    if value is None:
        return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    if type(value) is not str:
        raise TypeError(
            "created_at must be None or a native string in 'YYYY-MM-DDTHH:MM:SSZ' UTC format."
        )
    if not _CANONICAL_UTC_TIMESTAMP_PATTERN.fullmatch(value):
        raise ValueError("created_at must use exact 'YYYY-MM-DDTHH:MM:SSZ' UTC format.")
    try:
        datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
    except ValueError as error:
        raise ValueError(
            "created_at must be a valid UTC calendar timestamp in 'YYYY-MM-DDTHH:MM:SSZ' format."
        ) from error
    return value


def _require_finite_float32_array(
    values: object,
    *,
    label: str,
) -> np.ndarray:
    """Validate real finite values that remain finite after float32 conversion."""
    dense = cast(sparse.spmatrix, values).toarray() if sparse.issparse(values) else values
    array = np.asarray(dense)
    if array.dtype.kind not in {"i", "u", "f"}:
        raise TypeError(f"{label} must contain real numeric values.")
    if not np.isfinite(array).all():
        raise ValueError(f"{label} must contain only finite values.")
    with np.errstate(over="ignore", invalid="ignore"):
        float32_array = array.astype(np.float32, copy=False)
    if not np.isfinite(float32_array).all():
        raise ValueError(f"{label} contains values outside the finite float32 range.")
    return float32_array


def _require_continuous_obs_values(
    values: object,
    *,
    key: str,
    n_cells: int,
) -> np.ndarray:
    """Require one exact finite float32 observation vector."""
    array = _require_finite_float32_array(
        values,
        label=f"Continuous obs field {key!r}",
    )
    if array.ndim != 1 or array.shape[0] != n_cells:
        raise ValueError(
            f"Continuous obs field {key!r} must have shape ({n_cells},), got {array.shape}."
        )
    return array


def _normalize_finite_float32_embedding(
    embedding: np.ndarray,
    *,
    label: str,
) -> tuple[np.ndarray, np.ndarray, float, float]:
    """Normalize one validated embedding with a required nonzero range."""
    if embedding.ndim != 2 or embedding.shape[0] == 0:
        raise ValueError(f"{label} must be a non-empty 2D array.")
    working = embedding.astype(np.float64)
    axis_mins = working.min(axis=0)
    axis_maxs = working.max(axis=0)
    max_range = float((axis_maxs - axis_mins).max())
    if max_range <= 0:
        raise ValueError(f"{label} has no coordinate variation and cannot be normalized.")
    center = (axis_mins + axis_maxs) / 2
    scale_factor = 2.0 / max_range
    normalized = ((working - center) * scale_factor).astype(np.float32)
    if not np.isfinite(normalized).all():
        raise ValueError(f"{label} normalization produced non-finite coordinates.")
    return normalized, center.astype(np.float32), scale_factor, max_range


def _json_category_values(
    values: Iterable[object],
    *,
    field_key: str,
) -> list[JsonScalar]:
    """Preserve one exact JSON-scalar identity for each category label."""
    categories: list[str | bool | int | float] = []
    seen: set[tuple[str, object]] = set()
    for raw_value in values:
        value = raw_value.item() if isinstance(raw_value, np.generic) else raw_value
        token: tuple[str, object]
        if isinstance(value, bool):
            token = ("boolean", value)
        elif isinstance(value, str):
            token = ("string", value)
        elif isinstance(value, Integral):
            integer_value = int(value)
            if abs(integer_value) > 9_007_199_254_740_991:
                raise ValueError(
                    f"Categorical field {field_key!r} contains integer label "
                    f"{integer_value!r} outside JavaScript's exact integer range."
                )
            value = integer_value
            token = ("number", value)
        elif isinstance(value, Real) and math.isfinite(value):
            value = float(value)
            token = ("number", value)
        else:
            raise ValueError(f"Categorical field {field_key!r} labels must be finite JSON scalars.")
        if token in seen:
            raise ValueError(
                f"Categorical field {field_key!r} labels must be unique "
                "after exact JSON representation."
            )
        seen.add(token)
        categories.append(value)
    return categories


def _identity_obs_fields_from_compact_manifest(
    manifest: dict,
) -> list[dict]:
    expected_keys = {
        "_format",
        "n_points",
        "centroid_outlier_quantile",
        "latent_key",
        "compression",
        "_obsSchemas",
        "_continuousFields",
        "_categoricalFields",
    }
    if not isinstance(manifest, dict) or set(manifest) != expected_keys:
        raise ValueError("obs manifest must contain exactly the current compact_v1 fields.")
    if manifest["_format"] != MANIFEST_FORMAT_VERSION:
        raise ValueError("obs manifest must use the current compact_v1 format.")

    continuous_fields = manifest["_continuousFields"]
    categorical_fields = manifest["_categoricalFields"]
    if not isinstance(continuous_fields, list):
        raise ValueError("compact_v1 obs manifest _continuousFields must be a list.")
    if not isinstance(categorical_fields, list):
        raise ValueError("compact_v1 obs manifest _categoricalFields must be a list.")

    identity_fields: list[dict] = []
    manifest_keys: list[str] = []
    for field in continuous_fields:
        if (
            not isinstance(field, list)
            or len(field) not in (1, 3)
            or not isinstance(field[0], str)
            or not field[0]
        ):
            raise ValueError(
                "compact_v1 continuous observation fields must be exact "
                "[key] or [key, minValue, maxValue] tuples."
            )
        key = field[0]
        manifest_keys.append(key)
        identity_fields.append({"key": key, "kind": "continuous"})

    for field in categorical_fields:
        if (
            not isinstance(field, list)
            or len(field) not in (5, 7)
            or not isinstance(field[0], str)
            or not field[0]
            or not isinstance(field[1], list)
        ):
            raise ValueError(
                "compact_v1 categorical observation fields must be exact "
                "five- or seven-member tuples with a category array."
            )
        key = field[0]
        manifest_keys.append(key)
        identity_fields.append(
            {
                "key": key,
                "kind": "category",
                "n_categories": len(field[1]),
            }
        )

    _assert_unique_filename_components(
        manifest_keys,
        label="Observation field",
    )
    return identity_fields


def _to_dense(arr: np.ndarray | sparse.spmatrix) -> np.ndarray:
    """Convert sparse matrix to dense numpy array if necessary."""
    if sparse.issparse(arr):
        return np.asarray(cast(sparse.spmatrix, arr).toarray())
    return np.asarray(arr)


def _output_path_is_writable(
    path: Path,
    description: str,
) -> bool:
    """Admit one new staged artifact path."""
    if path.exists():
        raise FileExistsError(f"Cannot write {description}: staged path already exists: {path}")
    return True


def _publish_export_generation(staging_dir: Path, target_dir: Path) -> None:
    """Publish one complete staged directory, restoring the prior one on failure."""
    if not staging_dir.is_dir():
        raise RuntimeError(f"Staged export directory is missing: {staging_dir}")

    prior_dir: Path | None = None
    if target_dir.exists():
        backup_name = tempfile.mkdtemp(
            prefix=f".{target_dir.name}.cellucid-backup-",
            dir=target_dir.parent,
        )
        prior_dir = Path(backup_name)
        prior_dir.rmdir()
        target_dir.rename(prior_dir)

    try:
        staging_dir.rename(target_dir)
    except BaseException:
        if prior_dir is not None:
            try:
                prior_dir.rename(target_dir)
            except BaseException as restore_error:
                raise RuntimeError(
                    f"Failed to publish {target_dir} and failed to restore its prior "
                    f"generation from {prior_dir}."
                ) from restore_error
        raise

    if prior_dir is not None:
        shutil.rmtree(prior_dir)


@contextmanager
def _exclusive_export_generation(target_dir: Path) -> Iterator[None]:
    """Hold one non-waiting cross-process writer lock for an export target."""
    lock_path = target_dir.parent / f".{target_dir.name}.cellucid.lock"
    try:
        descriptor = os.open(
            lock_path,
            os.O_CREAT | os.O_EXCL | os.O_WRONLY,
            0o600,
        )
    except FileExistsError as error:
        raise RuntimeError(f"An export generation is already active for {target_dir}.") from error

    try:
        with os.fdopen(descriptor, "wb") as lock_file:
            lock_file.write(f"{os.getpid()}\n".encode("ascii"))
            lock_file.flush()
            os.fsync(lock_file.fileno())
        yield
    finally:
        lock_path.unlink()


def _write_binary(
    path: Path,
    data: np.ndarray,
    compression: int | None = None,
) -> Path:
    """
    Write binary data, optionally with gzip compression.

    Parameters
    ----------
    path : Path
        Output path. If compression is enabled, '.gz' will be appended.
    data : np.ndarray
        Data to write.
    compression : int or None
        Gzip compression level (1-9). None means no compression.

    Returns
    -------
    Path
        Actual path written (may have .gz suffix).
    """
    if compression is not None and (type(compression) is not int or not 1 <= compression <= 9):
        raise ValueError("compression must be None or one integer from 1 to 9.")

    if compression is not None:
        gz_path = Path(str(path) + ".gz")
        _atomic_write_gzip(
            gz_path,
            data,
            compresslevel=compression,
        )
        return gz_path
    else:
        data.tofile(path)
        return path


def _atomic_write_gzip(
    path: Path,
    data: np.ndarray,
    *,
    compresslevel: int,
) -> None:
    """Write one deterministic gzip payload and publish it atomically."""
    temporary_path: Path | None = None
    try:
        with tempfile.NamedTemporaryFile(
            dir=path.parent,
            prefix=f".{path.name}.",
            suffix=".tmp",
            delete=False,
        ) as temporary_file:
            temporary_path = Path(temporary_file.name)
            with open_deterministic_gzip_writer(
                cast(BinaryIO, temporary_file),
                compresslevel=compresslevel,
            ) as compressed:
                compressed.write(data.tobytes())
            temporary_file.flush()
            os.fsync(temporary_file.fileno())
        os.chmod(temporary_path, 0o644)
        os.replace(temporary_path, path)
        temporary_path = None
    finally:
        if temporary_path is not None:
            temporary_path.unlink(missing_ok=True)


def _quantize_continuous(
    values: np.ndarray,
    *,
    bits: int,
    field_name: str,
) -> tuple[np.ndarray, float, float, float]:
    """
    Quantize one finite continuous float32 vector to uint8 or uint16.

    Parameters
    ----------
    values : np.ndarray
        Float32 values to quantize.
    bits : int
        Number of bits for quantization (8 or 16).
    field_name : str
        Name of field for debug messages.

    Returns
    -------
    quantized : np.ndarray
        Quantized values as uint8 or uint16.
    min_val : float
        Minimum value (for dequantization).
    max_val : float
        Maximum value (for dequantization).
    scale : float
        Scale factor (for dequantization).
    """
    if type(bits) is not int or bits not in (8, 16):
        raise ValueError("Quantization bits must be exactly 8 or 16.")

    if values.size == 0:
        raise ValueError(
            f"Continuous field {field_name!r} cannot be quantized because it has no finite values."
        )
    if not np.isfinite(values).all():
        raise ValueError(f"Continuous field {field_name!r} must contain only finite values.")

    min_val = float(np.min(values))
    max_val = float(np.max(values))

    if max_val == min_val:
        raise ValueError(
            f"Continuous field {field_name!r} cannot be quantized because "
            f"all values are the constant {min_val!r}."
        )

    if bits == 8:
        max_quant = 254  # 255 is the categorical-outlier missing marker.
        dtype: type[np.uint8] | type[np.uint16] = np.uint8
    else:  # 16 bits
        max_quant = 65534  # 65535 is the categorical-outlier missing marker.
        dtype = np.uint16

    scale = max_quant / (max_val - min_val)
    normalized = (values - min_val) * scale
    quantized = np.clip(normalized, 0, max_quant).astype(dtype)

    return quantized, min_val, max_val, scale


def _quantize_nullable_outlier_quantiles(
    values: np.ndarray,
    *,
    bits: int,
    field_name: str,
) -> tuple[np.ndarray, float, float, float]:
    """Quantize generated outlier quantiles, preserving only NaN as missing."""
    if type(bits) is not int or bits not in (8, 16):
        raise ValueError("Quantization bits must be exactly 8 or 16.")
    if np.isinf(values).any():
        raise ValueError(
            f"Outlier quantiles {field_name!r} must contain only finite values or NaN."
        )

    missing_mask = np.isnan(values)
    quantized_finite, min_val, max_val, scale = _quantize_continuous(
        values[~missing_mask],
        bits=bits,
        field_name=field_name,
    )

    if bits == 8:
        dtype: type[np.uint8] | type[np.uint16] = np.uint8
        missing_value = 255
    else:
        dtype = np.uint16
        missing_value = 65535

    quantized = np.full(values.shape, missing_value, dtype=dtype)
    quantized[~missing_mask] = quantized_finite
    return quantized, min_val, max_val, scale


def _validate_prepare_codec_options(
    *,
    compression: int | None,
    var_quantization: int | None,
    obs_continuous_quantization: int | None,
    obs_categorical_dtype: object,
    centroid_outlier_quantile: float | None,
) -> None:
    """Validate the sole current compact_v1 codec configuration."""
    if compression is not None and (type(compression) is not int or not 1 <= compression <= 9):
        raise ValueError("compression must be None or one integer from 1 to 9.")

    for name, value in (
        ("var_quantization", var_quantization),
        ("obs_continuous_quantization", obs_continuous_quantization),
    ):
        if value is not None and (type(value) is not int or value not in (8, 16)):
            raise ValueError(f"{name} must be None, 8, or 16.")

    if type(obs_categorical_dtype) is not str or obs_categorical_dtype not in (
        "uint8",
        "uint16",
    ):
        raise ValueError("obs_categorical_dtype must be exactly 'uint8' or 'uint16'.")

    if centroid_outlier_quantile is not None and (
        isinstance(centroid_outlier_quantile, bool)
        or not isinstance(centroid_outlier_quantile, Real)
        or not math.isfinite(centroid_outlier_quantile)
        or centroid_outlier_quantile <= 0.5
        or centroid_outlier_quantile >= 1
    ):
        raise ValueError(
            "centroid_outlier_quantile must be None or one finite number "
            "strictly between 0.5 and 1."
        )


def _select_category_dtype(
    n_categories: int,
) -> tuple[type[np.uint8] | type[np.uint16], int]:
    """
    Select optimal dtype for category codes based on number of categories.

    Parameters
    ----------
    n_categories : int
        Number of unique categories (not counting missing).

    Returns
    -------
    dtype : np.dtype
        Optimal dtype (uint8 or uint16).
    missing_value : int
        Value to use for missing/NaN codes.
    """
    if n_categories > 65_535:
        raise ValueError(
            f"Categorical field has {n_categories:,} categories; "
            "the current contract supports at most 65,535."
        )
    if n_categories <= 255:
        # uint8 has 255 category codes (0-254), with 255 reserved for missing.
        return np.uint8, 255
    # uint16 has 65,535 category codes (0-65534), with 65535 for missing.
    return np.uint16, 65535


def _compute_centroids_for_field(
    coords: np.ndarray,
    codes: np.ndarray,
    categories: list[JsonScalar],
    outlier_quantile: float = 0.95,
    min_points: int = 10,
) -> list[dict]:
    """
    Compute centroids per category with outlier removal (based on embedding coords for display).

    Works with any dimensionality (1D, 2D, 3D, etc.) - the position will have the same
    number of dimensions as the input coords.
    """
    if coords.shape[0] != codes.shape[0]:
        raise ValueError("coords and codes must have the same length.")

    centroids: list[dict[str, object]] = []

    if not (0.5 < outlier_quantile < 1.0):
        raise ValueError("outlier_quantile must be strictly between 0.5 and 1.0")

    for code, label in enumerate(categories):
        mask = codes == code
        idx = np.nonzero(mask)[0]
        n = idx.size
        if n < min_points:
            continue

        pts = coords[idx, :]  # (n, ndim)
        center = pts.mean(axis=0)

        if n > min_points:
            dists = np.linalg.norm(pts - center, axis=1)
            thr = float(np.quantile(dists, outlier_quantile))
            inlier_mask = dists <= thr
            n_in = int(inlier_mask.sum())
            if n_in >= min_points:
                pts_in = pts[inlier_mask, :]
                center = pts_in.mean(axis=0)
                used_count = n_in
            else:
                used_count = n
        else:
            used_count = n

        centroids.append(
            {
                "category": label,
                "position": center.astype(float).tolist(),
                "n_points": int(used_count),
            }
        )

    return centroids


def _compute_centroids_for_all_dimensions(
    embeddings: dict[int, np.ndarray],
    codes: np.ndarray,
    categories: list[JsonScalar],
    outlier_quantile: float = 0.95,
    min_points: int = 10,
) -> dict[int, list[dict]]:
    """
    Compute centroids for each available dimension.

    Returns a dictionary keyed by dimension (1, 2, 3) with centroid lists for each.
    """
    centroids_by_dim = {}
    for dim, coords in embeddings.items():
        centroids_by_dim[dim] = _compute_centroids_for_field(
            coords, codes, categories, outlier_quantile, min_points
        )
    return centroids_by_dim


def _compute_latent_space_quantiles(
    latent: np.ndarray,
    codes: np.ndarray,
    categories: list[JsonScalar],
    min_points: int = 10,
) -> np.ndarray:
    """
    Compute per-cell outlier quantiles based on latent space distances to category centroids.
    """
    n_cells = latent.shape[0]
    quantiles = np.full(n_cells, np.nan, dtype=np.float32)

    for code, _label in enumerate(categories):
        mask = codes == code
        idx = np.nonzero(mask)[0]
        n = idx.size

        if n < min_points:
            continue

        pts = latent[idx, :]
        centroid = pts.mean(axis=0)
        dists = np.linalg.norm(pts - centroid, axis=1)
        sorted_dists = np.sort(dists)
        ranks = np.searchsorted(sorted_dists, dists, side="right")
        cell_quantiles = ranks.astype(np.float32) / n
        quantiles[idx] = cell_quantiles

    return quantiles


def _prepare_generation(
    latent_space: np.ndarray | sparse.spmatrix | None = None,
    obs: pd.DataFrame | None = None,
    var: pd.DataFrame | None = None,
    gene_expression: np.ndarray | sparse.spmatrix | None = None,
    var_gene_id_column: str | None = None,
    gene_identifiers: Sequence[str] | None = None,
    connectivities: np.ndarray | sparse.spmatrix | None = None,
    out_dir: Path | str = DEFAULT_EXPORT_DIR,
    obs_keys: Sequence[str] | None = None,
    centroid_outlier_quantile: float = 0.95,
    centroid_min_points: int = 10,
    # Optimization parameters (all disabled by default)
    var_quantization: int | None = None,
    obs_continuous_quantization: int | None = None,
    compression: int | None = None,
    # Dataset metadata parameters (for dataset_identity.json)
    *,
    _published_out_dir: Path,
    obs_categorical_dtype: Literal["uint8", "uint16"],
    dataset_name: str,
    dataset_id: str,
    created_at: str,
    dataset_description: str | None = None,
    source_name: str | None = None,
    source_url: str | None = None,
    source_citation: str | None = None,
    # Multi-dimensional embedding parameters (at least one required)
    X_umap_1d: np.ndarray | None = None,
    X_umap_2d: np.ndarray | None = None,
    X_umap_3d: np.ndarray | None = None,
    # Optional per-cell vector fields aligned to the embedding(s)
    # (e.g. scVelo velocity, CellRank drift vectors)
    vector_fields: dict[str, np.ndarray | sparse.spmatrix] | None = None,
    vector_field_default: str | None = None,
) -> None:
    """
    Export raw data arrays to files used by the WebGL viewer.

    Memory/Disk Optimization Options
    --------------------------------
    var_quantization : int or None
        Bits for gene expression quantization (8, 16, or None for full float32).
        8-bit reduces file size by 4x with minimal visual impact for colormapping.
    obs_continuous_quantization : int or None
        Bits for continuous obs field quantization (8, 16, or None for full float32).
    obs_categorical_dtype : 'uint8' or 'uint16'
        - 'uint8': Store up to 255 categories
        - 'uint16': Store up to 65,535 categories
    compression : int or None
        Gzip compression level (1-9). None disables compression.
        Level 6 is a good balance of speed and size. Files get .gz extension.

    Multi-Dimensional Embeddings
    ----------------------------
    At least one dimensional embedding must be provided. The viewer supports
    switching between different dimensionalities of the same data at runtime.
    All embeddings must have the same number of cells (rows) but different
    column counts matching their dimensionality.

    IMPORTANT: Each embedding is normalized independently to fit within the
    [-1, 1] coordinate range. Within each dimension, the same scale factor is
    used for all axes to preserve aspect ratios. This ensures each dimension
    fills the viewing area optimally without requiring manual zoom adjustment.

    X_umap_1d : np.ndarray, optional
        1D embedding coordinates, shape (n_cells, 1). Stored as points_1d.bin.
    X_umap_2d : np.ndarray, optional
        2D embedding coordinates, shape (n_cells, 2). Stored as points_2d.bin.
    X_umap_3d : np.ndarray, optional
        3D embedding coordinates, shape (n_cells, 3). Stored as points_3d.bin.
        This is the primary visualization and is used for centroid computation.

    vector_fields : dict[str, np.ndarray] or None
        Optional per-cell displacement vectors aligned to the embedding space.
        Every key must use the exact dimension-suffixed AnnData ``obsm`` form
        ``<field>_umap_<dim>d`` (for example, ``velocity_umap_2d`` or
        ``T_fwd_umap_3d``).

        Each value must be shaped exactly ``(n_cells, dim)`` and contain finite
        real values representable as float32.
        Vectors are scaled by the same per-dimension normalization scale as points.
        A field's metadata ``default_dimension`` is exactly its highest available
        vector dimension.
    vector_field_default : str or None
        Exact field id to select initially. Required when more than one vector
        field is declared; omitted for a single unambiguous field.

    Standard Parameters
    -------------------
    latent_space : np.ndarray or sparse matrix
        Latent space for outlier quantile calculation, shape (n_cells, n_dims).
    obs : pd.DataFrame
        Cell metadata, shape (n_cells, n_obs_columns).
    var : pd.DataFrame, optional
        Gene/feature metadata. Required if gene_expression is provided.
    gene_expression : np.ndarray or sparse matrix, optional
        Gene expression matrix, shape (n_cells, n_genes).
    var_gene_id_column : str or None
        Exact non-empty column name in var containing string gene identifiers.
        None uses string identifiers from var.index.
    gene_identifiers : sequence of str, optional
        Unique gene identifiers to export. Every identifier must be a non-empty
        string present in var. If None, all genes are exported.
    connectivities : array or sparse matrix, optional
        Exact weighted undirected graph, shape ``(n_cells, n_cells)``. Values
        must be finite and non-negative, the topology and weights must be
        exactly symmetric, and the diagonal must be zero. Sparse inputs must
        not contain duplicate coordinates or stored zero entries.
    out_dir : Path or str
        Output directory (default: exports/ under the current working directory).
    obs_keys : sequence of str or None
        Which obs columns to export. If None, all columns are exported.
    centroid_outlier_quantile : float
        Quantile of distances to keep as inliers when computing centroids.
    centroid_min_points : int
        Minimum number of points in a category to compute a centroid.
    force : bool
        If True, replace files in the export directory. If False, the export
        directory must be absent or empty.

    Dataset Metadata Parameters
    ---------------------------
    dataset_name : str
        Explicit human-readable name for the dataset.
    dataset_description : str, optional
        Description of the dataset.
    dataset_id : str
        Explicit unique identifier for the dataset.
    source_name : str, optional
        Name of the data source (e.g., "HLCA Consortium").
    source_url : str, optional
        URL to the data source.
    source_citation : str, optional
        Citation text for the data source.
    """
    _validate_prepare_codec_options(
        compression=compression,
        var_quantization=var_quantization,
        obs_continuous_quantization=obs_continuous_quantization,
        obs_categorical_dtype=obs_categorical_dtype,
        centroid_outlier_quantile=centroid_outlier_quantile,
    )
    dataset_name = _require_nonempty_string(
        dataset_name,
        label="dataset_name",
    )
    dataset_id = _require_nonempty_string(
        dataset_id,
        label="dataset_id",
    )

    out_dir = Path(out_dir)
    if out_dir.exists():
        if not out_dir.is_dir():
            raise NotADirectoryError(f"Export path exists but is not a directory: {out_dir}")
        if any(out_dir.iterdir()):
            raise FileExistsError(f"Staged export directory must be empty: {out_dir}")
    obs_manifest_filename = "obs_manifest.json"
    obs_binary_dirname = DEFAULT_OBS_DIRNAME
    var_manifest_filename = "var_manifest.json"
    var_binary_dirname = DEFAULT_VAR_DIRNAME
    obs_binary_dir = out_dir / obs_binary_dirname

    def published_path(path: Path) -> Path:
        return _published_out_dir / path.relative_to(out_dir)

    # =========================================================================
    # MULTI-DIMENSIONAL EMBEDDING VALIDATION & PROCESSING
    # =========================================================================
    # Collect all provided embeddings
    embeddings: dict[int, np.ndarray] = {}
    if X_umap_1d is not None:
        embeddings[1] = _require_finite_float32_array(
            X_umap_1d,
            label="X_umap_1d",
        )
    if X_umap_2d is not None:
        embeddings[2] = _require_finite_float32_array(
            X_umap_2d,
            label="X_umap_2d",
        )
    if X_umap_3d is not None:
        embeddings[3] = _require_finite_float32_array(
            X_umap_3d,
            label="X_umap_3d",
        )

    if not embeddings:
        raise ValueError(
            "At least one dimensional embedding must be provided. "
            "Use X_umap_1d, X_umap_2d, or X_umap_3d."
        )

    # Validate each embedding has correct dimensions
    n_cells = None
    for dim, arr in embeddings.items():
        if arr.ndim != 2:
            raise ValueError(f"X_umap_{dim}d must be a 2D array, got shape {arr.shape}.")
        if arr.shape[1] != dim:
            raise ValueError(
                f"X_umap_{dim}d must have exactly {dim} columns, got {arr.shape[1]}. "
                f"Shape is {arr.shape}."
            )
        if n_cells is None:
            n_cells = arr.shape[0]
        elif arr.shape[0] != n_cells:
            raise ValueError(
                f"All embeddings must have the same number of cells. "
                f"First embedding has {n_cells} cells, but X_umap_{dim}d has {arr.shape[0]} cells."
            )
    if n_cells is None or n_cells <= 0:
        raise ValueError("Embeddings must contain at least one cell.")

    connectivity_edges: ConnectivityEdgePairs | None = None
    if connectivities is not None:
        connectivity_edges = validate_connectivity_edges(
            connectivities,
            n_cells=n_cells,
        )

    # =========================================================================
    # NORMALIZE EACH EMBEDDING INDEPENDENTLY TO FIT WITHIN [-1, 1] RANGE
    # =========================================================================
    # Each dimensional embedding (1D, 2D, 3D) is normalized independently so that
    # it fills the viewing area optimally. Within each dimension, we use the same
    # scale factor for all axes to preserve aspect ratios.
    #
    # This ensures that switching between dimensions doesn't require manual zoom
    # adjustments - each dimension will fill the view appropriately.

    normalization_info = {}
    for dim, arr in embeddings.items():
        normalized, center, scale_factor, max_range = _normalize_finite_float32_embedding(
            arr,
            label=f"X_umap_{dim}d",
        )
        embeddings[dim] = normalized

        # Store info for logging
        normalization_info[dim] = {
            "original_range": max_range,
            "center": center.tolist(),
            "scale_factor": scale_factor,
        }

    # Track available dimensions for metadata
    available_dimensions = sorted(embeddings.keys())

    # The highest declared dimension is the one exact initial dimension.
    default_dimension = max(available_dimensions)

    # Print export settings summary
    print("=" * 60)
    print("Export Settings:")
    print(f"  Output directory: {_published_out_dir}")
    print(f"  Compression: {'gzip level ' + str(compression) if compression else 'disabled'}")
    print(
        f"  Var (gene) quantization: {str(var_quantization) + '-bit' if var_quantization else 'disabled (float32)'}"
    )
    print(
        f"  Obs continuous quantization: {str(obs_continuous_quantization) + '-bit' if obs_continuous_quantization else 'disabled (float32)'}"
    )
    print(f"  Obs categorical dtype: {obs_categorical_dtype}")
    print(f"  Available dimensions: {available_dimensions}")
    print(f"  Default dimension: {default_dimension}D")
    print("  Coordinate normalization (per-dimension, aspect-ratio preserved):")
    for dim in sorted(normalization_info.keys()):
        info = normalization_info[dim]
        print(f"    {dim}D: range {info['original_range']:.2f} → [-1, 1]")
    print("=" * 60)

    # Validate and convert latent space
    if latent_space is None:
        raise ValueError("latent_space is required for outlier quantile calculation.")
    latent = _require_finite_float32_array(
        latent_space,
        label="latent_space",
    )
    if latent.ndim != 2 or latent.shape[0] != n_cells:
        raise ValueError(
            f"latent_space must have shape ({n_cells}, n_dimensions), got {latent.shape}."
        )

    # Validate obs
    if obs is None:
        raise ValueError("obs DataFrame is required.")
    if len(obs) != n_cells:
        raise ValueError(f"obs has {len(obs)} rows, but embeddings have {n_cells} cells.")

    # Resolve and validate every observation payload identity before any output
    # path is created.
    if obs_keys is None:
        obs_keys = list(obs.columns)
    else:
        if isinstance(obs_keys, str | bytes):
            raise TypeError("obs_keys must be a sequence of strings, not a string scalar.")
        obs_keys = list(obs_keys)
        missing = [key for key in obs_keys if key not in obs.columns]
        if missing:
            raise KeyError(
                f"obs_keys contain columns not in obs: {missing}. "
                f"Available columns: {list(obs.columns)}"
            )
    safe_obs_keys = _assert_unique_filename_components(
        obs_keys,
        label="Observation field",
    )

    validated_continuous_obs: dict[str, np.ndarray] = {}
    obs_field_summaries: list[dict[str, object]] = []
    for key in obs_keys:
        series = obs[key]
        if isinstance(series.dtype, pd.CategoricalDtype) or pd.api.types.is_bool_dtype(series):
            kind = "category"
        elif pd.api.types.is_numeric_dtype(series):
            kind = "continuous"
        elif pd.api.types.is_string_dtype(series) or pd.api.types.is_object_dtype(series):
            kind = "category"
        else:
            raise TypeError(f"obs field {key!r} has unsupported dtype {series.dtype!r}.")

        if kind == "continuous":
            validated_continuous_obs[key] = _require_continuous_obs_values(
                series.to_numpy(),
                key=key,
                n_cells=n_cells,
            )
            if obs_continuous_quantization is None:
                dtype_str = "float32"
            elif obs_continuous_quantization == 8:
                dtype_str = "uint8"
            else:
                dtype_str = "uint16"
            obs_field_summaries.append(
                {
                    "key": key,
                    "kind": "continuous",
                    "quantized": obs_continuous_quantization is not None,
                    "quantization_bits": int(obs_continuous_quantization)
                    if obs_continuous_quantization is not None
                    else None,
                    "dtype": dtype_str,
                }
            )
            continue

        categorical = series.astype("category")
        categories = _json_category_values(
            categorical.cat.categories,
            field_key=key,
        )
        n_categories = len(categories)
        if obs_categorical_dtype == "uint8":
            if n_categories > 255:
                raise ValueError(
                    f"Field {key!r} has {n_categories} categories, but uint8 supports at most 255."
                )
            dtype_str = "uint8"
        else:
            if n_categories > 65_535:
                raise ValueError(
                    f"Field {key!r} has {n_categories} categories, "
                    "but uint16 supports at most 65,535."
                )
            dtype_str = "uint16"
        obs_field_summaries.append(
            {
                "key": key,
                "kind": "category",
                "category_count": n_categories,
                "codes_dtype": dtype_str,
                "outlier_quantized": obs_continuous_quantization is not None,
                "outlier_quantization_bits": int(obs_continuous_quantization)
                if obs_continuous_quantization is not None
                else None,
            }
        )

    # Resolve every gene payload identity before any earlier dataset artifact can
    # be written.
    genes_to_export: list[str] = []
    gene_id_to_idx: dict[str, int] = {}
    safe_gene_id_by_id: dict[str, str] = {}
    gene_expr_is_sparse = False
    gene_expression_for_export: np.ndarray | sparse.csc_matrix | None = None
    if gene_expression is not None:
        if var is None:
            raise ValueError("var DataFrame must be provided when gene_expression is given.")

        if sparse.issparse(gene_expression):
            gene_expr_is_sparse = True
            gene_expression_for_export = cast(
                sparse.spmatrix,
                gene_expression,
            ).tocsc()
        else:
            gene_expression_for_export = np.asarray(gene_expression)
        if gene_expression_for_export.ndim != 2:
            raise ValueError(
                "gene_expression must be a 2D array or sparse matrix; "
                f"got shape {gene_expression_for_export.shape}."
            )

        n_expr_cells, n_genes = gene_expression_for_export.shape
        if n_expr_cells != n_cells:
            raise ValueError(
                f"gene_expression has {n_expr_cells} cells, but embeddings have {n_cells} cells."
            )
        if len(var) != n_genes:
            raise ValueError(f"var has {len(var)} rows, but gene_expression has {n_genes} genes.")

        if var_gene_id_column is None:
            all_gene_ids = _require_string_identifiers(
                var.index.tolist(),
                label="var index",
            )
        else:
            if type(var_gene_id_column) is not str:
                raise TypeError(
                    "var_gene_id_column must be a native non-empty string or None; "
                    f"got {type(var_gene_id_column).__name__}."
                )
            if not var_gene_id_column:
                raise ValueError("var_gene_id_column must be a native non-empty string or None.")
            if var_gene_id_column not in var.columns:
                raise KeyError(
                    f"var_gene_id_column '{var_gene_id_column}' not found in var. "
                    f"Available columns: {list(var.columns)}"
                )
            all_gene_ids = _require_string_identifiers(
                var[var_gene_id_column].tolist(),
                label=f"var column {var_gene_id_column!r}",
            )
        safe_all_gene_ids = _assert_unique_filename_components(
            all_gene_ids,
            label="Gene",
        )
        gene_id_to_idx = {gene_id: index for index, gene_id in enumerate(all_gene_ids)}
        safe_gene_id_by_id = dict(zip(all_gene_ids, safe_all_gene_ids, strict=True))

        if gene_identifiers is None:
            genes_to_export = all_gene_ids
        else:
            if isinstance(gene_identifiers, str | bytes):
                raise TypeError(
                    "gene_identifiers must be a sequence of strings, not a string scalar."
                )
            genes_to_export = _require_string_identifiers(
                gene_identifiers,
                label="Requested gene",
            )
            _assert_unique_filename_components(
                genes_to_export,
                label="Requested gene",
            )
            missing_genes = [
                gene_id for gene_id in genes_to_export if gene_id not in gene_id_to_idx
            ]
            if missing_genes:
                raise KeyError(
                    f"gene_identifiers contain identifiers not present in var: {missing_genes}."
                )

    validated_vector_fields = validate_vector_fields(
        vector_fields,
        n_cells=n_cells,
        available_dimensions=available_dimensions,
        vector_field_default=vector_field_default,
    )
    scaled_vector_fields: dict[str, dict[int, np.ndarray]] = {}
    vector_fields_identity: dict[str, object] | None = None
    if validated_vector_fields.fields:
        fields_metadata: dict[str, dict[str, object]] = {}
        gzip_suffix = ".gz" if compression else ""
        _assert_unique_filename_components(
            list(validated_vector_fields.fields),
            label="Vector field",
        )
        for field_id, vectors_by_dimension in validated_vector_fields.fields.items():
            dimensions = sorted(vectors_by_dimension)
            scaled_vector_fields[field_id] = {}
            for dimension, vectors in vectors_by_dimension.items():
                scale_factor = normalization_info[dimension]["scale_factor"]
                scaled_vector_fields[field_id][dimension] = scale_vector_field(
                    vectors,
                    scale_factor=scale_factor,
                    label=f"Vector field {field_id!r} {dimension}D",
                )

            fields_metadata[field_id] = {
                "label": field_id,
                "basis": "umap",
                "available_dimensions": dimensions,
                "default_dimension": max(dimensions),
                "files": {
                    f"{dimension}d": (f"vectors/{field_id}_{dimension}d.bin{gzip_suffix}")
                    for dimension in dimensions
                },
            }

        vector_fields_identity = {
            "default_field": validated_vector_fields.default_field,
            "fields": fields_metadata,
        }

    out_dir.mkdir(parents=True, exist_ok=True)
    obs_binary_dir.mkdir(parents=True, exist_ok=True)

    # =========================================================================
    # SAVE DIMENSIONAL EMBEDDING FILES
    # =========================================================================
    for dim, arr in embeddings.items():
        dim_filename = f"points_{dim}d.bin"
        dim_path = out_dir / dim_filename
        check_path = Path(str(dim_path) + ".gz") if compression and compression > 0 else dim_path
        if _output_path_is_writable(check_path, check_path.name):
            actual_path = _write_binary(dim_path, arr, compression)
            suffix = " (gzip)" if compression else ""
            print(
                f"✓ Wrote {dim}D positions ({arr.shape[0]:,} cells × {dim} dims) "
                f"to {published_path(actual_path)}{suffix}"
            )

    # =========================================================================
    # SAVE VECTOR FIELDS (OPTIONAL)
    # =========================================================================
    if scaled_vector_fields:
        vectors_dir = out_dir / "vectors"
        vectors_dir.mkdir(parents=True, exist_ok=True)
        for field_id, vectors_by_dimension in scaled_vector_fields.items():
            for dimension, vectors in vectors_by_dimension.items():
                filename = f"{field_id}_{dimension}d.bin"
                path = vectors_dir / filename
                check_path = Path(str(path) + ".gz") if compression and compression > 0 else path
                if _output_path_is_writable(check_path, check_path.name):
                    actual_path = _write_binary(path, vectors, compression)
                    suffix = " (gzip)" if compression else ""
                    print(
                        f"✓ Wrote vector field '{field_id}' {dimension}D "
                        f"({vectors.shape[0]:,} cells × {dimension} comps) "
                        f"to {published_path(actual_path)}{suffix}"
                    )

    # Check if obs manifest already exists
    obs_manifest_path = out_dir / obs_manifest_filename
    if _output_path_is_writable(obs_manifest_path, "obs manifest"):
        # Compact format: separate lists for continuous and categorical fields
        obs_continuous_fields: list = []
        obs_categorical_fields: list = []
        # Track dtype info for schema (will use first encountered)
        continuous_dtype_info: dict = {}
        categorical_dtype_info: dict = {}

        for key, safe_key in zip(obs_keys, safe_obs_keys, strict=True):
            s = obs[key]

            # Decide kind: continuous vs categorical
            if isinstance(s.dtype, pd.CategoricalDtype) or pd.api.types.is_bool_dtype(s):
                kind = "category"
            elif pd.api.types.is_numeric_dtype(s):
                kind = "continuous"
            else:
                kind = "category"

            if kind == "continuous":
                values = validated_continuous_obs[key]

                # Apply quantization if requested
                if obs_continuous_quantization is not None:
                    quantized, min_val, max_val, scale = _quantize_continuous(
                        values, bits=obs_continuous_quantization, field_name=key
                    )

                    if obs_continuous_quantization == 8:
                        dtype_str = "uint8"
                        ext = "u8"
                    else:
                        dtype_str = "uint16"
                        ext = "u16"

                    value_fname = f"{safe_key}.values.{ext}"
                    value_path = obs_binary_dir / value_fname
                    actual_path = _write_binary(value_path, quantized, compression)

                    # Adjust path in manifest if compressed
                    manifest_path = f"{obs_binary_dirname}/{value_fname}"
                    if compression:
                        manifest_path += ".gz"

                    # Compact format: [key, minValue, maxValue]
                    obs_continuous_fields.append([key, min_val, max_val])
                    if not continuous_dtype_info:
                        continuous_dtype_info["ext"] = ext
                        continuous_dtype_info["dtype"] = dtype_str
                        continuous_dtype_info["quantized"] = True
                        continuous_dtype_info["quantizationBits"] = obs_continuous_quantization
                else:
                    # Full precision
                    value_fname = f"{safe_key}.values.f32"
                    value_path = obs_binary_dir / value_fname
                    actual_path = _write_binary(value_path, values, compression)

                    # Compact format: [key]
                    obs_continuous_fields.append([key])
                    if not continuous_dtype_info:
                        continuous_dtype_info["ext"] = "f32"
                        continuous_dtype_info["dtype"] = "float32"
                        continuous_dtype_info["quantized"] = False

            else:
                # Categorical
                cat = s.astype("category")
                categories = _json_category_values(
                    cat.cat.categories,
                    field_key=key,
                )
                codes = cat.cat.codes.to_numpy(dtype=np.int32)  # -1 for NaN

                if codes.shape[0] != n_cells:
                    raise ValueError(
                        f"Length mismatch for obs['{key}']: {codes.shape[0]} vs {n_cells}"
                    )

                n_categories = len(categories)

                dtype: type[np.uint8] | type[np.uint16]
                if obs_categorical_dtype == "uint8":
                    if n_categories > 255:
                        raise ValueError(
                            f"Field '{key}' has {n_categories} categories, "
                            "but uint8 supports at most 255."
                        )
                    dtype, missing_value = np.uint8, 255
                else:  # uint16
                    if n_categories > 65_535:
                        raise ValueError(
                            f"Field '{key}' has {n_categories} categories, "
                            "but uint16 supports at most 65,535."
                        )
                    dtype, missing_value = np.uint16, 65535

                codes_typed = np.full(n_cells, missing_value, dtype=dtype)
                valid_mask = codes >= 0
                codes_typed[valid_mask] = codes[valid_mask].astype(dtype)

                if dtype == np.uint8:
                    codes_fname = f"{safe_key}.codes.u8"
                    dtype_str = "uint8"
                else:
                    codes_fname = f"{safe_key}.codes.u16"
                    dtype_str = "uint16"

                codes_path = obs_binary_dir / codes_fname
                actual_path = _write_binary(codes_path, codes_typed, compression)

                manifest_codes_path = f"{obs_binary_dirname}/{codes_fname}"
                if compression:
                    manifest_codes_path += ".gz"

                # Compute centroids for all available dimensions
                if centroid_outlier_quantile is None:
                    centroids_by_dim = {dim: [] for dim in embeddings}
                else:
                    centroids_by_dim = _compute_centroids_for_all_dimensions(
                        embeddings,
                        codes,
                        categories,
                        outlier_quantile=centroid_outlier_quantile,
                        min_points=centroid_min_points,
                    )

                # Compute per-cell outlier quantiles based on latent space
                outlier_quantiles = _compute_latent_space_quantiles(
                    latent=latent,
                    codes=codes,
                    categories=categories,
                    min_points=centroid_min_points,
                )

                # Quantize outlier quantiles (they're always 0-1)
                if obs_continuous_quantization is not None:
                    oq_quantized, oq_min, oq_max, oq_scale = _quantize_nullable_outlier_quantiles(
                        outlier_quantiles,
                        bits=obs_continuous_quantization,
                        field_name=f"{key}_outliers",
                    )

                    if obs_continuous_quantization == 8:
                        oq_dtype_str = "uint8"
                        oq_ext = "u8"
                    else:
                        oq_dtype_str = "uint16"
                        oq_ext = "u16"

                    outlier_fname = f"{safe_key}.outliers.{oq_ext}"
                    outlier_path = obs_binary_dir / outlier_fname
                    _write_binary(outlier_path, oq_quantized, compression)

                    manifest_outlier_path = f"{obs_binary_dirname}/{outlier_fname}"
                    if compression:
                        manifest_outlier_path += ".gz"

                    # Compact format: [key, categories, codesDtype, codesMissingValue, centroidsByDim, outlierMinValue, outlierMaxValue]
                    # centroidsByDim is a dict keyed by dimension: {"1": [...], "2": [...], "3": [...]}
                    centroids_serializable = {
                        str(dim): cents for dim, cents in centroids_by_dim.items()
                    }
                    obs_categorical_fields.append(
                        [
                            key,
                            categories,
                            dtype_str,
                            int(missing_value),
                            centroids_serializable,
                            oq_min,
                            oq_max,
                        ]
                    )
                    if not categorical_dtype_info:
                        categorical_dtype_info["codesExt"] = "u8" if dtype == np.uint8 else "u16"
                        categorical_dtype_info["outlierExt"] = oq_ext
                        categorical_dtype_info["outlierDtype"] = oq_dtype_str
                        categorical_dtype_info["outlierQuantized"] = True
                else:
                    # Full precision outliers
                    outlier_fname = f"{safe_key}.outliers.f32"
                    outlier_path = obs_binary_dir / outlier_fname
                    _write_binary(outlier_path, outlier_quantiles.astype(np.float32), compression)

                    # Compact format: [key, categories, codesDtype, codesMissingValue, centroidsByDim]
                    # centroidsByDim is a dict keyed by dimension: {"1": [...], "2": [...], "3": [...]}
                    centroids_serializable = {
                        str(dim): cents for dim, cents in centroids_by_dim.items()
                    }
                    obs_categorical_fields.append(
                        [key, categories, dtype_str, int(missing_value), centroids_serializable]
                    )
                    if not categorical_dtype_info:
                        categorical_dtype_info["codesExt"] = "u8" if dtype == np.uint8 else "u16"
                        categorical_dtype_info["outlierExt"] = "f32"
                        categorical_dtype_info["outlierDtype"] = "float32"
                        categorical_dtype_info["outlierQuantized"] = False

        # Build compact manifest with schemas
        gz_suffix = ".gz" if compression else ""

        obs_schemas = {}
        if continuous_dtype_info:
            obs_schemas["continuous"] = {
                "pathPattern": f"{obs_binary_dirname}/{{key}}.values.{continuous_dtype_info['ext']}{gz_suffix}",
                "ext": continuous_dtype_info["ext"],
                "dtype": continuous_dtype_info["dtype"],
                "quantized": continuous_dtype_info.get("quantized", False),
            }
            if continuous_dtype_info.get("quantized"):
                obs_schemas["continuous"]["quantizationBits"] = continuous_dtype_info[
                    "quantizationBits"
                ]

        if categorical_dtype_info:
            obs_schemas["categorical"] = {
                "codesPathPattern": f"{obs_binary_dirname}/{{key}}.codes.{{ext}}{gz_suffix}",
                "outlierPathPattern": f"{obs_binary_dirname}/{{key}}.outliers.{categorical_dtype_info['outlierExt']}{gz_suffix}",
                "outlierExt": categorical_dtype_info["outlierExt"],
                "outlierDtype": categorical_dtype_info["outlierDtype"],
                "outlierQuantized": categorical_dtype_info.get("outlierQuantized", False),
            }

        obs_manifest_payload = {
            "_format": MANIFEST_FORMAT_VERSION,
            "n_points": int(n_cells),
            "centroid_outlier_quantile": float(centroid_outlier_quantile)
            if centroid_outlier_quantile is not None
            else None,
            "latent_key": "latent_space",
            "compression": compression if compression else None,
            "_obsSchemas": obs_schemas,
            "_continuousFields": obs_continuous_fields,
            "_categoricalFields": obs_categorical_fields,
        }
        obs_manifest_path.write_text(json.dumps(obs_manifest_payload), encoding="utf-8")

        total_fields = len(obs_continuous_fields) + len(obs_categorical_fields)
        print(
            f"✓ Wrote obs manifest ({total_fields} fields: {len(obs_continuous_fields)} continuous, "
            f"{len(obs_categorical_fields)} categorical) "
            f"to {published_path(obs_manifest_path)} "
            f"with binaries in {obs_binary_dirname}/"
        )

    expected_centroid_quantile = (
        float(centroid_outlier_quantile) if centroid_outlier_quantile is not None else None
    )
    expected_compression = compression if compression else None
    identity_obs_fields = _identity_obs_fields_from_compact_manifest(obs_manifest_payload)
    if (
        type(obs_manifest_payload.get("n_points")) is not int
        or obs_manifest_payload["n_points"] != int(n_cells)
        or obs_manifest_payload.get("centroid_outlier_quantile") != expected_centroid_quantile
        or obs_manifest_payload.get("latent_key") != "latent_space"
        or obs_manifest_payload.get("compression") != expected_compression
    ):
        raise ValueError(
            f"Observation manifest {obs_manifest_path} does not match the "
            "current export settings. Use force=True to replace it."
        )

    expected_identity_obs_fields: list[dict] = []
    for expected_kind in ("continuous", "category"):
        for field_info in obs_field_summaries:
            if field_info["kind"] != expected_kind:
                continue
            entry = {
                "key": field_info["key"],
                "kind": field_info["kind"],
            }
            if expected_kind == "category":
                entry["n_categories"] = field_info["category_count"]
            expected_identity_obs_fields.append(entry)
    if identity_obs_fields != expected_identity_obs_fields:
        raise ValueError(
            f"Observation manifest {obs_manifest_path} fields do not match "
            "the requested observation fields. Use force=True to replace it."
        )

    # Process gene expression if provided
    exported_var_field_count = 0
    if gene_expression is not None:
        if gene_expression_for_export is None:
            raise RuntimeError("Validated gene expression data is unavailable.")

        var_manifest_path = out_dir / var_manifest_filename
        if _output_path_is_writable(var_manifest_path, "var manifest"):
            var_binary_dir = out_dir / var_binary_dirname
            var_binary_dir.mkdir(parents=True, exist_ok=True)

            var_manifest_fields: list[list[Any]] = []

            for gene_id in tqdm.tqdm(genes_to_export, desc="Exporting genes"):
                gene_idx = gene_id_to_idx[gene_id]
                safe_gene_id = safe_gene_id_by_id[gene_id]

                if gene_expr_is_sparse:
                    sparse_expression = cast(
                        sparse.csc_matrix,
                        gene_expression_for_export,
                    )
                    col = sparse_expression.getcol(gene_idx).toarray().flatten()
                else:
                    col = gene_expression_for_export[:, gene_idx]

                gene_values = _require_finite_float32_array(
                    col,
                    label=f"Gene {gene_id!r} expression",
                )

                if gene_values.ndim != 1 or gene_values.shape[0] != n_cells:
                    raise ValueError(
                        f"Gene {gene_id!r} expression must have shape ({n_cells},), "
                        f"got {gene_values.shape}."
                    )

                # Apply quantization if requested
                if var_quantization is not None:
                    quantized, min_val, max_val, scale = _quantize_continuous(
                        gene_values,
                        bits=var_quantization,
                        field_name=gene_id,
                    )

                    if var_quantization == 8:
                        dtype_str = "uint8"
                        ext = "u8"
                    else:
                        dtype_str = "uint16"
                        ext = "u16"

                    value_fname = f"{safe_gene_id}.values.{ext}"
                    value_path = var_binary_dir / value_fname
                    _write_binary(value_path, quantized, compression)

                    manifest_path = f"{var_binary_dirname}/{value_fname}"
                    if compression:
                        manifest_path += ".gz"

                    # Compact format: [key, minValue, maxValue]
                    var_manifest_fields.append([gene_id, min_val, max_val])
                else:
                    # Full precision
                    value_fname = f"{safe_gene_id}.values.f32"
                    value_path = var_binary_dir / value_fname
                    _write_binary(value_path, gene_values, compression)

                    # Compact format: [key] for non-quantized
                    var_manifest_fields.append([gene_id])

            # Build compact manifest with schema
            gz_suffix = ".gz" if compression else ""
            if var_quantization is not None:
                ext = "u8" if var_quantization == 8 else "u16"
                dtype_str = "uint8" if var_quantization == 8 else "uint16"
                var_schema = {
                    "kind": "continuous",
                    "pathPattern": f"{var_binary_dirname}/{{key}}.values.{ext}{gz_suffix}",
                    "ext": ext,
                    "dtype": dtype_str,
                    "quantized": True,
                    "quantizationBits": var_quantization,
                }
            else:
                var_schema = {
                    "kind": "continuous",
                    "pathPattern": f"{var_binary_dirname}/{{key}}.values.f32{gz_suffix}",
                    "ext": "f32",
                    "dtype": "float32",
                    "quantized": False,
                }

            var_manifest_payload = {
                "_format": MANIFEST_FORMAT_VERSION,
                "n_points": int(n_cells),
                "var_gene_id_column": var_gene_id_column,
                "compression": compression if compression else None,
                "quantization": var_quantization,
                "_varSchema": var_schema,
                "fields": var_manifest_fields,
            }
            var_manifest_path.write_text(json.dumps(var_manifest_payload), encoding="utf-8")
            exported_var_field_count = len(var_manifest_fields)

            compression_info = f", gzip level {compression}" if compression else ""
            quant_info = f", {var_quantization}-bit quantized" if var_quantization else ""
            print(
                f"✓ Wrote var manifest ({len(var_manifest_fields)} genes{quant_info}{compression_info}) "
                f"to {published_path(var_manifest_path)}"
            )
    else:
        print("INFO: Gene expression was not requested; no var artifact was emitted.")

    # Process connectivity data if provided
    # GPU-optimized edge format for instanced rendering
    connectivity_meta: dict[str, int | str | None] = {
        "n_edges": None,
        "max_neighbors": None,
        "index_dtype": None,
    }
    if connectivities is not None:
        if connectivity_edges is None:
            raise RuntimeError("Validated connectivity edge pairs are unavailable.")
        connectivity_manifest_path = out_dir / CONNECTIVITY_MANIFEST_FILENAME
        connectivity_meta["n_edges"] = connectivity_edges.n_edges
        connectivity_meta["max_neighbors"] = connectivity_edges.max_neighbors
        connectivity_meta["index_dtype"] = connectivity_edges.index_dtype

        if _output_path_is_writable(
            connectivity_manifest_path,
            "connectivity manifest",
        ):
            connectivity_binary_dir = out_dir / CONNECTIVITY_BINARY_DIRNAME
            connectivity_binary_dir.mkdir(parents=True, exist_ok=True)

            # Write binary files (column-separated for better compression)
            sources_fname = "edges.src.bin"
            dests_fname = "edges.dst.bin"
            weights_fname = "edges.weights.f64.bin"
            sources_path = connectivity_binary_dir / sources_fname
            dests_path = connectivity_binary_dir / dests_fname
            weights_path = connectivity_binary_dir / weights_fname

            _write_binary(sources_path, connectivity_edges.sources, compression)
            _write_binary(
                dests_path,
                connectivity_edges.destinations,
                compression,
            )
            _write_binary(
                weights_path,
                connectivity_edges.weights,
                compression,
            )

            connectivity_manifest_payload = build_connectivity_manifest(
                n_cells=n_cells,
                n_edges=connectivity_edges.n_edges,
                max_neighbors=connectivity_edges.max_neighbors,
                index_bytes=connectivity_edges.index_bytes,
                index_dtype=connectivity_edges.index_dtype,
                compression=compression,
            )

            connectivity_manifest_path.write_text(
                json.dumps(connectivity_manifest_payload), encoding="utf-8"
            )

            print(
                f"✓ Wrote connectivity ({connectivity_edges.n_edges:,} edges, "
                f"max {connectivity_edges.max_neighbors} neighbors/cell, "
                f"{connectivity_edges.index_dtype}) "
                f"to {published_path(connectivity_binary_dir)}"
            )
    else:
        print("INFO: Connectivity was not requested; no connectivity artifact was emitted.")

    # =========================================================================
    # Generate dataset_identity.json (metadata for multi-dataset support)
    # =========================================================================
    identity_path = out_dir / "dataset_identity.json"

    from cellucid import __version__ as cellucid_version

    # Identity order and counts come from the exact emitted compact manifest.
    n_obs_fields = len(identity_obs_fields)
    n_categorical_fields = sum(field["kind"] == "category" for field in identity_obs_fields)
    n_continuous_fields = sum(field["kind"] == "continuous" for field in identity_obs_fields)

    # Build source info if provided
    source_info = None
    if source_name is not None:
        source_info = {"name": source_name}
        if source_url is not None:
            source_info["url"] = source_url
        if source_citation is not None:
            source_info["citation"] = source_citation

    # Build export settings
    export_settings = {
        "compression": compression if compression else None,
        "var_quantization": var_quantization,
        "obs_continuous_quantization": obs_continuous_quantization,
        "obs_categorical_dtype": obs_categorical_dtype,
    }

    # Build embeddings metadata
    gz_suffix = ".gz" if compression else ""
    embeddings_meta: dict[str, Any] = {
        "available_dimensions": available_dimensions,
        "default_dimension": default_dimension,
        "files": {},
    }
    for dim in available_dimensions:
        embeddings_meta["files"][f"{dim}d"] = f"points_{dim}d.bin{gz_suffix}"

    # Build identity payload
    identity_payload = {
        "version": 2,  # Bumped version for multi-dimensional support
        "id": dataset_id,
        "name": dataset_name,
        "description": dataset_description if dataset_description is not None else "",
        "created_at": created_at,
        "cellucid_data_version": cellucid_version,
        "stats": {
            "n_cells": int(n_cells),
            "n_genes": int(exported_var_field_count),
            "n_obs_fields": int(n_obs_fields),
            "n_categorical_fields": int(n_categorical_fields),
            "n_continuous_fields": int(n_continuous_fields),
            "has_connectivity": connectivity_meta.get("n_edges") is not None,
            "n_edges": connectivity_meta.get("n_edges"),
        },
        "embeddings": embeddings_meta,
        "obs_fields": identity_obs_fields,
        "export_settings": export_settings,
    }

    if source_info:
        identity_payload["source"] = source_info

    if vector_fields_identity:
        identity_payload["vector_fields"] = vector_fields_identity

    identity_path.write_text(json.dumps(identity_payload, indent=2), encoding="utf-8")
    print(f"✓ Wrote dataset identity to {published_path(identity_path)}")


[docs] def prepare( latent_space: np.ndarray | sparse.spmatrix | None = None, obs: pd.DataFrame | None = None, var: pd.DataFrame | None = None, gene_expression: np.ndarray | sparse.spmatrix | None = None, var_gene_id_column: str | None = None, gene_identifiers: Sequence[str] | None = None, connectivities: np.ndarray | sparse.spmatrix | None = None, out_dir: Path | str = DEFAULT_EXPORT_DIR, obs_keys: Sequence[str] | None = None, centroid_outlier_quantile: float = 0.95, centroid_min_points: int = 10, force: bool = False, var_quantization: int | None = None, obs_continuous_quantization: int | None = None, compression: int | None = None, *, obs_categorical_dtype: Literal["uint8", "uint16"], dataset_name: str, dataset_id: str, created_at: str | None = None, dataset_description: str | None = None, source_name: str | None = None, source_url: str | None = None, source_citation: str | None = None, X_umap_1d: np.ndarray | None = None, X_umap_2d: np.ndarray | None = None, X_umap_3d: np.ndarray | None = None, vector_fields: dict[str, np.ndarray | sparse.spmatrix] | None = None, vector_field_default: str | None = None, ) -> None: """Build and atomically publish one complete canonical Cellucid export generation. ``created_at`` defaults to the current UTC time. Reproducible builders can pass an exact ``YYYY-MM-DDTHH:MM:SSZ`` UTC timestamp; it is validated and preserved byte-for-byte in ``dataset_identity.json``. """ force = _require_native_boolean(force, label="force") centroid_min_points = _require_positive_native_integer( centroid_min_points, label="centroid_min_points", ) dataset_description = _require_optional_native_string( dataset_description, label="dataset_description", allow_empty=True, ) source_name = _require_optional_native_string( source_name, label="source_name", allow_empty=False, ) source_url = _require_optional_native_string( source_url, label="source_url", allow_empty=False, ) source_citation = _require_optional_native_string( source_citation, label="source_citation", allow_empty=False, ) created_at = _resolve_created_at(created_at) if source_name is None and (source_url is not None or source_citation is not None): raise ValueError( "source_name is required whenever source_url or source_citation is provided." ) target_dir = Path(out_dir) if target_dir.name == "": raise ValueError("out_dir must name a child export directory.") target_dir.parent.mkdir(parents=True, exist_ok=True) with _exclusive_export_generation(target_dir): if target_dir.is_symlink(): raise ValueError(f"out_dir must not be a symbolic link: {target_dir}") if target_dir.exists(): if not target_dir.is_dir(): raise NotADirectoryError(f"Export path exists but is not a directory: {target_dir}") if not force and any(target_dir.iterdir()): raise FileExistsError( f"Refusing to replace non-empty export directory {target_dir}. " "Pass force=True to publish a complete replacement generation." ) staging_dir = Path( tempfile.mkdtemp( prefix=f".{target_dir.name}.cellucid-stage-", dir=target_dir.parent, ) ) try: _prepare_generation( latent_space=latent_space, obs=obs, var=var, gene_expression=gene_expression, var_gene_id_column=var_gene_id_column, gene_identifiers=gene_identifiers, connectivities=connectivities, out_dir=staging_dir, obs_keys=obs_keys, centroid_outlier_quantile=centroid_outlier_quantile, centroid_min_points=centroid_min_points, var_quantization=var_quantization, obs_continuous_quantization=obs_continuous_quantization, obs_categorical_dtype=obs_categorical_dtype, compression=compression, _published_out_dir=target_dir, dataset_name=dataset_name, dataset_id=dataset_id, created_at=created_at, dataset_description=dataset_description, source_name=source_name, source_url=source_url, source_citation=source_citation, X_umap_1d=X_umap_1d, X_umap_2d=X_umap_2d, X_umap_3d=X_umap_3d, vector_fields=vector_fields, vector_field_default=vector_field_default, ) _publish_export_generation(staging_dir, target_dir) except BaseException: if staging_dir.exists(): try: shutil.rmtree(staging_dir) except BaseException as cleanup_error: raise RuntimeError( f"Failed to remove rejected staged generation {staging_dir}." ) from cleanup_error raise
def generate_datasets_manifest( exports_dir: str | Path = DEFAULT_EXPORT_DIR, *, default_dataset: str, ) -> Path: """ Validate an exports directory and atomically publish its datasets.json manifest. This utility helps maintain the datasets.json manifest file that the frontend uses to discover available demo datasets. Run this after adding or removing datasets. Parameters ---------- exports_dir : Path or str Directory containing dataset subdirectories (default: exports/). default_dataset : str Exact ID of the dataset selected by default. Returns ------- Path Path to the generated datasets.json file. Example ------- >>> from cellucid.prepare_data import generate_datasets_manifest >>> generate_datasets_manifest("./exports", default_dataset="my_dataset") """ exports_dir = Path(exports_dir) if not exports_dir.exists(): raise FileNotFoundError(f"Exports directory not found: {exports_dir}") if not exports_dir.is_dir(): raise NotADirectoryError(f"Exports path must be a directory: {exports_dir}") default_dataset = _require_nonempty_string( default_dataset, label="default_dataset", ) if default_dataset != default_dataset.strip() or re.search(r"[\x00-\x1f\x7f]", default_dataset): raise ValueError( "default_dataset must be exact text without surrounding whitespace " "or control characters." ) print(f"Scanning {exports_dir} for datasets...") datasets: list[dict[str, object]] = [] dataset_ids: set[str] = set() directory_names: dict[str, str] = {} for subdir in sorted(exports_dir.iterdir()): if not subdir.is_dir(): continue directory_name = subdir.name if ( len(directory_name.encode("utf-8")) > 180 or directory_name in {".", ".."} or re.fullmatch(r"[A-Za-z0-9_.-]+", directory_name, flags=re.ASCII) is None or directory_name.endswith(".") or directory_name.split(".", 1)[0].upper() in _WINDOWS_RESERVED_COMPONENTS ): raise ValueError( f"Dataset directory {directory_name!r} is not a portable URL/path component." ) directory_collision_key = directory_name.casefold() if directory_collision_key in directory_names: raise ValueError( f"Dataset directories {directory_names[directory_collision_key]!r} and " f"{directory_name!r} collide case-insensitively." ) directory_names[directory_collision_key] = directory_name identity_file = subdir / "dataset_identity.json" if not identity_file.is_file(): raise ValueError( f"Dataset directory {directory_name!r} has no dataset_identity.json file." ) try: identity = json.loads(identity_file.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as error: raise ValueError(f"{identity_file} must contain readable UTF-8 JSON.") from error if not isinstance(identity, dict): raise TypeError(f"{identity_file} must contain a JSON object.") if type(identity.get("version")) is not int or identity["version"] != 2: raise ValueError(f"{identity_file} version must be exactly 2.") dataset_id = _require_nonempty_string( identity.get("id"), label=f"{identity_file} dataset_id", ) if dataset_id != dataset_id.strip() or re.search(r"[\x00-\x1f\x7f]", dataset_id): raise ValueError( f"{identity_file} dataset_id must be exact text without surrounding " "whitespace or control characters." ) if dataset_id in dataset_ids: raise ValueError(f"duplicate dataset id {dataset_id!r}.") dataset_ids.add(dataset_id) dataset_name = _require_nonempty_string( identity.get("name"), label=f"{identity_file} dataset_name", ) if dataset_name != dataset_name.strip(): raise ValueError( f"{identity_file} dataset_name must not contain surrounding whitespace." ) dataset_entry: dict[str, object] = { "id": dataset_id, "path": f"{directory_name}/", "name": dataset_name, } if "description" in identity: description = identity["description"] if not isinstance(description, str): raise TypeError(f"{identity_file} description must be a string.") dataset_entry["description"] = description stats = identity.get("stats") if not isinstance(stats, dict): raise TypeError(f"{identity_file} stats must be a JSON object.") for count_key in ("n_cells", "n_genes"): count = stats.get(count_key) if type(count) is not int or count < 0 or count > (1 << 53) - 1: raise ValueError( f"{identity_file} stats.{count_key} must be a non-negative safe integer." ) dataset_entry[count_key] = count datasets.append(dataset_entry) print(f" ✓ Found dataset: {dataset_entry['name']} ({dataset_entry['id']})") if not datasets: raise ValueError(f"Exports directory contains no datasets: {exports_dir}") if default_dataset not in dataset_ids: raise ValueError( f"default_dataset {default_dataset!r} is missing from the validated datasets." ) manifest = {"version": 1, "default": default_dataset, "datasets": datasets} manifest_bytes = json.dumps(manifest, indent=2).encode("utf-8") manifest_path = exports_dir / "datasets.json" descriptor, temporary_name = tempfile.mkstemp( prefix=".datasets.json.", suffix=".tmp", dir=exports_dir, ) temporary_path = Path(temporary_name) try: with os.fdopen(descriptor, "wb") as output: output.write(manifest_bytes) output.flush() os.fsync(output.fileno()) os.replace(temporary_path, manifest_path) except BaseException: if temporary_path.exists(): temporary_path.unlink() raise print(f"✓ Wrote datasets manifest with {len(datasets)} datasets to {manifest_path}") print(f" Default dataset: {default_dataset}") return manifest_path