From e3bc9fe5e5009a2bb34508640017e4d5453f7225 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 2 Jul 2026 11:09:26 -0600 Subject: [PATCH 01/16] allow places mapping in config or from CSV --- .../output_plugin/opportunity/source/mod.rs | 3 + .../source/overture_places_mapping.rs | 94 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs diff --git a/rust/bambam/src/model/output_plugin/opportunity/source/mod.rs b/rust/bambam/src/model/output_plugin/opportunity/source/mod.rs index b821fe6a..fda51834 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/source/mod.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/source/mod.rs @@ -1,2 +1,5 @@ pub mod lodes; pub mod overture_opportunity_collection_model; +mod overture_places_mapping; + +pub use overture_places_mapping::OverturePlacesMappingConfig; diff --git a/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs new file mode 100644 index 00000000..48883fce --- /dev/null +++ b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs @@ -0,0 +1,94 @@ +use std::{collections::HashMap, path::PathBuf}; + +use csv::StringRecord; +use routee_compass_core::util::fs::read_utils; +use serde::{Deserialize, Serialize}; + +pub type PlacesMapping = HashMap>; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum OverturePlacesMappingConfig { + FromConfig(PlacesMapping), + FromCsv { + places_mapping_input_file: String, + places_category_column: String, + mep_category_column: String, + mep_category_separator: String, + }, +} + +impl OverturePlacesMappingConfig { + pub fn build(&self) -> Result { + match self { + OverturePlacesMappingConfig::FromConfig(hash_map) => Ok(hash_map.clone()), + OverturePlacesMappingConfig::FromCsv { + places_mapping_input_file, + places_category_column, + mep_category_column, + mep_category_separator, + } => { + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(places_mapping_input_file) + .map_err(|e| { + format!( + "failure reading mep mapping csv at {places_mapping_input_file}: {e}" + ) + })?; + let header_records = reader + .headers() + .map_err(|e| format!("mep mapping csv should have headers. {e}"))?; + let header: HashMap = header_records + .iter() + .enumerate() + .map(|(col_idx, name)| (name.to_string(), col_idx)) + .collect(); + + let mut result = HashMap::new(); + for (row_idx, row_result) in reader.into_records().enumerate() { + let (omf_label, mep_labels) = process_row( + row_idx, + row_result, + &header, + places_category_column, + mep_category_column, + mep_category_separator, + )?; + let _ = result.insert(omf_label, mep_labels); + } + Ok(result) + } + } + } +} + +/// helper function to process a row of the MEP mapping CSV file. +fn process_row( + row_idx: usize, + row_result: Result, + header: &HashMap, + places_category_column: &str, + mep_category_column: &str, + mep_category_separator: &str, +) -> Result<(String, Vec), String> { + let row = row_result + .map_err(|e| format!("failure getting row {row_idx} from mep mapping csv: {e}"))?; + let omf_label_col_idx = header.get(places_category_column).ok_or_else(|| { + format!("mep mapping csv missing expected {places_category_column} column") + })?; + let mep_label_col_idx = header + .get(mep_category_column) + .ok_or_else(|| format!("mep mapping csv missing expected {mep_category_column} column"))?; + + let omf_label = row.get(*omf_label_col_idx) + .ok_or_else(|| format!("mep mapping csv row {row_idx} missing expected {places_category_column} column index {omf_label_col_idx}"))? + .to_string(); + let mep_labels: Vec = row.get(*mep_label_col_idx) + .ok_or_else(|| format!("mep mapping csv row {row_idx} missing expected {mep_category_column} column index {mep_label_col_idx}"))? + .split(mep_category_separator) + .map(String::from) + .collect(); + + Ok((omf_label, mep_labels)) +} From 13483345979af04cbb88c7f04511ca23f0efc4f1 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 2 Jul 2026 11:10:15 -0600 Subject: [PATCH 02/16] wire places mapping config into OpportunitySource --- .../output_plugin/opportunity/opportunity_source.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rust/bambam/src/model/output_plugin/opportunity/opportunity_source.rs b/rust/bambam/src/model/output_plugin/opportunity/opportunity_source.rs index 58ad6393..0d568f63 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/opportunity_source.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/opportunity_source.rs @@ -1,4 +1,6 @@ -use crate::model::output_plugin::opportunity::OpportunityDataset; +use crate::model::output_plugin::opportunity::{ + source::OverturePlacesMappingConfig, OpportunityDataset, +}; use super::{ source::lodes::lodes_ops, @@ -39,7 +41,7 @@ pub enum OpportunitySource { OvertureMapsPlaces { collector_config: OvertureMapsCollectorConfig, bbox_boundary: Bbox, - places_activity_mapping: HashMap>, + places_activity_mapping: OverturePlacesMappingConfig, buildings_activity_mapping: Option>>, #[serde(default)] release_version: ReleaseVersion, @@ -70,6 +72,8 @@ impl OpportunitySource { buildings_activity_mapping, release_version, } => { + let places_mapping = places_activity_mapping.build()?; + // Instantiate Collection Model Object which re-structures activity mapping // information into a fully functional collection pipeline. This step allows // to reduce repetition in the configuration file by making some assumptions @@ -78,7 +82,7 @@ impl OpportunitySource { *collector_config, release_version.clone(), *bbox_boundary, - places_activity_mapping.clone(), + places_mapping, buildings_activity_mapping.clone(), ) .map_err(|e| format!("Error creating Overture OpportunityCollectionModel: {e}"))?; From 011c26f3a96fc167aef074aa01d034d8e4cc6fce Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 2 Jul 2026 11:11:17 -0600 Subject: [PATCH 03/16] config --- .../opportunity/source/overture_places_mapping.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs index 48883fce..03a34190 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs @@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize}; pub type PlacesMapping = HashMap>; +/// sources a mapping from OvertureMaps Places categories into MEP categories. +/// +/// # Serde +/// +/// untagged deserialization. attempts to first deserialize directly as a HashMap. +/// if that fails, attempts to read as a FromCsv object. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] pub enum OverturePlacesMappingConfig { From 42fc4ea7a02621a552c007363a43182396ef2c5b Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:11:52 -0600 Subject: [PATCH 04/16] additinoal info for group error --- rust/bambam-omf/src/collection/error.rs | 4 ++-- rust/bambam-omf/src/collection/taxonomy/taxonomy_model.rs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/bambam-omf/src/collection/error.rs b/rust/bambam-omf/src/collection/error.rs index f20892ef..4494e81c 100644 --- a/rust/bambam-omf/src/collection/error.rs +++ b/rust/bambam-omf/src/collection/error.rs @@ -30,8 +30,8 @@ pub enum OvertureMapsCollectionError { PredicateColumnNotFoundError(String), #[error("Error creating a runtime to handle async code: {0}")] TokioError(String), - #[error("Group Mapping operation Failed: {0}")] - GroupMappingError(String), + #[error("Group Mapping operation Failed, key '{0}' not found in mapping, found {1:?}")] + GroupMappingError(String, Vec), #[error("Processing records into opportunities failed: {0}")] ProcessingError(String), #[error("Serializing record into compass format failed: {0}")] diff --git a/rust/bambam-omf/src/collection/taxonomy/taxonomy_model.rs b/rust/bambam-omf/src/collection/taxonomy/taxonomy_model.rs index 93231920..5d6e2e1e 100644 --- a/rust/bambam-omf/src/collection/taxonomy/taxonomy_model.rs +++ b/rust/bambam-omf/src/collection/taxonomy/taxonomy_model.rs @@ -113,9 +113,10 @@ impl TaxonomyModel { Ok::( self.group_mappings .get(group) - .ok_or(OvertureMapsCollectionError::GroupMappingError(format!( - "Group {group} was not found in mapping" - )))? + .ok_or(OvertureMapsCollectionError::GroupMappingError( + group.clone(), + self.group_mappings.keys().cloned().collect(), + ))? .contains(category), ) }) From a8c7d94b58b684d920878cda935fcb3b0644ae82 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:12:18 -0600 Subject: [PATCH 05/16] debug logging of record metadata --- rust/bambam-omf/src/collection/taxonomy/taxonomy_builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/bambam-omf/src/collection/taxonomy/taxonomy_builder.rs b/rust/bambam-omf/src/collection/taxonomy/taxonomy_builder.rs index 51b1ec67..539b7de1 100644 --- a/rust/bambam-omf/src/collection/taxonomy/taxonomy_builder.rs +++ b/rust/bambam-omf/src/collection/taxonomy/taxonomy_builder.rs @@ -58,7 +58,7 @@ impl TaxonomyModelBuilder { } } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct TaxonomyCSVRecord { #[serde(rename = "Category code")] category: String, From 3614351bd4d56cff977662a8fd36019a5a2ef042 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:12:47 -0600 Subject: [PATCH 06/16] add more context to debug log of valid_frontier result --- rust/bambam-gtfs-flex/src/model/constraint/model.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/bambam-gtfs-flex/src/model/constraint/model.rs b/rust/bambam-gtfs-flex/src/model/constraint/model.rs index e14f9db4..ce3cdd06 100644 --- a/rust/bambam-gtfs-flex/src/model/constraint/model.rs +++ b/rust/bambam-gtfs-flex/src/model/constraint/model.rs @@ -47,12 +47,13 @@ impl ConstraintModel for GtfsFlexDepartureConstraintModel { // there is no supporting relation in the ZoneGraph, otherwise, accept as we will board here. let current_time = current_datetime(self.params.start_time, state, state_model)?; let lookup_result = current_zone(&self.lookup, ctx.dst)?; - let is_valid = match lookup_result { + let is_valid = match &lookup_result { Some(src_zone_id) => is_valid_departure(&self.lookup, &src_zone_id, ¤t_time), None => Ok(false), }?; log::debug!( - "gtfs-flex frontier is valid (can board here)? {is_valid}, {}", + "gtfs-flex frontier is valid (can board here)? {is_valid}, with current_time={}, current_zone={lookup_result:?}, context:\n {}", + current_time.format("%Y-%m-%d %H:%M:%S"), log_context(ctx, state, state_model) ); Ok(is_valid) From e1383173e2d19628f602b5cc211233a03d0fcde6 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:13:25 -0600 Subject: [PATCH 07/16] OnceLock no longer needed due to upstream API change --- .../multimodal/multimodal_traversal_ops.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/rust/bambam/src/model/traversal/multimodal/multimodal_traversal_ops.rs b/rust/bambam/src/model/traversal/multimodal/multimodal_traversal_ops.rs index d6d7bd0d..becd9270 100644 --- a/rust/bambam/src/model/traversal/multimodal/multimodal_traversal_ops.rs +++ b/rust/bambam/src/model/traversal/multimodal/multimodal_traversal_ops.rs @@ -1,5 +1,4 @@ use std::num::NonZeroU64; -use std::sync::OnceLock; use bambam_core::model::bambam_state; use bambam_core::model::state::{ @@ -42,7 +41,12 @@ pub fn mode_switch( _ => { // no leg assigned or a change in mode -> add the new leg let next_leg_idx = - state_ops::increment_active_leg_idx(state, state_model, max_trip_legs)?; + state_ops::increment_active_leg_idx(state, state_model, max_trip_legs).map_err( + |e| { + let msg = format!("while performing a mode switch, {e}"); + StateModelError::RuntimeError(msg) + }, + )?; state_ops::set_leg_mode(state, next_leg_idx, prev_mode, state_model, mode_to_state)?; } }; @@ -80,12 +84,6 @@ pub fn update_accumulators( Ok(()) } -/// this hack is used because StateModel::contains_key expects a &String, which would require -/// a new string allocation each time it is invoked here. to avoid this, we store a static [OnceLock]'d -/// String and reference it in the call once initialized. this should be removed once things are updated -/// on routee-compass-core, see . -static ROUTE_ID_STRING: OnceLock = OnceLock::new(); - /// tests if route_id is set, and if so, copies it to the current trip leg. pub fn update_route_id( state: &mut [StateVariable], @@ -94,8 +92,7 @@ pub fn update_route_id( leg_idx: LegIdx, max_trip_legs: NonZeroU64, ) -> Result<(), StateModelError> { - let route_id_key = ROUTE_ID_STRING.get_or_init(|| bambam_state::ROUTE_ID.to_string()); - if state_model.contains_key(route_id_key) { + if state_model.contains_key(bambam_state::ROUTE_ID) { let route_id_label = state_model.get_custom_i64(state, bambam_state::ROUTE_ID)?; state_ops::set_leg_route_id_raw(state, leg_idx, route_id_label, state_model) } else { From c29ec3529b1413d62a03942c70114bead5c79615 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:13:57 -0600 Subject: [PATCH 08/16] no info log on empty (avoid panic) --- .../overture_opportunity_collection_model.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rust/bambam/src/model/output_plugin/opportunity/source/overture_opportunity_collection_model.rs b/rust/bambam/src/model/output_plugin/opportunity/source/overture_opportunity_collection_model.rs index e1e67b51..329cfa63 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/source/overture_opportunity_collection_model.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/source/overture_opportunity_collection_model.rs @@ -268,12 +268,14 @@ impl OvertureOpportunityCollectionModel { activity_types, )?; - log::info!( - "Total opportunities per category {:?}", - (0..mep_vectors[0].len()) - .map(|i| mep_vectors.iter().map(|row| row[i] as i16 as f64).sum()) - .collect::>() - ); + if !mep_vectors.is_empty() { + log::info!( + "Total opportunities per category {:?}", + (0..mep_vectors[0].len()) + .map(|i| mep_vectors.iter().map(|row| row[i] as i16 as f64).sum()) + .collect::>() + ); + } // Collect geometries let mep_geometries: Vec>> = buildings_records From 6aaf06c051bda5367e3236fc2e8797b71673e8d0 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:15:16 -0600 Subject: [PATCH 09/16] concatenate destination filter arguments from both config and query for mode-specific filter settings --- rust/bambam-core/src/model/bambam_typed.rs | 28 +++++++++++++++++-- .../isochrone/isochrone_output_plugin.rs | 2 +- .../opportunity/opportunity_output_plugin.rs | 5 +--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/rust/bambam-core/src/model/bambam_typed.rs b/rust/bambam-core/src/model/bambam_typed.rs index f6c8a961..e6c11749 100644 --- a/rust/bambam-core/src/model/bambam_typed.rs +++ b/rust/bambam-core/src/model/bambam_typed.rs @@ -37,13 +37,14 @@ use std::collections::HashMap; +use itertools::Itertools; use routee_compass::plugin::output::OutputPluginError; use routee_compass_core::model::cost::TraversalCost; use serde_json::{json, Value}; use crate::model::{ bambam_field, - destination::{BinningConfig, DestinationPredicate}, + destination::{BinningConfig, DestinationFilter, DestinationPredicate}, output_plugin::{ isochrone::{GeometryModelConfig, IsochroneAlgorithm, IsochroneOutputFormat}, opportunity::{OpportunityFormat, OpportunityOrientation}, @@ -116,6 +117,26 @@ impl<'a> BambamOutputRow<'a> { ) -> Result<(), OutputPluginError> { set_field(self.0, bambam_field::OPPORTUNITY_TOTALS, totals) } + + /// grabs any destination filters from both two places: + /// - info section (set via the config file) + /// - request section (set via the search query) + pub fn get_destination_filter(&self) -> Result, OutputPluginError> { + let info = self.info_ref()?; + let info_filter = info.get_config_destination_predicates()?; + let req = self.request()?; + let req_filter: Option> = + get_field_opt(req.0, bambam_field::DESTINATION_FILTER)?; + match (info_filter, req_filter) { + (None, None) => Ok(None), + (None, Some(preds)) => Ok(Some(DestinationFilter(preds))), + (Some(preds), None) => Ok(Some(DestinationFilter(preds))), + (Some(preds_config), Some(preds_query)) => { + let preds = preds_config.into_iter().chain(preds_query).collect_vec(); + Ok(Some(DestinationFilter(preds))) + } + } + } } // ─── request section ────────────────────────────────────────────────────────── @@ -152,7 +173,8 @@ impl<'a> InfoSectionRef<'a> { get_field_opt(self.0, bambam_field::BIN_RANGE) } - pub fn get_destination_filter( + /// gets any destination predicates set on the BAMBAM config. + pub fn get_config_destination_predicates( &self, ) -> Result>, OutputPluginError> { get_field_opt(self.0, bambam_field::DESTINATION_FILTER) @@ -541,7 +563,7 @@ mod tests { let info = row.info_ref().unwrap(); assert!(info.get_activity_types().unwrap().is_none()); assert!(info.get_bin_range().unwrap().is_none()); - assert!(info.get_destination_filter().unwrap().is_none()); + assert!(info.get_config_destination_predicates().unwrap().is_none()); assert!(info.get_opportunity_format().unwrap().is_none()); assert!(info.get_opportunity_orientation().unwrap().is_none()); assert!(info.get_geometry_model().unwrap().is_none()); diff --git a/rust/bambam/src/model/output_plugin/isochrone/isochrone_output_plugin.rs b/rust/bambam/src/model/output_plugin/isochrone/isochrone_output_plugin.rs index dac2a64c..adb35c0c 100644 --- a/rust/bambam/src/model/output_plugin/isochrone/isochrone_output_plugin.rs +++ b/rust/bambam/src/model/output_plugin/isochrone/isochrone_output_plugin.rs @@ -114,7 +114,7 @@ impl<'a> TryFrom<&'a BambamOutputRow<'a>> for GetIsochroneRequest { fn try_from(value: &'a BambamOutputRow<'a>) -> Result { let info = value.info_ref()?; let format = info.get_opportunity_format()?; - let filter = info.get_destination_filter()?.map(DestinationFilter); + let filter = value.get_destination_filter()?; let geometry_model_config = info .get_geometry_model()? .ok_or_else(|| missing_expected("info.geometry_model"))?; diff --git a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs index f8ac83fc..753deff0 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs @@ -63,10 +63,7 @@ impl OutputPlugin for OpportunityOutputPlugin { row.set_opportunity_totals(&self.totals)?; // read destination filter from the row info - let filter = row - .info_ref()? - .get_destination_filter()? - .map(DestinationFilter); + let filter = row.get_destination_filter()?; match format { OpportunityFormat::Aggregate => { From ac6a5f618f35972c8f20dba32a2d88568e00c28c Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:15:26 -0600 Subject: [PATCH 10/16] make "negate" optional --- .../src/model/destination/filter.rs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/rust/bambam-core/src/model/destination/filter.rs b/rust/bambam-core/src/model/destination/filter.rs index 835c2033..5f50c89e 100644 --- a/rust/bambam-core/src/model/destination/filter.rs +++ b/rust/bambam-core/src/model/destination/filter.rs @@ -10,6 +10,7 @@ pub struct DestinationFilter(pub Vec); /// additional modifiers to apply when collecting destinations for a bin. #[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum DestinationPredicate { /// only accept destinations where the provided feature, a boolean value, /// is true (or, if negate == true, where the feature is false). @@ -17,7 +18,7 @@ pub enum DestinationPredicate { /// state variable feature to match feature: String, /// if true, invert the value stored at the feature - negate: bool, + negate: Option, }, } @@ -43,7 +44,7 @@ impl std::fmt::Display for DestinationPredicate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { DestinationPredicate::Boolean { feature, negate } => { - if !negate { + if !negate.unwrap_or_default() { format!("{feature}=true") } else { format!("{feature}=false") @@ -68,7 +69,7 @@ impl DestinationPredicate { error: e, } })?; - Ok(variable != *negate) // if negate=false, variable should be true, and vice versa + Ok(variable != negate.unwrap_or_default()) // if negate=false, variable should be true, and vice versa } } } @@ -105,7 +106,7 @@ mod tests { // When feature is true and negate is false, should return true let predicate = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: false, + negate: Some(false), }; let (state_model, state) = mock(&[("is_available", true)]); @@ -120,7 +121,7 @@ mod tests { // When feature is true and negate is true, should return false let predicate = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: true, + negate: Some(true), }; let (state_model, state) = mock(&[("is_available", true)]); @@ -135,7 +136,7 @@ mod tests { // When feature is false and negate is false, should return false let predicate = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: false, + negate: Some(false), }; let (state_model, state) = mock(&[("is_available", false)]); @@ -150,7 +151,7 @@ mod tests { // When feature is false and negate is true, should return true let predicate = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: true, + negate: Some(true), }; let (state_model, state) = mock(&[("is_available", false)]); @@ -165,7 +166,7 @@ mod tests { // Filter should only check the specified feature and ignore other variables let predicate = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: false, + negate: Some(false), }; let filter = DestinationFilter(vec![predicate]); @@ -181,11 +182,11 @@ mod tests { // Multiple predicates in a filter should all pass for the filter to return true let pred1 = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: false, + negate: Some(false), }; let pred2 = DestinationPredicate::Boolean { feature: "is_active".to_string(), - negate: false, + negate: Some(false), }; let filter = DestinationFilter(vec![pred1, pred2]); @@ -201,11 +202,11 @@ mod tests { // If one predicate fails, the entire filter should fail let pred1 = DestinationPredicate::Boolean { feature: "is_available".to_string(), - negate: false, + negate: Some(false), }; let pred2 = DestinationPredicate::Boolean { feature: "is_active".to_string(), - negate: false, + negate: Some(false), }; let filter = DestinationFilter(vec![pred1, pred2]); From d8e60c0d1a817faaf6dd5b9987d01faabfc64265 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:15:52 -0600 Subject: [PATCH 11/16] fix out-of-bounds errors --- .../src/model/state/multimodal_state_ops.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/rust/bambam-core/src/model/state/multimodal_state_ops.rs b/rust/bambam-core/src/model/state/multimodal_state_ops.rs index 82f6bd30..83966766 100644 --- a/rust/bambam-core/src/model/state/multimodal_state_ops.rs +++ b/rust/bambam-core/src/model/state/multimodal_state_ops.rs @@ -97,10 +97,14 @@ pub fn contains_leg( state: &[StateVariable], leg_idx: LegIdx, state_model: &StateModel, + max_trip_legs: NonZeroU64, ) -> Result { + if leg_idx >= max_trip_legs.get() { + return Ok(false); + } let name = fieldname::leg_mode_fieldname(leg_idx); let label = state_model.get_custom_i64(state, &name)?; - Ok(label >= 0) + Ok(label > -1) } /// get the travel mode for a leg. @@ -110,7 +114,10 @@ pub fn get_leg_mode_label( state_model: &StateModel, max_trip_legs: NonZeroU64, ) -> Result, StateModelError> { - validate_leg_idx(leg_idx, max_trip_legs)?; + validate_leg_idx(leg_idx, max_trip_legs).map_err(|e| { + let msg = format!("while getting leg mode label, found requested leg idx is invalid: {e}"); + StateModelError::RuntimeError(msg) + })?; let name = fieldname::leg_mode_fieldname(leg_idx); let label = state_model.get_custom_i64(state, &name)?; if label < 0 { @@ -129,7 +136,11 @@ pub fn get_existing_leg_mode<'a>( max_trip_legs: NonZeroU64, mode_to_state: &'a CategoricalStateMapping, ) -> Result<&'a str, StateModelError> { - let label_opt = get_leg_mode_label(state, leg_idx, state_model, max_trip_legs)?; + let label_opt = + get_leg_mode_label(state, leg_idx, state_model, max_trip_legs).map_err(|e| { + let msg = format!("while getting existing leg mode, error getting the mode label: {e}"); + StateModelError::RuntimeError(msg) + })?; match label_opt { None => Err(StateModelError::RuntimeError(format!( "Internal Error: get_leg_mode called on leg idx {leg_idx} but mode label is not set" @@ -212,7 +223,13 @@ pub fn get_mode_label_sequence( let mut labels: Vec = vec![]; for leg_idx in 0..max_trip_legs.get() { - let mode_label_opt = get_leg_mode_label(state, leg_idx, state_model, max_trip_legs)?; + let mode_label_opt = get_leg_mode_label(state, leg_idx, state_model, max_trip_legs) + .map_err(|e| { + let msg = format!( + "while getting model label sequence, failed to get mode label for index: {e}" + ); + StateModelError::RuntimeError(msg) + })?; match mode_label_opt { None => break, Some(mode_label) => { @@ -234,12 +251,14 @@ pub fn get_mode_sequence( ) -> Result, StateModelError> { let mut modes: Vec = vec![]; let mut leg_idx = 0; - while contains_leg(state, leg_idx, state_model)? { + + while contains_leg(state, leg_idx, state_model, max_trip_legs)? { let mode = get_existing_leg_mode(state, leg_idx, state_model, max_trip_legs, mode_to_state)?; modes.push(mode.to_string()); leg_idx += 1; } + Ok(modes) } @@ -256,7 +275,10 @@ pub fn increment_active_leg_idx( let next_leg_idx_u64 = match get_active_leg_idx(state, state_model)? { Some(leg_idx) => { let next = leg_idx + 1; - validate_leg_idx(next, max_trip_legs)?; + validate_leg_idx(next, max_trip_legs).map_err(|e| { + let msg = format!("while incrementing active leg idx, next index is invalid: {e}"); + StateModelError::RuntimeError(msg) + })?; next } None => 0, From 3bb86f218d32c5fe5435c1e3f1c1f6b1bce3fc20 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Wed, 8 Jul 2026 15:16:45 -0600 Subject: [PATCH 12/16] flip key/value typo for reverse lookup operation --- .../opportunity/source/overture_places_mapping.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs index 03a34190..ea4d6272 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/source/overture_places_mapping.rs @@ -61,7 +61,12 @@ impl OverturePlacesMappingConfig { mep_category_column, mep_category_separator, )?; - let _ = result.insert(omf_label, mep_labels); + for mep_label in mep_labels.into_iter() { + result + .entry(mep_label.clone()) + .and_modify(|omf: &mut Vec| omf.push(omf_label.clone())) + .or_insert(vec![omf_label.clone()]); + } } Ok(result) } From 8191e0f07578a60544bfa0b53733a2be9a24f576 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 9 Jul 2026 13:01:58 -0600 Subject: [PATCH 13/16] improve flex import logging --- rust/bambam-gtfs-flex/Cargo.toml | 1 + .../bambam-gtfs-flex/src/app/gtfs_flex_cli.rs | 1 + .../src/app/import_dataset.rs | 36 ++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/rust/bambam-gtfs-flex/Cargo.toml b/rust/bambam-gtfs-flex/Cargo.toml index 2e017aca..5bca626f 100644 --- a/rust/bambam-gtfs-flex/Cargo.toml +++ b/rust/bambam-gtfs-flex/Cargo.toml @@ -12,6 +12,7 @@ bambam-core = { workspace = true } chrono = { workspace = true } clap = { workspace = true } csv = { workspace = true } +env_logger = { workspace = true } flate2 = { workspace = true } geo = { workspace = true } geo-types = { workspace = true } diff --git a/rust/bambam-gtfs-flex/src/app/gtfs_flex_cli.rs b/rust/bambam-gtfs-flex/src/app/gtfs_flex_cli.rs index d5b09ff4..34056726 100644 --- a/rust/bambam-gtfs-flex/src/app/gtfs_flex_cli.rs +++ b/rust/bambam-gtfs-flex/src/app/gtfs_flex_cli.rs @@ -36,6 +36,7 @@ pub struct GtfsFLexCliArguments { impl Cli { /// runs the app pub fn run(&self) -> Result<(), GtfsFlexError> { + env_logger::init(); match &self.command { Commands::ProcessGtfsFlexFeeds(args) => { let in_dir = Path::new(&args.input_directory); diff --git a/rust/bambam-gtfs-flex/src/app/import_dataset.rs b/rust/bambam-gtfs-flex/src/app/import_dataset.rs index a81c5ddb..f0446bb9 100644 --- a/rust/bambam-gtfs-flex/src/app/import_dataset.rs +++ b/rust/bambam-gtfs-flex/src/app/import_dataset.rs @@ -106,7 +106,7 @@ pub fn process_gtfs_flex_bundle( out_directory_path: &Path, date_requested: &str, ) -> Result<(), GtfsFlexError> { - println!("=== Processing GTFS-Flex bundle ==="); + log::info!("=== Processing GTFS-Flex bundle ==="); // discover gtfs-flex feeds discover_gtfs_flex_feeds(flex_directory_path)?; @@ -116,7 +116,7 @@ pub fn process_gtfs_flex_bundle( gtfs_flex_dataset.write(out_directory_path)?; - println!("=== GTFS-Flex processing complete ==="); + log::info!("=== GTFS-Flex processing complete ==="); Ok(()) } @@ -132,7 +132,7 @@ pub fn discover_gtfs_flex_feeds(flex_directory_path: &Path) -> Result<(), GtfsFl error, })?; - println!("Found zip files in {:?}:", flex_directory_path); + log::info!("Found zip files in {:?}:", flex_directory_path); let mut count = 0; for entry in entries { @@ -144,13 +144,13 @@ pub fn discover_gtfs_flex_feeds(flex_directory_path: &Path) -> Result<(), GtfsFl if path.is_file() && path.extension().is_some_and(|e| e == "zip") { if let Some(name) = path.file_name() { - println!(" {}", name.to_string_lossy()); + log::info!(" {}", name.to_string_lossy()); count += 1; } } } - println!("Total GTFS-flex feeds found: {}", count); + log::info!("Total GTFS-flex feeds found: {}", count); Ok(()) } @@ -161,7 +161,7 @@ pub fn process_flex_files( flex_directory_path: &Path, date_requested: &str, ) -> Result { - println!("Processing GTFS-Flex feeds in {:?}", flex_directory_path); + log::info!("Processing GTFS-Flex feeds in {:?}", flex_directory_path); let iter = std::fs::read_dir(flex_directory_path).map_err(|error| GtfsFlexError::Io { path: flex_directory_path.to_path_buf(), @@ -178,8 +178,6 @@ pub fn process_flex_files( let path = entry.path(); if path.is_file() && path.extension().is_some_and(|e| e == "zip") { - println!(" Processing {:?}", path); - // extract feed name let feed_name = path .file_stem() @@ -187,6 +185,8 @@ pub fn process_flex_files( .unwrap_or("unknown_feed") .to_string(); + log::info!("Processing feed {feed_name} in file {:?}", path); + // load GTFS let gtfs = Gtfs::from_path(&path).map_err(|error| GtfsFlexError::GtfsRead { path: path.to_path_buf(), @@ -195,11 +195,15 @@ pub fn process_flex_files( // process files for requested date and time and get valid zones let archive_dataset = process_archive(>fs, date_requested, &feed_name, idx)?; + log::info!( + "BAMBAM dataset created with {} records", + archive_dataset.records.len() + ); dataset.extend(archive_dataset); } } - println!("GTFS-Flex feeds processed!"); + log::info!("GTFS-Flex feeds processed!"); Ok(dataset) } @@ -217,7 +221,7 @@ pub fn process_archive( GtfsFlexError::Runtime(msg) })?; - println!(" requested date: {:?}", date); + log::info!("requested date: {:?}", date); // filter calendar for the requested date let weekday = match date.weekday() { @@ -229,7 +233,7 @@ pub fn process_archive( chrono::Weekday::Sat => |c: &Calendar| c.saturday, chrono::Weekday::Sun => |c: &Calendar| c.sunday, }; - println!(" requested day: {:?}", date.weekday()); + log::info!("requested day: {:?}", date.weekday()); let active_service_ids: Vec<&str> = gtfs .calendar @@ -238,7 +242,10 @@ pub fn process_archive( .map(|c| c.id.as_str()) .collect(); - // println!(" active service_ids: {:?}", active_service_ids); + log::info!( + "ServiceIds active on date {date_requested}: [{}]", + active_service_ids.join(", ") + ); // 1. Map route_id -> agency_id (fallback to archive_idx if missing) // at the end, route_to_agency contains ALL AgencyIds referenced by @@ -327,9 +334,12 @@ pub fn process_archive( geometry: wkt_str, }); } + [_src, _dst] => { + log::debug!("GTFS-Flex Trip {} has 2 StopTime entries but not valid_flex_trip_stops, skipping.", trip.id) + } other => { log::warn!( - "GTFS-Flex Trip {} has {} StopTime entries, assumed should always be 2", + "GTFS-Flex Trip {} has {} StopTime entries, assumed should always be 2.", trip.id, other.len() ); From 84df8a7bb24d010ea9a3276fc2d01b1ac28902a8 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 9 Jul 2026 14:31:16 -0600 Subject: [PATCH 14/16] prevent NaN on MEP outputs by ensuring opportunity totals are present in no_aggregate_opportunities helper function --- .../opportunity/opportunity_output_plugin.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs index 753deff0..0c1792b2 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs @@ -45,7 +45,11 @@ impl OutputPlugin for OpportunityOutputPlugin { match format { OpportunityFormat::Aggregate => { // no matches found. inject zeroed-out opportunity data into the row. - no_aggregate_opportunities(&mut row, &self.model.activity_types())?; + no_aggregate_opportunities( + &mut row, + &self.model.activity_types(), + &self.totals, + )?; return Ok(()); } OpportunityFormat::Disaggregate => { @@ -136,7 +140,10 @@ fn process_disaggregate_opportunities( fn no_aggregate_opportunities( row: &mut BambamOutputRow<'_>, activity_types: &[String], + opportunity_totals: &HashMap, ) -> Result<(), OutputPluginError> { + row.set_opportunity_totals(&opportunity_totals)?; + let bin_config = row.info_ref()?.get_bin_range()?.ok_or_else(|| { OutputPluginError::OutputPluginFailed( "row with aggregate opportunities has no bin range config".to_string(), From 9452467a89f48484088e37d6e4181132c581c7c1 Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 9 Jul 2026 15:29:57 -0600 Subject: [PATCH 15/16] improved error for invalid record --- rust/bambam-gtfs-flex/src/util/zone/relation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/bambam-gtfs-flex/src/util/zone/relation.rs b/rust/bambam-gtfs-flex/src/util/zone/relation.rs index 0043759c..7979604d 100644 --- a/rust/bambam-gtfs-flex/src/util/zone/relation.rs +++ b/rust/bambam-gtfs-flex/src/util/zone/relation.rs @@ -113,7 +113,7 @@ impl TryFrom<&ZonalRelationRecord> for ZonalRelation { }), _ => { let msg = format!( - "GTFS-Flex record for {}-{} has invalid combination of optional fields", + "GTFS-Flex record for pair {} -> {} has invalid combination of optional fields. all 3 may be omitted, dst_zone_id may be by itself, or all 3 must be present. found dst_zone_id: {dst_zone_id:?} start_time: {start_time:?} end_time: {end_time:?}", record.src_zone_id, record.dst_zone_id.clone().unwrap_or_default() ); From d418b2b8deedcf9dad4109a82c2dc5c1a8b9e13f Mon Sep 17 00:00:00 2001 From: Rob Fitzgerald Date: Thu, 9 Jul 2026 15:30:12 -0600 Subject: [PATCH 16/16] clippy --- rust/bambam-gtfs-flex/src/model/constraint/model.rs | 2 +- .../output_plugin/opportunity/opportunity_output_plugin.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/bambam-gtfs-flex/src/model/constraint/model.rs b/rust/bambam-gtfs-flex/src/model/constraint/model.rs index ce3cdd06..5d321379 100644 --- a/rust/bambam-gtfs-flex/src/model/constraint/model.rs +++ b/rust/bambam-gtfs-flex/src/model/constraint/model.rs @@ -48,7 +48,7 @@ impl ConstraintModel for GtfsFlexDepartureConstraintModel { let current_time = current_datetime(self.params.start_time, state, state_model)?; let lookup_result = current_zone(&self.lookup, ctx.dst)?; let is_valid = match &lookup_result { - Some(src_zone_id) => is_valid_departure(&self.lookup, &src_zone_id, ¤t_time), + Some(src_zone_id) => is_valid_departure(&self.lookup, src_zone_id, ¤t_time), None => Ok(false), }?; log::debug!( diff --git a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs index 0c1792b2..1c7747ee 100644 --- a/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs +++ b/rust/bambam/src/model/output_plugin/opportunity/opportunity_output_plugin.rs @@ -142,7 +142,7 @@ fn no_aggregate_opportunities( activity_types: &[String], opportunity_totals: &HashMap, ) -> Result<(), OutputPluginError> { - row.set_opportunity_totals(&opportunity_totals)?; + row.set_opportunity_totals(opportunity_totals)?; let bin_config = row.info_ref()?.get_bin_range()?.ok_or_else(|| { OutputPluginError::OutputPluginFailed(