Source code for phenotypic.post._merge_metadata
from __future__ import annotations
from typing import List
import pandas as pd
from pydantic import field_validator
from phenotypic.abc_._post_measurement import PostMeasurement
from ._utils import _ensure_prefix
[docs]
class MergeMetadata(PostMeasurement):
"""Merge multiple metadata columns into a single new metadata column.
Concatenates the string values of two or more metadata columns using
a delimiter to produce a combined identifier. Useful for creating
composite keys (e.g., combining Strain and Condition into SampleID).
Args:
columns: Names of the metadata columns to merge. ``Metadata_``
prefix is added automatically if missing. Must contain at
least 2 names.
label: Name for the new merged column. ``Metadata_`` prefix is
added automatically if missing.
delimiter: String used to join the column values. Defaults to
``"_"``.
Returns:
pd.DataFrame: The input DataFrame with the new merged column
inserted after the last source column. All source columns
are kept.
Raises:
ValueError: If columns contains fewer than 2 names.
KeyError: If any source column does not exist in the DataFrame.
Examples:
Merge strain and condition into a sample ID:
>>> import pandas as pd
>>> from phenotypic.post import MergeMetadata
>>> df = pd.DataFrame({
... "Metadata_Strain": ["WT", "mut"],
... "Metadata_Condition": ["30C", "37C"],
... "Object_Label": [1, 2],
... })
>>> merge = MergeMetadata(
... columns=["Strain", "Condition"],
... label="SampleID",
... delimiter="_",
... )
>>> result = merge.apply(df)
>>> list(result["Metadata_SampleID"])
['WT_30C', 'mut_37C']
"""
columns: List[str] = []
label: str = ""
delimiter: str = "_"
@field_validator("columns", mode="before")
@classmethod
def _prefix_columns(cls, columns: List[str] | None) -> List[str]:
"""Add the ``Metadata_`` prefix and reject a single-column merge.
Accepts ``None``/``[]`` (the "unset" state) and normalizes to an
empty list. A genuinely-invalid *single*-column list raises; the
empty default validates cleanly so ``model_validate`` / assignment
round-trips work.
"""
if columns and len(columns) < 2:
raise ValueError("columns must contain at least 2 column names to merge")
return [_ensure_prefix(c) for c in columns] if columns else []
@field_validator("label")
@classmethod
def _prefix_label(cls, label: str) -> str:
"""Prepend the ``Metadata_`` prefix to a non-empty label."""
return _ensure_prefix(label) if label else ""
def _operate(self, df: pd.DataFrame) -> pd.DataFrame:
"""Merge the specified columns into a new column.
Args:
df: Measurement DataFrame containing the source columns.
Returns:
DataFrame with the new merged column inserted after the last
source column.
"""
# Validate all source columns exist
for col in self.columns:
if col not in df.columns:
raise KeyError(
f"Column '{col}' not found in DataFrame. "
f"Available columns: {list(df.columns)}"
)
# Join column values with delimiter
merged = df[self.columns[0]].astype(str)
for col in self.columns[1:]:
merged = merged + self.delimiter + df[col].astype(str)
# Insert after the last source column
result = df.copy()
last_pos = max(result.columns.get_loc(c) for c in self.columns)
result.insert(last_pos + 1, self.label, merged)
return result