Skip to content

panoptes.pipeline.index

panoptes.pipeline.index

Build the query surface by walking the documents the pipeline wrote.

observations.csv was a Firestore-generated summary. With no Firestore, nothing produces the surface you query to ask "which observations do I have, how long are they, how many frames are usable". This builds it from the documents that are already on disk.

Two files, and the second is derived from the first:

frames.parquet One row per frame, from every metadata.json under the processed tree.

observations.parquet One row per sequence, from grouping frames.parquet. Never written directly from the tree. If it were, the two could disagree and nothing would notice, which is the observations.csv failure one level up.

They are separate files rather than one table because they cannot share a schema: image_calibration_saturation_provenance is meaningless for a sequence and duration_minutes is meaningless for a frame. One table would be half nulls and every query would have to filter on a row-type column to be correct.

The rule that keeps this honest

The index must be deletable. If it cannot be removed, rebuilt from the tree, and give the same answers, something has entered the index that belongs in the documents. That is not hypothetical: it is exactly how observations.csv became the sole holder of total_exptime, which is now null for every long sequence with nowhere to recover it from.

Nothing here opens a FITS file or reads a pixel. See data contract 4.2, and

206 and #207 for the shape.

flatten

flatten(document: dict[str, Any], prefix: str = '') -> dict[str, Any]

Flatten nested maps into single-level keys joined by SEPARATOR.

{"image": {"camera": {"white_lvln": 11765}}} becomes {"image_camera_white_lvln": 11765}. Lists are left alone: nothing in a metadata document holds one that wants to be a column, and check_document already refuses arrays of arrays.

Source code in src/panoptes/pipeline/index.py
def flatten(document: dict[str, Any], prefix: str = "") -> dict[str, Any]:
    """Flatten nested maps into single-level keys joined by `SEPARATOR`.

    ``{"image": {"camera": {"white_lvln": 11765}}}`` becomes
    ``{"image_camera_white_lvln": 11765}``. Lists are left alone: nothing in a
    metadata document holds one that wants to be a column, and `check_document`
    already refuses arrays of arrays.
    """
    flat: dict[str, Any] = {}
    for key, value in document.items():
        name = f"{prefix}{key}"
        if isinstance(value, dict):
            flat.update(flatten(value, f"{name}{SEPARATOR}"))
        else:
            flat[name] = value
    return flat

find_documents

find_documents(processed_root: Path | str, files: FileSettings | None = None) -> Iterator[Path]

Every metadata document under processed_root, in a stable order.

Sorted so two builds of the same tree produce rows in the same order, which is what lets the rebuild check compare frames directly.

Source code in src/panoptes/pipeline/index.py
def find_documents(processed_root: Path | str, files: FileSettings | None = None) -> Iterator[Path]:
    """Every metadata document under `processed_root`, in a stable order.

    Sorted so two builds of the same tree produce rows in the same order, which
    is what lets the rebuild check compare frames directly.
    """
    files = files or FileSettings()
    return iter(sorted(Path(processed_root).rglob(str(files.metadata_filename))))

build_frames

build_frames(processed_root: Path | str, files: FileSettings | None = None) -> pandas.DataFrame

One row per frame, from every document under processed_root.

A document that cannot be read is skipped with a warning rather than stopping the walk -- over half a million frames, one bad file must not cost the whole index, and the frame it describes is still in the tree to be reprocessed.

Missing fields become null columns rather than errors. Documents written by an older pipeline still have to index; that is the whole point of being able to rebuild.

Source code in src/panoptes/pipeline/index.py
def build_frames(processed_root: Path | str, files: FileSettings | None = None) -> pandas.DataFrame:
    """One row per frame, from every document under `processed_root`.

    A document that cannot be read is skipped with a warning rather than
    stopping the walk -- over half a million frames, one bad file must not cost
    the whole index, and the frame it describes is still in the tree to be
    reprocessed.

    Missing fields become null columns rather than errors. Documents written by
    an older pipeline still have to index; that is the whole point of being able
    to rebuild.
    """
    rows = []
    for path in find_documents(processed_root, files):
        document = products.read_document(path)
        if document is None:
            logger.warning(f"Skipping unreadable document: {path}")
            continue

        for *parents, leaf in DROPPED:
            block = document
            for parent in parents:
                block = block.get(parent) if isinstance(block, dict) else None
            if isinstance(block, dict):
                block.pop(leaf, None)

        rows.append(flatten(document))

    # `pandas` unions the keys, so a field absent from one document arrives as a
    # null in that row rather than failing the build. `reindex` extends that to
    # a field absent from *every* document: without it, a tree written before a
    # field existed produces no column at all and the aggregation below raises
    # `KeyError` on exactly the old documents this is supposed to tolerate.
    frames = pandas.DataFrame(rows)
    missing = [c for c in REQUIRED_FRAME_COLUMNS if c not in frames.columns]
    if missing:
        logger.debug(f"No document supplied {missing}; indexing them as null.")
    return frames.reindex(columns=[*dict.fromkeys([*REQUIRED_FRAME_COLUMNS, *frames.columns])])

build_observations

build_observations(frames: DataFrame) -> pandas.DataFrame

One row per sequence, grouped from frames.

Four of these columns are the point, because the thing this replaces could not express them:

num_usable Frames that actually reached ImageStatus.MATCHED, as distinct from frames that exist. Answerable only because status is recorded per frame.

total_exptime A sum over frames rather than a number the index alone holds. That distinction is why the field is currently null for every long sequence in the archive with nowhere to recover it from. Summed with min_count=1, so a sequence where no frame recorded an exposure stays null instead of reporting a confident zero -- the whole point being to stop missing data from looking like measured data.

camera_num_serials Distinct serials recorded against this camera uid across every sequence in the index. This is the defect data contract 2.3 describes: 14d3bd carries 2,332 sequences on one serial and 4 on another. It is a property of the camera, not of a sequence, so grouping by sequence cannot see it -- within any one sequence the serial is almost always constant. Necessarily limited by what is indexed: an index covering one sequence cannot observe cross-sequence variation.

num_serials Distinct serials within one sequence. A different, rarer defect, kept because it is nearly free once the column is there.

Source code in src/panoptes/pipeline/index.py
def build_observations(frames: pandas.DataFrame) -> pandas.DataFrame:
    """One row per sequence, grouped from `frames`.

    Four of these columns are the point, because the thing this replaces could
    not express them:

    ``num_usable``
        Frames that actually reached `ImageStatus.MATCHED`, as distinct from
        frames that exist. Answerable only because status is recorded per frame.

    ``total_exptime``
        A sum over frames rather than a number the index alone holds. That
        distinction is why the field is currently null for every long sequence
        in the archive with nowhere to recover it from. Summed with
        ``min_count=1``, so a sequence where *no* frame recorded an exposure
        stays null instead of reporting a confident zero -- the whole point
        being to stop missing data from looking like measured data.

    ``camera_num_serials``
        Distinct serials recorded against this camera uid **across every
        sequence in the index**. This is the defect data contract 2.3 describes:
        `14d3bd` carries 2,332 sequences on one serial and 4 on another. It is a
        property of the camera, not of a sequence, so grouping by sequence
        cannot see it -- within any one sequence the serial is almost always
        constant. Necessarily limited by what is indexed: an index covering one
        sequence cannot observe cross-sequence variation.

    ``num_serials``
        Distinct serials *within* one sequence. A different, rarer defect, kept
        because it is nearly free once the column is there.
    """
    if frames.empty:
        return pandas.DataFrame(columns=list(OBSERVATION_COLUMNS))

    table = frames.copy()
    table["image_time"] = pandas.to_datetime(
        table["image_image_time"], format="mixed", utc=True, errors="coerce"
    )

    # `groupby` drops null keys silently, which would make frames disappear from
    # the index with no signal. Say so instead.
    unplaceable = table["sequence_sequence_id"].isna().sum()
    if unplaceable:
        logger.warning(f"{unplaceable} frame(s) have no sequence id and are not indexed")
        table = table[table["sequence_sequence_id"].notna()]
    if table.empty:
        return pandas.DataFrame(columns=list(OBSERVATION_COLUMNS))

    # Serial cardinality per *camera*, across the whole table -- see the
    # docstring. Computed before grouping by sequence, because that grouping is
    # exactly what hides it.
    serials_per_camera = table.groupby("sequence_camera_camera_id", observed=True)[
        "sequence_camera_serial_number"
    ].nunique()

    grouped = table.groupby("sequence_sequence_id", observed=True)
    observations = grouped.agg(
        unit_id=("unit_unit_id", "first"),
        camera_id=("sequence_camera_camera_id", "first"),
        field_name=("sequence_field_name", "first"),
        sequence_time=("sequence_sequence_time", "first"),
        num_frames=("image_uid", "size"),
        start_time=("image_time", "min"),
        end_time=("image_time", "max"),
        total_exptime=("image_camera_exptime", lambda values: values.sum(min_count=1)),
        num_serials=("sequence_camera_serial_number", "nunique"),
    )
    observations["num_usable"] = grouped["image_status"].apply(
        lambda status: (status == ImageStatus.MATCHED.name).sum()
    )
    observations["duration_minutes"] = (
        observations.end_time - observations.start_time
    ).dt.total_seconds() / 60
    observations["camera_num_serials"] = observations.camera_id.map(serials_per_camera)

    inconsistent = sorted(serials_per_camera.index[serials_per_camera > 1].tolist())
    if inconsistent:
        logger.warning(
            f"Camera uid(s) {inconsistent} record more than one serial across sequences; "
            "this is a data defect rather than a camera swap -- see data contract 2.3"
        )

    return observations.reset_index()[list(OBSERVATION_COLUMNS)]

schema

schema(frames: DataFrame, observations: DataFrame) -> dict[str, Any]

The column contract, as data rather than as source code.

207 requires the flattened-name mapping to be recorded where

panoptes-data can read it. SEPARATOR and DROPPED are constants in this package, which a consumer in another repository cannot import and should not have to reverse-engineer from a parquet footer. This writes them down.

No timestamps: a rebuild of an unchanged tree must produce an identical manifest, or the deletable rule stops being checkable.

Source code in src/panoptes/pipeline/index.py
def schema(frames: pandas.DataFrame, observations: pandas.DataFrame) -> dict[str, Any]:
    """The column contract, as data rather than as source code.

    #207 requires the flattened-name mapping to be *recorded* where
    `panoptes-data` can read it. `SEPARATOR` and `DROPPED` are constants in this
    package, which a consumer in another repository cannot import and should not
    have to reverse-engineer from a parquet footer. This writes them down.

    No timestamps: a rebuild of an unchanged tree must produce an identical
    manifest, or the deletable rule stops being checkable.
    """
    return {
        "version": SCHEMA_VERSION,
        "separator": SEPARATOR,
        "dropped": [list(block) for block in DROPPED],
        "required_frame_columns": list(REQUIRED_FRAME_COLUMNS),
        "files": {
            FRAMES_FILENAME: {
                "row": "frame",
                "columns": {name: str(dtype) for name, dtype in frames.dtypes.items()},
            },
            OBSERVATIONS_FILENAME: {
                "row": "sequence",
                "derived_from": FRAMES_FILENAME,
                "columns": {name: str(dtype) for name, dtype in observations.dtypes.items()},
            },
        },
    }

build

build(processed_root: Path | str, index_root: Path | str | None = None, files: FileSettings | None = None) -> dict[str, Path]

Write both index files and the schema manifest, returning what was written.

index_root defaults to the processed tree's own root, which keeps the index beside what it describes. It is regenerable, so nothing is lost by putting it somewhere else, or by deleting it.

Source code in src/panoptes/pipeline/index.py
def build(
    processed_root: Path | str,
    index_root: Path | str | None = None,
    files: FileSettings | None = None,
) -> dict[str, Path]:
    """Write both index files and the schema manifest, returning what was written.

    `index_root` defaults to the processed tree's own root, which keeps the
    index beside what it describes. It is regenerable, so nothing is lost by
    putting it somewhere else, or by deleting it.
    """
    index_root = Path(index_root if index_root is not None else processed_root)
    index_root.mkdir(parents=True, exist_ok=True)

    frames = build_frames(processed_root, files)
    observations = build_observations(frames)

    written = {
        "frames": index_root / FRAMES_FILENAME,
        "observations": index_root / OBSERVATIONS_FILENAME,
        "schema": index_root / SCHEMA_FILENAME,
    }
    frames.to_parquet(written["frames"], index=False)
    observations.to_parquet(written["observations"], index=False)
    written["schema"].write_text(json.dumps(schema(frames, observations), indent=2, sort_keys=True))

    logger.info(f"Indexed {len(frames)} frame(s) in {len(observations)} sequence(s) into {index_root}")
    return written

read

read(index_root: Path | str) -> tuple[pandas.DataFrame, pandas.DataFrame]

Read both index files back, frames first.

Source code in src/panoptes/pipeline/index.py
def read(index_root: Path | str) -> tuple[pandas.DataFrame, pandas.DataFrame]:
    """Read both index files back, frames first."""
    index_root = Path(index_root)
    return (
        pandas.read_parquet(index_root / FRAMES_FILENAME),
        pandas.read_parquet(index_root / OBSERVATIONS_FILENAME),
    )