diff --git a/docs/apis/Apis.md b/docs/apis/Apis.md index dd9280a047f..ab6b41a25cd 100644 --- a/docs/apis/Apis.md +++ b/docs/apis/Apis.md @@ -18,7 +18,7 @@ entities on a vector map. The [Actuator API](../sandbox/ActuatorAPI.md) provides endpoints for checking the health status of the OTP instance and reading live application metrics. -The [Geocoder API](../sandbox/GeocoderAPI.md) allows you to geocode stop names. +The [Geocoder API](../sandbox/GeocoderAPI.md) allows you to geocode stop names and codes. ## Legacy APIs (to be removed) diff --git a/docs/sandbox/GeocoderAPI.md b/docs/sandbox/GeocoderAPI.md index 0405724fff6..f7a1be3f293 100644 --- a/docs/sandbox/GeocoderAPI.md +++ b/docs/sandbox/GeocoderAPI.md @@ -26,7 +26,7 @@ To enable this you need to add the feature to `otp-config.json`. The required geocode API for Stop and From/To searches in the debug client. -Path: `/otp/routers/{routerId}/geocode` +Path: `/otp/geocode` It supports the following URL parameters: @@ -40,12 +40,12 @@ It supports the following URL parameters: #### Stop clusters A stop cluster is a deduplicated groups of stops. This means that for any stop that has a parent -station only the parent is returned and for stops that have identical names and are very close +station only the parent is returned and for stops that have _identical_ names and are very close to each other, only one is returned. -This is useful for a general purpose fuzzy "stop" search. +This is useful for a general purpose fuzzy stop search. -Path: `/otp/routers/{routerId}/geocode/stopClusters` +Path: `/otp/geocode/stopClusters` It supports the following URL parameters: diff --git a/src/ext-test/java/org/opentripplanner/ext/geocoder/LuceneIndexTest.java b/src/ext-test/java/org/opentripplanner/ext/geocoder/LuceneIndexTest.java index 371bcb249b6..b1fb33dfdd3 100644 --- a/src/ext-test/java/org/opentripplanner/ext/geocoder/LuceneIndexTest.java +++ b/src/ext-test/java/org/opentripplanner/ext/geocoder/LuceneIndexTest.java @@ -7,6 +7,7 @@ import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.Multimap; +import java.time.LocalDate; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -15,9 +16,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.opentripplanner.model.FeedInfo; import org.opentripplanner.transit.model._data.TransitModelForTest; import org.opentripplanner.transit.model.basic.TransitMode; import org.opentripplanner.transit.model.framework.Deduplicator; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.network.Route; +import org.opentripplanner.transit.model.organization.Agency; import org.opentripplanner.transit.model.site.RegularStop; import org.opentripplanner.transit.model.site.Station; import org.opentripplanner.transit.model.site.StopLocation; @@ -28,57 +33,63 @@ class LuceneIndexTest { private static final TransitModelForTest TEST_MODEL = TransitModelForTest.of(); + static final Agency BVG = Agency + .of(id("bvg")) + .withName("BVG") + .withTimezone("Europe/Berlin") + .build(); + // Berlin - static Station BERLIN_HAUPTBAHNHOF_STATION = TEST_MODEL + static final Station BERLIN_HAUPTBAHNHOF_STATION = TEST_MODEL .station("Hauptbahnhof") .withCoordinate(52.52495, 13.36952) .build(); - static Station ALEXANDERPLATZ_STATION = TEST_MODEL + static final Station ALEXANDERPLATZ_STATION = TEST_MODEL .station("Alexanderplatz") .withCoordinate(52.52277, 13.41046) .build(); - static RegularStop ALEXANDERPLATZ_BUS = TEST_MODEL + static final RegularStop ALEXANDERPLATZ_BUS = TEST_MODEL .stop("Alexanderplatz Bus") .withCoordinate(52.52277, 13.41046) .withVehicleType(BUS) .withParentStation(ALEXANDERPLATZ_STATION) .build(); - static RegularStop ALEXANDERPLATZ_RAIL = TEST_MODEL + static final RegularStop ALEXANDERPLATZ_RAIL = TEST_MODEL .stop("Alexanderplatz S-Bahn") .withCoordinate(52.52157, 13.41123) .withVehicleType(TransitMode.RAIL) .withParentStation(ALEXANDERPLATZ_STATION) .build(); - static RegularStop LICHTERFELDE_OST_1 = TEST_MODEL + static final RegularStop LICHTERFELDE_OST_1 = TEST_MODEL .stop("Lichterfelde Ost") .withId(id("lichterfelde-gleis-1")) .withCoordinate(52.42986, 13.32808) .build(); - static RegularStop LICHTERFELDE_OST_2 = TEST_MODEL + static final RegularStop LICHTERFELDE_OST_2 = TEST_MODEL .stop("Lichterfelde Ost") .withId(id("lichterfelde-gleis-2")) .withCoordinate(52.42985, 13.32807) .build(); - static RegularStop WESTHAFEN = TEST_MODEL + static final RegularStop WESTHAFEN = TEST_MODEL .stop("Westhafen") .withVehicleType(null) .withCoordinate(52.42985, 13.32807) .build(); // Atlanta - static Station FIVE_POINTS_STATION = TEST_MODEL + static final Station FIVE_POINTS_STATION = TEST_MODEL .station("Five Points") .withCoordinate(33.753899, -84.39156) .build(); - static RegularStop ARTS_CENTER = TEST_MODEL + static final RegularStop ARTS_CENTER = TEST_MODEL .stop("Arts Center") .withCode("4456") .withCoordinate(52.52277, 13.41046) .build(); - static RegularStop ARTHUR = TEST_MODEL + static final RegularStop ARTHUR = TEST_MODEL .stop("Arthur Langford Jr Pl SW at 220") .withCoordinate(52.52277, 13.41046) .build(); @@ -105,6 +116,7 @@ static void setup() { .of(ALEXANDERPLATZ_STATION, BERLIN_HAUPTBAHNHOF_STATION, FIVE_POINTS_STATION) .forEach(stopModel::withStation); var transitModel = new TransitModel(stopModel.build(), new Deduplicator()); + transitModel.index(); var transitService = new DefaultTransitService(transitModel) { private final Multimap modes = ImmutableMultimap .builder() @@ -119,6 +131,32 @@ public List getModesOfStopLocation(StopLocation stop) { return List.copyOf(modes.get(stop)); } } + + @Override + public Agency getAgencyForId(FeedScopedId id) { + if (id.equals(BVG.getId())) { + return BVG; + } + return null; + } + + @Override + public Set getRoutesForStop(StopLocation stop) { + return Set.of(TransitModelForTest.route("route1").withAgency(BVG).build()); + } + + @Override + public FeedInfo getFeedInfo(String feedId) { + return new FeedInfo( + "F", + "A Publisher", + "http://example.com", + "de", + LocalDate.MIN, + LocalDate.MIN, + "1" + ); + } }; index = new LuceneIndex(transitService); mapper = new StopClusterMapper(transitService); @@ -128,7 +166,7 @@ public List getModesOfStopLocation(StopLocation stop) { void stopLocations() { var result1 = index.queryStopLocations("lich", true).toList(); assertEquals(1, result1.size()); - assertEquals(LICHTERFELDE_OST_1.getName().toString(), result1.get(0).getName().toString()); + assertEquals(LICHTERFELDE_OST_1.getName().toString(), result1.getFirst().getName().toString()); var result2 = index.queryStopLocations("alexan", true).collect(Collectors.toSet()); assertEquals(Set.of(ALEXANDERPLATZ_BUS, ALEXANDERPLATZ_RAIL), result2); @@ -174,21 +212,22 @@ class StopClusters { } ) void stopClustersWithTypos(String searchTerm) { - var result1 = index.queryStopClusters(searchTerm).toList(); - assertEquals(List.of(mapper.map(ALEXANDERPLATZ_STATION)), result1); + var results = index.queryStopClusters(searchTerm).toList(); + var ids = results.stream().map(StopCluster::id).toList(); + assertEquals(List.of(ALEXANDERPLATZ_STATION.getId()), ids); } @Test void fuzzyStopClusters() { - var result1 = index.queryStopClusters("arts").toList(); - assertEquals(List.of(mapper.map(ARTS_CENTER).get()), result1); + var result1 = index.queryStopClusters("arts").map(StopCluster::id).toList(); + assertEquals(List.of(ARTS_CENTER.getId()), result1); } @Test void deduplicatedStopClusters() { var result = index.queryStopClusters("lich").toList(); assertEquals(1, result.size()); - assertEquals(LICHTERFELDE_OST_1.getName().toString(), result.get(0).name()); + assertEquals(LICHTERFELDE_OST_1.getName().toString(), result.getFirst().name()); } @ParameterizedTest @@ -220,8 +259,8 @@ void deduplicatedStopClusters() { } ) void stopClustersWithSpace(String query) { - var result = index.queryStopClusters(query).toList(); - assertEquals(List.of(mapper.map(FIVE_POINTS_STATION)), result); + var result = index.queryStopClusters(query).map(StopCluster::id).toList(); + assertEquals(List.of(FIVE_POINTS_STATION.getId()), result); } @ParameterizedTest @@ -229,16 +268,24 @@ void stopClustersWithSpace(String query) { void fuzzyStopCode(String query) { var result = index.queryStopClusters(query).toList(); assertEquals(1, result.size()); - assertEquals(ARTS_CENTER.getName().toString(), result.get(0).name()); + assertEquals(ARTS_CENTER.getName().toString(), result.getFirst().name()); } @Test void modes() { var result = index.queryStopClusters("westh").toList(); assertEquals(1, result.size()); - var stop = result.get(0); + var stop = result.getFirst(); assertEquals(WESTHAFEN.getName().toString(), stop.name()); assertEquals(List.of(FERRY.name(), BUS.name()), stop.modes()); } + + @Test + void agenciesAndFeedPublisher() { + var result = index.queryStopClusters("alexanderplatz").toList().getFirst(); + assertEquals(ALEXANDERPLATZ_STATION.getName().toString(), result.name()); + assertEquals(List.of(StopClusterMapper.toAgency(BVG)), result.agencies()); + assertEquals("A Publisher", result.feedPublisher().name()); + } } } diff --git a/src/ext/java/org/opentripplanner/ext/geocoder/GeocoderResource.java b/src/ext/java/org/opentripplanner/ext/geocoder/GeocoderResource.java index 39ed6da297c..f5d1f950632 100644 --- a/src/ext/java/org/opentripplanner/ext/geocoder/GeocoderResource.java +++ b/src/ext/java/org/opentripplanner/ext/geocoder/GeocoderResource.java @@ -21,24 +21,30 @@ /** * OTP simple built-in geocoder used by the debug client. */ -@Path("/routers/{ignoreRouterId}/geocode") +@Path("/geocode") @Produces(MediaType.APPLICATION_JSON) public class GeocoderResource { private final OtpServerRequestContext serverContext; - /** - * @deprecated The support for multiple routers are removed from OTP2. See - * https://github.com/opentripplanner/OpenTripPlanner/issues/2760 - */ - @Deprecated - @PathParam("ignoreRouterId") - private String ignoreRouterId; - public GeocoderResource(@Context OtpServerRequestContext requestContext) { serverContext = requestContext; } + /** + * This class is only here for backwards-compatibility. It will be removed in the future. + */ + @Path("/routers/{ignoreRouterId}/geocode") + public static class GeocoderResourceOldPath extends GeocoderResource { + + public GeocoderResourceOldPath( + @Context OtpServerRequestContext serverContext, + @PathParam("ignoreRouterId") String ignore + ) { + super(serverContext); + } + } + /** * Geocode using data using the OTP graph for stops, clusters and street names * diff --git a/src/ext/java/org/opentripplanner/ext/geocoder/LuceneIndex.java b/src/ext/java/org/opentripplanner/ext/geocoder/LuceneIndex.java index 71b0cf67b34..56769db0028 100644 --- a/src/ext/java/org/opentripplanner/ext/geocoder/LuceneIndex.java +++ b/src/ext/java/org/opentripplanner/ext/geocoder/LuceneIndex.java @@ -42,7 +42,6 @@ import org.apache.lucene.store.ByteBuffersDirectory; import org.opentripplanner.ext.geocoder.StopCluster.Coordinate; import org.opentripplanner.framework.i18n.I18NString; -import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.standalone.api.OtpServerRequestContext; import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.StopLocation; @@ -60,6 +59,7 @@ public class LuceneIndex implements Serializable { private static final String LAT = "latitude"; private static final String LON = "longitude"; private static final String MODE = "mode"; + private static final String AGENCY_IDS = "agency_ids"; private final TransitService transitService; private final Analyzer analyzer; @@ -67,6 +67,7 @@ public class LuceneIndex implements Serializable { public LuceneIndex(TransitService transitService) { this.transitService = transitService; + StopClusterMapper stopClusterMapper = new StopClusterMapper(transitService); this.analyzer = new PerFieldAnalyzerWrapper( @@ -80,7 +81,6 @@ public LuceneIndex(TransitService transitService) { var directory = new ByteBuffersDirectory(); - var stopClusterMapper = new StopClusterMapper(transitService); try { try ( var directoryWriter = new IndexWriter( @@ -99,6 +99,7 @@ public LuceneIndex(TransitService transitService) { stopLocation.getCode(), stopLocation.getCoordinate().latitude(), stopLocation.getCoordinate().longitude(), + Set.of(), Set.of() ) ); @@ -114,6 +115,7 @@ public LuceneIndex(TransitService transitService) { null, stopLocationsGroup.getCoordinate().latitude(), stopLocationsGroup.getCoordinate().longitude(), + Set.of(), Set.of() ) ); @@ -128,11 +130,12 @@ public LuceneIndex(TransitService transitService) { directoryWriter, StopCluster.class, stopCluster.id().toString(), - new NonLocalizedString(stopCluster.name()), + I18NString.of(stopCluster.name()), stopCluster.code(), stopCluster.coordinate().lat(), stopCluster.coordinate().lon(), - stopCluster.modes() + stopCluster.modes(), + stopCluster.agencyIds() ) ); } @@ -176,17 +179,34 @@ public Stream queryStopLocationGroups(String query, boolean * one of those is chosen at random and returned. */ public Stream queryStopClusters(String query) { - return matchingDocuments(StopCluster.class, query, false).map(LuceneIndex::toStopCluster); + return matchingDocuments(StopCluster.class, query, false).map(this::toStopCluster); } - private static StopCluster toStopCluster(Document document) { - var id = FeedScopedId.parse(document.get(ID)); + private StopCluster toStopCluster(Document document) { + var clusterId = FeedScopedId.parse(document.get(ID)); var name = document.get(NAME); var code = document.get(CODE); var lat = document.getField(LAT).numericValue().doubleValue(); var lon = document.getField(LON).numericValue().doubleValue(); var modes = Arrays.asList(document.getValues(MODE)); - return new StopCluster(id, code, name, new Coordinate(lat, lon), modes); + var agencies = Arrays + .stream(document.getValues(AGENCY_IDS)) + .map(id -> transitService.getAgencyForId(FeedScopedId.parse(id))) + .filter(Objects::nonNull) + .map(StopClusterMapper::toAgency) + .toList(); + var feedPublisher = StopClusterMapper.toFeedPublisher( + transitService.getFeedInfo(clusterId.getFeedId()) + ); + return new StopCluster( + clusterId, + code, + name, + new Coordinate(lat, lon), + modes, + agencies, + feedPublisher + ); } static IndexWriterConfig iwcWithSuggestField(Analyzer analyzer, final Set suggestFields) { @@ -214,7 +234,8 @@ private static void addToIndex( @Nullable String code, double latitude, double longitude, - Collection modes + Collection modes, + Collection agencyIds ) { String typeName = type.getSimpleName(); @@ -235,6 +256,9 @@ private static void addToIndex( for (var mode : modes) { document.add(new TextField(MODE, mode, Store.YES)); } + for (var ids : agencyIds) { + document.add(new TextField(AGENCY_IDS, ids, Store.YES)); + } try { writer.addDocument(document); diff --git a/src/ext/java/org/opentripplanner/ext/geocoder/LuceneStopCluster.java b/src/ext/java/org/opentripplanner/ext/geocoder/LuceneStopCluster.java new file mode 100644 index 00000000000..f58d7aa9af9 --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/geocoder/LuceneStopCluster.java @@ -0,0 +1,17 @@ +package org.opentripplanner.ext.geocoder; + +import java.util.Collection; +import javax.annotation.Nullable; +import org.opentripplanner.transit.model.framework.FeedScopedId; + +/** + * A package-private helper type for transporting data before serializing. + */ +record LuceneStopCluster( + FeedScopedId id, + @Nullable String code, + String name, + StopCluster.Coordinate coordinate, + Collection modes, + Collection agencyIds +) {} diff --git a/src/ext/java/org/opentripplanner/ext/geocoder/StopCluster.java b/src/ext/java/org/opentripplanner/ext/geocoder/StopCluster.java index afb60960ed4..8ffd44511fd 100644 --- a/src/ext/java/org/opentripplanner/ext/geocoder/StopCluster.java +++ b/src/ext/java/org/opentripplanner/ext/geocoder/StopCluster.java @@ -1,6 +1,7 @@ package org.opentripplanner.ext.geocoder; import java.util.Collection; +import java.util.List; import javax.annotation.Nullable; import org.opentripplanner.transit.model.framework.FeedScopedId; @@ -18,10 +19,22 @@ record StopCluster( @Nullable String code, String name, Coordinate coordinate, - Collection modes + Collection modes, + List agencies, + @Nullable FeedPublisher feedPublisher ) { /** * Easily serializable version of a coordinate */ public record Coordinate(double lat, double lon) {} + + /** + * Easily serializable version of an agency + */ + public record Agency(FeedScopedId id, String name) {} + + /** + * Easily serializable version of a feed publisher + */ + public record FeedPublisher(String name) {} } diff --git a/src/ext/java/org/opentripplanner/ext/geocoder/StopClusterMapper.java b/src/ext/java/org/opentripplanner/ext/geocoder/StopClusterMapper.java index bc2f4f7022b..6f16d4a0cce 100644 --- a/src/ext/java/org/opentripplanner/ext/geocoder/StopClusterMapper.java +++ b/src/ext/java/org/opentripplanner/ext/geocoder/StopClusterMapper.java @@ -2,16 +2,20 @@ import com.google.common.collect.Iterables; import java.util.Collection; +import java.util.List; import java.util.Optional; import org.opentripplanner.framework.collection.ListUtils; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.model.FeedInfo; +import org.opentripplanner.transit.model.network.Route; +import org.opentripplanner.transit.model.organization.Agency; import org.opentripplanner.transit.model.site.StopLocation; import org.opentripplanner.transit.model.site.StopLocationsGroup; import org.opentripplanner.transit.service.TransitService; /** - * Mappers for generating {@link StopCluster} from the transit model. + * Mappers for generating {@link LuceneStopCluster} from the transit model. */ class StopClusterMapper { @@ -29,7 +33,7 @@ class StopClusterMapper { * - of "identical" stops which are very close to each other and have an identical name, only one * is chosen (at random) */ - Iterable generateStopClusters( + Iterable generateStopClusters( Collection stopLocations, Collection stopLocationsGroups ) { @@ -55,35 +59,67 @@ Iterable generateStopClusters( return Iterables.concat(deduplicatedStops, stations); } - StopCluster map(StopLocationsGroup g) { + LuceneStopCluster map(StopLocationsGroup g) { var modes = transitService.getModesOfStopLocationsGroup(g).stream().map(Enum::name).toList(); - return new StopCluster( + var agencies = agenciesForStopLocationsGroup(g) + .stream() + .map(s -> s.getId().toString()) + .toList(); + return new LuceneStopCluster( g.getId(), null, g.getName().toString(), toCoordinate(g.getCoordinate()), - modes + modes, + agencies ); } - Optional map(StopLocation sl) { + Optional map(StopLocation sl) { + var agencies = agenciesForStopLocation(sl).stream().map(a -> a.getId().toString()).toList(); return Optional .ofNullable(sl.getName()) .map(name -> { var modes = transitService.getModesOfStopLocation(sl).stream().map(Enum::name).toList(); - return new StopCluster( + return new LuceneStopCluster( sl.getId(), sl.getCode(), name.toString(), toCoordinate(sl.getCoordinate()), - modes + modes, + agencies ); }); } + private List agenciesForStopLocation(StopLocation stop) { + return transitService.getRoutesForStop(stop).stream().map(Route::getAgency).distinct().toList(); + } + + private List agenciesForStopLocationsGroup(StopLocationsGroup group) { + return group + .getChildStops() + .stream() + .flatMap(sl -> agenciesForStopLocation(sl).stream()) + .distinct() + .toList(); + } + private static StopCluster.Coordinate toCoordinate(WgsCoordinate c) { return new StopCluster.Coordinate(c.latitude(), c.longitude()); } + static StopCluster.Agency toAgency(Agency a) { + return new StopCluster.Agency(a.getId(), a.getName()); + } + + static StopCluster.FeedPublisher toFeedPublisher(FeedInfo fi) { + if (fi == null) { + return null; + } else { + return new StopCluster.FeedPublisher(fi.getPublisherName()); + } + } + private record DeduplicationKey(I18NString name, WgsCoordinate coordinate) {} } diff --git a/src/main/java/org/opentripplanner/apis/APIEndpoints.java b/src/main/java/org/opentripplanner/apis/APIEndpoints.java index 959815f1716..b6b70eb238e 100644 --- a/src/main/java/org/opentripplanner/apis/APIEndpoints.java +++ b/src/main/java/org/opentripplanner/apis/APIEndpoints.java @@ -63,6 +63,8 @@ private APIEndpoints() { addIfEnabled(SandboxAPIMapboxVectorTilesApi, VectorTilesResource.class); addIfEnabled(SandboxAPIParkAndRideApi, ParkAndRideResource.class); addIfEnabled(SandboxAPIGeocoder, GeocoderResource.class); + // scheduled to be removed and only here for backwards compatibility + addIfEnabled(SandboxAPIGeocoder, GeocoderResource.GeocoderResourceOldPath.class); addIfEnabled(SandboxAPITravelTime, TravelTimeResource.class); // scheduled to be removed diff --git a/src/test/java/org/opentripplanner/transit/service/DefaultTransitServiceTest.java b/src/test/java/org/opentripplanner/transit/service/DefaultTransitServiceTest.java index 2a26dc681be..f04fe782a0a 100644 --- a/src/test/java/org/opentripplanner/transit/service/DefaultTransitServiceTest.java +++ b/src/test/java/org/opentripplanner/transit/service/DefaultTransitServiceTest.java @@ -43,6 +43,8 @@ static void setup() { .build(); var transitModel = new TransitModel(stopModel, new Deduplicator()); + transitModel.addTripPattern(RAIL_PATTERN.getId(), RAIL_PATTERN); + transitModel.index(); service = new DefaultTransitService(transitModel) {