Source code for phenotypic.plotting._plot_meas_time_series

"""Multi-page replicate-preserving measurement time-series plot."""

from __future__ import annotations

import inspect
import json
import math
from datetime import date, datetime, timedelta
from typing import Any

import pandas as pd
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from phenotypic.abc_.plotting import (
    PlotMeas,
    PlotOutput,
    PlotPage,
    canonical_group_key,
)
from phenotypic.sdk_ import ColumnRef, ColumnRefList, is_metadata_header

from ._metadata import (
    normalize_measurement_metadata_columns,
    normalize_metadata_column_reference,
    normalize_metadata_column_references,
)
from phenotypic.schema import CULTURE, GENETIC


[docs] class PlotMeasTimeSeries(BaseModel, PlotMeas): """Plot replicate scatter time series across grouped environments. Each page is one genetic grouping, each row is a measurement, and each column is one environmental grouping. Replicates remain separate traces; values are never averaged or otherwise aggregated. Args: page_by: Columns defining separate figure pages. environment_by: Columns defining subplot columns within a page. replicate_by: Columns identifying biological or technical replicates. time: Numeric or ordered time column used on the x-axis. measurements: Numeric measurement columns. An empty list selects eligible columns automatically. connect: Whether to connect points within each replicate trace. """ model_config = ConfigDict(extra="forbid") page_by: ColumnRefList = Field( default_factory=lambda: [str(GENETIC.STRAIN)] ) environment_by: ColumnRefList replicate_by: ColumnRefList time: ColumnRef = str(CULTURE.TIME) measurements: ColumnRefList = Field(default_factory=list) connect: bool = True @field_validator("page_by", "environment_by", "replicate_by", "measurements", mode="before") @classmethod def _normalize_column_references(cls, value: Any) -> list[str]: """Accept current and flat metadata references in plotting fields.""" return normalize_metadata_column_references(value) @field_validator("time", mode="before") @classmethod def _normalize_time_reference(cls, value: str) -> str: """Accept current and flat metadata references for the time axis.""" if not isinstance(value, str): raise ValueError("time must be a string column reference") return normalize_metadata_column_reference(value) @model_validator(mode="after") def _validate_roles(self) -> "PlotMeasTimeSeries": roles = { "page_by" : self.page_by, "environment_by": self.environment_by, "replicate_by" : self.replicate_by, } for name, columns in roles.items(): if not columns: raise ValueError(f"{name} must contain at least one column") duplicates = sorted( {column for column in columns if columns.count(column) > 1} ) if duplicates: raise ValueError(f"{name} contains duplicate columns: {duplicates}") claimed: dict[str, str] = {self.time: "time"} for role, columns in roles.items(): for column in columns: previous = claimed.get(column) if previous is not None: raise ValueError( f"column {column!r} cannot be used for both {previous} and {role}" ) claimed[column] = role return self
[docs] def inspect( self, subject: Any = None, *, for_save: bool = False, **overrides: Any, ) -> PlotOutput: """Build one Plotly page per configured page grouping. Args: subject: Current measurement mirror as a pandas DataFrame. for_save: Accepted for the common plotting contract. Plot geometry is identical for interactive and saved output. **overrides: Optional field-value overrides for this invocation. Returns: Ordered multi-page output. Empty input returns no pages. """ del for_save configured = self.model_copy(update=overrides) if overrides else self if not isinstance(subject, pd.DataFrame): raise TypeError( "PlotMeasTimeSeries.inspect requires a pandas DataFrame subject" ) subject = normalize_measurement_metadata_columns(subject) if subject.empty: return PlotOutput(pages=()) configured._validate_input_columns(subject) measurements = configured._measurement_columns(subject) if not measurements: raise ValueError("no eligible numeric measurement columns were found") pages: list[PlotPage] = [] page_groups = list(_group_rows(subject, configured.page_by)) page_groups.sort(key=lambda item: _typed_group_key(configured.page_by, item[0])) for page_values, page_frame in page_groups: page_pairs = _group_pairs(configured.page_by, page_values) page_key = _canonical_group_key(page_pairs) pages.append( PlotPage( key=page_key, label=_display_pairs( page_pairs, values_only=len(page_pairs) == 1 ), metadata={ column: _metadata_group_value(value) for column, value in page_pairs }, figure=configured._build_page(page_frame, measurements), ) ) return PlotOutput(pages=tuple(pages))
[docs] def report(self, subject: Any = None, **overrides: Any) -> PlotOutput: """Return the complete multi-page report.""" return self.inspect(subject, **overrides)
def _validate_input_columns(self, frame: pd.DataFrame) -> None: requested = [ *self.page_by, *self.environment_by, *self.replicate_by, self.time, *self.measurements, ] missing = [column for column in requested if column not in frame.columns] if missing: raise ValueError(f"measurement table is missing columns: {missing}") for role, columns in ( ("page_by", self.page_by), ("environment_by", self.environment_by), ("replicate_by", self.replicate_by), ): for column in columns: _validate_group_values(frame[column], role=role, column=column) if self.measurements: nonnumeric = [ column for column in self.measurements if not pd.api.types.is_numeric_dtype(frame[column]) ] if nonnumeric: raise ValueError( f"explicit measurements must be numeric: {nonnumeric}" ) def _measurement_columns(self, frame: pd.DataFrame) -> list[str]: if self.measurements: return list(self.measurements) excluded = { *self.page_by, *self.environment_by, *self.replicate_by, self.time, *_nonmeasurement_schema_headers(), *_known_analysis_headers(), } return [ column for column in frame.columns if column not in excluded and pd.api.types.is_numeric_dtype(frame[column]) and not is_metadata_header(column) and not column.startswith(("Object_", "Grid_", "Quality", "QC_")) ] def _build_page( self, frame: pd.DataFrame, measurements: list[str], ) -> Any: from plotly.subplots import make_subplots import plotly.graph_objects as go environments = list(_group_rows(frame, self.environment_by)) environments.sort( key=lambda item: _typed_group_key(self.environment_by, item[0]) ) columns = len(environments) subplot_titles = [] for row_index in range(len(measurements)): for values, _ in environments: pairs = _group_pairs(self.environment_by, values) subplot_titles.append(_display_pairs(pairs) if row_index == 0 else "") figure = make_subplots( rows=len(measurements), cols=columns, subplot_titles=subplot_titles, shared_xaxes=False, vertical_spacing=min(0.12, 0.35 / max(len(measurements), 1)), ) shown_legends: set[str] = set() for column_index, (_, environment_frame) in enumerate(environments, start=1): replicates = list(_group_rows(environment_frame, self.replicate_by)) replicates.sort( key=lambda item: _typed_group_key(self.replicate_by, item[0]) ) for replicate_values, replicate_frame in replicates: pairs = _group_pairs(self.replicate_by, replicate_values) replicate_key = _canonical_group_key(pairs) replicate_label = _display_pairs(pairs) ordered = replicate_frame.sort_values( self.time, kind="mergesort" ) for row_index, measurement in enumerate(measurements, start=1): showlegend = replicate_key not in shown_legends figure.add_trace( go.Scatter( x=ordered[self.time].tolist(), y=ordered[measurement].tolist(), mode="lines+markers" if self.connect else "markers", name=replicate_label, legendgroup=replicate_key, showlegend=showlegend, ), row=row_index, col=column_index, ) if showlegend: shown_legends.add(replicate_key) for row_index, measurement in enumerate(measurements, start=1): figure.update_xaxes(title_text=self.time, row=row_index, col=column_index) if column_index == 1: figure.update_yaxes( title_text=measurement, row=row_index, col=column_index, ) figure.update_layout( height=max(360, 300 * len(measurements)), width=max(650, 450 * columns), legend_title_text="Replicate", ) return figure
def _group_rows( frame: pd.DataFrame, columns: list[str], ) -> list[tuple[Any, pd.DataFrame]]: """Group rows without pandas' null-category or mixed-type coercion. The caller's frame remains untouched and first-seen row order is retained inside each group. Callers sort the returned groups by their canonical typed key when a deterministic presentation order is required. """ grouped: dict[str, tuple[Any, list[int]]] = {} values_frame = frame.loc[:, columns] for position, row in enumerate( values_frame.itertuples(index=False, name=None) ): normalized = tuple(_normalize_group_value(value) for value in row) raw_values: Any = normalized[0] if len(normalized) == 1 else normalized key = _canonical_group_key(list(zip(columns, normalized))) if key not in grouped: grouped[key] = (raw_values, []) grouped[key][1].append(position) return [ (values, frame.iloc[positions]) for values, positions in grouped.values() ] def _group_pairs( columns: list[str], raw_values: Any, ) -> list[tuple[str, Any]]: values = raw_values if isinstance(raw_values, tuple) else (raw_values,) return [ (column, _normalize_group_value(value)) for column, value in zip(columns, values) ] def _typed_group_key(columns: list[str], values: Any) -> str: return _canonical_group_key(_group_pairs(columns, values)) def _canonical_group_key(pairs: list[tuple[str, Any]]) -> str: """Return a typed group key without losing pandas nanoseconds.""" encoded: list[list[str | None]] = [] for column, value in pairs: if isinstance(value, pd.Timedelta): encoded.append([column, "timedelta_ns", str(value.value)]) elif isinstance(value, pd.Timestamp): encoded.append([column, "datetime_ns", value.isoformat()]) else: encoded.extend(json.loads(canonical_group_key([(column, value)]))) return json.dumps(encoded, ensure_ascii=False, separators=(",", ":")) def _normalize_group_value(value: Any) -> Any: if pd.isna(value): return None if isinstance(value, (pd.Timestamp, pd.Timedelta)): return value if hasattr(value, "item"): value = value.item() if isinstance(value, (str, int, float, bool, datetime, date, timedelta)): return value raise TypeError(f"unsupported grouping value {value!r} ({type(value).__name__})") def _metadata_group_value(value: Any) -> str | int | float | bool | None: """Return a JSON-native selector value for a plot manifest.""" if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, pd.Timedelta): return f"{value.value} ns" if isinstance(value, pd.Timestamp): return value.isoformat() if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, timedelta): nanoseconds = ( (value.days * 86_400 + value.seconds) * 1_000_000_000 + value.microseconds * 1_000 ) return f"{nanoseconds} ns" raise TypeError( f"unsupported metadata grouping value {value!r} ({type(value).__name__})" ) def _display_pairs( pairs: list[tuple[str, Any]], *, values_only: bool = False, ) -> str: if values_only: value = pairs[0][1] return "<null>" if value is None else str(value) return ", ".join( f"{column}={'<null>' if value is None else value}" for column, value in pairs ) def _validate_group_values( series: pd.Series, *, role: str, column: str, ) -> None: for value in series.drop_duplicates().tolist(): try: normalized = _normalize_group_value(value) except (TypeError, ValueError) as exc: raise ValueError( f"{role} column {column!r} contains unsupported grouping value {value!r}" ) from exc if isinstance(normalized, float) and not math.isfinite(normalized): raise ValueError( f"{role} column {column!r} contains infinite grouping value {value!r}" ) def _nonmeasurement_schema_headers() -> set[str]: import phenotypic.schema as schema from phenotypic.schema import MeasurementInfo headers: set[str] = set() for _, candidate in inspect.getmembers(schema, inspect.isclass): if not issubclass(candidate, MeasurementInfo) or candidate is MeasurementInfo: continue kind = candidate.kind() if kind in {"identity", "quality"}: headers.update(candidate.get_headers()) return headers def _known_analysis_headers() -> set[str]: import phenotypic.schema as schema from phenotypic.schema import MeasurementInfo headers: set[str] = set() for name, candidate in inspect.getmembers(schema, inspect.isclass): if not issubclass(candidate, MeasurementInfo) or candidate is MeasurementInfo: continue if "MODEL" in name or name == "EDGE_CORRECTION": headers.update(candidate.get_headers()) return headers __all__ = ["PlotMeasTimeSeries"]