Read what the pipeline writes: the document tree, and the index over it.
panoptes-pipeline processes a frame and writes a JSON document beside the
products, in a tree that mirrors the raw archive layout::
<processed_root>/PAN012/358d0f/20180824T035917/
observation.json # the sequence document
20180824T040118/
metadata.json # {unit, sequence, image}
image.fits
sources.parquet
Those files are the source of truth. Walking them produces frames.parquet
(one row per frame) and observations.parquet (one row per sequence, grouped
from the frames), with schema.json recording the column contract. That is
the query surface this package reads.
Why this module exists rather than a few json.loads calls
A vocabulary shared across a repository boundary with nothing declaring it
leaves the reader guessing a field name, and the guess goes stale
(panoptes/panoptes-data#12, #13). The names here come from data contract_
section 3 and are read back from schema.json where the producer wrote them
down, rather than being inferred from what happened to be in a record.
The flattening in particular is a contract, not a convenience. A document is a
nested map -- {"image": {"camera": {"exptime": 120.0}}} -- and a DataFrame
column is a flat name, so the two are joined by a separator. That separator is
_ and never .: contract 3.2 calls a dotted name a view over a nested
map rather than storage, and a dotted camera.serial_number is exactly the
confusion between "document path" and "column" being avoided. read_schema
reads the producer's declared separator
so a change there is visible here instead of silently renaming every column.
.. _data contract:
https://github.com/panoptes/panoptes-pipeline/blob/main/plans/data-contract.md
DocumentsUnavailableError
Bases: FileNotFoundError
A document or index was asked for and the tree cannot supply it.
Source code in src/panoptes/data/documents.py
| class DocumentsUnavailableError(FileNotFoundError):
"""A document or index was asked for and the tree cannot supply it."""
|
SchemaVersionError
Bases: RuntimeError
The index declares a column contract this package does not know.
Source code in src/panoptes/data/documents.py
| class SchemaVersionError(RuntimeError):
"""The index declares a column contract this package does not know."""
|
Contract
Bases: NamedTuple
How a document's nested keys become column names.
The manifest records these precisely so a consumer in another repository
does not reverse-engineer them from a parquet footer (contract 3.2). Read
them rather than assume them, and a change on the producing side is a
change to a file here instead of a column that quietly stopped existing.
Source code in src/panoptes/data/documents.py
| class Contract(NamedTuple):
"""How a document's nested keys become column names.
The manifest records these precisely so a consumer in another repository
does not reverse-engineer them from a parquet footer (contract 3.2). Read
them rather than assume them, and a change on the producing side is a
change to a file here instead of a column that quietly stopped existing.
"""
separator: str = SEPARATOR
dropped: tuple[tuple[str, ...], ...] = DROPPED
required_frame_columns: tuple[str, ...] = REQUIRED_FRAME_COLUMNS
|
flatten
flatten(
document: Mapping[str, Any],
prefix: str = "",
separator: str = SEPARATOR,
) -> dict[str, Any]
Flatten nested maps into single-level keys joined by separator.
{"image": {"camera": {"exptime": 120.0}}} becomes
{"image_camera_exptime": 120.0}, which is the name the index carries
for the same value. Lists are left alone: nothing in a document holds one
that wants to be a column.
This mirrors panoptes.pipeline.index.flatten, deliberately rather than by
import -- the reader does not depend on the writer's package, only on the
contract both follow. Reading a tree with no index built over it still has
to produce the same names, or a sequence read from documents and the same
sequence read from the index would disagree about what its columns are
called.
Source code in src/panoptes/data/documents.py
| def flatten(
document: Mapping[str, Any], prefix: str = "", separator: str = SEPARATOR
) -> dict[str, Any]:
"""Flatten nested maps into single-level keys joined by `separator`.
``{"image": {"camera": {"exptime": 120.0}}}`` becomes
``{"image_camera_exptime": 120.0}``, which is the name the index carries
for the same value. Lists are left alone: nothing in a document holds one
that wants to be a column.
This mirrors `panoptes.pipeline.index.flatten`, deliberately rather than by
import -- the reader does not depend on the writer's package, only on the
contract both follow. Reading a tree with no index built over it still has
to produce the same names, or a sequence read from documents and the same
sequence read from the index would disagree about what its columns are
called.
"""
flat: dict[str, Any] = {}
for key, value in document.items():
name = f"{prefix}{key}"
if isinstance(value, Mapping):
flat.update(flatten(value, f"{name}{separator}", separator=separator))
else:
flat[name] = value
return flat
|
read_schema
read_schema(
index_root: Path | str,
) -> dict[str, Any] | None
The column contract the index was written with, or None if absent.
Returns the manifest the producer wrote, having first checked its version.
An index built by a pipeline newer than this package is refused rather than
read hopefully: the manifest exists so that a column contract change is
visible, and reading one anyway would make it invisible again.
A tree with no manifest is not an error. Documents can be read without an
index, and an index built before manifests existed is still parquet.
Raises:
| Type |
Description |
SchemaVersionError
|
if the manifest declares a version this package
does not know how to read.
|
Source code in src/panoptes/data/documents.py
| def read_schema(index_root: Path | str) -> dict[str, Any] | None:
"""The column contract the index was written with, or None if absent.
Returns the manifest the producer wrote, having first checked its version.
An index built by a pipeline newer than this package is refused rather than
read hopefully: the manifest exists so that a column contract change is
visible, and reading one anyway would make it invisible again.
A tree with no manifest is not an error. Documents can be read without an
index, and an index built before manifests existed is still parquet.
Raises:
SchemaVersionError: if the manifest declares a version this package
does not know how to read.
"""
path = Path(index_root) / SCHEMA_FILENAME
if not path.is_file():
return None
try:
manifest = json.loads(path.read_text())
except (OSError, UnicodeError, json.JSONDecodeError) as e:
raise SchemaVersionError(f"The index manifest at {path} could not be read: {e}") from e
if not isinstance(manifest, Mapping):
raise SchemaVersionError(f"The index manifest at {path} is not an object.")
version = manifest.get("version")
if version != SCHEMA_VERSION:
raise SchemaVersionError(
f"The index at {index_root} declares schema version {version!r}, and this "
f"version of panoptes-data reads version {SCHEMA_VERSION}. The column "
f"contract changed on the producing side; upgrade panoptes-data rather "
f"than reading the index against the wrong vocabulary."
)
return dict(manifest)
|
contract_for
contract_for(index_root: Path | str | None) -> Contract
The column contract the index at index_root declares, else the defaults.
A tree with no index still has a contract -- the constants above -- because
documents are readable without one.
Source code in src/panoptes/data/documents.py
| def contract_for(index_root: Path | str | None) -> Contract:
"""The column contract the index at `index_root` declares, else the defaults.
A tree with no index still has a contract -- the constants above -- because
documents are readable without one.
"""
manifest = read_schema(index_root) if index_root is not None else None
if manifest is None:
return Contract()
dropped = manifest.get("dropped")
required = manifest.get("required_frame_columns")
return Contract(
separator=manifest.get("separator") or SEPARATOR,
dropped=tuple(tuple(block) for block in dropped) if dropped else (),
required_frame_columns=tuple(required) if required else (),
)
|
drop_blocks
drop_blocks(
document: dict[str, Any],
dropped: tuple[tuple[str, ...], ...] = DROPPED,
) -> dict[str, Any]
Remove the declared blocks from document, in place, returning it.
Source code in src/panoptes/data/documents.py
| def drop_blocks(
document: dict[str, Any], dropped: tuple[tuple[str, ...], ...] = DROPPED
) -> dict[str, Any]:
"""Remove the declared blocks from `document`, in place, returning it."""
for block in dropped:
*parents, leaf = block
node = document
for parent in parents:
node = node.get(parent) if isinstance(node, Mapping) else None
if isinstance(node, dict):
node.pop(leaf, None)
return document
|
sequence_directory
sequence_directory(
processed_root: Path | str, sequence_id: str
) -> Path
The directory holding one sequence's documents.
<processed_root>/{unit_id}/{camera_id}/{sequence_time}, which is both
the archive's layout and the document path units/{unit_id}/observations/
{sequence_id}. That they are the same shape is why a tree of files and a
document store are the same thing here.
ImagePathInfo parses a frame path, so the sequence time stands in for
the image time to validate the three components and then the frame level is
dropped. Borrowing the validator this way keeps one definition of a
well-formed identifier rather than a second regex that can drift from it.
Raises:
| Type |
Description |
ValueError
|
if sequence_id is not a well-formed sequence identifier.
|
Source code in src/panoptes/data/documents.py
| def sequence_directory(processed_root: Path | str, sequence_id: str) -> Path:
"""The directory holding one sequence's documents.
``<processed_root>/{unit_id}/{camera_id}/{sequence_time}``, which is both
the archive's layout and the document path ``units/{unit_id}/observations/
{sequence_id}``. That they are the same shape is why a tree of files and a
document store are the same thing here.
`ImagePathInfo` parses a *frame* path, so the sequence time stands in for
the image time to validate the three components and then the frame level is
dropped. Borrowing the validator this way keeps one definition of a
well-formed identifier rather than a second regex that can drift from it.
Raises:
ValueError: if `sequence_id` is not a well-formed sequence identifier.
"""
try:
unit_id, camera_id, sequence_time = str(sequence_id).split("_")
path_info = ImagePathInfo(path=f"{unit_id}/{camera_id}/{sequence_time}/{sequence_time}")
except ValueError as e:
raise ValueError(
f"{sequence_id!r} is not a well-formed sequence id, so no directory in the "
f"processed tree corresponds to it. It should look like "
f"PAN012_358d0f_20180824T035917: {e}"
) from e
return Path(processed_root) / path_info.as_path().parent
|
require_root
require_root(root: Path | str | None, what: str) -> Path
root as an existing directory, or a DocumentsUnavailableError saying why not.
Both failures get their own message. A root that is not configured and a
root that is mistyped are different mistakes, and reporting the second as
the first sends you to edit a setting that is already correct.
Source code in src/panoptes/data/documents.py
| def require_root(root: Path | str | None, what: str) -> Path:
"""`root` as an existing directory, or a `DocumentsUnavailableError` saying why not.
Both failures get their own message. A root that is not configured and a
root that is mistyped are different mistakes, and reporting the second as
the first sends you to edit a setting that is already correct.
"""
if root is None:
raise DocumentsUnavailableError(NO_ROOT_MESSAGE)
root = Path(root)
if not root.is_dir():
raise DocumentsUnavailableError(
f"The {what} {root} is not a directory. It should point at the directory "
f"holding the unit folders, e.g. <root>/PAN012/358d0f/20180824T035917/."
)
return root
|
find_frame_documents
find_frame_documents(
processed_root: Path | str, sequence_id: str
) -> Iterator[Path]
Every metadata.json of one sequence, in a stable order.
Sorted by path, which is sorted by image time, because the directory name
is the flattened image time. Two reads of the same tree therefore produce
rows in the same order.
Source code in src/panoptes/data/documents.py
| def find_frame_documents(processed_root: Path | str, sequence_id: str) -> Iterator[Path]:
"""Every ``metadata.json`` of one sequence, in a stable order.
Sorted by path, which is sorted by image time, because the directory name
*is* the flattened image time. Two reads of the same tree therefore produce
rows in the same order.
"""
directory = sequence_directory(processed_root, sequence_id)
return iter(sorted(directory.glob(f"*/{METADATA_FILENAME}")))
|
read_document
read_document(path: Path | str) -> dict[str, Any] | None
Read one JSON document, or None if it is missing or unusable.
"Unusable" includes a file that parses as valid JSON but is not an object.
json.loads will happily return a list or a number, and every caller
here expects a mapping, so returning one would move the failure to an
AttributeError somewhere less informative.
Source code in src/panoptes/data/documents.py
| def read_document(path: Path | str) -> dict[str, Any] | None:
"""Read one JSON document, or None if it is missing or unusable.
"Unusable" includes a file that parses as valid JSON but is not an object.
``json.loads`` will happily return a list or a number, and every caller
here expects a mapping, so returning one would move the failure to an
`AttributeError` somewhere less informative.
"""
try:
document = json.loads(Path(path).read_text())
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return document if isinstance(document, dict) else None
|
read_observation
read_observation(
processed_root: Path | str,
sequence_id: str,
contract: Contract | None = None,
) -> dict[str, Any]
The sequence's observation.json, flattened.
Raises:
Source code in src/panoptes/data/documents.py
| def read_observation(
processed_root: Path | str, sequence_id: str, contract: Contract | None = None
) -> dict[str, Any]:
"""The sequence's ``observation.json``, flattened.
Raises:
DocumentsUnavailableError: if the sequence has no readable observation
document under `processed_root`.
"""
path = sequence_directory(processed_root, sequence_id) / OBSERVATION_FILENAME
document = read_document(path)
if document is None:
raise DocumentsUnavailableError(
f"No readable observation document for {sequence_id} at {path}. The "
f"sequence is not in this processed tree, or the pipeline has not "
f"aggregated it yet."
)
return flatten(document, separator=(contract or Contract()).separator)
|
read_frames
read_frames(
processed_root: Path | str,
sequence_id: str,
contract: Contract | None = None,
) -> pd.DataFrame
One row per frame of sequence_id, from its metadata.json documents.
The rows carry the same column names frames.parquet does, which takes two
steps beyond flattening and neither is cosmetic. The contract's dropped
blocks are removed, so this does not produce columns the index lacks; and
the result is reindexed to the contract's required columns, so a tree whose
documents predate a field still yields the column the index would have held
as null. Without both, the two ways into the same values disagree about
what they are called, in opposite directions.
A document that cannot be read is not skipped, and a frame the observation
document counts but whose document is absent is an error. The pipeline's own
index walk skips unreadable files, because one bad file must not cost an
index over half a million frames -- but here the unit of work is a single
observation, and a frame missing from it reads as an observation with fewer
frames. That is the same failure as a partial local archive, which
ObservationInfo.get_image_list already refuses to paper over.
Raises:
| Type |
Description |
DocumentsUnavailableError
|
if the sequence has no frame documents, if
one of them cannot be read, or if the observation document counts
more frames than the tree holds.
|
Source code in src/panoptes/data/documents.py
| def read_frames(
processed_root: Path | str, sequence_id: str, contract: Contract | None = None
) -> pd.DataFrame:
"""One row per frame of `sequence_id`, from its ``metadata.json`` documents.
The rows carry the same column names `frames.parquet` does, which takes two
steps beyond flattening and neither is cosmetic. The contract's dropped
blocks are removed, so this does not produce columns the index lacks; and
the result is reindexed to the contract's required columns, so a tree whose
documents predate a field still yields the column the index would have held
as null. Without both, the two ways into the same values disagree about
what they are called, in opposite directions.
A document that cannot be read is not skipped, and a frame the observation
document counts but whose document is absent is an error. The pipeline's own
index walk skips unreadable files, because one bad file must not cost an
index over half a million frames -- but here the unit of work is a single
observation, and a frame missing from it reads as an observation with fewer
frames. That is the same failure as a partial local archive, which
`ObservationInfo.get_image_list` already refuses to paper over.
Raises:
DocumentsUnavailableError: if the sequence has no frame documents, if
one of them cannot be read, or if the observation document counts
more frames than the tree holds.
"""
contract = contract or Contract()
paths = list(find_frame_documents(processed_root, sequence_id))
# A glob only sees the documents that exist. If the observation document
# counts more frames than that, the missing ones would simply not appear --
# a shorter table that reads as a smaller observation rather than as an
# incomplete one.
expected = (
read_document(sequence_directory(processed_root, sequence_id) / OBSERVATION_FILENAME) or {}
).get("num_frames")
if isinstance(expected, int) and len(paths) < expected:
raise DocumentsUnavailableError(
f"{sequence_id} has {len(paths)} frame document(s) under "
f"{sequence_directory(processed_root, sequence_id)}, but its observation "
f"document counts {expected} frame(s). The tree is incomplete for this "
f"sequence; reading it would report a shorter observation rather than a "
f"partial one."
)
if not paths:
raise DocumentsUnavailableError(
f"No frame documents for {sequence_id} under "
f"{sequence_directory(processed_root, sequence_id)}. The sequence is not "
f"in this processed tree, or none of its frames have been processed."
)
rows = []
for path in paths:
document = read_document(path)
if document is None:
raise DocumentsUnavailableError(
f"The frame document {path} could not be read, so the metadata for "
f"{sequence_id} would be short by one frame rather than wrong in a "
f"way you could see. Reprocess that frame."
)
rows.append(flatten(drop_blocks(document, contract.dropped), separator=contract.separator))
# `pandas` unions the keys, so a field absent from one document arrives as
# a null in that row rather than failing the read. Documents written by an
# older pipeline still have to load. `reindex` extends that to a field
# absent from *every* document, which unioning cannot see.
frames = pd.DataFrame(rows)
return frames.reindex(
columns=list(dict.fromkeys([*contract.required_frame_columns, *frames.columns]))
)
|
index_columns
index_columns(
index_root: Path | str | None, filename: str
) -> list[str]
The column names one index file carries, read from its footer.
Cheap: parquet keeps its schema in the file, so this does not read a row.
Used to ask for columns the index only may have -- the producer guarantees
a small required set and everything else depends on what the documents
happened to hold.
Raises:
Source code in src/panoptes/data/documents.py
| def index_columns(index_root: Path | str | None, filename: str) -> list[str]:
"""The column names one index file carries, read from its footer.
Cheap: parquet keeps its schema in the file, so this does not read a row.
Used to ask for columns the index only *may* have -- the producer guarantees
a small required set and everything else depends on what the documents
happened to hold.
Raises:
DocumentsUnavailableError: if the index file is not there.
SchemaVersionError: if the index declares a version this package does
not read.
"""
index_root = require_root(index_root, "index root")
read_schema(index_root)
path = index_root / filename
if not path.is_file():
raise DocumentsUnavailableError(
f"No {filename} at {index_root}. Build the index over the processed tree "
f"with panoptes-pipeline before querying it."
)
return list(parquet.read_schema(path).names)
|
read_index
read_index(
index_root: Path | str | None,
filename: str,
columns: list[str] | None = None,
) -> pd.DataFrame
Read one of the index files, with its dtypes intact.
columns is pushed down into the parquet read, so asking for four columns
of frames.parquet does not pay for the other fifty.
Reading parquet rather than CSV is what fixes panoptes/panoptes-data#13 at
the root: a serial like 032071000633 is a string in the file and comes
back a string. pd.read_csv had to infer it, inferred float64, and
returned 3.207100e+10 -- a corrupted value for the one field that links
a frame to a physical camera body.
Raises:
Source code in src/panoptes/data/documents.py
| def read_index(
index_root: Path | str | None, filename: str, columns: list[str] | None = None
) -> pd.DataFrame:
"""Read one of the index files, with its dtypes intact.
`columns` is pushed down into the parquet read, so asking for four columns
of ``frames.parquet`` does not pay for the other fifty.
Reading parquet rather than CSV is what fixes panoptes/panoptes-data#13 at
the root: a serial like ``032071000633`` is a string in the file and comes
back a string. ``pd.read_csv`` had to infer it, inferred float64, and
returned ``3.207100e+10`` -- a corrupted value for the one field that links
a frame to a physical camera body.
Raises:
DocumentsUnavailableError: if the index file or a requested column is
not there.
SchemaVersionError: if the index declares a schema version this package
does not read.
"""
index_root = require_root(index_root, "index root")
read_schema(index_root)
path = index_root / filename
if not path.is_file():
raise DocumentsUnavailableError(
f"No {filename} at {index_root}. Build the index over the processed tree "
f"with panoptes-pipeline before querying it."
)
try:
return pd.read_parquet(path, columns=columns)
except (KeyError, ValueError) as e:
raise DocumentsUnavailableError(
f"{path} does not carry the column(s) asked for: {e}"
) from e
|