Skip to content

Commit f5dc8b6

Browse files
committed
interval calculation changes to take in the account previous and next hour
1 parent 3a45ded commit f5dc8b6

4 files changed

Lines changed: 162 additions & 140 deletions

File tree

.github/workflows/ci-cd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ jobs:
1919
with:
2020
checkAndTestOutsideDocker: true
2121
codeCoverageEnabled: true
22-
performRelease: true
22+
performRelease: false
2323
checkAndTestInsideDocker: false

src/components/timetable/departureUtils.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import omit from 'lodash/omit';
66
import cloneDeep from 'lodash/cloneDeep';
77
import { trimRouteId } from 'util/domain';
88
import { normalizeDepartures } from './intervalsNormalizer.mjs';
9-
import { calculateAverageInterval, fixFirstLastHourIntervals } from './intervalCalculation.mjs';
9+
import { calculateIntervals } from './intervalCalculation.mjs';
1010

1111
export { computeCombinedColumn } from './combinedColumn.mjs';
1212

@@ -112,13 +112,15 @@ const groupDeparturesByHour = (filteredDepartures, routeIds) => {
112112
const counts = {};
113113
const lowestMinutes = {};
114114
const highestMinutes = {};
115+
const minutesByRoute = {};
115116

116117
for (const [routeId, items] of Object.entries(routeGroups)) {
117-
const minutesArray = items.map(item => item.minutes);
118+
const minutesArray = items.map(item => item.minutes).sort((a, b) => a - b);
118119
counts[routeId] = minutesArray.length;
119-
intervals[routeId] = calculateAverageInterval(minutesArray.length);
120-
lowestMinutes[routeId] = Math.min(...minutesArray);
121-
highestMinutes[routeId] = Math.max(...minutesArray);
120+
intervals[routeId] = null; // filled in by calculateIntervals
121+
[lowestMinutes[routeId]] = minutesArray;
122+
highestMinutes[routeId] = minutesArray[minutesArray.length - 1];
123+
minutesByRoute[routeId] = minutesArray;
122124
}
123125

124126
return {
@@ -128,6 +130,7 @@ const groupDeparturesByHour = (filteredDepartures, routeIds) => {
128130
counts,
129131
lowestMinutes,
130132
highestMinutes,
133+
minutesByRoute,
131134
};
132135
},
133136
);
@@ -196,7 +199,7 @@ export const prepareOrderedDepartureHoursByRoute = departures => {
196199
return aTime - bTime;
197200
});
198201

199-
fixFirstLastHourIntervals(sorted);
202+
calculateIntervals(sorted);
200203

201204
const { firstDepartures, lastDepartures } = calculateFirstAndLastDepartures(sorted, routeIds);
202205

Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,88 @@
11
/**
2-
* Compute the average interval (headway) in minutes.
2+
* Compute the interval (headway) in minutes using the span formula:
33
*
4-
* For a full hour use the default windowMinutes=60, which gives 60/count.
5-
* For the first hour of service pass (60 - firstDepartureMinute) as the window,
6-
* because the route is only active from that minute to the end of the hour.
7-
* For the last hour of service pass lastDepartureMinute as the window,
8-
* because the route is only active from the start of the hour to that minute.
4+
* interval = (lastMinute - firstMinute) / (count - 1)
95
*
10-
* @param {number} count - number of departures in the window
11-
* @param {number} [windowMinutes=60] - effective service window in minutes
12-
* @returns {number} rounded average headway in minutes, or 60 when count < 2
6+
* firstMinute and lastMinute are the effective endpoints after cross-hour
7+
* borrowing has been applied:
8+
* - borrow previous hour's last departure as (highestMinutes - 60)
9+
* - borrow next hour's first departure as (lowestMinutes + 60)
10+
*
11+
* Falls back to 60 only when count < 2 and no borrowing is possible.
12+
*
13+
* @param {number} firstMinute - effective first minute (may be negative after borrowing)
14+
* @param {number} lastMinute - effective last minute (may be > 59 after borrowing)
15+
* @param {number} count - number of departures in this hour
16+
* @returns {number} rounded headway in minutes
1317
*/
14-
export const calculateAverageInterval = (count, windowMinutes = 60) => {
15-
if (count < 2) return 60;
16-
return Math.round(windowMinutes / count);
18+
const spanInterval = (firstMinute, lastMinute, count) => {
19+
const gaps = count - 1;
20+
if (gaps < 1) return 60;
21+
return Math.round((lastMinute - firstMinute) / gaps);
1722
};
1823

1924
/**
20-
* Adjusts the interval for the first and last hour of service for each route.
25+
* Fills in `intervals` for every hour entry in `sorted` using the span
26+
* formula with cross-hour borrowing.
2127
*
22-
* In a middle hour buses are assumed to cover the full 60-minute window, so
23-
* interval = 60/count. At the edges this over- or under-estimates the headway:
28+
* For each hour and each route present in that hour:
29+
* - effectiveFirst = previous hour's highestMinutes[route] - 60
30+
* (if previous hour has that route), else lowestMinutes
31+
* - effectiveLast = next hour's lowestMinutes[route] + 60
32+
* (if next hour has that route), else highestMinutes
33+
* - interval = (effectiveLast - effectiveFirst) / (allDepartures - 1)
34+
* where allDepartures = count of current hour only (the span already
35+
* accounts for the borrowed endpoints, not the borrowed departures
36+
* themselves).
2437
*
25-
* - First hour: the route starts at lowestMinutes[routeId], so the effective
26-
* window is (60 - lowestMinutes[routeId]).
27-
* - Last hour: the route ends at highestMinutes[routeId], so the effective
28-
* window is highestMinutes[routeId].
38+
* Actually the span covers all gaps including the borrowed ones, so we count
39+
* the total number of gaps spanned:
40+
* gaps = (current count - 1)
41+
* + 1 if we borrowed from previous (adds one gap on the left)
42+
* + 1 if we borrowed from next (adds one gap on the right)
2943
*
30-
* Mutates the sorted array in place.
31-
* Routes that only appear in a single hour are left unchanged (default window).
44+
* Mutates `intervals` in place.
3245
*
3346
* @param {Array<{
3447
* hours: string,
35-
* intervals: Object<string, number>,
48+
* intervals: Object<string, number|null>,
3649
* counts: Object<string, number>,
3750
* lowestMinutes: Object<string, number>,
38-
* highestMinutes: Object<string, number>
51+
* highestMinutes: Object<string, number>,
3952
* }>} sorted - hour entries sorted chronologically
4053
*/
41-
export const fixFirstLastHourIntervals = (sorted) => {
54+
export const calculateIntervals = (sorted) => {
4255
const allRouteIds = new Set(sorted.flatMap((e) => Object.keys(e.intervals)));
4356

4457
for (const routeId of allRouteIds) {
45-
let firstIdx = -1;
46-
let lastIdx = -1;
47-
4858
for (let i = 0; i < sorted.length; i++) {
49-
if (routeId in sorted[i].intervals) {
50-
if (firstIdx === -1) firstIdx = i;
51-
lastIdx = i;
52-
}
53-
}
59+
const cur = sorted[i];
60+
if (!(routeId in cur.counts)) continue;
5461

55-
// Route only appears in a single hour — cannot distinguish first vs last,
56-
// leave the default (60 / count) unchanged.
57-
if (firstIdx === -1 || firstIdx === lastIdx) continue;
62+
const count = cur.counts[routeId];
63+
let firstMinute = cur.lowestMinutes[routeId];
64+
let lastMinute = cur.highestMinutes[routeId];
65+
let gaps = count - 1;
5866

59-
// First hour: window starts at the first departure minute
60-
const first = sorted[firstIdx];
61-
first.intervals[routeId] = calculateAverageInterval(
62-
first.counts[routeId],
63-
60 - first.lowestMinutes[routeId],
64-
);
67+
// Borrow from previous hour (only if there is one with this route)
68+
const prev = i > 0 ? sorted[i - 1] : null;
69+
if (prev && routeId in prev.highestMinutes) {
70+
firstMinute = prev.highestMinutes[routeId] - 60;
71+
gaps += 1;
72+
}
73+
74+
// Borrow from next hour (only if there is one with this route)
75+
const next = i < sorted.length - 1 ? sorted[i + 1] : null;
76+
if (next && routeId in next.lowestMinutes) {
77+
lastMinute = next.lowestMinutes[routeId] + 60;
78+
gaps += 1;
79+
}
6580

66-
// Last hour: window ends at the last departure minute
67-
const last = sorted[lastIdx];
68-
last.intervals[routeId] = calculateAverageInterval(
69-
last.counts[routeId],
70-
last.highestMinutes[routeId],
71-
);
81+
if (gaps < 1) {
82+
cur.intervals[routeId] = 60;
83+
} else {
84+
cur.intervals[routeId] = Math.round((lastMinute - firstMinute) / gaps);
85+
}
86+
}
7287
}
7388
};

0 commit comments

Comments
 (0)