diff --git a/common/components/charts/Legend.tsx b/common/components/charts/Legend.tsx index fc7b24b2..13ef0c7e 100644 --- a/common/components/charts/Legend.tsx +++ b/common/components/charts/Legend.tsx @@ -6,6 +6,7 @@ import { useDelimitatedRoute } from '../../utils/router'; interface LegendProps { showUnderRatio?: boolean; + usingTmBenchmark?: boolean; } interface LegendLongTermProps { @@ -13,7 +14,7 @@ interface LegendLongTermProps { onToggleTrendline: () => void; } -export const LegendSingleDay: React.FC = ({ showUnderRatio }) => { +export const LegendSingleDay: React.FC = ({ showUnderRatio, usingTmBenchmark }) => { const { line } = useDelimitatedRoute(); return ( @@ -31,7 +32,7 @@ export const LegendSingleDay: React.FC = ({ showUnderRatio }) => { 'grid w-full grid-cols-2 items-baseline p-1 px-4 text-left text-xs lg:flex lg:flex-row lg:gap-4' } > - +
= ({ showUnderRatio }) => { ); }; -const LegendSingle: React.FC = ({ showUnderRatio = false }) => { +const LegendSingle: React.FC = ({ + showUnderRatio = false, + usingTmBenchmark = false, +}) => { return ( <>

Compare to{' '} {' '} - MBTA benchmark: + {usingTmBenchmark ? 'TransitMatters' : 'MBTA'} benchmark:

diff --git a/common/components/charts/SingleDayLineChart.tsx b/common/components/charts/SingleDayLineChart.tsx index 3e5b565c..83827d18 100644 --- a/common/components/charts/SingleDayLineChart.tsx +++ b/common/components/charts/SingleDayLineChart.tsx @@ -25,64 +25,65 @@ import { ChartBorder } from './ChartBorder'; const pointColors = ( data: DataPoint[], metric_field: string, - benchmark_field?: string, + effectiveBenchmark: (number | null)[], showUnderRatio?: boolean ) => { - return data.map((point: DataPoint) => { - if (benchmark_field) { - const benchmarkValue = point[benchmark_field]; - // Check for null, undefined, NaN, or other invalid values - if ( - benchmarkValue === null || - benchmarkValue === undefined || - typeof benchmarkValue !== 'number' || - !Number.isFinite(benchmarkValue) - ) { - return CHART_COLORS.GREY; - } - const ratio = point[metric_field] / benchmarkValue; - if (!Number.isFinite(ratio)) { - return CHART_COLORS.GREY; - } else if (ratio <= 0.05 && showUnderRatio) { - // Not actually 100% off, but we want to show it as an extreme - return CHART_COLORS.PURPLE; - } else if (ratio <= 0.5 && showUnderRatio) { - return CHART_COLORS.RED; - } else if (ratio <= 0.75 && showUnderRatio) { - return CHART_COLORS.YELLOW; - } else if (ratio <= 1.25) { - return CHART_COLORS.GREEN; - } else if (ratio <= 1.5) { - return CHART_COLORS.YELLOW; - } else if (ratio <= 2.0) { - return CHART_COLORS.RED; - } else if (ratio > 2.0) { - return CHART_COLORS.PURPLE; - } + return data.map((point: DataPoint, idx: number) => { + const benchmarkValue = effectiveBenchmark[idx]; + if ( + benchmarkValue === null || + benchmarkValue === undefined || + typeof benchmarkValue !== 'number' || + !Number.isFinite(benchmarkValue) + ) { + return CHART_COLORS.GREY; } - - return CHART_COLORS.GREY; //whatever + const ratio = point[metric_field] / benchmarkValue; + if (!Number.isFinite(ratio)) { + return CHART_COLORS.GREY; + } else if (ratio <= 0.05 && showUnderRatio) { + // Not actually 100% off, but we want to show it as an extreme + return CHART_COLORS.PURPLE; + } else if (ratio <= 0.5 && showUnderRatio) { + return CHART_COLORS.RED; + } else if (ratio <= 0.75 && showUnderRatio) { + return CHART_COLORS.YELLOW; + } else if (ratio <= 1.25) { + return CHART_COLORS.GREEN; + } else if (ratio <= 1.5) { + return CHART_COLORS.YELLOW; + } else if (ratio <= 2.0) { + return CHART_COLORS.RED; + } else if (ratio > 2.0) { + return CHART_COLORS.PURPLE; + } + return CHART_COLORS.GREY; }); }; -const departureFromNormalString = (metric: number, benchmark: number, showUnderRatio?: boolean) => { +const departureFromNormalString = ( + metric: number, + benchmark: number, + showUnderRatio?: boolean, + referenceWord: 'schedule' | 'benchmark' = 'schedule' +) => { // Handle invalid benchmark values if (!benchmark || typeof benchmark !== 'number' || !Number.isFinite(benchmark)) { return ''; } const ratio = metric / benchmark; if (showUnderRatio && ratio <= 0.5) { - return '50%+ under schedule'; + return `50%+ under ${referenceWord}`; } else if (showUnderRatio && ratio <= 0.75) { - return '25%+ under schedule'; + return `25%+ under ${referenceWord}`; } else if (!isFinite(ratio) || ratio <= 1.25) { return ''; } else if (ratio <= 1.5) { - return '25%+ over schedule'; + return `25%+ over ${referenceWord}`; } else if (ratio <= 2.0) { - return '50%+ over schedule'; + return `50%+ over ${referenceWord}`; } else if (ratio > 2.0) { - return '100%+ over schedule'; + return `100%+ over ${referenceWord}`; } return ''; }; @@ -94,6 +95,7 @@ export const SingleDayLineChart: React.FC = ({ metricField, pointField, benchmarkField, + tmBenchmarkField, fname, includeBothStopsForLocation = false, location, @@ -108,21 +110,38 @@ export const SingleDayLineChart: React.FC = ({ const isMobile = !useBreakpoint('md'); const labels = useMemo(() => data.map((item) => item[pointField]), [data, pointField]); - // Format benchmark data if it exists. - const benchmarkData = data.map((datapoint) => { + // Per-point MBTA scheduled benchmark. + const mbtaBenchmarkData = data.map((datapoint) => { const value = benchmarkField && datapoint[benchmarkField]; - // Handle NaN, null, undefined, and other falsy values if (!value || typeof value !== 'number' || !Number.isFinite(value)) { return null; } return value; }); - const displayBenchmarkData = benchmarkData.some((datapoint) => datapoint !== null); + + // Per-point TM historical benchmark (already capped at the MBTA value by the + // backend generator, so we never go *above* the MBTA number). + const tmBenchmarkData = data.map((datapoint) => { + const raw = tmBenchmarkField && datapoint[tmBenchmarkField]; + if (!raw || typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw; + }); + + // Effective benchmark = TM when available, else MBTA. This is what the + // chart band *and* the dot colors are compared against. When TM is absent + // (bus, CR, missing pair) we transparently fall back to the MBTA value. + const effectiveBenchmarkData = data.map( + (_, idx) => tmBenchmarkData[idx] ?? mbtaBenchmarkData[idx] + ); + const displayBenchmarkData = effectiveBenchmarkData.some((d) => d !== null); + const usingTmBenchmark = tmBenchmarkData.some((d) => d !== null); const multiplier = units === 'Minutes' ? 1 / 60 : 1; - const benchmarkDataFormatted = benchmarkData - .map((datapoint) => (datapoint ? (datapoint * multiplier).toFixed(2) : null)) - .filter((datapoint) => datapoint !== null); + const effectiveBenchmarkFormatted = effectiveBenchmarkData + .map((d) => (d !== null ? (d * multiplier).toFixed(2) : null)) + .filter((d) => d !== null); const convertedData = data.map((datapoint) => ((datapoint[metricField] as number) * multiplier).toFixed(2) @@ -146,14 +165,14 @@ export const SingleDayLineChart: React.FC = ({ pointBackgroundColor: pointColors( data, metricField, - benchmarkField, + effectiveBenchmarkData, showUnderRatio ), pointHoverRadius: 3, pointHoverBackgroundColor: pointColors( data, metricField, - benchmarkField, + effectiveBenchmarkData, showUnderRatio ), pointRadius: 3, @@ -161,9 +180,12 @@ export const SingleDayLineChart: React.FC = ({ data: convertedData, }, { - label: `Benchmark MBTA`, + // Dataset label switches based on whether any point on this + // chart uses the TM value. The tooltip overrides this per + // point to get the exact name right for each hover. + label: usingTmBenchmark ? `TransitMatters Benchmark` : `MBTA Benchmark`, backgroundColor: '#a0a0a030', - data: benchmarkDataFormatted, + data: effectiveBenchmarkFormatted, pointRadius: 0, pointHoverRadius: 3, fill: true, @@ -182,13 +204,22 @@ export const SingleDayLineChart: React.FC = ({ position: 'nearest', callbacks: { label: (tooltipItem) => { - if ( - !tooltipItem.parsed.y || - (tooltipItem.parsed.y === 0 && tooltipItem.dataset.label === 'Benchmark MBTA') - ) { + const datasetLabel = tooltipItem.dataset.label ?? ''; + const isBenchmark = + datasetLabel === 'MBTA Benchmark' || + datasetLabel === 'TransitMatters Benchmark'; + if (!tooltipItem.parsed.y || (tooltipItem.parsed.y === 0 && isBenchmark)) { return ''; } - return `${tooltipItem.dataset.label}: ${ + // Per-point label: a chart in "TM mode" can still have + // individual points that fell back to MBTA; name each + // point's benchmark source honestly. + const displayLabel = isBenchmark + ? tmBenchmarkData[tooltipItem.dataIndex] != null + ? 'TransitMatters Benchmark' + : 'MBTA Benchmark' + : datasetLabel; + return `${displayLabel}: ${ units === 'Minutes' ? getFormattedTimeString(tooltipItem.parsed.y, 'minutes') : `${tooltipItem.parsed.y} ${units}` @@ -197,11 +228,16 @@ export const SingleDayLineChart: React.FC = ({ afterBody: (tooltipItems) => { const result: string[] = []; - // Add departure from normal information + const benchmarkItem = tooltipItems.find( + (t) => + t.dataset.label === 'MBTA Benchmark' || + t.dataset.label === 'TransitMatters Benchmark' + ); const departureInfo = departureFromNormalString( tooltipItems[0].parsed.y ?? 0, - tooltipItems[1]?.parsed.y ?? 0, - showUnderRatio + benchmarkItem?.parsed.y ?? 0, + showUnderRatio, + usingTmBenchmark ? 'benchmark' : 'schedule' ); if (departureInfo) { result.push(departureInfo); @@ -314,7 +350,7 @@ export const SingleDayLineChart: React.FC = ({ {alerts && }

{showLegend && benchmarkField ? ( - + ) : (
)} diff --git a/common/types/charts.ts b/common/types/charts.ts index c52748f6..fad25769 100644 --- a/common/types/charts.ts +++ b/common/types/charts.ts @@ -12,6 +12,7 @@ export interface SingleDayDataPoint { dwell_time_sec?: number; benchmark_travel_time_sec?: number; benchmark_headway_time_sec?: number | null; + tm_benchmark_travel_time_sec?: number | null; threshold_flag_1?: string; speed_mph?: number; benchmark_speed_mph?: number; @@ -73,6 +74,7 @@ export enum BenchmarkFieldKeys { benchmarkTravelTimeSec = 'benchmark_travel_time_sec', benchmarkHeadwayTimeSec = 'benchmark_headway_time_sec', benchmarkSpeedMph = 'benchmark_speed_mph', + tmBenchmarkTravelTimeSec = 'tm_benchmark_travel_time_sec', } export type PointField = PointFieldKeys; @@ -113,6 +115,7 @@ export interface SingleDayLineProps extends LineProps { metricField: MetricField; date: string | undefined; benchmarkField?: BenchmarkField; + tmBenchmarkField?: BenchmarkField; units: 'Minutes' | 'MPH'; } diff --git a/common/types/dataPoints.ts b/common/types/dataPoints.ts index 3f8d6d1a..3e255bbe 100644 --- a/common/types/dataPoints.ts +++ b/common/types/dataPoints.ts @@ -11,6 +11,7 @@ export interface TravelTimePoint extends DataPoint { arr_dt: string; travel_time_sec: number; benchmark_travel_time_sec: number; + tm_benchmark_travel_time_sec?: number | null; threshold_flag_1?: string; threshold_flag_2?: string; threshold_flag_3?: string; diff --git a/modules/traveltimes/charts/TravelTimesSingleChart.tsx b/modules/traveltimes/charts/TravelTimesSingleChart.tsx index 5d85fa09..97a9ef15 100644 --- a/modules/traveltimes/charts/TravelTimesSingleChart.tsx +++ b/modules/traveltimes/charts/TravelTimesSingleChart.tsx @@ -37,6 +37,7 @@ export const TravelTimesSingleChart: React.FC = ({ metricField={MetricFieldKeys.travelTimeSec} pointField={PointFieldKeys.depDt} benchmarkField={BenchmarkFieldKeys.benchmarkTravelTimeSec} + tmBenchmarkField={BenchmarkFieldKeys.tmBenchmarkTravelTimeSec} includeBothStopsForLocation={true} units="Minutes" location={getLocationDetails(fromStation, toStation)} diff --git a/server/chalicelib/s3_historical.py b/server/chalicelib/s3_historical.py index 74cc281c..d931e89a 100644 --- a/server/chalicelib/s3_historical.py +++ b/server/chalicelib/s3_historical.py @@ -10,7 +10,7 @@ import itertools import math -from chalicelib import date_utils +from chalicelib import date_utils, tm_benchmarks def pairwise(iterable): @@ -234,6 +234,9 @@ def travel_times(stops_a: list, stops_b: list, start_date: date, end_date: date) # not every vehicle will have vehicle_consist vehicle_consist = departure.get("vehicle_consist") + tm_benchmark = tm_benchmarks.get_travel_time_benchmark( + departure["route_id"], departure["stop_id"], arrival["stop_id"] + ) travel_times.append( { "route_id": departure["route_id"], @@ -242,6 +245,7 @@ def travel_times(stops_a: list, stops_b: list, start_date: date, end_date: date) "arr_dt": date_utils.return_formatted_date(arr_dt), "travel_time_sec": travel_time_sec, "benchmark_travel_time_sec": benchmark, + "tm_benchmark_travel_time_sec": tm_benchmark, "vehicle_consist": vehicle_consist, "vehicle_label": departure["vehicle_label"], } diff --git a/server/chalicelib/tm_benchmarks.py b/server/chalicelib/tm_benchmarks.py new file mode 100644 index 00000000..50c9f2e9 --- /dev/null +++ b/server/chalicelib/tm_benchmarks.py @@ -0,0 +1,66 @@ +"""Read TransitMatters travel-time benchmarks produced by mbta-performance. + +Benchmarks live at s3://tm-mbta-performance/Benchmarks-tm/traveltimes/{Color}.json +as `{"color": "...", "benchmarks": {"{from}|{to}": seconds}}`. Each color file is +small and changes at most monthly, so we cache it in-process for the lifetime of +the Lambda container. +""" + +import json +import logging +from typing import Optional + +from botocore.exceptions import ClientError + +from chalicelib import s3 + +logger = logging.getLogger(__name__) + +BENCHMARKS_PREFIX = "Benchmarks-tm/traveltimes" + +# route_id (as it appears in LAMP events) -> slow-zones archive color folder +ROUTE_ID_TO_COLOR = { + "Red": "Red", + "Blue": "Blue", + "Orange": "Orange", + "Green-B": "Green", + "Green-C": "Green", + "Green-D": "Green", + "Green-E": "Green", + "Mattapan": "Mattapan", +} + +_cache: dict[str, dict[str, int]] = {} +# Colors whose file we've already tried to load. Avoids retrying on every call +# for lines that have no benchmark file (bus, CR, or new lines before backfill). +_attempted: set[str] = set() + + +def _load_color(color: str) -> dict[str, int]: + key = f"{BENCHMARKS_PREFIX}/{color}.json" + try: + obj = s3.s3.get_object(Bucket=s3.BUCKET, Key=key) + except ClientError as e: + logger.info(f"No TM benchmarks for {color} at s3://{s3.BUCKET}/{key}: {e.response['Error'].get('Code')}") + return {} + try: + payload = json.loads(obj["Body"].read().decode("utf-8")) + return payload.get("benchmarks", {}) or {} + except (ValueError, KeyError) as e: + logger.warning(f"Failed to parse TM benchmarks for {color}: {e}") + return {} + + +def _benchmarks_for_color(color: str) -> dict[str, int]: + if color not in _attempted: + _cache[color] = _load_color(color) + _attempted.add(color) + return _cache.get(color, {}) + + +def get_travel_time_benchmark(route_id: str, from_stop: str, to_stop: str) -> Optional[int]: + """Return the TM travel-time benchmark in seconds, or None if unavailable.""" + color = ROUTE_ID_TO_COLOR.get(route_id) + if color is None: + return None + return _benchmarks_for_color(color).get(f"{from_stop}|{to_stop}")