Source code for phenotypic.util._measurement_outputs

"""Utilities for documenting and splitting measurement output tables."""

from __future__ import annotations

import inspect
from dataclasses import dataclass
from functools import lru_cache
from typing import Iterable, TypeAlias, TypeGuard

import pandas as pd
import polars as pl

from phenotypic.schema import MeasurementInfo


MeasurementFrame: TypeAlias = pd.DataFrame | pl.DataFrame


@dataclass(frozen=True)
class _MeasurementProducer:
    """A public producer class and the ``MeasurementInfo`` enums it owns."""

    output_key: str
    primary_infos: tuple[type[MeasurementInfo], ...]
    shared_infos: tuple[type[MeasurementInfo], ...] = ()


[docs] def split_measurements(df: MeasurementFrame) -> dict[str, MeasurementFrame]: """Split a measurements table into producer-specific data frames. Columns backed by public ``MeasureFeatures`` and ``SetAnalyzer`` ``MeasurementInfo`` enums define each split. Columns that do not belong to any producer-owned enum are preserved in every split as context columns. Args: df: A pandas or polars measurements DataFrame. Returns: Mapping of producer class name to a same-type DataFrame containing all context columns plus that producer's recognized measurement columns. Returns an empty mapping when no producer-owned measurement columns are present. Raises: TypeError: If *df* is not a pandas or polars DataFrame. """ columns = _columns(df) groups = _producer_column_groups(columns) if not groups: return {} producer_columns = { column for group_columns in groups.values() for column in group_columns } context_columns = [column for column in columns if column not in producer_columns] return { output_key: _select_columns(df, context_columns + group_columns) for output_key, group_columns in groups.items() }
[docs] def generate_output_key(df: MeasurementFrame) -> pd.DataFrame: """Generate a column-description key for recognized output columns. Args: df: A pandas or polars measurements DataFrame. Returns: A pandas DataFrame with ``column_header`` and ``description`` columns, preserving the input column order and omitting columns that are not backed by a public ``MeasurementInfo`` member. Raises: TypeError: If *df* is not a pandas or polars DataFrame. """ descriptions = _measurement_descriptions() records = [ {"column_header": column, "description": descriptions[column]} for column in _columns(df) if column in descriptions ] return pd.DataFrame(records, columns=["column_header", "description"])
def _columns(df: MeasurementFrame) -> list[str]: """Return DataFrame columns as strings after validating the frame type.""" if isinstance(df, pd.DataFrame): return [str(column) for column in df.columns] if isinstance(df, pl.DataFrame): return [str(column) for column in df.columns] raise TypeError( "split_measurements() and generate_output_key() require a pandas " f"or polars DataFrame, got {type(df).__name__}." ) def _select_columns(df: MeasurementFrame, columns: list[str]) -> MeasurementFrame: """Select *columns* while preserving the input DataFrame implementation.""" if isinstance(df, pd.DataFrame): return df.loc[:, columns].copy() if isinstance(df, pl.DataFrame): return df.select(columns) raise TypeError( "split_measurements() requires a pandas or polars DataFrame, " f"got {type(df).__name__}." ) def _producer_column_groups(columns: Iterable[str]) -> dict[str, list[str]]: """Map producer class names to their present measurement columns.""" ordered_columns = list(columns) groups: dict[str, list[str]] = {} for producer in _discover_measurement_producers(): primary_headers = _headers_for_infos(producer.primary_infos) shared_headers = _headers_for_infos(producer.shared_infos) present_primary = [ column for column in ordered_columns if column in primary_headers ] if not present_primary: continue producer_headers = set(present_primary) producer_headers.update( column for column in ordered_columns if column in shared_headers ) groups[producer.output_key] = [ column for column in ordered_columns if column in producer_headers ] return groups def _headers_for_infos(infos: Iterable[type[MeasurementInfo]]) -> set[str]: """Return every header declared by *infos*.""" return {member.value for info in infos for member in info} @lru_cache(maxsize=1) def _discover_measurement_producers() -> tuple[_MeasurementProducer, ...]: """Discover public measurement producers from public modules.""" import phenotypic.analysis as analysis_module import phenotypic.measure as measure_module from phenotypic.abc_ import MeasureFeatures from phenotypic.analysis.abc_ import ModelFitter, QualityCheck, SetAnalyzer from phenotypic.schema import MODEL_METRICS producers: list[_MeasurementProducer] = [] for name, cls in inspect.getmembers(measure_module, inspect.isclass): if name.startswith("_"): continue if not issubclass(cls, MeasureFeatures) or cls is MeasureFeatures: continue infos = _declared_info_classes(cls) if infos: producers.append(_MeasurementProducer(name, infos)) for name, cls in inspect.getmembers(analysis_module, inspect.isclass): if name.startswith("_"): continue if not issubclass(cls, SetAnalyzer) or cls in ( SetAnalyzer, ModelFitter, QualityCheck, ): continue infos = _declared_info_classes(cls) if not infos: continue if issubclass(cls, ModelFitter): primary_infos = tuple(info for info in infos if info is not MODEL_METRICS) if primary_infos: producers.append( _MeasurementProducer( name, primary_infos, shared_infos=(MODEL_METRICS,), ) ) else: producers.append(_MeasurementProducer(name, infos)) return tuple(producers) def _declared_info_classes(cls: type) -> tuple[type[MeasurementInfo], ...]: """Return ``MeasurementInfo`` classes declared on a producer class.""" infos: list[type[MeasurementInfo]] = [] single = _as_info_class( cls.__dict__.get( "_measurement_infoclass", getattr(cls, "_measurement_infoclass", None), ) ) if single is not None: infos.append(single) plural = cls.__dict__.get( "_measurement_infoclasses", getattr(cls, "_measurement_infoclasses", ()), ) if isinstance(plural, (list, tuple)): for item in plural: info = _as_info_class(item) if info is not None: infos.append(info) return tuple(dict.fromkeys(infos)) def _as_info_class(value: object) -> type[MeasurementInfo] | None: """Coerce a class/private-attr value into a ``MeasurementInfo`` class.""" if _is_info_class(value): return value default = getattr(value, "default", None) if _is_info_class(default): return default return None def _is_info_class(value: object) -> TypeGuard[type[MeasurementInfo]]: """Return whether *value* is a concrete ``MeasurementInfo`` subclass.""" return ( isinstance(value, type) and issubclass(value, MeasurementInfo) and value is not MeasurementInfo ) @lru_cache(maxsize=1) def _measurement_descriptions() -> dict[str, str]: """Build ``column_header -> description`` from the public schema.""" import phenotypic.schema as schema descriptions: dict[str, str] = {} for name in getattr(schema, "__all__", ()): obj = getattr(schema, name, None) if not _is_info_class(obj): continue for member in obj: descriptions.setdefault(member.value, member.desc) return descriptions __all__ = ["generate_output_key", "split_measurements"]