Source code for passengersim.summaries.path_bid_price_history

"""Mergeable native path bid-price moments, independent of database recording."""

from __future__ import annotations

from typing import TYPE_CHECKING, Self

import pandas as pd

from .generic import GenericSimulationTables, MissingDataError, SimulationTableItem

if TYPE_CHECKING:
    from passengersim import Simulation


def _moments_from_rows(rows: list[tuple[int, str, int, int, float, float, float, float]]) -> pd.DataFrame:
    columns = ["path_id", "cabin_code", "days_prior", "n_observations", "mean", "M2", "augmented_mean", "augmented_M2"]
    return (
        pd.DataFrame(rows, columns=columns)
        .astype(
            {
                "path_id": "int64",
                "cabin_code": "object",
                "days_prior": "int64",
                "n_observations": "int64",
                "mean": "float64",
                "M2": "float64",
                "augmented_mean": "float64",
                "augmented_M2": "float64",
            }
        )
        .set_index(["path_id", "cabin_code", "days_prior"])
        .sort_index()
    )


def _merge_moments(left: pd.DataFrame | None, right: pd.DataFrame | None) -> pd.DataFrame | None:
    """Merge paired moments using the same observation weights for both prices."""
    from .leg_bid_price_history import _merge_moments as merge

    if left is None:
        return None if right is None else right.copy()
    if right is None:
        return left.copy()
    result = merge(left, right)
    rename = {"augmented_mean": "mean", "augmented_M2": "M2"}
    augmented = merge(
        left[["n_observations", *rename]].rename(columns=rename),
        right[["n_observations", *rename]].rename(columns=rename),
    )
    result["augmented_mean"] = augmented["mean"]
    result["augmented_M2"] = augmented["M2"]
    return result


def _extract_moments(sim: Simulation) -> pd.DataFrame | None:
    moments = sim._path_bid_price_moments
    return None if moments is None else moments.copy()


def _aggregate_moments(summaries: list[GenericSimulationTables]) -> pd.DataFrame | None:
    result = None
    for summary in summaries:
        try:
            # Use the descriptor so lazy PXSIM results are loaded before merging,
            # including custom summary classes that only carry the stored payload.
            frame = SimTabPathBidPriceHistory.path_bid_price_history._get_raw(summary)
        except MissingDataError:
            continue
        result = _merge_moments(result, frame)
    return result


class _PathBidPriceHistoryItem(SimulationTableItem):
    """Present derived statistics while retaining mergeable moments in storage."""

    @property
    def __doc__(self) -> str | None:
        # A subclass docstring otherwise shadows the base descriptor's API docs.
        return self._doc

    def __get__(
        self, instance: GenericSimulationTables | None, owner: type[GenericSimulationTables] | None = None
    ) -> pd.DataFrame | None | Self:
        if instance is None:
            return self
        moments = super().__get__(instance, owner)
        if moments is None:
            return None
        n = moments.n_observations
        # Build a separate public view: callers cannot accidentally corrupt the
        # raw moments required by worker aggregation or a later archive merge.
        return pd.DataFrame(
            {
                "bid_price_mean": moments["mean"].where(n > 0),
                "bid_price_std_dev": (moments.M2 / (n - 1).where(n > 1)).pow(0.5),
                "augmented_bid_price_mean": moments.augmented_mean.where(n > 0),
                "augmented_bid_price_std_dev": (moments.augmented_M2 / (n - 1).where(n > 1)).pow(0.5),
                "n_observations": n,
            },
            index=moments.index,
        )

    def _get_raw(self, instance: GenericSimulationTables) -> pd.DataFrame | None:
        # The base descriptor handles lazy PXSIM loading as well as live results.
        return super().__get__(instance, type(instance))

    def _get_export(self, instance: GenericSimulationTables) -> pd.DataFrame | None:
        return self.__get__(instance, type(instance))


[docs] class SimTabPathBidPriceHistory(GenericSimulationTables): """Daily bid-price summaries pooled across all retained samples and trials.""" path_bid_price_history: pd.DataFrame | None = _PathBidPriceHistoryItem( aggregation_func=_aggregate_moments, extraction_func=_extract_moments, doc="""Daily path/cabin bid-price moments, or None when not collected. Indexed by ``(path_id, cabin_code, days_prior)``. Includes ``bid_price_mean``, ``bid_price_std_dev``, ``augmented_bid_price_mean``, ``augmented_bid_price_std_dev``, and one shared ``n_observations`` count. Standard deviations use the sample convention and are undefined for n < 2. Enable with ``simulation_controls.collect_path_bid_prices=True``. Each retained sample contributes one paired observation per day. Totals sum one-passenger reporting prices, retaining finite sold-out prices. Augmentation adds ``fcst_revenue - adjusted_fare_price`` for the lowest-fare class (by ``fcst_revenue``) with a finite adjusted fare at the active timeframe: positive for ``bp``; ``bp_loose`` also allows zero only when the total bid price is zero within 1e-9, so the zero-clipped class can currently open. Positive adjusted fares remain eligible even when currently closed. Booking code breaks equal-price ties. It includes MR or KI adjustment, hybrid weighting, clipping, and class exemptions as used by offer checks. If no class qualifies as a reference, the adjustment is zero and no reference offer exists. The reference may change across time/samples; this is its threshold, not a universal threshold for every class. Offer checks treat differences smaller than 1e-9 as equality; ``bp`` closes at equality and ``bp_loose`` opens. Only bid-price-controlled carriers are collected. No adjustment gives identical prices. Sold-out totals retain the finite reporting convention. Raw moments survive trial/worker pooling and saved-result round trips. """, )