Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
f21b94c
begin gbfs integration
Jun 29, 2026
c63c2f0
Merge branch 'main' into rjf/gbfs
Jun 29, 2026
15061f2
Merge branch 'main' into rjf/gbfs
Jul 15, 2026
f72e037
resolve gbfs_types import + ignore pyo3 feature
Jul 15, 2026
1b0c27f
wire in tokio runtime + fix error channel
Jul 15, 2026
50fe4d6
CLI GBFS app retrieves geofence + system info from GBFS URL
Jul 15, 2026
059ac76
cargo sort
Jul 15, 2026
40d90a7
clippy
Jul 15, 2026
561b25b
fmt
Jul 16, 2026
9d093b3
remove unused lifetime
Jul 16, 2026
fe0496b
checkpoint: GBFS import supporting 2.3 + 3.0 versions and building ou…
Jul 21, 2026
46c66d8
data importer CLIs
Jul 24, 2026
24dfa31
clippy
Jul 24, 2026
e66618e
scaffold models for GBFS
Jul 24, 2026
3a7d440
fmt
Jul 24, 2026
9fddb0c
GBFS spatiotemporal lookup + rules aggregation
Jul 28, 2026
0a75394
cargo sort
Jul 28, 2026
b2c400d
clippy
Jul 28, 2026
dd3c053
stub out boarding check + build out features + accessors
Jul 29, 2026
7b9ab43
fmt
Jul 29, 2026
671266b
GBFS constraint implemented
Jul 30, 2026
6b042ce
clippy
Jul 30, 2026
deb079c
traversal model for GBFS
Aug 12, 2026
9b5c752
add doc comment
Aug 13, 2026
1398055
wire in gbfs model/engine
Aug 13, 2026
0b57d5d
rename agency_id to system_id
Aug 13, 2026
8b2a154
clearer error message
Aug 13, 2026
de1902f
geometry file is not CSV
Aug 13, 2026
126bf85
Merge branch 'main' into rjf/gbfs
Aug 13, 2026
8527042
test/traverse edges using current time, not start time
Aug 13, 2026
d25fd7e
clippy
Aug 13, 2026
2c20de6
record system ids, not fully-qualified ids
Aug 13, 2026
377c8ef
rename to system_ids_input_file
Aug 13, 2026
336f995
remove duplicate time delta injection
Aug 13, 2026
5c7cf1f
ensure a zone exists where both predicates are true
Aug 13, 2026
e40ca43
assert ride start AND through are supported for departure
robfitzgerald Aug 13, 2026
6cf23cc
fix name
Aug 13, 2026
bb26af6
Potential fix for pull request finding
robfitzgerald Aug 13, 2026
0667c36
prevent CLI parallelism argument of 0
robfitzgerald Aug 13, 2026
1d37ba1
typo of GBFS versions
robfitzgerald Aug 13, 2026
10ffaeb
rename CLI, feed batch into compass output writer
Aug 13, 2026
2c816f1
clippy
Aug 13, 2026
68d03ec
fix dropped progress bar calls
Aug 13, 2026
4c591a2
include URL on failure
Aug 13, 2026
ad3c28e
clippy
Aug 13, 2026
c94a361
reverse GBFS destination default and flip destination logic to "falsify"
Aug 18, 2026
f229309
Merge branch 'main' into rjf/gbfs
Aug 20, 2026
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
2 changes: 2 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ downloader = { version = "0.2.8" }
env_logger = "0.11.8"
flate2 = "1.0"
futures = { version = "0.3.31", features = ["executor"] }
gbfs_types = { version = "0.1.6", default-features = false, features = ["reqwest_blocking"] }
geo = { version = "0.33.1", features = ["use-serde"] }
geo-buffer = "0.2.0"
geo-traits = "0.3.0"
Expand All @@ -60,6 +61,7 @@ ordered-float = { version = "5.1.0", features = ["serde"] }
osmio = "0.14.0"
osmpbf = "0.3.4"
parquet = { version = "=58.0.0", features = ["snap", "async", "object_store"] }
pyo3 = { version = "0.29.0", features = ["extension-module", "serde"] }
rand = "0.10.0"
rayon = "1.10.0"
regex = { version = "1.11.1" }
Expand Down
10 changes: 10 additions & 0 deletions rust/bambam-gbfs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,25 @@ license = "BSD-3-Clause"
description = "GBFS Extensions for The Behavior and Advanced Mobility Big Access Model"

[dependencies]
bambam-core = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
csv = { workspace = true }
env_logger = { workspace = true }
flate2 = { workspace = true }
futures = { workspace = true }
gbfs_types = { workspace = true }
geo = { workspace = true }
geozero = { workspace = true }
humantime = { workspace = true }
itertools = { workspace = true }
kdam = { workspace = true }
log = { workspace = true }
rayon = { workspace = true }
reqwest = { workspace = true }
routee-compass-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_with = { workspace = true }
tokio = { workspace = true }
uom = { workspace = true }
32 changes: 32 additions & 0 deletions rust/bambam-gbfs/src/app/download/download_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Deserialize, Clone, Debug)]
pub enum UnversionedGbfsVersion {
#[serde(rename = "2.2")]
V2_2,
#[serde(rename = "2.3")]
V2_3,
#[serde(rename = "3.0")]
V3_0,
}

impl Serialize for UnversionedGbfsVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
UnversionedGbfsVersion::V2_2 => serializer.serialize_str("2.2"),
UnversionedGbfsVersion::V2_3 => serializer.serialize_str("2.3"),
UnversionedGbfsVersion::V3_0 => serializer.serialize_str("3.0"),
}
}
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UnversionedGbfsMetadata {
pub last_updated: Value,
pub ttl: Value,
pub version: UnversionedGbfsVersion,
}
22 changes: 22 additions & 0 deletions rust/bambam-gbfs/src/app/download/entry_point.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use clap::ValueEnum;
use serde::{Deserialize, Serialize};

/// target of an initial HTTP call to a GBFS archive.
/// for an explanation, see <https://gbfs.org/get-started/#2-transform-your-data-into-gbfs-structure>.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EntryPoint {
/// manifest.json file
Manifest,
/// gbfs.json file for a specific GBFS version.
Gbfs,
}

impl std::fmt::Display for EntryPoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EntryPoint::Manifest => write!(f, "manifest"),
EntryPoint::Gbfs => write!(f, "gbfs"),
}
}
}
240 changes: 240 additions & 0 deletions rust/bambam-gbfs/src/app/download/gbfs_record.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
use geo::Geometry;
use geozero::{ToGeo, geojson::GeoJson};
use itertools::Itertools;
use serde::Serialize;

use crate::{
app::download::{GbfsVersion, ZoneConstraints},
model::gbfs::GbfsZoneRecord,
};

pub enum GbfsRecord {
V3_0(super::GbfsV3Import),
V2_3(super::GbfsV2_3Import),
V2_2(super::GbfsV2_2Import),
}

impl Serialize for GbfsRecord {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
GbfsRecord::V3_0(record) => record.serialize(serializer),
GbfsRecord::V2_3(record) => record.serialize(serializer),
GbfsRecord::V2_2(record) => record.serialize(serializer),
}
}
}

impl GbfsRecord {
/// downloads a dataset from a URL for a given GBFS version.
pub async fn download_from_gbfs_endpoint(
client: &reqwest::Client,
url: &str,
version: GbfsVersion,
) -> Result<Self, String> {
match version {
GbfsVersion::V3_0 => {
let gbfs = super::gbfs_v3_0::run_v3_0_gbfs(client, url).await?;
Ok(Self::V3_0(gbfs))
}
GbfsVersion::V2_3 => {
let gbfs = super::gbfs_v2_3::run_v2_3_gbfs(client, url).await?;
Ok(Self::V2_3(gbfs))
}
GbfsVersion::V2_2 => {
let gbfs = super::gbfs_v2_2::run_v2_2_gbfs(client, url).await?;
Ok(Self::V2_2(gbfs))
}
}
}

pub fn no_geofence(&self) -> bool {
match self {
GbfsRecord::V3_0(record) => record.geofence.data.geofencing_zones.features.is_empty(),
GbfsRecord::V2_3(record) => record.geofence.data.geofencing_zones.features.is_empty(),
GbfsRecord::V2_2(record) => record.geofence.data.geofencing_zones.features.is_empty(),
}
}

pub fn system_id(&self) -> String {
match self {
GbfsRecord::V3_0(record) => record.info.data.system_id.clone(),
GbfsRecord::V2_3(record) => record.info.data.system_id.clone(),
GbfsRecord::V2_2(record) => record.info.data.system_id.clone(),
}
}

pub fn n_features(&self) -> usize {
match self {
GbfsRecord::V3_0(record) => record.geofence.data.geofencing_zones.features.len(),
GbfsRecord::V2_3(record) => record.geofence.data.geofencing_zones.features.len(),
GbfsRecord::V2_2(record) => record.geofence.data.geofencing_zones.features.len(),
}
}

/// gets the geometry for a feature.
pub fn get_feature_geometry(&self, idx: usize) -> Result<Geometry, String> {
match self {
GbfsRecord::V3_0(record) => {
let f = record
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let geom_str = serde_json::to_string(f)
.map_err(|e| format!("failure deserializing feature: {e}"))?;
let geojson = GeoJson(&geom_str);
let geometry = geojson
.to_geo()
.map_err(|e| format!("unable to read GeoJSON as MultiPolygon: {e}"))?;
Ok(geometry)
}
GbfsRecord::V2_3(record) => {
let f = record
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let geom_str = serde_json::to_string(f)
.map_err(|e| format!("failure deserializing feature: {e}"))?;
let geojson = GeoJson(&geom_str);
let geometry = geojson
.to_geo()
.map_err(|e| format!("unable to read GeoJSON as MultiPolygon: {e}"))?;
Ok(geometry)
}
GbfsRecord::V2_2(record) => {
let f = record
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let geom_str = serde_json::to_string(f)
.map_err(|e| format!("failure deserializing feature: {e}"))?;
let geojson = GeoJson(&geom_str);
let geometry = geojson
.to_geo()
.map_err(|e| format!("unable to read GeoJSON as MultiPolygon: {e}"))?;
Ok(geometry)
}
}
}

/// converts a feature in the GBFS dataset into a [GbfsZoneRecord].
pub fn get_feature_zone_record(&self, idx: usize) -> Result<GbfsZoneRecord, String> {
match self {
GbfsRecord::V3_0(gbfs) => {
let system_id = gbfs.info.data.system_id.clone();
let global_constraints =
ZoneConstraints::from_v3_0(gbfs.geofence.data.global_rules.as_ref());
let feature = gbfs
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let found_constraints: Vec<ZoneConstraints> =
match feature.properties.rules.as_ref() {
Some(rs) => rs
.iter()
.filter(|r| r.vehicle_type_ids.is_none())
.map(|r| r.into())
.collect_vec(),
None => vec![],
};
let start = feature.properties.start.clone();
let end = feature.properties.end.clone();
// merge global and feature-specific constraints
let zone_constraints = ZoneConstraints::merge_constraints(
&global_constraints,
&found_constraints,
None,
)
.unwrap_or_else(ZoneConstraints::allow_all);
GbfsZoneRecord::new(system_id, idx, start, end, zone_constraints)
}
GbfsRecord::V2_3(gbfs) => {
let system_id = gbfs.info.data.system_id.clone();
let global_constraints = vec![];
let feature = gbfs
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let found_constraints: Vec<ZoneConstraints> =
match feature.properties.rules.as_ref() {
Some(rs) => rs
.iter()
.filter(|r| r.vehicle_type_id.is_none())
.map(|r| r.into())
.collect_vec(),
None => vec![],
};
let start = process_optional_ts_to_string(feature.properties.start)?;
let end = process_optional_ts_to_string(feature.properties.end)?;
// merge global and feature-specific constraints
let zone_constraints = ZoneConstraints::merge_constraints(
&global_constraints,
&found_constraints,
None,
)
.unwrap_or_else(ZoneConstraints::allow_all);
GbfsZoneRecord::new(system_id, idx, start, end, zone_constraints)
}
GbfsRecord::V2_2(gbfs) => {
let system_id = gbfs.info.data.system_id.clone();
let global_constraints = vec![];
let feature = gbfs
.geofence
.data
.geofencing_zones
.features
.get(idx)
.ok_or_else(|| format!("feature index {idx} not found"))?;
let found_constraints: Vec<ZoneConstraints> =
match feature.properties.rules.as_ref() {
Some(rs) => rs
.iter()
.filter(|r| r.vehicle_type_id.is_none())
.map(|r| r.into())
.collect_vec(),
None => vec![],
};
let start = process_optional_ts_to_string(feature.properties.start)?;
let end = process_optional_ts_to_string(feature.properties.end)?;
// merge global and feature-specific constraints
let zone_constraints = ZoneConstraints::merge_constraints(
&global_constraints,
&found_constraints,
None,
)
.unwrap_or_else(ZoneConstraints::allow_all);
GbfsZoneRecord::new(system_id, idx, start, end, zone_constraints)
}
}
}
}

fn process_optional_ts_to_string(s: Option<i64>) -> Result<Option<String>, String> {
match s {
None => Ok(None),
Some(ts) => timestamp_from_int(ts).map(Some),
}
}

fn timestamp_from_int(t: i64) -> Result<String, String> {
chrono::DateTime::from_timestamp(t, 0)
.ok_or_else(|| format!("could not parse timestamp '{t}'"))
.map(|ts| ts.to_rfc3339())
}
Loading
Loading