Dynamic Peak Pricing Threshold Adjustment Scripts

The task on this page is narrow and operational: recalibrate the peak-window boundary for a single fare zone from live tap telemetry, safely enough that a revenue analyst can sign the settlement it produces. Static peak-hour multipliers fail under modern demand volatility — a fixed 07:00–09:00 window either suppresses ridership during an artificially inflated shoulder or absorbs unpriced crowding when a surge lands at 09:15. Both outcomes surface later as revenue leakage and reconciliation mismatches. A dynamic peak pricing threshold adjustment script resolves this by shifting the window boundary against a smoothed demand signal, while emitting an audit record for every change. It is a deterministic control step inside the Fare Rule Validation & Calculation Engines stack, and specifically a tuning job under the Threshold Tuning Frameworks that own shift-magnitude limits, cooldowns, and per-zone overrides.

This page targets transit operations teams, revenue analysts, and Python automation builders who own the pricing scheduler. It assumes tap events have already been decoded and normalized upstream during smart card schema mapping, so the script here consumes a clean, timezone-aware demand series and concerns itself only with when the peak window opens and closes — not with card media or base-fare computation. The adjustment loop ingests normalized streams from automated passenger counters (APC), vehicle location (AVL), and validator tap velocity — often arriving over the same channel as GTFS-RT Realtime Sync — and treats each evaluation as an idempotent, write-ahead-logged transaction so that a message-queue retry can never double-shift a live boundary.

Smoothing, Trigger Ratio & the Confidence Gate

Before any boundary moves, three quantities are computed and gated. First, raw tap velocity is filtered with an exponentially weighted moving average so a single APC dropout spike cannot drive a shift. For smoothing factor α\alpha and raw sample xtx_t:

st=αxt+(1α)st1s_t = \alpha\,x_t + (1-\alpha)\,s_{t-1}

Second, the current smoothed demand is compared not to a mean but to the median of the trailing window, which resists the false surges that optical counters produce during heavy boarding. With s~\tilde{s} the median of the last nn smoothed samples, the trigger ratio is:

rt=sts~r_t = \frac{s_t}{\tilde{s}}

A boundary shift is proposed only when rtrtriggerr_t \ge r_{\text{trigger}}. The shift magnitude is then derived from how far the ratio exceeds parity and clamped to a per-zone ceiling, so no single evaluation can slew the window by more than a few minutes:

Δ=clamp ⁣((rt1)k,  Δmax,  +Δmax)\Delta = \operatorname{clamp}\!\big(\lfloor (r_t - 1)\,k \rfloor,\; -\Delta_{\max},\; +\Delta_{\max}\big)

Third — and this is what prevents oscillatory pricing — the shift is committed only if the trailing-window sample standard deviation clears a floor, σσmin\sigma \ge \sigma_{\min}. A high ratio built on a flat, low-variance signal is treated as sensor noise, not demand, and rejected. Together the ratio trigger, the clamp, and the confidence floor keep the boundary from flip-flopping between adjacent windows and confusing rider-facing fare displays.

Trigger crossing that slides the peak-window opening boundary earlier A morning demand timeline. Raw tap velocity is a jagged line; the EWMA-smoothed demand is a bold line. A dashed trailing-window median sits low, and a dashed trigger band sits above it at the median times the trigger ratio. Around 06:45 the smoothed line rises through the trigger band; that crossing is marked. Because demand was already elevated before the static 07:00 peak start, the script back-dates the opening boundary from the solid 07:00 line to a dashed 06:45 line, a shift of Δ = 15 minutes earlier. trigger = median × r_trigger trailing-window median Δ = 15 min r_t crosses trigger → opening boundary slides −Δ 06:30 07:00 07:30 08:00 08:30 09:00 morning demand window (local time) smoothed tap velocity EWMA smoothed s_t raw tap velocity x_t median / trigger band static peak start 07:00 proposed start 06:45 (−Δ)

Adjustment Decision Flow

The cycle below shows the guard rails each evaluation passes before a peak-window shift is written to the ledger, looping back after the cooldown elapses:

Guard rails an evaluation passes before a peak-window shift is committed A control loop over the smoothed demand window. First it checks whether the cooldown has elapsed; if not, it returns to the top. If yes, it checks whether the demand ratio meets the trigger; if not, it returns to the top. If yes, it computes the clamped shift magnitude, then checks whether the confidence sigma clears the minimum threshold; if not, it returns to the top. If yes, it writes an APPROVED record to the write-ahead log and commits to the fare ledger, after which the loop re-arms for the next cycle. Every rejection routes back to the top so no boundary moves until all three gates pass. yes yes yes no no no return to top re-arm next cycle Smoothed demand window Cooldown elapsed? Demand ratio ≥ trigger? Compute clamped shift magnitude Confidence σ ≥ min threshold? Write APPROVED record to WAL Commit to fare ledger

Step-by-Step Implementation

The module below is a complete, type-hinted adjustment engine with explicit error boundaries, a cryptographic audit trail, and idempotent write-ahead-logged state. pandas handles time-series resampling and smoothing; the peak-window boundary is shifted in whole minutes, while the fare multiplier applied inside the window is carried as Decimal — never float — so the value that ultimately touches money stays reproducible across a reprocess. Script execution must be idempotent: duplicate threshold pushes during a network partition or queue retry corrupt the fare state machine and invalidate reconciliation reports, so every approved change is keyed by an audit hash in the WAL before it is broadcast downstream.

import logging
import json
import hashlib
from dataclasses import dataclass
from decimal import Decimal
from datetime import datetime, timedelta
from typing import Dict, Optional, Any
import pandas as pd
import numpy as np

# Configure structured audit logging (see https://docs.python.org/3/library/logging.html)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    handlers=[logging.StreamHandler()]
)
logger = logging.getLogger("peak_threshold_adjuster")

class RevenueReconciliationError(Exception):
    """Raised when threshold adjustment violates reconciliation or state constraints."""
    pass

@dataclass
class ThresholdConfig:
    base_peak_start: str = "07:00"
    base_peak_end: str = "09:00"
    demand_trigger_ratio: float = 1.25
    cooldown_minutes: int = 45
    max_shift_minutes: int = 30
    min_confidence_std: float = 0.15
    smoothing_alpha: float = 0.3
    # Fare multiplier applied inside the peak window — money-safe Decimal, never float.
    peak_multiplier: Decimal = Decimal("1.50")

@dataclass
class AdjustmentRecord:
    timestamp: datetime
    zone_id: str
    legacy_start: datetime
    legacy_end: datetime
    proposed_start: datetime
    proposed_end: datetime
    demand_ratio: float
    confidence_interval: float
    status: str = "PENDING"
    audit_hash: str = ""

    def __post_init__(self) -> None:
        if not self.audit_hash:
            payload = f"{self.timestamp.isoformat()}|{self.zone_id}|{self.proposed_start.isoformat()}|{self.proposed_end.isoformat()}"
            self.audit_hash = hashlib.sha256(payload.encode()).hexdigest()[:12]

class DynamicPeakAdjuster:
    def __init__(self, config: ThresholdConfig) -> None:
        self.config = config
        self._last_adjustment: Optional[datetime] = None
        self._wal: Dict[str, AdjustmentRecord] = {}

    def _validate_input(self, tap_series: pd.Series) -> pd.Series:
        if tap_series.empty:
            raise ValueError("Tap velocity series cannot be empty")
        if not isinstance(tap_series.index, pd.DatetimeIndex):
            raise TypeError("Series index must be a DatetimeIndex with timezone awareness")
        return tap_series.sort_index()

    def _compute_smoothed_demand(self, series: pd.Series) -> pd.Series:
        # Exponential weighted moving average to filter sensor noise.
        return series.ewm(alpha=self.config.smoothing_alpha, adjust=False).mean()

    def _check_cooldown(self, current_time: datetime) -> bool:
        if self._last_adjustment is None:
            return True
        return (current_time - self._last_adjustment) >= timedelta(minutes=self.config.cooldown_minutes)

    def evaluate_and_adjust(
        self,
        zone_id: str,
        tap_series: pd.Series,
        current_thresholds: Dict[str, datetime],
        current_time: Optional[datetime] = None
    ) -> Optional[AdjustmentRecord]:
        try:
            clean_taps = self._validate_input(tap_series)
            current_time = current_time or pd.Timestamp.now(tz=clean_taps.index.tz)

            if not self._check_cooldown(current_time):
                logger.info("Cooldown active for zone %s. Skipping adjustment.", zone_id)
                return None

            smoothed = self._compute_smoothed_demand(clean_taps)

            # Robust baseline: median of the trailing window resists APC dropout
            # spikes far better than a mean, which a single false surge can skew.
            recent = smoothed.to_numpy()[-7:]
            baseline = float(np.percentile(recent, 50)) if recent.size else 0.0
            current_demand = float(smoothed.iloc[-1])
            ratio = current_demand / baseline if baseline > 0 else 1.0

            if ratio < self.config.demand_trigger_ratio:
                logger.debug("Zone %s demand ratio %.2f below trigger. No shift.", zone_id, ratio)
                return None

            # Calculate shift magnitude (constrained optimization).
            shift_minutes = int((ratio - 1.0) * 15)
            shift_minutes = max(-self.config.max_shift_minutes, min(self.config.max_shift_minutes, shift_minutes))

            legacy_start = current_thresholds["peak_start"]
            legacy_end = current_thresholds["peak_end"]

            proposed_start = legacy_start - timedelta(minutes=shift_minutes)
            proposed_end = legacy_end + timedelta(minutes=shift_minutes)

            # Confidence gate: require a statistically meaningful spread before
            # committing a shift (sample std of the trailing window).
            std_dev = float(np.std(recent, ddof=1)) if recent.size >= 2 else 0.0
            if std_dev < self.config.min_confidence_std:
                logger.warning("Zone %s confidence too low (sigma=%.3f). Reverting to baseline.", zone_id, std_dev)
                return None

            record = AdjustmentRecord(
                timestamp=current_time,
                zone_id=zone_id,
                legacy_start=legacy_start,
                legacy_end=legacy_end,
                proposed_start=proposed_start,
                proposed_end=proposed_end,
                demand_ratio=ratio,
                confidence_interval=std_dev,
                status="APPROVED"
            )

            # Idempotent WAL write, keyed by audit hash.
            self._wal[record.audit_hash] = record
            self._last_adjustment = current_time
            logger.info("Threshold adjustment approved for %s. Audit ID: %s", zone_id, record.audit_hash)
            return record

        except (ValueError, TypeError, KeyError) as e:
            logger.error("Adjustment pipeline failed for zone %s: %s", zone_id, e)
            raise RevenueReconciliationError(f"Pipeline failure: {e}") from e

    def commit_to_ledger(self, record: AdjustmentRecord) -> Dict[str, Any]:
        if record.status != "APPROVED":
            raise RevenueReconciliationError("Cannot commit non-approved record")
        if record.audit_hash not in self._wal:
            raise RevenueReconciliationError("Record missing from write-ahead log. Idempotency check failed.")

        ledger_entry = {
            "audit_id": record.audit_hash,
            "zone": record.zone_id,
            "effective_start": record.proposed_start.isoformat(),
            "effective_end": record.proposed_end.isoformat(),
            "peak_multiplier": str(self.config.peak_multiplier),  # Decimal serialized as string
            "reconciliation_flag": True
        }
        logger.info("Committed to fare ledger: %s", json.dumps(ledger_entry))
        return ledger_entry

Two decisions in evaluate_and_adjust are load-bearing. First, only ValueError, TypeError, and KeyError are converted into a RevenueReconciliationError — a KeyboardInterrupt or a genuine bug still propagates rather than being silently swallowed as a reconciliation failure. Second, the WAL write and the _last_adjustment stamp happen together, so the cooldown clock and the audit trail can never disagree about whether a shift actually occurred.

Validation & Test Cases

Threshold logic is only safe if it behaves identically whether it runs at 07:00 or during a queue-replay at 07:00:03. Exercise the engine directly with small in-memory fixtures covering a genuine surge, a quiet window, and a cooldown replay.

import pandas as pd

def _thresholds(day: str = "2024-10-01"):
    return {
        "peak_start": pd.Timestamp(f"{day} 07:00", tz="UTC"),
        "peak_end": pd.Timestamp(f"{day} 09:00", tz="UTC"),
    }

now = pd.Timestamp("2024-10-01 08:00", tz="UTC")

# --- Case 1: genuine surge — ratio clears the trigger, spread clears sigma ---
cfg = ThresholdConfig(demand_trigger_ratio=1.20, cooldown_minutes=15, max_shift_minutes=15)
engine = DynamicPeakAdjuster(cfg)
idx = pd.date_range("2024-10-01 07:00", periods=12, freq="15min", tz="UTC")
surge = pd.Series([100, 105, 112, 125, 150, 185, 225, 270, 315, 360, 405, 450], index=idx)
r1 = engine.evaluate_and_adjust("ZONE_A", surge, _thresholds(), current_time=now)
print(r1.status, r1.zone_id, r1.proposed_start < r1.legacy_start)
# -> APPROVED ZONE_A True   (peak window opens earlier)

# --- Case 2: quiet window — flat demand never reaches the trigger ratio -----
flat = pd.Series([200] * 12, index=idx)
r2 = engine.evaluate_and_adjust("ZONE_B", flat, _thresholds(), current_time=now)
print(r2)
# -> None   (ratio ~1.0, below the 1.20 trigger — no shift proposed)

# --- Case 3: cooldown replay — same engine, an immediate second surge -------
r3 = engine.evaluate_and_adjust("ZONE_A", surge, _thresholds(), current_time=now)
print(r3)
# -> None   (cooldown has not elapsed since Case 1 committed at 08:00)

Case 1 confirms a real accelerating surge produces an APPROVED record whose proposed window opens earlier than the legacy boundary. Case 2 confirms a flat signal is left untouched — the ratio never crosses the trigger, so no boundary moves and no ledger noise is generated. Case 3 is the one that protects settlement: replaying the same surge through the same engine at the same instant returns None because the cooldown has not elapsed, which is exactly the idempotency guarantee a retrying message queue needs. Wire all three into your suite and they double as a regression guard when you later retune demand_trigger_ratio or min_confidence_std — the returned statuses must not move.

Transit-Specific Debugging & Reconciliation

When deploying dynamic threshold scripts in production, revenue analysts and mobility engineers should follow these diagnostics to isolate drift and maintain settlement integrity:

Symptom Root cause Resolution
Inflated trigger ratio Tap streams arrive with mixed UTC offsets from offline validators Normalize every index to UTC before ingestion and confirm resampling aligns to the agency’s standard interval; misaligned windows artificially raise demand_trigger_ratio.
Persistent low confidence Optical/IR APCs drop counts during heavy boarding or door-cycle faults Cross-reference tap velocity with AVL dwell times; if confidence_interval stays below min_confidence_std, flag the zone for sensor calibration rather than suppressing shifts.
Reconciliation mismatch Network partition during broadcast leaves a ledger row without its WAL entry Join fare transactions to adjustment logs on audit_hash; a missing hash means re-run commit_to_ledger with the cached AdjustmentRecord to restore state without duplicating rules.
Oscillatory pricing Thresholds flip-flop between adjacent evaluation windows Raise cooldown_minutes or min_confidence_std and apply hysteresis bands; smoothing dampens noise but cannot stabilize a fundamentally unstable signal.

For audit extraction, pipe structured logs to a centralized store and pull approved shifts by their hash, then reconcile each against a yield-variance report:

grep "Threshold adjustment approved" /var/log/transit_fare_engine.log | jq -r '.audit_id, .zone'

Integration Note

This script is a leaf task inside the Threshold Tuning Frameworks that define the elasticity curves, shift ceilings, and per-zone overrides it obeys — those overrides are themselves keyed to the boundaries established in Fare Zone Taxonomy Design, so a zone that never earns a peak window simply supplies a max_shift_minutes of zero. Downstream, an approved boundary change alters which taps fall inside a priced window, which is why the committed record must reach the same reconciliation ledger that Building Graceful Degradation for Offline Fare Readers flushes to after a network partition: both write against the identical audit_hash contract so an offline-cached tap and a shifted boundary reconcile against one another rather than double-counting.

Frequently Asked Questions

Why shift the peak-window boundary instead of scaling the fare multiplier directly?

Because riders and reconciliation both reason in windows, not curves. Moving the boundary keeps a single, auditable “peak is in effect from X to Y” statement that a fare display can show and a settlement report can join against. The multiplier applied inside that window stays a fixed Decimal set by policy; the script only decides when it applies. Continuously scaling the multiplier per evaluation would make every tap’s price a function of a noisy signal, which is far harder to audit and explain to a rider.

My ratio clears the trigger but no shift is written — what happened?

The confidence gate rejected it. A high demand_ratio built on a flat, low-variance trailing window fails the min_confidence_std floor and is treated as sensor noise, not demand. This is intentional: it is the mechanism that stops oscillatory pricing. Check the WARNING log line reporting sigma; if genuine surges are being blocked, the APC feed is likely under-reporting variance and the zone needs calibration, not a lower floor.

Is the script safe to run twice on the same evaluation window?

Yes — that is the whole point of the WAL and cooldown. A second call inside cooldown_minutes returns None, and even a forced re-commit is keyed by audit_hash, so an identical proposed boundary maps to the same ledger row rather than a duplicate. This is what lets a retrying queue or a partial partition replay events without corrupting fare state.

Should demand ratios and sigma use Decimal like the fare multiplier?

No. Decimal is reserved for values that touch money — here, the peak_multiplier. Demand ratios, minute offsets, and standard deviations are statistical quantities where binary float is correct and faster; forcing Decimal on them buys nothing and slows the smoothing loop. The rule is money-in-Decimal, statistics-in-float.

↑ Part of Threshold Tuning Frameworks