"""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.
""",
)