diff --git a/docs/onebusaway-gtfs-transformer-cli.md b/docs/onebusaway-gtfs-transformer-cli.md index ec386d41..efe4c6cc 100644 --- a/docs/onebusaway-gtfs-transformer-cli.md +++ b/docs/onebusaway-gtfs-transformer-cli.md @@ -20,6 +20,7 @@ * [Path Expressions](#path-expressions-) * [Retain an Entity](#retain-an-entity) * [Remove an Entity](#remove-an-entity) + * [Retain Up From Polygon](#retain-up-from-polygon) * [Trim a Trip](#trim-a-trip) * [Generate Stop Times](#generate-stop-times) * [Extend Service Calendars](#extend-service-calendars) @@ -270,6 +271,30 @@ You can remove a specific entity from a feed. Note that removing an entity has a cascading effect. If you remove a trip, all the stop times that depend on that trip will also be removed. If you remove a route, all the trips and stop times for that route will be removed. + +#### Retain Up From Polygon + +Retain Up From Polygon is an operation that filters GTFS input data based on a specified geographic area, using a polygon defined in WKT (Well-Known Text) format, which is configurable in the JSON transformer snippet. + +This strategy applies two main functions: + + * **Retain Function**: retains **up** all stops, trips, and routes that are located inside the defined polygon. + + The algorithm starts by applying retain up to each entity, traversing the entity dependency tree. Starting from the stop, retain up is applied to the stop_times referencing this stop, then to the trips, and so on. + + Once the base of the entity tree is reached, it automatically applies retain **down** to all the traversed entities. Therefore, all the trips of the route and then all the stop_times of each trip will be tagged as **retain**. + + * **Remove Function**: any entities not retained within the polygon are removed. + +This strategy ensures that the GTFS output retains only the entities directly or indirectly linked to the geographical area concerned. + +**Parameters**: + + * **polygon**: a required argument, which accepts the polygon in WKT format using the WGS84 coordinate system (SRID: 4326). This polygon defines the area of interest for filtering. + +``` +{"op":"transform","class":"org.onebusaway.gtfs_transformer.impl.RetainUpFromPolygon","polygon":"POLYGON ((-123.0 37.0, -123.0 38.0, -122.0 38.0, -122.0 37.0, -123.0 37.0))"} +``` #### Trim a Trip diff --git a/onebusaway-gtfs-transformer/pom.xml b/onebusaway-gtfs-transformer/pom.xml index dfbfa636..a5b18a6b 100644 --- a/onebusaway-gtfs-transformer/pom.xml +++ b/onebusaway-gtfs-transformer/pom.xml @@ -77,6 +77,11 @@ com.sun.xml.bind jaxb-impl + + org.locationtech.jts + jts-core + 1.19.0 + diff --git a/onebusaway-gtfs-transformer/src/main/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygon.java b/onebusaway-gtfs-transformer/src/main/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygon.java new file mode 100644 index 00000000..d7adfb52 --- /dev/null +++ b/onebusaway-gtfs-transformer/src/main/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygon.java @@ -0,0 +1,99 @@ +package org.onebusaway.gtfs_transformer.impl; + +import java.util.ArrayList; +import java.util.List; + +import java.io.Serializable; + +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.geom.*; + +import org.onebusaway.csv_entities.schema.annotations.CsvField; +import org.onebusaway.gtfs.model.IdentityBean; +import org.onebusaway.gtfs.model.Stop; +import org.onebusaway.gtfs.serialization.GtfsEntitySchemaFactory; +import org.onebusaway.gtfs.services.GtfsMutableRelationalDao; +import org.onebusaway.gtfs_transformer.factory.EntityRetentionGraph; +import org.onebusaway.gtfs_transformer.services.GtfsTransformStrategy; +import org.onebusaway.gtfs_transformer.services.TransformContext; + +public class RetainUpFromPolygon implements GtfsTransformStrategy { + + @CsvField(optional = false) + private String polygon; + + @CsvField(ignore = true) + private Geometry polygonGeometry; + + public void setPolygon(String polygon) { + this.polygon = polygon; + this.polygonGeometry = buildPolygon(polygon); + + if (this.polygonGeometry == null || !this.polygonGeometry.isValid() || this.polygonGeometry.isEmpty()) { + throw new IllegalArgumentException("The provided polygon is invalid or empty."); + } + } + + @Override + public String getName() { + return this.getClass().getSimpleName(); + } + + @Override + public void run(TransformContext transformContext, GtfsMutableRelationalDao gtfsMutableRelationalDao) { + EntityRetentionGraph graph = new EntityRetentionGraph(gtfsMutableRelationalDao); + graph.setRetainBlocks(false); + // browse all stops and retain only those inside polygon/multipolygon + for (Stop stop : gtfsMutableRelationalDao.getAllStops()) { + if (insidePolygon(polygonGeometry,stop.getLon(),stop.getLat())){ + graph.retain(stop, true); + } + } + + // remove non retained objects + for (Class entityClass : GtfsEntitySchemaFactory.getEntityClasses()) { + List objectsToRemove = new ArrayList(); + for (Object entity : gtfsMutableRelationalDao.getAllEntitiesForType(entityClass)) { + if (!graph.isRetained(entity)){ + objectsToRemove.add(entity); + } + } + for (Object toRemove : objectsToRemove){ + gtfsMutableRelationalDao.removeEntity((IdentityBean) toRemove); + } + } + } + + /* + * Creates a Geometry object (polygon or multi-polygon) from the provided WKT string. + * + * @param polygonWKT The WKT representation of the polygon. + * @return The Geometry object. + * @throws IllegalArgumentException if the WKT string is invalid or cannot be parsed. + */ + private Geometry buildPolygon(String polygonWKT) { + WKTReader reader = new WKTReader(); + try{ + return reader.read(polygonWKT); + } catch (ParseException e){ + throw new IllegalArgumentException( + String.format("Error parsing WKT string: %s", e.getMessage()), e + ); + } + } + /* + * insidePolygon Checks whether a given point (specified by its longitude and latitude) is inside a given polygon or multipolygon. + * + * @param geometry The Geometry object representing the polygon or multipolygon. + * @param lon the longitude of the point to check. + * @param lat the latitude of the point to check. + * @return true if the point is within the boundaries of the geometry; false otherwise. + */ + private boolean insidePolygon(Geometry geometry, double lon, double lat) { + GeometryFactory geometryFactory = new GeometryFactory(); + Point point = geometryFactory.createPoint(new Coordinate(lon, lat)); + return geometry.contains(point); + } + +} \ No newline at end of file diff --git a/onebusaway-gtfs-transformer/src/test/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygonTest.java b/onebusaway-gtfs-transformer/src/test/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygonTest.java new file mode 100644 index 00000000..a00febd0 --- /dev/null +++ b/onebusaway-gtfs-transformer/src/test/java/org/onebusaway/gtfs_transformer/impl/RetainUpFromPolygonTest.java @@ -0,0 +1,76 @@ +package org.onebusaway.gtfs_transformer.impl; +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.onebusaway.gtfs.services.GtfsMutableRelationalDao; +import org.onebusaway.gtfs.services.MockGtfs; +import org.onebusaway.gtfs_transformer.services.TransformContext; + +public class RetainUpFromPolygonTest { + + private RetainUpFromPolygon retainUpFromPolygon = new RetainUpFromPolygon(); + private TransformContext _context = new TransformContext(); + private MockGtfs _gtfs; + + @BeforeEach + public void setup() throws IOException{ + + _gtfs = MockGtfs.create(); + // Insert mock data into the GTFS for testing: + // 1 agency + _gtfs.putAgencies(1); + // 4 routes + _gtfs.putRoutes(4); + // 4 trips + _gtfs.putTrips(4, "r$0","sid$0"); + // 8 stops + _gtfs.putStops(8); + // 13 stop times + _gtfs.putLines("stop_times.txt", + "trip_id,arrival_time,departure_time,stop_id,stop_sequence,stop_headsign,pickup_type,drop_off_type,shape_dist_traveled", + // Trip t0: sequence of stops s0,s1,s2,s3 + "t0,08:00:00,08:25:00,s0,0,,,,", + "t0,08:30:00,08:55:00,s1,1,,,,", + "t0,09:00:00,09:55:00,s2,2,,,,", + "t0,10:00:00,10:30:00,s3,3,,,,", + // Trip t1: reverse sequence of stops s3,s2,s1,s0 + "t1,08:00:00,08:25:00,s3,0,,,,", + "t1,08:30:00,08:55:00,s2,1,,,,", + "t1,09:00:00,09:55:00,s1,2,,,,", + "t1,10:00:00,10:00:00,s0,3,,,,", + // Trip t2: sequence of stops s3,s4,s5 + "t2,10:00:00,10:55:00,s3,0,,,,", + "t2,11:00:00,11:25:00,s4,1,,,,", + "t2,11:30:00,11:55:00,s5,2,,,,", + // Trip t3: Additional stops + "t3,12:00:00,12:25:00,s6,0,,,,", + "t3,12:30:00,12:55:00,s7,1,,,,"); + } + + @Test + public void testRetainUpFromPolygonTest() throws IOException { + GtfsMutableRelationalDao dao = _gtfs.read(); + + // Define a polygon in WKT (Well-Known Text) format + // This polygon is designed to include only the first 4 stops (S0 to S4) + String polygonWKT = "POLYGON ((-122.308 47.653, -122.308 47.666, -122.307 47.666, -122.307 47.665, -122.307 47.661, -122.307 47.657, -122.307 47.653, -122.308 47.653))"; + retainUpFromPolygon.setPolygon(polygonWKT); + + // Execute the retainUpFromPolygon strategy based on the polygon + retainUpFromPolygon.run(_context, dao); + + // Verify that the number of routes is reduced to 3 + assertEquals(3,dao.getAllRoutes().size()); + + // Verify that the number of trips is reduced to 3 + assertEquals(3,dao.getAllTrips().size()); + + // Verify that the number of stops is reduced to 6 + assertEquals(6,dao.getAllStops().size()); + + // Verify that the number of stop times is reduced to 11 + assertEquals(11,dao.getAllStopTimes().size()); + } +} \ No newline at end of file