Skip to content

Commit 6010f9e

Browse files
authored
feat: add gbfs bounding box (#1393)
1 parent c671504 commit 6010f9e

File tree

14 files changed

+230
-61
lines changed

14 files changed

+230
-61
lines changed

api/src/feeds/impl/models/gbfs_feed_impl.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from feeds.impl.models.bounding_box_impl import BoundingBoxImpl
12
from feeds.impl.models.feed_impl import FeedImpl
23
from feeds.impl.models.gbfs_version_impl import GbfsVersionImpl
34
from shared.database_gen.sqlacodegen_models import Gbfsfeed as GbfsFeedOrm
@@ -29,4 +30,6 @@ def from_orm(cls, feed: GbfsFeedOrm | None) -> GbfsFeed | None:
2930
if feed.gbfsversions
3031
else []
3132
)
33+
gbfs_feed.bounding_box = BoundingBoxImpl.from_orm(feed.bounding_box)
34+
gbfs_feed.bounding_box_generated_at = feed.bounding_box_generated_at
3235
return gbfs_feed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import unittest
2+
from datetime import datetime
3+
4+
from geoalchemy2 import WKTElement
5+
6+
from feeds_gen.models.source_info import SourceInfo
7+
from shared.database_gen.sqlacodegen_models import Gbfsfeed, Location, Gbfsversion
8+
from feeds.impl.models.gbfs_feed_impl import GbfsFeedImpl
9+
from feeds.impl.models.location_impl import LocationImpl
10+
from feeds.impl.models.gbfs_version_impl import GbfsVersionImpl
11+
from feeds.impl.models.bounding_box_impl import BoundingBoxImpl
12+
13+
POLYGON = "POLYGON ((3.0 1.0, 4.0 1.0, 4.0 2.0, 3.0 2.0, 3.0 1.0))"
14+
15+
16+
class TestGbfsFeedImpl(unittest.TestCase):
17+
def setUp(self):
18+
self.location_orm = Location(
19+
id="loc1",
20+
country_code="US",
21+
country="United States",
22+
subdivision_name="California",
23+
municipality="San Francisco",
24+
)
25+
self.version_orm = Gbfsversion(
26+
id="ver1",
27+
version="2.2",
28+
url="https://example.com/gbfs.json",
29+
)
30+
self.bounding_box_orm = WKTElement(POLYGON, srid=4326)
31+
self.feed_orm = Gbfsfeed(
32+
id="feed1",
33+
stable_id="feed_stable_1",
34+
created_at=datetime(2024, 1, 1, 10, 0, 0),
35+
data_type="gbfs",
36+
system_id="sys1",
37+
operator_url="https://provider.com",
38+
locations=[self.location_orm],
39+
gbfsversions=[self.version_orm],
40+
bounding_box=self.bounding_box_orm,
41+
bounding_box_generated_at=datetime(2024, 1, 1, 12, 0, 0),
42+
authentication_type=0,
43+
authentication_info_url="https://auth.info",
44+
api_key_parameter_name="api_key",
45+
license_url="https://license.info",
46+
)
47+
48+
def test_from_orm_all_fields(self):
49+
expected = GbfsFeedImpl(
50+
id="feed_stable_1",
51+
system_id="sys1",
52+
data_type="gbfs",
53+
created_at=datetime(2024, 1, 1, 10, 0, 0),
54+
external_ids=[],
55+
redirects=[],
56+
provider_url="https://provider.com",
57+
locations=[LocationImpl.from_orm(self.location_orm)],
58+
versions=[GbfsVersionImpl.from_orm(self.version_orm)],
59+
bounding_box=BoundingBoxImpl.from_orm(self.bounding_box_orm),
60+
bounding_box_generated_at=datetime(2024, 1, 1, 12, 0, 0),
61+
source_info=SourceInfo(
62+
producer_url=None,
63+
authentication_type=0,
64+
authentication_info_url="https://auth.info",
65+
api_key_parameter_name="api_key",
66+
license_url="https://license.info",
67+
),
68+
)
69+
result = GbfsFeedImpl.from_orm(self.feed_orm)
70+
self.assertEqual(result, expected)
71+
72+
def test_from_orm_empty_fields(self):
73+
feed_orm = Gbfsfeed(
74+
id="feed2",
75+
stable_id="feed_stable_2",
76+
system_id=None,
77+
operator_url=None,
78+
locations=[],
79+
gbfsversions=[],
80+
bounding_box=None,
81+
bounding_box_generated_at=None,
82+
)
83+
expected = GbfsFeedImpl(
84+
id="feed_stable_2",
85+
system_id=None,
86+
provider_url=None,
87+
external_ids=[],
88+
redirects=[],
89+
locations=[],
90+
versions=[],
91+
bounding_box=None,
92+
bounding_box_generated_at=None,
93+
source_info=SourceInfo(
94+
producer_url=None,
95+
authentication_type=None,
96+
authentication_info_url=None,
97+
api_key_parameter_name=None,
98+
license_url=None,
99+
),
100+
)
101+
result = GbfsFeedImpl.from_orm(feed_orm)
102+
self.assertEqual(result, expected)
103+
104+
def test_from_orm_none(self):
105+
result = GbfsFeedImpl.from_orm(None)
106+
self.assertIsNone(result)

docs/DatabaseCatalogAPI.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,13 @@ components:
512512
type: array
513513
items:
514514
$ref: "#/components/schemas/GbfsVersion"
515+
bounding_box:
516+
$ref: "#/components/schemas/BoundingBox"
517+
bounding_box_generated_at:
518+
description: The date and time the bounding box was generated, in ISO 8601 date-time format.
519+
type: string
520+
example: 2023-07-10T22:06:00Z
521+
format: date-time
515522

516523
GbfsVersion:
517524
type: object

functions-python/reverse_geolocation/src/reverse_geolocation_processor.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,10 @@ def get_storage_client():
219219

220220
@track_metrics(metrics=("time", "memory", "cpu"))
221221
def update_dataset_bounding_box(
222-
gtfs_dataset: Gtfsdataset, stops_df: pd.DataFrame, db_session: Session
222+
feed: Gtfsfeed | Gbfsfeed,
223+
gtfs_dataset: Gtfsdataset,
224+
stops_df: pd.DataFrame,
225+
db_session: Session,
223226
) -> shapely.Polygon:
224227
"""
225228
Update the bounding box of the dataset using the stops DataFrame.
@@ -237,16 +240,17 @@ def update_dataset_bounding_box(
237240
f")",
238241
srid=4326,
239242
)
240-
if not gtfs_dataset:
241-
return to_shape(bounding_box)
242-
gtfs_feed = db_session.get(Gtfsfeed, gtfs_dataset.feed_id)
243-
if not gtfs_feed:
244-
raise ValueError(
245-
f"GTFS feed for dataset {gtfs_dataset.stable_id} does not exist in the database."
246-
)
247-
gtfs_feed.bounding_box = bounding_box
248-
gtfs_feed.bounding_box_dataset = gtfs_dataset
249-
gtfs_dataset.bounding_box = bounding_box
243+
if feed.data_type == "gtfs":
244+
if not gtfs_dataset:
245+
return to_shape(bounding_box)
246+
feed.bounding_box = bounding_box
247+
feed.bounding_box_dataset = gtfs_dataset
248+
gtfs_dataset.bounding_box = bounding_box
249+
elif feed.data_type == "gbfs":
250+
feed.bounding_box = bounding_box
251+
feed.bounding_box_generated_at = get_db_timestamp(db_session)
252+
else:
253+
raise ValueError("The data type must be either 'gtfs' or 'gbfs'.")
250254

251255
return to_shape(bounding_box)
252256

@@ -349,7 +353,12 @@ def reverse_geolocation_process(
349353
gtfs_dataset = load_dataset(dataset_id, db_session)
350354
feed = load_feed(stable_id, data_type, logger, db_session)
351355

352-
bounding_box = update_dataset_bounding_box(gtfs_dataset, stops_df, db_session)
356+
bounding_box = update_dataset_bounding_box(
357+
feed=feed,
358+
gtfs_dataset=gtfs_dataset,
359+
stops_df=stops_df,
360+
db_session=db_session,
361+
)
353362

354363
location_groups = reverse_geolocation(
355364
strategy=strategy,

functions-python/reverse_geolocation/tests/test_reverse_geolocation_processor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ def test_update_dataset_bounding_box_success(self, db_session):
368368
feed_id = faker.uuid4(cast_to=str)
369369
feed = Gtfsfeed(
370370
id=feed_id,
371+
data_type="gtfs",
371372
stable_id=faker.uuid4(cast_to=str),
372373
)
373374
dataset_id = faker.uuid4(cast_to=str)
@@ -391,7 +392,7 @@ def test_update_dataset_bounding_box_success(self, db_session):
391392

392393
# Call the function
393394
bounding_box = update_dataset_bounding_box(
394-
dataset, stops_df, db_session=db_session
395+
feed, dataset, stops_df, db_session=db_session
395396
)
396397

397398
# Expected bounding box: POLYGON((30 10, 40 10, 40 20, 30 20, 30 10))
@@ -425,7 +426,7 @@ def test_update_dataset_bounding_box_exception(self, db_session):
425426

426427
with self.assertRaises(Exception):
427428
update_dataset_bounding_box(
428-
faker.uuid4(cast_to=str), stops_df, db_session=db_session
429+
MagicMock(), faker.uuid4(cast_to=str), stops_df, db_session=db_session
429430
)
430431

431432
@patch("reverse_geolocation_processor.parse_request_parameters")

liquibase/changelog.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,5 @@
7070
<include file="changes/feat_pt_152.sql" relativeToChangelogFile="true"/>
7171
<include file="changes/feat_fix_geolocation_circular_dep.sql" relativeToChangelogFile="true"/>
7272
<include file="changes/feat_1325.sql" relativeToChangelogFile="true"/>
73+
<include file="changes/feat_pt_156.sql" relativeToChangelogFile="true"/>
7374
</databaseChangeLog>

liquibase/changes/feat_pt_156.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add the new columns to gbfsfeed (if not already added)
2+
-- No foreign keys to versions as versions are not kept between updates
3+
ALTER TABLE gbfsfeed
4+
ADD COLUMN IF NOT EXISTS bounding_box geometry(Polygon, 4326);
5+
ALTER TABLE gbfsfeed
6+
ADD COLUMN IF NOT EXISTS bounding_box_generated_at TIMESTAMP DEFAULT NULL;

web-app/public/locales/en/feeds.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
"gtfsVisualizationTooltip": "View the routes and stops of the feed",
7777
"detailedCoveredAreaView": "Detailed",
7878
"detailedCoveredAreaViewTooltip": "View the detailed covered area of the feed",
79-
"unableToGenerateBoundingBox": "Unable to generate bounding box from the feed's stops.txt file.",
79+
"unableToGenerateBoundingBox": "Unable to generate bounding box from the feed",
8080
"unableToGetGbfsMap": "Error fetching GBFS Map",
8181
"areYouOfficialProducer": "Are you the official producer or transit agency responsible for this data ?",
8282
"isOfficialSource": "Is this an official data source?",

0 commit comments

Comments
 (0)