Skip to content

Commit 852144b

Browse files
kaligrafytahini
authored andcommitted
Add curve-aware speed analysis for rail paths
Closes #128 Implement curve radius analysis for rail transit paths (tram, metro, rail, monorail, etc.) that estimates speed limits imposed by track curvature and adjusts travel times accordingly when geometry is precise enough. transition-common: - Add railCurves/ module with geometry analysis (radius estimation via inscribed circle method), curvature segmentation, speed profile generation (forward-backward pass with acceleration/deceleration), and segment travel time calculation - Integrate curve-aware calculations into PathGeographyGenerator for manual routing mode only (engine/engineCustom use OSRM shapes) - Add geometry resolution detection (high/low/none/almostStraight) to skip curve analysis when path shapes are too coarse - Add constants, types, and shared utility modules transition-frontend: - Add CurveStatsPanel with interactive speed profile chart (Recharts) supporting time and distance X-axis modes, proportional chart width, and configurable tick intervals - Add CurveStatsTable for per-segment travel time breakdown - Add map layers for curve segments (orange) and large-angle vertices (orange circles) to help users identify imprecise path geometry - Update TransitPathStatistics to show curve-aware travel times for rail modes, hiding curve comparison when geometry is not high-res - Update TransitPathEdit with curve segment layer toggle and geometry resolution warnings guiding users to improve path precision - Add styles for curve stats panel, table, chart, and toggle controls Locales: - Add English and French translations for all new curve analysis UI strings, warnings, and labels Tests: - Add PathRailCurves.test.ts for geometry, curvature analysis, speed profile, and segment travel time modules - Add curve-aware branching tests to PathGeographyGenerator.test.ts verifying correct activation by mode, routing engine, and resolution - Add geojsonOutputTestUtils.ts for curve analysis debugging/testing New package: - recharts for curve stats panel chart
1 parent 47354e5 commit 852144b

26 files changed

+5567
-215
lines changed

locales/en/main.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"Locked": "Locked",
2424
"delete": "delete",
2525
"Delete": "Delete",
26+
"Close": "Close",
2627
"loading": "loading",
2728
"Loading": "Loading",
2829
"Next": "Next",
@@ -307,5 +308,6 @@
307308
},
308309
"osmAttribution": "© OpenStreetMap contributors",
309310
"aerialAttribution": "Aerial imagery"
310-
}
311+
},
312+
"DownloadSvg": "Download SVG"
311313
}

locales/en/transit.json

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,8 @@
465465
"TravelTimes": "Travel times",
466466
"IncludingDwellTimes": "With dwell times",
467467
"ExcludingDwellTimes": "Without dwell times",
468+
"ExcludingDwellTimesWithCurves": "Without dwell times (with curve limits)",
469+
"ExcludingDwellTimesNoCurves": "Without dwell times (no curve limits)",
468470
"IncludingDwellTimesAndLayover": "With dwell times and layover time",
469471
"LayoverTime": "Layover time",
470472
"Speeds": "Speeds",
@@ -513,7 +515,41 @@
513515
"DefaultDecelerationIsInvalid": "Deceleration is invalid.",
514516
"DefaultDecelerationIsTooLow": "Deceleration is too low.",
515517
"DefaultDecelerationIsTooHigh": "Deceleration is too high (uncomfortable)."
516-
}
518+
},
519+
"ShowCurveSegmentsLayer": "Show speed-restricted curve segments on map",
520+
"ShowCurveSegmentsLayerHelp": "Highlights path sections where the curve radius limits the maximum speed (shown in orange on the map).",
521+
"CurveStats": "Curve Speed Statistics",
522+
"CurveStatsSummary": "Summary",
523+
"TravelTimeWithoutCurves": "Travel time (no curve limits)",
524+
"TravelTimeWithCurves": "Travel time (with curve limits)",
525+
"CurveTimeDifference": "Time added by curves",
526+
"AvgSpeed": "Avg. speed",
527+
"AvgSpeedWithoutCurves": "Avg. speed (no curve limits)",
528+
"AvgSpeedWithCurves": "Avg. speed (with curve limits)",
529+
"RunningSpeed": "Running speed (max)",
530+
"PerSegmentBreakdown": "Per-Segment Breakdown",
531+
"Segment": "Segment",
532+
"Distance": "Distance",
533+
"TimeWithoutCurves": "Time (no curves)",
534+
"TimeWithCurves": "Time (curves)",
535+
"Difference": "Diff.",
536+
"MinRadius": "Min. Radius",
537+
"CurveSpeedLimit": "Curve Limit",
538+
"SpeedProfile": "Speed Profile",
539+
"SpeedVsTime": "Speed vs Time",
540+
"SpeedVsDistance": "Speed vs Distance",
541+
"DistanceKm": "Distance (km)",
542+
"TimeMinutes": "Time (min)",
543+
"SpeedKmH": "Speed (km/h)",
544+
"ActualSpeed": "Actual Speed",
545+
"MaxSpeedByCurve": "Maximum Speed (including curve limits)",
546+
"TotalRunningTime": "Running time",
547+
"TotalDwellTime": "Dwell time",
548+
"TotalTripTime": "Total trip time",
549+
"TravelTime": "Travel time",
550+
"GeometryResolutionWarningNone": "The path shape uses only straight lines between stations. Curves are ignored in the calculation; travel times use simple kinematic calculation (acceleration/cruise/deceleration).",
551+
"GeometryResolutionWarningLow": "The path shape has low resolution (abrupt direction changes detected). Curves are ignored in the calculation; travel times use simple kinematic calculation. Vertices with insufficient precision are highlighted with orange circles on the map. Add or adjust waypoints at these locations to smooth the path for curve-aware analysis.",
552+
"GeometryResolutionWarningAlmostStraight": "The path geometry appears to be almost a straight line with no intermediate waypoints. Please validate that there are no angles greater than 10° between stations. If there are curves, please draw the path shape as precisely as possible."
517553
},
518554
"transitRouting": {
519555
"Routing": "Routing",

locales/fr/main.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"Locked": "Verrouillé",
2424
"delete": "supprimer",
2525
"Delete": "Supprimer",
26+
"Close": "Fermer",
2627
"loading": "chargement",
2728
"Loading": "Chargement",
2829
"Next": "Suivant",
@@ -307,5 +308,6 @@
307308
},
308309
"osmAttribution": "© Contributeurs OpenStreetMap",
309310
"aerialAttribution": "Imagerie aérienne"
310-
}
311+
},
312+
"DownloadSvg": "Télécharger SVG"
311313
}

locales/fr/transit.json

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,8 @@
465465
"TravelTimes": "Temps de parcours",
466466
"IncludingDwellTimes": "Avec les temps d'arrêt",
467467
"ExcludingDwellTimes": "Sans les temps d'arrêt",
468+
"ExcludingDwellTimesWithCurves": "Sans les temps d'arrêt (avec limites de courbes)",
469+
"ExcludingDwellTimesNoCurves": "Sans les temps d'arrêt (sans limites de courbes)",
468470
"IncludingDwellTimesAndLayover": "Avec les temps d'arrêt et le battement",
469471
"LayoverTime": "Battement",
470472
"Speeds": "Vitesses",
@@ -513,7 +515,41 @@
513515
"DefaultDecelerationIsInvalid": "La décélération est invalide.",
514516
"DefaultDecelerationIsTooLow": "La décélération est trop faible.",
515517
"DefaultDecelerationIsTooHigh": "La décélération est trop élevée (inconfortable)."
516-
}
518+
},
519+
"ShowCurveSegmentsLayer": "Afficher les segments de courbe à vitesse limitée sur la carte",
520+
"ShowCurveSegmentsLayerHelp": "Met en évidence les sections du parcours où le rayon de courbure limite la vitesse maximale (affiché en orange sur la carte).",
521+
"CurveStats": "Statistiques de vitesse en courbe",
522+
"CurveStatsSummary": "Résumé",
523+
"TravelTimeWithoutCurves": "Temps de parcours (sans limites de courbe)",
524+
"TravelTimeWithCurves": "Temps de parcours (avec limites de courbe)",
525+
"CurveTimeDifference": "Temps ajouté par les courbes",
526+
"AvgSpeed": "Vitesse moy.",
527+
"AvgSpeedWithoutCurves": "Vitesse moy. (sans limites de courbe)",
528+
"AvgSpeedWithCurves": "Vitesse moy. (avec limites de courbe)",
529+
"RunningSpeed": "Vitesse de croisière (max)",
530+
"PerSegmentBreakdown": "Détail par segment",
531+
"Segment": "Segment",
532+
"Distance": "Distance",
533+
"TimeWithoutCurves": "Temps (sans courbes)",
534+
"TimeWithCurves": "Temps (courbes)",
535+
"Difference": "Diff.",
536+
"MinRadius": "Rayon min.",
537+
"CurveSpeedLimit": "Limite courbe",
538+
"SpeedProfile": "Profil de vitesse",
539+
"SpeedVsTime": "Vitesse vs Temps",
540+
"SpeedVsDistance": "Vitesse vs Distance",
541+
"DistanceKm": "Distance (km)",
542+
"TimeMinutes": "Temps (min)",
543+
"SpeedKmH": "Vitesse (km/h)",
544+
"ActualSpeed": "Vitesse réelle",
545+
"MaxSpeedByCurve": "Vitesse maximale (incluant les limites en courbe)",
546+
"TotalRunningTime": "Temps de parcours",
547+
"TotalDwellTime": "Temps d'arrêt",
548+
"TotalTripTime": "Temps total du trajet",
549+
"TravelTime": "Temps de parcours",
550+
"GeometryResolutionWarningNone": "La forme du parcours utilise seulement des lignes droites entre les stations. Les courbes sont ignorées dans le calcul; les temps de parcours utilisent un calcul cinématique simple (accélération/vitesse de croisière/décélération).",
551+
"GeometryResolutionWarningLow": "La forme du parcours a une résolution faible (changements de direction abrupts détectés). Les courbes sont ignorées dans le calcul; les temps de parcours utilisent un calcul cinématique simple. Les sommets avec une précision insuffisante sont surlignés par des cercles orange sur la carte. Ajoutez ou ajustez les points de passage à ces endroits pour lisser le parcours et activer l'analyse des courbes.",
552+
"GeometryResolutionWarningAlmostStraight": "La géométrie du parcours semble être presque une ligne droite sans points intermédiaires. Veuillez valider qu'il n'y a pas d'angles de plus de 10° entre les stations. S'il y a des courbes, veuillez dessiner la forme du parcours aussi précisément que possible."
517553
},
518554
"transitRouting": {
519555
"Routing": "Calcul de chemin",

packages/transition-common/src/services/line/types.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* This file is licensed under the MIT License.
55
* License text available at https://opensource.org/licenses/MIT
66
*/
7+
8+
import lineModes from '../../config/lineModes';
9+
710
/**
811
* Right-of-way categories:
912
* ROW A: Fully controlled separated track/lanes used exclusively by transit vehicles, priority at all time
@@ -50,7 +53,7 @@ export type TransitMode = (typeof transitModes)[number];
5053
* Curves max speed: https://www.youtube.com/watch?v=veGEOSEDSlE
5154
* Curves geometry: https://www.thepwayengineer.com/blog/categories/track-geometry
5255
* Track geometry: https://en.wikipedia.org/wiki/Track_geometry
53-
* Gardient/Slope: https://en.wikipedia.org/wiki/Grade_(slope)#Railways
56+
* Gradient/Slope: https://en.wikipedia.org/wiki/Grade_(slope)#Railways
5457
*
5558
* @param {TransitMode | undefined | null} mode The transit mode to check
5659
* @returns true if the mode is a rail-based mode
@@ -60,6 +63,50 @@ export function isRailMode(mode: TransitMode | undefined | null): mode is RailMo
6063
return (railModes as readonly string[]).includes(mode);
6164
}
6265

66+
/**
67+
* Return the default running speed (km/h) for a given transit mode,
68+
* looked up from the lineModes configuration (`config/lineModes.ts`).
69+
*
70+
* Used as a fallback when the path does not have an explicit
71+
* `defaultRunningSpeedKmH` value. This single lookup keeps the map
72+
* curve-segment visualisation, the speed-profile chart and the
73+
* statistics panel consistent.
74+
*
75+
* @param mode The transit mode to look up.
76+
* @param fallback Value returned when the mode is unknown or null (default 80).
77+
*/
78+
export function getDefaultRunningSpeedKmH(mode: TransitMode | undefined | null, fallback = 80): number {
79+
if (!mode) return fallback;
80+
const entry = lineModes.find((m) => m.value === mode);
81+
return entry?.defaultValues?.data?.defaultRunningSpeedKmH ?? fallback;
82+
}
83+
84+
/**
85+
* Return the default acceleration (m/s²) for a given transit mode,
86+
* looked up from the lineModes configuration (`config/lineModes.ts`).
87+
*
88+
* @param mode The transit mode to look up.
89+
* @param fallback Value returned when the mode is unknown or null (default 0.8).
90+
*/
91+
export function getDefaultAcceleration(mode: TransitMode | undefined | null, fallback = 0.8): number {
92+
if (!mode) return fallback;
93+
const entry = lineModes.find((m) => m.value === mode);
94+
return entry?.defaultValues?.data?.defaultAcceleration ?? fallback;
95+
}
96+
97+
/**
98+
* Return the default deceleration (m/s²) for a given transit mode,
99+
* looked up from the lineModes configuration (`config/lineModes.ts`).
100+
*
101+
* @param mode The transit mode to look up.
102+
* @param fallback Value returned when the mode is unknown or null (default 0.8).
103+
*/
104+
export function getDefaultDeceleration(mode: TransitMode | undefined | null, fallback = 0.8): number {
105+
if (!mode) return fallback;
106+
const entry = lineModes.find((m) => m.value === mode);
107+
return entry?.defaultValues?.data?.defaultDeceleration ?? fallback;
108+
}
109+
63110
/** Vertical alignments:
64111
* underground: below grade (subway, tunnel, etc.) (level -1)
65112
* surface: at-grade (level 0) (solid, not water)

packages/transition-common/src/services/path/PathGeographyGenerator.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import {
1515
durationFromAccelerationDecelerationDistanceAndRunningSpeed,
1616
kphToMps
1717
} from 'chaire-lib-common/lib/utils/PhysicsUtils';
18+
import {
19+
calculateSegmentTimeWithCurves,
20+
calculateNoDwellTimeWithCurves,
21+
detectGeometryResolution,
22+
shouldUseCurveAnalysis
23+
} from './railCurves/segmentTravelTime';
24+
import { isRailMode, type RailMode } from '../line/types';
1825

1926
/**
2027
* Get the coordinates from a geometry
@@ -77,7 +84,7 @@ const handleLegs = (path: any, points: Geojson.Feature<Geojson.Point>[], legs: (
7784
segmentDuration += Math.ceil(leg.duration);
7885
segmentDistance += Math.ceil(leg.distance);
7986

80-
// Path cannot finish at a waypoint, so this last segment si not part of the total calculations.
87+
// Path cannot finish at a waypoint, so this last segment is not part of the total calculations.
8188
if (i === legs.length - 1 && !nodeIds[nextNodeIndex]) {
8289
// last leg is to a waypoint (missing node at the end)
8390
segments.push(segmentCoordinatesStartIndex);
@@ -111,14 +118,58 @@ const handleLegs = (path: any, points: Geojson.Feature<Geojson.Point>[], legs: (
111118
? segmentDuration
112119
: segmentDistance / runningSpeed; // no acceleration/deceleration
113120

114-
const calculatedSegmentDuration = Math.ceil(
115-
durationFromAccelerationDecelerationDistanceAndRunningSpeed(
116-
acceleration,
117-
deceleration,
118-
segmentDistance,
119-
runningSpeed
120-
)
121-
);
121+
// For rail modes with 'manual' (click on map) routing, use curve-aware
122+
// travel time calculation. When routingEngine is 'engine' or 'engineCustom',
123+
// OSRM already provides realistic travel times that account for the
124+
// road/rail network, so curve analysis is not needed.
125+
// For now, cant (the lateral slope between the two tracks)
126+
// and other speed limits are not supported.
127+
const pathMode = path.getMode();
128+
let calculatedSegmentDuration: number | null;
129+
130+
// For rail modes with non-engine routing, check if geometry
131+
// resolution is high enough to use curve-aware calculations.
132+
const segmentCoords = globalCoordinates.slice(segmentCoordinatesStartIndex, globalCoordinates.length);
133+
const segGeometryResolution = detectGeometryResolution(segmentCoords, 1);
134+
const useSegCurves = shouldUseCurveAnalysis(segGeometryResolution);
135+
136+
if (routingEngine === 'manual' && isRailMode(pathMode) && useSegCurves && segmentCoords.length >= 3) {
137+
const maxSpeedKmH = runningSpeed * 3.6; // Convert from m/s to km/h
138+
const curveOptions = {
139+
mode: pathMode as RailMode,
140+
runningSpeedKmH: maxSpeedKmH,
141+
accelerationMps2: acceleration,
142+
decelerationMps2: deceleration,
143+
maxSpeedKmH
144+
};
145+
146+
// Use curve-aware calculation WITH accel/decel for station stops
147+
const curveResult = calculateSegmentTimeWithCurves(
148+
globalCoordinates,
149+
segmentCoordinatesStartIndex,
150+
globalCoordinates.length - 1,
151+
curveOptions
152+
);
153+
calculatedSegmentDuration = Math.ceil(curveResult.timeSeconds);
154+
155+
// "No dwell" time: curve-aware but WITHOUT accel/decel for station stops
156+
noDwellTimeDuration = calculateNoDwellTimeWithCurves(
157+
globalCoordinates,
158+
segmentCoordinatesStartIndex,
159+
globalCoordinates.length - 1,
160+
curveOptions
161+
);
162+
} else {
163+
// Standard calculation for non-rail modes
164+
calculatedSegmentDuration = Math.ceil(
165+
durationFromAccelerationDecelerationDistanceAndRunningSpeed(
166+
acceleration,
167+
deceleration,
168+
segmentDistance,
169+
runningSpeed
170+
)
171+
);
172+
}
122173
segmentDuration = calculatedSegmentDuration !== null ? calculatedSegmentDuration : -1;
123174

124175
if (segmentDuration <= 0) {

0 commit comments

Comments
 (0)