Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2caf6fa
first stable implementation
davidgamez Aug 13, 2025
3cd15ad
revisit all functions and clean-up code
davidgamez Aug 13, 2025
e55899b
fix duplicate reverse geolocation and add gtfs to the verifier
davidgamez Aug 14, 2025
38f6057
Merge branch 'main' into feat/reverse_geolocation_per_polygon_strategy
davidgamez Aug 15, 2025
fdf9bd4
Update Feedlocationgrouppoint implementation
davidgamez Aug 18, 2025
e019989
Fix location primary key violation
davidgamez Aug 18, 2025
861ee0d
Fix failing tests
davidgamez Aug 18, 2025
94b3649
fix failing test
davidgamez Aug 19, 2025
9fe5ac7
increase reverse_geolocation_processor test coverage
davidgamez Aug 19, 2025
720cf8d
Increase per polygon test coverage
davidgamez Aug 19, 2025
186562b
fix per polygon batch
davidgamez Aug 19, 2025
1b5d27c
fix number of stops per cluster
davidgamez Aug 19, 2025
6862ba4
fix number of stops per cluster
davidgamez Aug 19, 2025
0f2e305
Increase locations helper test coverage
davidgamez Aug 19, 2025
dcf64a3
Add additional commit and make sure current stop is processed
davidgamez Aug 19, 2025
2997ce7
Fix task executor function URL
davidgamez Aug 19, 2025
062ebe6
Change the default strategy to per polygon
davidgamez Aug 20, 2025
f6c9cc8
Merge branch 'main' into feat/reverse_geolocation_per_polygon_strategy
davidgamez Aug 21, 2025
6e7eedb
Add maximum executions check
davidgamez Aug 22, 2025
492f2d2
Add missing environment variables
davidgamez Aug 22, 2025
3a08423
Fix location group cache saving
davidgamez Aug 25, 2025
0b02ee5
Fix duplicate calls
davidgamez Aug 25, 2025
3b7ff56
Add missing file
davidgamez Aug 25, 2025
139d9ba
Enhance logging
davidgamez Aug 25, 2025
badc9fa
Fix duplicate admin levels
davidgamez Aug 25, 2025
ffc4905
Fix lint
davidgamez Aug 25, 2025
ed05df1
Fix import
davidgamez Aug 25, 2025
b41101e
fix tests imports
davidgamez Aug 25, 2025
792ad45
Add create_refresh_materialized_view_task call
davidgamez Aug 25, 2025
40cec16
Fix logging percentage
davidgamez Aug 25, 2025
6c5729a
Add missing refresh view variables
davidgamez Aug 25, 2025
575db17
Add missing refresh view variables
davidgamez Aug 25, 2025
0626178
Improve task creation logs
davidgamez Aug 25, 2025
452ceb0
Fix gcp function parameter name
davidgamez Aug 25, 2025
b15eeef
add missing task name in call
davidgamez Aug 25, 2025
1aa0298
Merge branch 'main' into feat/reverse_geolocation_per_polygon_strategy
davidgamez Aug 26, 2025
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
22 changes: 14 additions & 8 deletions api/src/shared/common/gcp_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import json
import logging
import os
from google.cloud import tasks_v2
from google.protobuf.timestamp_pb2 import Timestamp

REFRESH_VIEW_TASK_EXECUTOR_BODY = json.dumps(
{"task": "refresh_materialized_view", "payload": {"dry_run": False}}
).encode()


def create_refresh_materialized_view_task():
"""
Expand Down Expand Up @@ -39,20 +44,19 @@ def create_refresh_materialized_view_task():
logging.debug("Queue name from env: %s", queue)
gcp_region = os.getenv("GCP_REGION")
environment_name = os.getenv("ENVIRONMENT")
url = f"https://{gcp_region}-" f"{project}.cloudfunctions.net/" f"tasks-executor-{environment_name}"

url = f"https://{gcp_region}-" f"{project}.cloudfunctions.net/" f"tasks_executor-{environment_name}"
# Enqueue the task
try:
create_http_task_with_name(
client=tasks_v2.CloudTasksClient(),
body=b"",
body=REFRESH_VIEW_TASK_EXECUTOR_BODY,
url=url,
project_id=project,
gcp_region=gcp_region,
queue_name=queue,
task_name=task_name,
task_time=proto_time,
http_method=tasks_v2.HttpMethod.GET,
http_method=tasks_v2.HttpMethod.POST,
)
logging.info("Scheduled refresh materialized view task for %s", task_name)
return {"message": "Refresh task for %s scheduled." % task_name}, 200
Expand Down Expand Up @@ -95,10 +99,12 @@ def create_http_task_with_name(
headers={"Content-Type": "application/json"},
),
)
logging.info("Task created with task_name: %s", task_name)
try:
response = client.create_task(parent=parent, task=task)
logging.info("Task created with task_name: %s", task_name)
except Exception as e:
logging.error("Error creating task: %s", e)
logging.error("response: %s", response)
logging.info("Successfully created task in create_http_task_with_name")
if "Requested entity already exists" in str(e):
logging.info("Task already exists for %s, skipping.", task_name)
else:
logging.error("Error creating task: %s", e)
logging.error("response: %s", response)
3 changes: 2 additions & 1 deletion functions-python/batch_datasets/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
from sqlalchemy.orm import Session

from shared.database_gen.sqlacodegen_models import Gtfsfeed, Gtfsdataset
from shared.dataset_service.main import BatchExecutionService, BatchExecution
from shared.dataset_service.dataset_service_commons import BatchExecution
from shared.dataset_service.main import BatchExecutionService
from shared.database.database import with_db_session
from shared.helpers.logger import init_logger

Expand Down
43 changes: 43 additions & 0 deletions functions-python/dataset_service/dataset_service_commons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional


# Status of the dataset trace
class Status(Enum):
FAILED = "FAILED"
SUCCESS = "SUCCESS"
PUBLISHED = "PUBLISHED"
NOT_PUBLISHED = "NOT_PUBLISHED"
PROCESSING = "PROCESSING"


# Stage of the pipeline
class PipelineStage(Enum):
DATASET_PROCESSING = "DATASET_PROCESSING"
LOCATION_EXTRACTION = "LOCATION_EXTRACTION"
GBFS_VALIDATION = "GBFS_VALIDATION"


# Dataset trace class to store the trace of a dataset
@dataclass
class DatasetTrace:
stable_id: str
status: Status
timestamp: datetime
dataset_id: Optional[str] = None
trace_id: Optional[str] = None
execution_id: Optional[str] = None
file_sha256_hash: Optional[str] = None
hosted_url: Optional[str] = None
pipeline_stage: PipelineStage = PipelineStage.DATASET_PROCESSING
error_message: Optional[str] = None


# Batch execution class to store the trace of a batch execution
@dataclass
class BatchExecution:
execution_id: str
timestamp: datetime
feeds_total: int
62 changes: 19 additions & 43 deletions functions-python/dataset_service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,30 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import importlib
import logging
import uuid
from datetime import datetime
from enum import Enum
from dataclasses import dataclass, asdict
from typing import Optional, Final
from dataclasses import asdict
from typing import Final
from google.cloud import datastore
from google.cloud.datastore import Client

# This allows the module to be run as a script or imported as a module
if __package__ is None or __package__ == "":
import os
import sys

sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import dataset_service_commons
else:
dataset_service_commons = importlib.import_module(
".dataset_service_commons", package=__package__
)

Status = dataset_service_commons.Status
PipelineStage = dataset_service_commons.PipelineStage
BatchExecution = dataset_service_commons.BatchExecution
DatasetTrace = dataset_service_commons.DatasetTrace

# This files contains the dataset trace and batch execution models and services.
# The dataset trace is used to store the trace of a dataset and the batch execution
Expand All @@ -30,45 +45,6 @@
# The persistent layer used is Google Cloud Datastore.


# Status of the dataset trace
class Status(Enum):
FAILED = "FAILED"
SUCCESS = "SUCCESS"
PUBLISHED = "PUBLISHED"
NOT_PUBLISHED = "NOT_PUBLISHED"
PROCESSING = "PROCESSING"


# Stage of the pipeline
class PipelineStage(Enum):
DATASET_PROCESSING = "DATASET_PROCESSING"
LOCATION_EXTRACTION = "LOCATION_EXTRACTION"
GBFS_VALIDATION = "GBFS_VALIDATION"


# Dataset trace class to store the trace of a dataset
@dataclass
class DatasetTrace:
stable_id: str
status: Status
timestamp: datetime
dataset_id: Optional[str] = None
trace_id: Optional[str] = None
execution_id: Optional[str] = None
file_sha256_hash: Optional[str] = None
hosted_url: Optional[str] = None
pipeline_stage: PipelineStage = PipelineStage.DATASET_PROCESSING
error_message: Optional[str] = None


# Batch execution class to store the trace of a batch execution
@dataclass
class BatchExecution:
execution_id: str
timestamp: datetime
feeds_total: int


dataset_trace_collection: Final[str] = "dataset_trace"
batch_execution_collection: Final[str] = "batch_execution"

Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@
from datetime import datetime
from unittest.mock import patch, MagicMock

from main import (
DatasetTrace,
DatasetTraceService,
Status,
BatchExecutionService,
BatchExecution,
)
from dataset_service_commons import DatasetTrace, Status, BatchExecution
from main import DatasetTraceService, BatchExecutionService


class TestDatasetService(unittest.TestCase):
Expand Down
1 change: 1 addition & 0 deletions functions-python/helpers/.coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ omit =
database.py
*/database_gen/*
*/dataset_service/*
*/shared/common/*

[report]
exclude_lines =
Expand Down
100 changes: 99 additions & 1 deletion functions-python/helpers/locations.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from enum import Enum
from typing import Dict, Optional

from geoalchemy2 import WKTElement
from sqlalchemy.orm import Session
from sqlalchemy import func, cast
from geoalchemy2.types import Geography

import pycountry
from shared.database_gen.sqlacodegen_models import Feed, Location
from shared.database_gen.sqlacodegen_models import Feed, Location, Geopolygon
import logging


Expand All @@ -11,8 +16,14 @@ class ReverseGeocodingStrategy(str, Enum):
Enum for reverse geocoding strategies.
"""

# Per point strategy uses point-in-polygon to find the location for each point
# It queries the database for each point, which can be slow for large datasets
PER_POINT = "per-point"

# Per polygon strategy uses point-in-polygon to find the location for each point
# It queries the database for each polygon, which can be faster for large datasets
PER_POLYGON = "per-polygon"


def get_country_code(country_name: str) -> Optional[str]:
"""
Expand Down Expand Up @@ -133,3 +144,90 @@ def translate_feed_locations(feed: Feed, location_translations: Dict):
if location_translation["country_translation"]
else location.country
)


def to_shapely(g):
"""
Convert a GeoAlchemy WKB/WKT element or WKT string into a Shapely geometry.
If it's already a Shapely geometry, return it as-is.
"""
# Import here to avoid adding unnecessary dependencies if not used to GCP functions
from shapely import wkt as shapely_wkt
from geoalchemy2 import WKTElement, WKBElement
from geoalchemy2.shape import to_shape

if isinstance(g, WKBElement):
return to_shape(g)
if isinstance(g, WKTElement):
return shapely_wkt.loads(g.data)
if isinstance(g, str):
# assume WKT
return shapely_wkt.loads(g)
return g # assume already shapely


def select_highest_level_polygon(geopolygons: list[Geopolygon]) -> Optional[Geopolygon]:
"""
Select the geopolygon with the highest admin_level from a list of geopolygons.
Admin levels are compared, with NULL treated as the lowest priority.
"""
if not geopolygons:
return None
# Treat NULL admin_level as the lowest priority
return max(
geopolygons, key=lambda g: (-1 if g.admin_level is None else g.admin_level)
)


def select_lowest_level_polygon(geopolygons: list[Geopolygon]) -> Optional[Geopolygon]:
"""
Select the geopolygon with the lowest admin_level from a list of geopolygons.
Admin levels are compared, with NULL treated as the lowest priority.
"""
if not geopolygons:
return None
# Treat NULL admin_level as the lowest priority
return min(
geopolygons, key=lambda g: (100 if g.admin_level is None else g.admin_level)
)


def get_country_code_from_polygons(geopolygons: list[Geopolygon]) -> Optional[str]:
"""
Given a list of polygon GeoJSON-like features (each with 'properties'),
return the country code (ISO 3166-1 alpha-2) from the most likely polygon.

Args:
polygons: List of dicts, each must have 'properties' with
'admin_level' and 'iso_3166_1_code'

Returns:
A two-letter country code string or None if not found
"""
country_polygons = [g for g in geopolygons if g.iso_3166_1_code]
if not country_polygons:
return None

# Prefer the one with the lowest admin_level (most local)
lowest_admin_level_polygon = select_lowest_level_polygon(country_polygons)
return lowest_admin_level_polygon.iso_3166_1_code


def get_geopolygons_covers(stop_point: WKTElement, db_session: Session):
"""
Get all geopolygons that cover a given point using BigQuery-compatible semantics.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] what does this have to do w big query?

@davidgamez davidgamez Aug 27, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that we have to cast(Geopolygon.geometry, Geography(srid=4326)) due to the original values from BigQuery tables being Geography instead of geometry

"""
# BigQuery-compatible point-in-polygon (geodesic + border-inclusive)
geopolygons = (
db_session.query(Geopolygon)
# optional prefilter to use your GiST index on geometry (fast)
.filter(func.ST_Intersects(Geopolygon.geometry, stop_point))
# exact check matching BigQuery's GEOGRAPHY semantics
.filter(
func.ST_Covers(
cast(Geopolygon.geometry, Geography(srid=4326)),
cast(stop_point, Geography(srid=4326)),
)
).all()
)
return geopolygons
4 changes: 2 additions & 2 deletions functions-python/helpers/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
import logging
import threading

import google.cloud.logging

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this import inside the function, as the unit tests can fail when running locally, and the console is not authenticated with Google services


from shared.common.logging_utils import get_env_logging_level


Expand Down Expand Up @@ -61,6 +59,8 @@ def init_logger():
if _logging_initialized:
return
try:
import google.cloud.logging

client = google.cloud.logging.Client()
client.setup_logging()
except Exception as error:
Expand Down
3 changes: 2 additions & 1 deletion functions-python/helpers/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ google-cloud-firestore
google-cloud-bigquery

# Additional package
pycountry
pycountry
shapely
Loading
Loading