The one way data enters the package, and what the validator refuses
Every method in the package reads its data from the input panel: one long frame that the caller builds and the validator checks. One row is one number, said by one source, about one subject, knowable from one moment. Long rather than wide, so three forecasters are the same code path as one.
The columns
The column contract defines the required and optional fields. The example below builds those fields and exercises validation.
Two source names carry meaning, consensus and actual. Every other name is a forecaster, opaque to the library and named by the caller.
The validator returns the panel in canonical order — sorted by subject, source and known_at, with a fresh index — so downstream code never depends on the order a caller happened to build it in.
Every timestamp is UTC-aware
known_at and period_end must carry UTC. Naive timestamps and other zones are refused, and never coerced: a panel is joined across sources and venues, and silently converting a naive timestamp would be guessing which zone the caller meant.
try: pf.validate_estimates( estimates.assign(known_at=estimates.known_at.dt.tz_localize(None)) )except pf.PanelError as refusal:print(refusal)
input panel is not valid: known_at must be tz-aware UTC, not naive or another zone. A panel is joined across sources and venues, and an unlabelled timestamp leaves the anchoring order dependent on whoever produced the row.
The contract is a schema, not a pile of checks
The format is declared as a Pandera schema and validated lazily, so one call reports every way a panel is malformed rather than stopping at the first. A caller fixing a panel gets the whole list.
input panel is not valid: known_at must be a datetime column. A string column silently compares lexically, which is not the ordering anchoring needs.; value must be numeric.
Two failures, one message. The failures come back as a PanelError — the domain error type for this boundary — so a caller never learns which validation library is underneath.
What else it refuses, and why
cases = {"a subject that ends on two different dates": estimates.assign( period_end=pd.to_datetime(["2026-01-31", "2026-04-30", "2026-01-31"], utc=True) ),"one source revising twice at the same instant": pd.concat( [estimates, estimates.iloc[[1]]], ignore_index=True ),"a row that cannot be placed in time": estimates.assign( known_at=pd.to_datetime([None, "2026-01-15", "2026-01-12"], utc=True) ),}for description, frame in cases.items():try: pf.validate_estimates(frame)except pf.PanelError as refusal:print(f"{description}:\n{refusal}\n")
a subject that ends on two different dates:
input panel is not valid: one subject carries more than one period_end. A subject ends once, and the age rules read that date.
one source revising twice at the same instant:
input panel is not valid: a source's estimate for one subject repeats at one instant. Two values knowable at the same moment leave the anchoring order undefined.
a row that cannot be placed in time:
input panel is not valid: a column that must be complete has a missing value in it.; a row is missing one of entity, period, target, source, known_at. A row that cannot be placed in time or attributed to a source cannot be anchored.
Each refusal is there because the alternative is worse:
Two period ends for one subject would make the age rules read a different date depending on which row was consulted.
Two values from one source at one instant leave the anchoring order undefined. There is no correct answer to “which was later”.
A missing key or timestamp means a row that cannot be placed in time or attributed to a source, and therefore cannot be anchored at all.
One row per subject, which is what a method reads
Methods read a subject at a time: the forecast, the consensus and, when it exists, the actual, side by side.
A value column per source, and a known_at column per source. period_end travels with the subject, so a method can reason about the period without going back to the panel.