Skip to content

Commit

Permalink
Merge pull request #233 from ibi-group/feature/OTP-1187-notify-bus-op…
Browse files Browse the repository at this point in the history
…erator

Notify Bus Operator
  • Loading branch information
br648 authored Jun 10, 2024
2 parents f3a0cfb + 10a32a7 commit 6a57187
Show file tree
Hide file tree
Showing 30 changed files with 1,566 additions and 197 deletions.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ The follow parameters are used to interact with an OTP server.
| OTP_API_ROOT | This is the address of the OTP server, including the root path to the OTP API, to which all OTP related requests will be sent to. | http://otp-server.example.com/otp |
| OTP_PLAN_ENDPOINT | This defines the plan endpoint part of the requesting URL. If a request is made to this, the assumption is that a plan request has been made and that the response should be processed accordingly. | /plan |

### Trip Actions

OTP-middleware supports triggering certain actions when someone activates live tracking of a monitored trip
and reaches a location or is about to enter a path. Actions include location-sensitive API calls to notify various services.
In the context of live trip tracking, actions may include notifying transit vehicle operators or triggering traffic signals.

#### Bus Notify Actions

Bus notifier actions are defined in the optional file `bus-notifier-actions.yml` in the same configuration folder as `env.yml`.
The file contains a list of actions defined by an agency ID and a fully-qualified trigger class:

```yaml
- agencyId: id1
trigger: com.example.package.MyTriggerClass
```
Known trigger classes below are in package `org.opentripplanner.middleware.triptracker.interactions.busnotifiers`
and implement its `BusOperatorInteraction` interface:

| Class | Description |
| ----- |------------------------------------------------------------------------------|
| UsRideGwinnettNotifyBusOperator | Triggers select route bus operator notifications in Gwinnett County, GA, USA |

### Monitored Components

This application allows you to monitor various system components (e.g., OTP API, OTP UI, and Data Tools) that work together
Expand Down Expand Up @@ -273,4 +296,7 @@ The special E2E client settings should be defined in `env.yml`:
| TRIP_INSTRUCTION_UPCOMING_RADIUS | integer | Optional | 10 | The radius in meters under which an upcoming instruction is given. |
| TWILIO_ACCOUNT_SID | string | Optional | your-account-sid | Twilio settings available at: https://twilio.com/user/account |
| TWILIO_AUTH_TOKEN | string | Optional | your-auth-token | Twilio settings available at: https://twilio.com/user/account |
| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL | string | Optional | http://host.example.com | US Ride Gwinnett County bus notifier API. |
| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY | string | Optional | your-api-key | API key for the US Ride Gwinnett County bus notifier API. |
| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES | string | Optional | agency_id:route_id | A comma separated list of US Ride Gwinnett County routes that can be notified. |
| VALIDATE_ENVIRONMENT_CONFIG | boolean | Optional | true | If set to false, the validation of the env.yml file against this schema will be skipped. |
2 changes: 2 additions & 0 deletions configurations/default/bus-notifier-actions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- agencyId: GCT
trigger: org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator
6 changes: 5 additions & 1 deletion configurations/default/env.yml.tmp
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,8 @@ TRIP_TRACKING_RAIL_ON_TRACK_RADIUS: 200
# The radius in meters under which an immediate instruction is given.
TRIP_INSTRUCTION_IMMEDIATE_RADIUS: 2
# The radius in meters under which an upcoming instruction is given.
TRIP_INSTRUCTION_UPCOMING_RADIUS: 10
TRIP_INSTRUCTION_UPCOMING_RADIUS: 10

US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL: https://bus.notifier.example.com
US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY: your-key
US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES: agency_id:route_id
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

@JsonIgnoreProperties(ignoreUnknown = true)
Expand All @@ -22,9 +24,12 @@ public class TrackedJourney extends Model {

public List<TrackingLocation> locations = new ArrayList<>();

public Map<String, String> busNotificationMessages = new HashMap<>();

public static final String TRIP_ID_FIELD_NAME = "tripId";

public static final String LOCATIONS_FIELD_NAME = "locations";
public static final String BUS_NOTIFICATION_MESSAGES_FIELD_NAME = "busNotificationMessages";

public static final String END_TIME_FIELD_NAME = "endTime";

Expand Down Expand Up @@ -77,4 +82,13 @@ public int hashCode() {
public TrackingLocation lastLocation() {
return locations.get(locations.size() - 1);
}

public void updateNotificationMessage(String routeId, String body) {
busNotificationMessages.put(routeId, body);
Persistence.trackedJourneys.updateField(
id,
BUS_NOTIFICATION_MESSAGES_FIELD_NAME,
busNotificationMessages
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public class Leg implements Cloneable {
public Double distance;
public Boolean pathway;
public String mode;
public String route;
public Boolean interlineWithPreviousLeg;
public Place from;
public Place to;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.opentripplanner.middleware.triptracker.TripTrackingData;
import org.opentripplanner.middleware.utils.ConfigUtils;
import org.opentripplanner.middleware.utils.DateTimeUtils;
import org.opentripplanner.middleware.utils.I18nUtils;
import org.opentripplanner.middleware.utils.ItineraryUtils;
import org.opentripplanner.middleware.utils.NotificationUtils;
import org.slf4j.Logger;
Expand Down Expand Up @@ -894,8 +895,7 @@ private OtpUser getOtpUser() {
* Retrieves and caches the user on first call (assuming the user for a trip does not change).
*/
private Locale getOtpUserLocale() {
OtpUser user = getOtpUser();
return Locale.forLanguageTag(user == null || user.preferredLocale == null ? "en-US" : user.preferredLocale);
return I18nUtils.getOtpUserLocale(getOtpUser());
}

private String getTripUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,27 @@ public static Leg getExpectedLeg(Coordinates position, Itinerary itinerary) {
return null;
}

/**
* Get the leg following the expected leg.
*/
@Nullable
public static Leg getNextLeg(Leg expectedLeg, Itinerary itinerary) {
if (canUseTripLegs(itinerary)) {
for (int i = 0; i < itinerary.legs.size(); i++) {
Leg leg = itinerary.legs.get(i);
if (leg.equals(expectedLeg) && (i + 1 < itinerary.legs.size())) {
return itinerary.legs.get(i + 1);
}
}
}
return null;
}

/**
* Get the leg that is nearest to the current position. Note, to be considered when working with transit legs: if
* the trip involves traversing a cul-de-sac, the entrance and exit legs would be very close together if not
* identical. In this scenario it would be possible for the current position to be attributed to the exit leg,
* therefore missing the instruction at the end of the cul-de-dac.
* therefore missing the instruction at the end of the cul-de-sac.
*/
private static Leg getNearestLeg(Coordinates position, Itinerary itinerary) {
double shortestDistance = Double.MAX_VALUE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import org.eclipse.jetty.http.HttpStatus;
import org.opentripplanner.middleware.models.TrackedJourney;
import org.opentripplanner.middleware.persistence.Persistence;
import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.BusOperatorActions;
import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator;
import org.opentripplanner.middleware.triptracker.response.EndTrackingResponse;
import org.opentripplanner.middleware.triptracker.response.TrackingResponse;
import spark.Request;

import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsInt;
import static org.opentripplanner.middleware.utils.ItineraryUtils.removeAgencyPrefix;
import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt;

public class ManageTripTracking {
Expand Down Expand Up @@ -52,7 +55,8 @@ private static TrackingResponse doUpdateTracking(Request request, TripTrackingDa

TravelerPosition travelerPosition = new TravelerPosition(
trackedJourney,
tripData.trip.journeyState.matchingItinerary
tripData.trip.journeyState.matchingItinerary,
Persistence.otpUsers.getById(tripData.trip.userId)
);
TripStatus tripStatus = TripStatus.getTripStatus(travelerPosition);
trackedJourney.lastLocation().tripStatus = tripStatus;
Expand Down Expand Up @@ -108,7 +112,7 @@ public static TrackingResponse startOrUpdateTracking(Request request) {
public static EndTrackingResponse endTracking(Request request) {
TripTrackingData tripData = TripTrackingData.fromRequestJourneyId(request);
if (tripData != null) {
return completeJourney(tripData.journey, false);
return completeJourney(tripData, false);
}
return null;
}
Expand All @@ -122,7 +126,7 @@ public static EndTrackingResponse forciblyEndTracking(Request request) {
TripTrackingData tripData = TripTrackingData.fromRequestTripId(request);
if (tripData != null) {
if (tripData.journey != null) {
return completeJourney(tripData.journey, true);
return completeJourney(tripData, true);
} else {
logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Journey for provided trip id does not exist!");
return null;
Expand All @@ -132,9 +136,19 @@ public static EndTrackingResponse forciblyEndTracking(Request request) {
}

/**
* Complete a journey by defining the ending type, time and condition.
* Complete a journey by defining the ending type, time and condition. Also cancel possible upcoming bus
* notification.
*/
private static EndTrackingResponse completeJourney(TrackedJourney trackedJourney, boolean isForciblyEnded) {
private static EndTrackingResponse completeJourney(TripTrackingData tripData, boolean isForciblyEnded) {
TravelerPosition travelerPosition = new TravelerPosition(
tripData.journey,
tripData.trip.journeyState.matchingItinerary,
Persistence.otpUsers.getById(tripData.trip.userId)
);
BusOperatorActions
.getDefault()
.handleCancelNotificationAction(travelerPosition);
TrackedJourney trackedJourney = travelerPosition.trackedJourney;
trackedJourney.end(isForciblyEnded);
Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_TIME_FIELD_NAME, trackedJourney.endTime);
Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_CONDITION_FIELD_NAME, trackedJourney.endCondition);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ public TrackingLocation(Double lat, Double lon, Date timestamp) {
public TrackingLocation(Instant instant, double lat, double lon) {
this(lat, lon, new Date(instant.toEpochMilli()));
}

/** Used in testing **/
public TrackingLocation(Date timestamp, double lat, double lon) {
this(lat, lon, timestamp);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,32 @@
import io.leonard.PolylineUtils;
import org.opentripplanner.middleware.otp.response.Leg;
import org.opentripplanner.middleware.otp.response.Step;
import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.BusOperatorActions;
import org.opentripplanner.middleware.utils.Coordinates;
import org.opentripplanner.middleware.utils.DateTimeUtils;

import javax.annotation.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

import static org.opentripplanner.middleware.triptracker.TripInstruction.NO_INSTRUCTION;
import static org.opentripplanner.middleware.triptracker.TripInstruction.TRIP_INSTRUCTION_UPCOMING_RADIUS;
import static org.opentripplanner.middleware.utils.GeometryUtils.getDistance;
import static org.opentripplanner.middleware.utils.GeometryUtils.isPointBetween;
import static org.opentripplanner.middleware.utils.ItineraryUtils.isBusLeg;

/**
* Locate the traveler in relation to the nearest step or destination and provide the appropriate instructions.
*/
public class TravelerLocator {

public static final int ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES = 15;

private TravelerLocator() {
}

Expand All @@ -34,14 +43,14 @@ public static String getInstruction(
) {
if (hasRequiredWalkLeg(travelerPosition)) {
if (hasRequiredTripStatus(tripStatus)) {
TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip);
TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, tripStatus);
if (tripInstruction != null) {
return tripInstruction.build();
}
}

if (tripStatus.equals(TripStatus.DEVIATED)) {
TripInstruction tripInstruction = getBackOnTrack(travelerPosition, isStartOfTrip);
TripInstruction tripInstruction = getBackOnTrack(travelerPosition, isStartOfTrip, tripStatus);
if (tripInstruction != null) {
return tripInstruction.build();
}
Expand Down Expand Up @@ -71,31 +80,49 @@ private static boolean hasRequiredTripStatus(TripStatus tripStatus) {
* provider this, else suggest the closest street to head towards.
*/
@Nullable
private static TripInstruction getBackOnTrack(TravelerPosition travelerPosition, boolean isStartOfTrip) {
TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip);
if (instruction != null && instruction.hasInstruction) {
private static TripInstruction getBackOnTrack(
TravelerPosition travelerPosition,
boolean isStartOfTrip,
TripStatus tripStatus
) {
TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, tripStatus);
if (instruction != null && instruction.hasInstruction()) {
return instruction;
}
Step nearestStep = snapToStep(travelerPosition);
return (nearestStep != null)
? new TripInstruction(nearestStep.streetName)
? new TripInstruction(nearestStep.streetName, travelerPosition.locale)
: null;
}

/**
* Align the traveler's position to the nearest step or destination.
*/
@Nullable
public static TripInstruction alignTravelerToTrip(TravelerPosition travelerPosition, boolean isStartOfTrip) {
if (isApproachingDestination(travelerPosition)) {
return new TripInstruction(getDistanceToDestination(travelerPosition), travelerPosition.expectedLeg.to.name);
public static TripInstruction alignTravelerToTrip(
TravelerPosition travelerPosition,
boolean isStartOfTrip,
TripStatus tripStatus
) {
Locale locale = travelerPosition.locale;

if (isApproachingEndOfLeg(travelerPosition)) {
if (isBusLeg(travelerPosition.nextLeg) && isWithinOperationalNotifyWindow(travelerPosition)) {
BusOperatorActions
.getDefault()
.handleSendNotificationAction(tripStatus, travelerPosition);
// Regardless of whether the notification is sent or qualifies, provide a 'wait for bus' instruction.
return new TripInstruction(travelerPosition.nextLeg, travelerPosition.currentTime, locale);
}
return new TripInstruction(getDistanceToEndOfLeg(travelerPosition), travelerPosition.expectedLeg.to.name, locale);
}

Step nextStep = snapToStep(travelerPosition);
if (nextStep != null && (!isPositionPastStep(travelerPosition, nextStep) || isStartOfTrip)) {
return new TripInstruction(
getDistance(travelerPosition.currentPosition, new Coordinates(nextStep)),
nextStep
nextStep,
locale
);
}
return null;
Expand All @@ -120,14 +147,42 @@ private static boolean isPositionPastStep(TravelerPosition travelerPosition, Ste
/**
* Is the traveler approaching the leg destination.
*/
private static boolean isApproachingDestination(TravelerPosition travelerPosition) {
return getDistanceToDestination(travelerPosition) <= TRIP_INSTRUCTION_UPCOMING_RADIUS;
private static boolean isApproachingEndOfLeg(TravelerPosition travelerPosition) {
return getDistanceToEndOfLeg(travelerPosition) <= TRIP_INSTRUCTION_UPCOMING_RADIUS;
}

/**
* Make sure the traveler is on schedule or ahead of schedule (but not too far) to be within an operational window
* for the bus service.
*/
public static boolean isWithinOperationalNotifyWindow(TravelerPosition travelerPosition) {
var busDepartureTime = getBusDepartureTime(travelerPosition.nextLeg);
return
(travelerPosition.currentTime.equals(busDepartureTime) || travelerPosition.currentTime.isBefore(busDepartureTime)) &&
ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES >= getMinutesAheadOfDeparture(travelerPosition.currentTime, busDepartureTime);
}

/**
* Get how far ahead in minutes the traveler is from the bus departure time.
*/
public static long getMinutesAheadOfDeparture(Instant currentTime, Instant busDepartureTime) {
return Duration.between(busDepartureTime, currentTime).toMinutes();
}

/**
* Get the bus departure time.
*/
public static Instant getBusDepartureTime(Leg busLeg) {
return ZonedDateTime.ofInstant(
busLeg.startTime.toInstant().plusSeconds(busLeg.departureDelay),
DateTimeUtils.getOtpZoneId()
).toInstant();
}

/**
* Get the distance from the traveler's current position to the leg destination.
*/
private static double getDistanceToDestination(TravelerPosition travelerPosition) {
private static double getDistanceToEndOfLeg(TravelerPosition travelerPosition) {
Coordinates legDestination = new Coordinates(travelerPosition.expectedLeg.to);
return getDistance(travelerPosition.currentPosition, legDestination);
}
Expand Down
Loading

0 comments on commit 6a57187

Please sign in to comment.