"""Single source of truth for the metadata column namespace.
Replaces every hardcoded ``"Metadata_"`` prefix literal across the codebase.
Prefixes and labels are derived from the ``phenotypic.schema`` metadata enums,
so they track the per-enum ``category()`` strings automatically: while every
metadata enum still returns ``"Metadata"`` the namespace collapses to a single
``"Metadata_"`` prefix, and once the categories flip to per-topic
``Metadata<Topic>`` strings these helpers pick up the new prefixes with no
caller changes.
"""
from __future__ import annotations
from collections.abc import Sequence
from functools import lru_cache
import phenotypic.schema as _schema
from phenotypic.schema import MeasurementInfo
@lru_cache(maxsize=1)
def _metadata_enums() -> tuple[type[MeasurementInfo], ...]:
"""Every exported ``MeasurementInfo`` enum in the metadata namespace.
A metadata-namespace enum is one whose ``category()`` starts with
``"Metadata"`` (the framework ``METADATA`` enum plus the experimental-tag
vocabulary). Cached: the schema export surface is fixed at import time.
"""
out = []
for name in _schema.__all__:
obj = getattr(_schema, name)
if (
isinstance(obj, type)
and issubclass(obj, MeasurementInfo)
and obj is not MeasurementInfo
and list(obj)
and obj.category().startswith("Metadata")
):
out.append(obj)
return tuple(out)
#: Bio-semantic cluster order for the metadata front-block (category granularity).
#: The human-facing column axis. REMBI (header_to_module / by_module / manifest)
#: remains a SEPARATE provenance axis and is not affected by this ordering.
_METADATA_CLUSTER_ORDER: tuple[str, ...] = (
# (1) Identity — who / where is this colony
"MetadataSample",
"MetadataPlate",
# (2) Strain — genetic identity
"MetadataGenetic",
# (3) Condition — chemical then temporal/physical environment
"MetadataCondition",
"MetadataCulture",
# (4) Design & provenance
"MetadataExperiment",
"MetadataStudy",
"MetadataAcquisition",
# Framework per-image bookkeeping — last (relocated to the trailing region
# of the measurement frame by order_measurement_columns()).
"MetadataImage",
)
@lru_cache(maxsize=1)
def _cluster_ordered_enums() -> tuple[type[MeasurementInfo], ...]:
"""The metadata enums sorted by ``_METADATA_CLUSTER_ORDER`` (then stable).
An enum whose category is absent from the cluster order sorts last; the
coverage-gate test forbids that state, so it is a defensive fallback only.
"""
rank = {cat: i for i, cat in enumerate(_METADATA_CLUSTER_ORDER)}
return tuple(
sorted(_metadata_enums(), key=lambda e: rank.get(e.category(), len(rank)))
)
#: Stride between metadata categories in ``canonical_metadata_order``. Must exceed
#: the largest metadata enum's member count so per-category definition ranks never
#: bleed into the next category.
_CATEGORY_STRIDE = 1000
[docs]
def order_measurement_columns(columns: Sequence[str]) -> list[str]:
"""Canonical measurement-frame column order.
``[front metadata] -> [measurements] -> [MetadataImage_*] -> [info block]``.
Front (user/experimental) metadata is cluster/definition ordered via
:func:`canonical_metadata_order`; unknown/uncategorized ``Metadata_*`` tags fall
to the end of the front block alphabetically. The framework ``MetadataImage_*``
block is per-image provenance and trails the measurements. The per-object info
block (``Object_Label`` + ``Bbox_*`` / ``Grid_*``) is detected by name and moves
last. Measurements keep their incoming relative order.
Pure over column-name strings, so both the pandas (``df[...]``) and polars
(``df.select(...)``) paths reuse it.
"""
from phenotypic.schema import METADATA, OBJECT
rank = canonical_metadata_order()
image_prefix = f"{METADATA.category()}_"
label = str(OBJECT.LABEL)
front: list[str] = []
image_meta: list[str] = []
info: list[str] = []
meas: list[str] = []
for c in columns:
if c.startswith(image_prefix):
image_meta.append(c)
elif is_metadata_header(c):
front.append(c)
elif c == label or c.startswith("Bbox_") or c.startswith("Grid_"):
info.append(c)
else:
meas.append(c)
# Unknown/uncategorized Metadata_* tags sort AFTER every known header. Ranks
# use a 1000-stride, so len(rank) (~72) is not a valid "after everything"
# sentinel — derive it from the actual max rank.
unknown_rank = max(rank.values(), default=0) + 1
front.sort(key=lambda c: (rank.get(c, unknown_rank), str(c)))
# Object_Label leads the info block; Bbox_*/Grid_* keep their incoming order
# (stable sort) so the trailing block matches #180's info-frame geometry order.
info.sort(key=lambda c: 0 if c == label else 1)
return front + meas + image_meta + info
#: Fallback prefix for metadata columns whose bare label is not in the
#: recommended vocabulary (routed to REMBI ``Uncategorized``). Every metadata
#: column — per-topic or generic — belongs to the ``Metadata`` family.
_GENERIC_PREFIX = "Metadata_"
@lru_cache(maxsize=1)
def _label_to_category() -> dict[str, str]:
"""Map each bare metadata label to the category of the enum that owns it.
The first enum (in schema export order) to declare a label wins, mirroring
the recommended-vocabulary contract where labels are unique across enums.
"""
out: dict[str, str] = {}
for e in _metadata_enums():
for m in e:
out.setdefault(m.label, e.category())
return out