Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:locations.geojson file parsing and geometry validation #1879

Merged
merged 14 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2024 MobilityData LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mobilitydata.gtfsvalidator.notice;

import static org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRef.TERM_DEFINITIONS;
import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRefs;

/**
* A polygon in `locations.geojson` is unparsable or invalid.
*
* <p>Each polygon must be valid by the definition of the <a
* href="http://www.opengis.net/doc/is/sfa/1.2.1" target="_blank"> OpenGIS Simple Features
* Specification, section 6.1.11 </a>.
*/
@GtfsValidationNotice(severity = ERROR, sections = @SectionRefs(TERM_DEFINITIONS))
public class InvalidGeometryNotice extends ValidationNotice {

/** The id of the faulty record. */
private final String featureId;

/** The geometry type of the feature containing the invalid polygon. */
private final String geometryType;

/** The validation error details. */
private final String message;

public InvalidGeometryNotice(String featureId, String geometryType, String validationError) {
cka-y marked this conversation as resolved.
Show resolved Hide resolved
this.featureId = featureId;
this.geometryType = geometryType;
this.message = validationError;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2024 MobilityData LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mobilitydata.gtfsvalidator.notice;

import static org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRef.TERM_DEFINITIONS;
import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRefs;

/** A required element is missing in `locations.geojson`. */
@GtfsValidationNotice(severity = ERROR, sections = @SectionRefs(TERM_DEFINITIONS))
public class MissingRequiredElementNotice extends ValidationNotice {
/** Index of the feature in the feature collection. */
private final int featureIndex;

/** The id of the faulty record. */
private final String featureId;

/** The missing required element. */
private final String missingElement;

public MissingRequiredElementNotice(String featureId, String missingElement, int featureIndex) {
this.featureId = featureId;
this.featureIndex = featureIndex;
this.missingElement = missingElement;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2024 MobilityData LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mobilitydata.gtfsvalidator.notice;

import static org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRef.TERM_DEFINITIONS;
import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRefs;

/**
* A GeoJSON feature has an unsupported geometry type in `locations.geojson`.
*
* <p>Each feature must have a geometry type that is supported by the GTFS spec. The supported
* geometry types `Polygon` and `MultiPolygon`.
*/
@GtfsValidationNotice(severity = ERROR, sections = @SectionRefs(TERM_DEFINITIONS))
public class UnsupportedGeometryTypeNotice extends ValidationNotice {

/** The index of the feature in the feature collection. */
private final int featureIndex;

/** The id of the faulty record. */
private final String featureId;

/** The geometry type of the faulty record. */
private final String geometryType;

public UnsupportedGeometryTypeNotice(int featureIndex, String featureId, String geometryType) {
this.featureIndex = featureIndex;
this.featureId = featureId;
this.geometryType = geometryType;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
* This class is the parent of containers holding table (csv) entities and containers holding JSON
* entities
*
* @param <T> The entity for this container (e.g. GtfsCalendarDate or GtfsGeojsonFeature )
* @param <T> The entity for this container (e.g. GtfsCalendarDate or GtfsGeoJSONFeature )
* @param <D> The descriptor for the file for the container (e.g. GtfsCalendarDateTableDescriptor or
* GtfsGeojsonFileDescriptor)
* GtfsGeoJSONFileDescriptor)
*/
public abstract class GtfsEntityContainer<T extends GtfsEntity, D extends GtfsFileDescriptor> {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

/**
* This class provides some info about the different files within a GTFS dataset. Its children
* relate to either a csv table or a geojson file.
* relate to either a csv table or a GeoJSON file.
*
* @param <T> The entity that will be extracted from the file. For example, GtfsCalendarDate or
* GtfsGeojsonFeature
* GtfsGeoJSONFeature
*/
public abstract class GtfsFileDescriptor<T extends GtfsEntity> {

Expand Down
1 change: 1 addition & 0 deletions main/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
implementation 'org.thymeleaf:thymeleaf:3.0.15.RELEASE'
implementation 'com.vladsch.flexmark:flexmark-all:0.64.8'
implementation 'io.github.classgraph:classgraph:4.8.146'
implementation 'org.locationtech.jts:jts-core:1.20.0'
testImplementation group: 'junit', name: 'junit', version: '4.13'
testImplementation 'com.google.truth:truth:1.0.1'
testImplementation 'com.google.truth.extensions:truth-java8-extension:1.0.1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,11 +605,16 @@ public ImmutableSortedSet<String> getFilenames() {
}

public Boolean hasFlexFeatures() {
return specFeatures.keySet().stream()
.anyMatch(
feature ->
feature.getFeatureGroup() != null
&& feature.getFeatureGroup().equals("Flexible Services")
&& specFeatures.get(feature));
try {
return specFeatures.keySet().stream()
.anyMatch(
feature ->
feature.getFeatureGroup() != null
&& feature.getFeatureGroup().equals("Flexible Services")
&& specFeatures.get(feature));

} catch (Exception e) {
return false;
}
cka-y marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package org.mobilitydata.gtfsvalidator.table;

import com.google.common.flogger.FluentLogger;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.*;
import org.mobilitydata.gtfsvalidator.notice.*;
import org.mobilitydata.gtfsvalidator.util.geojson.GeoJSONGeometryValidator;
import org.mobilitydata.gtfsvalidator.util.geojson.GeometryType;
import org.mobilitydata.gtfsvalidator.util.geojson.UnparsableGeoJSONFeatureException;
import org.mobilitydata.gtfsvalidator.validator.ValidatorProvider;

/**
* This class knows how to load a GeoJSON file. Typical GeoJSON file: { "type": "FeatureCollection",
* "features": [ { "id": "area_548", "type": "Feature", "geometry": { "type": "Polygon",
* "coordinates": [ [ [ -122.4112929, 48.0834848 ], ... ] ] }, "properties": { "stop_name": "Some
* name", "stop_desc": "Some description" } }, ... ] }
*/
public class GeoJSONFileLoader extends TableLoader {
cka-y marked this conversation as resolved.
Show resolved Hide resolved
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private GeoJSONGeometryValidator geometryValidator;

@Override
public GtfsEntityContainer load(
GtfsFileDescriptor fileDescriptor,
ValidatorProvider validatorProvider,
InputStream inputStream,
NoticeContainer noticeContainer) {
GtfsGeoJSONFileDescriptor geoJSONFileDescriptor = (GtfsGeoJSONFileDescriptor) fileDescriptor;
geometryValidator = new GeoJSONGeometryValidator(noticeContainer);
try {
List<GtfsGeoJSONFeature> entities = extractFeaturesFromStream(inputStream, noticeContainer);
return geoJSONFileDescriptor.createContainerForEntities(entities, noticeContainer);
} catch (JsonParseException jpex) {
// TODO: Add a notice for malformed locations.geojson
logger.atSevere().withCause(jpex).log("Malformed JSON in locations.geojson");
return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS);
} catch (IOException ioex) {
noticeContainer.addSystemError(new IOError(ioex));
return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS);
} catch (UnparsableGeoJSONFeatureException ugex) {
logger.atSevere().withCause(ugex).log("Unparsable GeoJSON feature");
return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS);
} catch (Exception ex) {
logger.atSevere().withCause(ex).log("Error while loading locations.geojson");
return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS);
}
}

public List<GtfsGeoJSONFeature> extractFeaturesFromStream(
InputStream inputStream, NoticeContainer noticeContainer)
throws IOException, UnparsableGeoJSONFeatureException {
List<GtfsGeoJSONFeature> features = new ArrayList<>();
boolean hasUnparsableFeature = false;
try (InputStreamReader reader = new InputStreamReader(inputStream)) {
JsonObject jsonObject = JsonParser.parseReader(reader).getAsJsonObject();
JsonArray featuresArray = jsonObject.getAsJsonArray("features");
int i = 0;
for (JsonElement feature : featuresArray) {
GtfsGeoJSONFeature gtfsGeoJSONFeature = extractFeature(feature, noticeContainer, i++);
hasUnparsableFeature |= gtfsGeoJSONFeature == null;
if (gtfsGeoJSONFeature != null) {
features.add(gtfsGeoJSONFeature);
}
}
}
cka-y marked this conversation as resolved.
Show resolved Hide resolved
if (hasUnparsableFeature) {
throw new UnparsableGeoJSONFeatureException("Unparsable GeoJSON feature");
}
return features;
}

public GtfsGeoJSONFeature extractFeature(
JsonElement feature, NoticeContainer noticeContainer, int featureIndex) {
GtfsGeoJSONFeature gtfsGeoJSONFeature;
List<String> missingRequiredFields = new ArrayList<>();
String featureId = null;
if (feature.isJsonObject()) {
JsonObject featureObject = feature.getAsJsonObject();
// Handle feature id
if (!featureObject.has(GtfsGeoJSONFeature.FEATURE_ID_FIELD_NAME)) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.FEATURE_ID_FIELD_NAME);
} else {
featureId = featureObject.get(GtfsGeoJSONFeature.FEATURE_ID_FIELD_NAME).getAsString();
if (featureId == null || featureId.isEmpty()) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.FEATURE_ID_FIELD_NAME);
}
}

// Handle properties
if (!featureObject.has(GtfsGeoJSONFeature.FEATURE_PROPERTIES_FIELD_NAME)) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.FEATURE_PROPERTIES_FIELD_NAME);
} else {
// TODO: parse stop_name and stop_desc
}

// Handle geometry
if (!featureObject.has(GtfsGeoJSONFeature.GEOMETRY_FIELD_NAME)) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.GEOMETRY_FIELD_NAME);
} else {
JsonObject geometry = featureObject.getAsJsonObject(GtfsGeoJSONFeature.GEOMETRY_FIELD_NAME);
// Handle geometry type and coordinates
if (!geometry.has(GtfsGeoJSONFeature.GEOMETRY_TYPE_FIELD_NAME)) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.GEOMETRY_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.GEOMETRY_TYPE_FIELD_NAME);
} else if (!geometry.has(GtfsGeoJSONFeature.GEOMETRY_COORDINATES_FIELD_NAME)) {
missingRequiredFields.add(
GtfsGeoJSONFeature.FEATURE_COLLECTION_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.GEOMETRY_FIELD_NAME
+ '.'
+ GtfsGeoJSONFeature.GEOMETRY_COORDINATES_FIELD_NAME);
} else if (missingRequiredFields
.isEmpty()) { // All required fields are present - Validate geometry
// Create a new GtfsGeoJsonFeature
gtfsGeoJSONFeature = new GtfsGeoJSONFeature();
gtfsGeoJSONFeature.setFeatureId(
featureObject.get(GtfsGeoJSONFeature.FEATURE_ID_FIELD_NAME).getAsString());

String type = geometry.get(GtfsGeoJSONFeature.GEOMETRY_TYPE_FIELD_NAME).getAsString();

if (type.equals(GeometryType.POLYGON.getType())) {
gtfsGeoJSONFeature.setGeometryType(GeometryType.POLYGON);
Polygon polygon =
geometryValidator.createPolygon(
geometry.getAsJsonArray(GtfsGeoJSONFeature.GEOMETRY_COORDINATES_FIELD_NAME),
gtfsGeoJSONFeature);
if (polygon == null) return null;
gtfsGeoJSONFeature.setGeometryDefinition(polygon);

} else if (type.equals(GeometryType.MULTI_POLYGON.getType())) {
gtfsGeoJSONFeature.setGeometryType(GeometryType.MULTI_POLYGON);
MultiPolygon multiPolygon =
geometryValidator.createMultiPolygon(
geometry.getAsJsonArray(GtfsGeoJSONFeature.GEOMETRY_COORDINATES_FIELD_NAME),
gtfsGeoJSONFeature);
if (multiPolygon == null) return null;
gtfsGeoJSONFeature.setGeometryDefinition(multiPolygon);

} else {
noticeContainer.addValidationNotice(
new UnsupportedGeometryTypeNotice(featureIndex, featureId, type));
}

return gtfsGeoJSONFeature;
}
}
}
addMissingRequiredFieldsNotice(missingRequiredFields, noticeContainer, featureId, featureIndex);
return null;
}

private static void addMissingRequiredFieldsNotice(
List<String> missingRequiredFields,
NoticeContainer noticeContainer,
String featureId,
int featureIndex) {
if (featureId == null) {
featureId = "N/A";
}
cka-y marked this conversation as resolved.
Show resolved Hide resolved
for (String missingRequiredField : missingRequiredFields) {
noticeContainer.addValidationNotice(
new MissingRequiredElementNotice(featureId, missingRequiredField, featureIndex));
}
}
}
Loading
Loading