panoptes.pipeline.products¶
panoptes.pipeline.products ¶
Write the per-frame metadata document and products to a local tree.
The cloud removal deleted the Firestore writer, and extract_metadata was
left with nothing calling it -- so the pipeline produces no metadata at all
while everything downstream still reads products that are no longer being
made. This module closes that loop without Google Cloud.
Nothing here is new machinery. extract_metadata already returns
dict(unit=..., sequence=..., image=...), and the deleted orchestrator read
exactly that from assets/metadata.json before writing each key to its own
Firestore document. JSON was always the wire format and Firestore was a
sink. What this module does is keep the file and drop the forward.
Layout¶
The directory tree mirrors the document path, which already mirrors the raw
bucket layout ImagePathInfo parses::
<root>/PAN007/f6eb3d/20251127T044424/20251127T044520/
metadata.json # {unit, sequence, image}
image.fits # reduced
extras.fits # named ImageHDUs
sources.parquet
So re-attaching a document store later is a walk-and-upload rather than a
migration, and rsync to the processing server needs no special handling.
The observation-level observation.json is deliberately not written here.
It is the one file two frame workers would contend on, and it belongs with the
processing entry points that know when a sequence is complete.
See data contract 3.
DocumentError ¶
frame_directory ¶
The directory holding one frame's products.
<root>/{unit_id}/{camera_id}/{sequence_time}/{image_time}/.
ImagePathInfo.as_path produces the same hierarchy but with the image time
as a filename stem rather than a directory, because it was built to name
a single blob. A frame owns four files here, so it gets a directory.
Source code in src/panoptes/pipeline/products.py
encode_value ¶
Convert one value into something json can write and a store can read.
Times become ISO 8601 strings, which is the wire format the contract
specifies and what a document store parses on ingest. Numpy scalars become
Python scalars -- they arrive from every measured quantity in the pipeline
and json cannot write them.
Source code in src/panoptes/pipeline/products.py
check_document ¶
Raise DocumentError unless document is document-store compatible.
Three rules, from data contract 3.2, and each has already been broken once:
- Field names carry no
.. - No arrays of arrays. A document store has no nested-array type, so a list of lists silently loses its shape.
- Bulk tables stay out of the document. A DataFrame or a large ndarray belongs beside it as parquet, which is what keeps the document far below the 1 MB Firestore limit.
This runs before the write rather than at upload time, because the point of the local tree is that attaching a store later is an upload and not a migration. A document that fails this check would make it a migration.
Source code in src/panoptes/pipeline/products.py
write_document ¶
Write document as JSON, atomically, after checking it.
The rename is atomic on POSIX, so a reader never sees a half-written
document. That matters more than it looks: metadata.json is the
idempotency cache key for the processing walk, and a truncated one would
be read as "this frame is done".
Source code in src/panoptes/pipeline/products.py
read_document ¶
Read a metadata document, or return None if it is missing or unusable.
A frame whose document cannot be read is treated exactly like one that has no document: it gets reprocessed. Raising here would mean a single bad file stops a walk over half a million frames, and the file is about to be rewritten anyway.
"Unusable" includes a file that parses as valid JSON but is not an object.
json.loads happily returns a list, a number or a string, and every
caller here expects a mapping -- returning one would move the failure to an
AttributeError at the call site, which is exactly the walk-stopping crash
this function exists to prevent.
Source code in src/panoptes/pipeline/products.py
record_processing ¶
record_processing(metadata: dict[str, Any], params: PipelineParams, status: ImageStatus) -> dict[str, Any]
Stamp the document with the settings and stage it was produced at.
The fingerprint is what the next work-list walk compares against, so the field names live here rather than being spelled out at each call site.
Source code in src/panoptes/pipeline/products.py
write_image ¶
write_image(path: Path, reduced: ndarray, *, background: ndarray | None = None, rms: ndarray | None = None, mask: ndarray | None = None, header: Header | None = None, force_new: bool = True) -> Path
Write one frame's pixels as a single multi-extension FITS file.
PRIMARY is the reduced science frame; BACKGROUND, RMS and
MASK follow as named extensions. One file rather than two because
reduced = data - background: any change to the background model changes
the science frame as well, so they share a fingerprint and a lifetime, and
splitting them could only let them drift apart in a way the work-list walk
cannot see -- it reads only the metadata document. fits.open is lazy, so
a reader touching only PRIMARY pays nothing for the rest.
Raw pixels are not written. The raw frame already exists, unchanged, in the raw tree; copying the largest array in the pipeline across the archive buys nothing, and the reduction stays auditable because what was subtracted is right here beside it.
background and rms are the low-resolution meshes, not their
interpolation up to the frame's shape. photutils computes a mesh and
interpolates it, so at box_size = (79, 84) a 6000x4000 frame has a
~50x71 model behind a 96 MB array: the array is four orders of magnitude
larger than the thing it was made from and reconstructs exactly from it,
given the parameters, which the document records and fingerprints. Passing
the mesh per color also keeps information the old summed array threw away.
The mask is compressed. It is derivable -- it is raw >= saturation, and
saturation sits in the document with its provenance -- but it is the
extension most often wanted and a boolean array costs almost nothing once
compressed.
Source code in src/panoptes/pipeline/products.py
write_frame ¶
write_frame(root: Path | str, path_info: ImagePathInfo, metadata: Mapping[str, Any], *, reduced: ndarray | None = None, background: ndarray | None = None, rms: ndarray | None = None, mask: ndarray | None = None, sources: DataFrame | None = None, header: Header | None = None, files: FileSettings | None = None, force_new: bool = True) -> dict[str, Path]
Write one frame's document and products, returning what was written.
Only metadata is required. The products are optional so a caller that
has not plate-solved, or is re-emitting a document after a settings
change, writes what it has instead of a file full of nulls. The returned
mapping names only the files that were actually written.
background and rms are the low-resolution meshes, not their
interpolation up to the frame's shape; see write_image for why.