-
Notifications
You must be signed in to change notification settings - Fork 2
/
TransitManager.cs
296 lines (240 loc) · 14.4 KB
/
TransitManager.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
using CorvallisBus.Core.DataAccess;
using CorvallisBus.Core.Models;
using CorvallisBus.Core.Models.Connexionz;
using CorvallisBus.Core.WebClients;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CorvallisBus
{
// Maps a stop ID to a dictionary that maps a route number to a list of arrival times.
// Intended for client consumption.
using ClientBusSchedule = Dictionary<int, Dictionary<string, List<BusArrivalTime>>>;
// Maps a 5-digit stop ID to a dictionary that maps a route number to an arrival estimate in minutes.
// Exists to provide some compile-time semantics to differ between schedules and estimates.
using BusArrivalEstimates = Dictionary<int, Dictionary<string, List<int>>>;
public static class TransitManager
{
/// <summary>
/// The greatest number of minutes from now that an estimate can have.
/// </summary>
public const int ESTIMATES_MAX_ADVANCE_MINUTES = 30;
/// <summary>
/// The range of minutes in which an estimate time can replace a scheduled time.
/// </summary>
public const int ESTIMATE_CORRELATION_TOLERANCE_MINUTES = 10;
/// <summary>
/// The smallest number of minutes from now that a scheduled time can be rendered.
/// </summary>
public const int SCHEDULE_CUTOFF_MINUTES = 20;
/// <summary>
/// Returns the bus schedule for the given stop IDs, incorporating the ETA from Connexionz.
/// </summary>
public static async Task<ClientBusSchedule> GetSchedule(ITransitRepository repository, ITransitClient client, DateTimeOffset currentTime, IEnumerable<int> stopIds)
{
var schedulesTask = repository.GetScheduleAsync();
var estimatesTask = GetEtas(repository, client, stopIds);
var schedule = await schedulesTask;
var estimates = await estimatesTask;
var todaySchedule = stopIds.Where(schedule.ContainsKey)
.ToDictionary(platformNo => platformNo, makePlatformSchedule);
return todaySchedule;
Dictionary<string, List<BusArrivalTime>> makePlatformSchedule(int platformNo) =>
schedule[platformNo].ToDictionary(routeSchedule => routeSchedule.RouteNo,
routeSchedule => InterleaveRouteScheduleAndEstimates(
routeSchedule,
estimates.ContainsKey(platformNo)
? estimates[platformNo]
: new Dictionary<string, List<int>>(),
currentTime));
}
private static BusStopRouteDaySchedule? GetBestGuessDaySchedule(BusStopRouteSchedule routeSchedule, DateTimeOffset currentTime)
{
BusStopRouteDaySchedule? daySchedule = null;
var potentialDaySchedules = routeSchedule.DaySchedules.Where(ds => DaysOfWeekUtils.TodayMayFallInsideDaySchedule(ds, currentTime));
int numPotentialSchedules = potentialDaySchedules.Count();
if (numPotentialSchedules == 1)
daySchedule = potentialDaySchedules.First();
// ASSUMPTION: if there are multiple matches, it is because one is a spillover from the previous day, and one is the current day
// In this case, we wish to continue showing the spillover until it is no longer valid
if (numPotentialSchedules > 1)
{
daySchedule = potentialDaySchedules.First(
ds => (ds.Days & DaysOfWeekUtils.ToDaysOfWeek(currentTime.DayOfWeek)) != DaysOfWeekUtils.ToDaysOfWeek(currentTime.DayOfWeek));
}
if (daySchedule != null && DaysOfWeekUtils.TimeInSpilloverWindow(daySchedule, currentTime))
daySchedule = new BusStopRouteDaySchedule(daySchedule.Days, daySchedule.Times.Select(t => t - TimeSpan.FromDays(1)).ToList());
return daySchedule;
}
private static List<BusArrivalTime> InterleaveRouteScheduleAndEstimates(BusStopRouteSchedule routeSchedule,
Dictionary<string, List<int>> stopEstimates, DateTimeOffset currentTime)
{
var arrivalTimes = Enumerable.Empty<BusArrivalTime>();
var daySchedule = GetBestGuessDaySchedule(routeSchedule, currentTime);
if (daySchedule != null)
{
var relativeSchedule = MakeRelativeScheduleWithinCutoff(daySchedule, currentTime);
arrivalTimes = relativeSchedule.Select(minutes => new BusArrivalTime(minutes, isEstimate: false));
}
if (stopEstimates.ContainsKey(routeSchedule.RouteNo))
{
var routeEstimates = stopEstimates[routeSchedule.RouteNo];
arrivalTimes = arrivalTimes.Where(arrivalTime =>
!routeEstimates.Any(estimate =>
Math.Abs(arrivalTime.MinutesFromNow - estimate) <= ESTIMATE_CORRELATION_TOLERANCE_MINUTES));
arrivalTimes = arrivalTimes.Concat(
routeEstimates.Select(estimate => new BusArrivalTime(estimate, isEstimate: true)));
}
arrivalTimes = arrivalTimes.OrderBy(arrivalTime => arrivalTime);
return arrivalTimes.ToList();
}
// don't show arrival times at 9 AM if it's already 11 AM
private static IEnumerable<int> MakeRelativeScheduleWithinCutoff(BusStopRouteDaySchedule daySchedule, DateTimeOffset currentTime)
{
var scheduleCutoff = currentTime.TimeOfDay + TimeSpan.FromMinutes(20);
var timeOfDay = currentTime.TimeOfDay;
// Truncate any seconds on timeOfDay so that we don't get off-by-one errors
// when converting a scheduled time with a seconds component to minutesFromNow and back.
timeOfDay -= TimeSpan.FromSeconds(timeOfDay.Seconds);
return daySchedule.Times.Where(ts => ts > scheduleCutoff)
.Select(ts => (int)(ts - timeOfDay).TotalMinutes);
}
/// <summary>
/// Shamelessly c/ped from stackoverflow.
/// </summary>
public static double DistanceTo(double lat1, double lon1, double lat2, double lon2, char unit = 'K')
{
double rlat1 = Math.PI * lat1 / 180;
double rlat2 = Math.PI * lat2 / 180;
double theta = lon1 - lon2;
double rtheta = Math.PI * theta / 180;
double dist =
Math.Sin(rlat1) * Math.Sin(rlat2) + Math.Cos(rlat1) *
Math.Cos(rlat2) * Math.Cos(rtheta);
dist = Math.Acos(dist);
dist = dist * 180 / Math.PI;
dist = dist * 60 * 1.1515;
switch (unit)
{
case 'K': //Kilometers -> default
return dist * 1.609344;
case 'N': //Nautical Miles
return dist * 0.8684;
case 'M': //Miles
return dist;
}
return dist;
}
private static List<FavoriteStop> GetFavoriteStops(BusStaticData staticData, IEnumerable<int> stopIds, LatLong? optionalUserLocation)
{
var favoriteStops = stopIds.Where(staticData.Stops.ContainsKey).Select(id =>
{
var stop = staticData.Stops[id];
var distanceFromUser = optionalUserLocation != null
? DistanceTo(optionalUserLocation.Value.Lat, optionalUserLocation.Value.Lon, stop.Lat, stop.Long, 'M')
: double.NaN;
return new FavoriteStop(stop.Id, stop.Name, stop.RouteNames, stop.Lat, stop.Long, distanceFromUser, IsNearestStop: false);
})
.ToList();
var nearestStop = optionalUserLocation != null
? staticData.Stops.Values
.Aggregate((s1, s2) =>
DistanceTo(optionalUserLocation.Value.Lat, optionalUserLocation.Value.Lon, s1.Lat, s1.Long, 'M') <
DistanceTo(optionalUserLocation.Value.Lat, optionalUserLocation.Value.Lon, s2.Lat, s2.Long, 'M') ? s1 : s2)
: null;
if (nearestStop != null && !favoriteStops.Any(f => f.Id == nearestStop.Id))
{
var distanceFromUser = optionalUserLocation != null
? DistanceTo(optionalUserLocation.Value.Lat, optionalUserLocation.Value.Lon, nearestStop.Lat, nearestStop.Long, 'M')
: double.NaN;
favoriteStops.Add(new FavoriteStop(nearestStop.Id, nearestStop.Name, nearestStop.RouteNames,
nearestStop.Lat, nearestStop.Long,
distanceFromUser, IsNearestStop: true));
}
favoriteStops.Sort((f1, f2) => f1.DistanceFromUser.CompareTo(f2.DistanceFromUser));
return favoriteStops;
}
private static readonly BusArrivalTime ARRIVAL_TIME_SEED = new BusArrivalTime(int.MaxValue, isEstimate: false);
private static FavoriteStopViewModel ToViewModel(FavoriteStop favorite, BusStaticData staticData,
ClientBusSchedule schedule, DateTimeOffset currentTime)
{
var routeSchedules = schedule[favorite.Id]
.Where(rs => rs.Value.Any())
.OrderBy(rs => rs.Value.Aggregate(ARRIVAL_TIME_SEED, BusArrivalTime.Min))
.Take(2)
.ToList();
var firstRoute = routeSchedules.Count > 0 ? staticData.Routes[routeSchedules[0].Key] : null;
var secondRoute = routeSchedules.Count > 1 ? staticData.Routes[routeSchedules[1].Key] : null;
return new FavoriteStopViewModel(
StopId: favorite.Id,
StopName: favorite.Name,
FirstRouteName: firstRoute != null ? firstRoute.RouteNo : string.Empty,
FirstRouteColor: firstRoute != null ? firstRoute.Color : string.Empty,
FirstRouteArrivals: routeSchedules.Count > 0 ? RouteArrivalsSummary.ToEstimateSummary(routeSchedules[0].Value, currentTime) : "No arrivals!",
SecondRouteName: secondRoute != null ? secondRoute.RouteNo : string.Empty,
SecondRouteColor: secondRoute != null ? secondRoute.Color : string.Empty,
SecondRouteArrivals: routeSchedules.Count > 1 ? RouteArrivalsSummary.ToEstimateSummary(routeSchedules[1].Value, currentTime) : string.Empty,
Lat: favorite.Lat,
Long: favorite.Long,
DistanceFromUser: double.IsNaN(favorite.DistanceFromUser) ? "" : $"{favorite.DistanceFromUser:F1} miles",
IsNearestStop: favorite.IsNearestStop
);
}
public static async Task<List<FavoriteStopViewModel>> GetFavoritesViewModel(ITransitRepository repository,
ITransitClient client, DateTimeOffset currentTime, IEnumerable<int> stopIds, LatLong? optionalUserLocation)
{
var staticData = await repository.GetStaticDataAsync();
var favoriteStops = GetFavoriteStops(staticData, stopIds, optionalUserLocation);
var scheduleTask = GetSchedule(repository, client, currentTime, favoriteStops.Select(f => f.Id));
var schedule = await scheduleTask;
var result = favoriteStops.Select(favorite => ToViewModel(favorite, staticData, schedule, currentTime))
.ToList();
return result;
}
/// <summary>
/// Gets the ETA info for a set of stop IDS.
/// The outer dictionary takes a route number and gives a dictionary that takes a stop ID to an ETA.
/// </summary>
public static async Task<BusArrivalEstimates> GetEtas(ITransitRepository repository, ITransitClient client, IEnumerable<int> stopIds)
{
var toPlatformTag = await repository.GetPlatformTagsAsync();
var tasks = stopIds.Select(getEtaIfTagExists);
var results = await Task.WhenAll(tasks);
return results.ToDictionary(eta => eta.stopId,
eta => eta.platformET?.RouteEstimatedArrivals
?.ToDictionary(routeEta => routeEta.RouteNo,
routeEta => routeEta.EstimatedArrivalTime)
?? new Dictionary<string, List<int>>());
async Task<(int stopId, ConnexionzPlatformET? platformET)> getEtaIfTagExists(int id)
=> (id, toPlatformTag.TryGetValue(id, out int tag) ? await client.GetEta(tag) : null);
}
/// <summary>
/// Gets a user friendly arrivals summary for the requested stops.
/// Returns a dictionary which takes a stop ID and returns the list of route arrival summaries (used to populate a table).
/// </summary>
public static async Task<Dictionary<int, List<RouteArrivalsSummary>>> GetArrivalsSummary(ITransitRepository repository, ITransitClient client, DateTimeOffset currentTime, IEnumerable<int> stopIds)
{
var schedule = await GetSchedule(repository, client, currentTime, stopIds);
var staticData = await repository.GetStaticDataAsync();
var matchingStopIds = stopIds.Where(staticData.Stops.ContainsKey);
var arrivalsSummaries = matchingStopIds.ToDictionary(stopId => stopId,
stopId => ToRouteArrivalsSummaries(staticData.Stops[stopId].RouteNames, schedule[stopId], currentTime, staticData));
return arrivalsSummaries;
}
private static List<RouteArrivalsSummary> ToRouteArrivalsSummaries(List<string> routeNames,
Dictionary<string, List<BusArrivalTime>> stopArrivals, DateTimeOffset currentTime, BusStaticData staticData)
{
var arrivalsSummaries =
routeNames.Select(routeName =>
new KeyValuePair<string, List<BusArrivalTime>>(routeName,
stopArrivals.ContainsKey(routeName) ? stopArrivals[routeName] : new List<BusArrivalTime>()))
.OrderBy(kvp => kvp.Value.DefaultIfEmpty(ARRIVAL_TIME_SEED).Min())
.Select(kvp => RouteArrivalsSummary.Create(routeName: kvp.Key, routeArrivalTimes: kvp.Value, currentTime: currentTime))
.Where(ras => staticData.Routes.ContainsKey(ras.RouteName))
.ToList();
return arrivalsSummaries;
}
}
}