diff --git a/Cargo.toml b/Cargo.toml index 8579d4f..2a7ebfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] description = "Read GTFS (public transit timetables) files" name = "gtfs-structures" -version = "0.43.0" +version = "0.44.0" authors = [ "Tristram Gräbener ", "Antoine Desbordes ", diff --git a/fixtures/fares_v2/agency.txt b/fixtures/fares_v2/agency.txt new file mode 100644 index 0000000..f366803 --- /dev/null +++ b/fixtures/fares_v2/agency.txt @@ -0,0 +1 @@ +agency_name,agency_url,agency_timezone,agency_lang diff --git a/fixtures/fares_v2/areas.txt b/fixtures/fares_v2/areas.txt new file mode 100644 index 0000000..f446ba8 --- /dev/null +++ b/fixtures/fares_v2/areas.txt @@ -0,0 +1,5 @@ +area_id,area_name +ZN1,Zone 1 - Vancouver +ZN2,Zone 2 - Burnaby, Richmond and North Vancouver +ZN3,Zone 3 - Surrey and Coquitlam +sea_island,Sea Island (Vancouver Airport YVR Airport, Sea Island Centre, Templeton) \ No newline at end of file diff --git a/fixtures/fares_v2/fare_leg_rules.txt b/fixtures/fares_v2/fare_leg_rules.txt new file mode 100644 index 0000000..0fbf713 --- /dev/null +++ b/fixtures/fares_v2/fare_leg_rules.txt @@ -0,0 +1,15 @@ +leg_group_id,network_id,fare_product_id,from_area_id,to_area_id,rule_priority +flat_fare_leg,translink_bus,bus_flat_fare,,, +sea_island_ZN1,skytrain_seabus,sea_island_2_zone_fare,sea_island,ZN1,1 +sea_island_ZN2,skytrain_seabus,sea_island_1_zone_fare,sea_island,ZN2,1 +sea_island_ZN3,skytrain_seabus,sea_island_2_zone_fare,sea_island,ZN3,1 +sea_island_sea_island,skytrain_seabus,sea_island_sea_island_fare,sea_island,sea_island,2 +ZN1_ZN1,skytrain_seabus,1_zone_fare,ZN1, +ZN2_ZN2,skytrain_seabus,1_zone_fare,ZN2, +ZN3_ZN3,skytrain_seabus,1_zone_fare,ZN3, +ZN1_ZN2,skytrain_seabus,2_zone_fare,ZN1, +ZN1_ZN2,skytrain_seabus,2_zone_fare,ZN2, +ZN2_ZN3,skytrain_seabus,2_zone_fare,ZN2, +ZN2_ZN3,skytrain_seabus,2_zone_fare,ZN3, +ZN1_ZN3,skytrain_seabus,3_zone_fare,ZN1, +ZN1_ZN3,skytrain_seabus,3_zone_fare,ZN3, \ No newline at end of file diff --git a/fixtures/fares_v2/fare_media.txt b/fixtures/fares_v2/fare_media.txt new file mode 100644 index 0000000..09a08db --- /dev/null +++ b/fixtures/fares_v2/fare_media.txt @@ -0,0 +1,6 @@ +fare_media_id,fare_media_name,fare_media_type +cash,Cash,0 +contactless,Contactless,3 +compass_card,Compass Card,2 +compass_ticket,Compass Ticket,1 +wallet,Mobile Wallet,3 \ No newline at end of file diff --git a/fixtures/fares_v2/fare_products.txt b/fixtures/fares_v2/fare_products.txt new file mode 100644 index 0000000..256608b --- /dev/null +++ b/fixtures/fares_v2/fare_products.txt @@ -0,0 +1,12 @@ +fare_product_id,fare_product_name,amount,currency,fare_media_id +bus_flat_fare,Bus Flat Fare,3.20,CAD,contactless +bus_flat_fare,Bus Flat Fare,3.20,CAD,cash +bus_flat_fare,Bus Flat Fare,2.60,CAD,compass_card +bus_flat_fare_daily,Daily pass,11.50,CAD,compass_card +bus_flat_fare_daily,Daily pass,11.50,CAD,compass_ticket +1_zone_fare,1-Zone Fare,3.20,CAD,contactless +2_zone_fare,2-Zone Fare,4.65,CAD,contactless +3_zone_fare,3-Zone Fare,6.35,CAD,contactless +sea_island_1_zone_fare,Sea Island travel + 1-zone Fare,8.20,CAD,contactless +sea_island_2_zone_fare,Sea Island travel + 2-zone fare,9.65,CAD,contactless +sea_island_sea_island_fare,Free fare inside Sea Island,0,CAD,contactless \ No newline at end of file diff --git a/fixtures/fares_v2/networks.txt b/fixtures/fares_v2/networks.txt new file mode 100644 index 0000000..975309b --- /dev/null +++ b/fixtures/fares_v2/networks.txt @@ -0,0 +1,3 @@ +network_id,network_name +translink_bus,Translink Buses +skytrain_seabus,SkyTrain and SeaBus \ No newline at end of file diff --git a/fixtures/fares_v2/rider_categories.txt b/fixtures/fares_v2/rider_categories.txt new file mode 100644 index 0000000..f3d41a2 --- /dev/null +++ b/fixtures/fares_v2/rider_categories.txt @@ -0,0 +1,3 @@ +rider_category_id,rider_category_name,is_default_fare_category,eligibility_url +adult,Adult,1, +concession,Concession,,https://www.translink.ca/transit-fares/pricing-and-fare-zones#fare-pricing \ No newline at end of file diff --git a/fixtures/fares_v2/route_networks.txt b/fixtures/fares_v2/route_networks.txt new file mode 100644 index 0000000..85a8e36 --- /dev/null +++ b/fixtures/fares_v2/route_networks.txt @@ -0,0 +1,5 @@ +route_id,network_id +10232,translink_bus +11201,translink_bus +13686,skytrain_seabus +30052,skytrain_seabus \ No newline at end of file diff --git a/fixtures/fares_v2/routes.txt b/fixtures/fares_v2/routes.txt new file mode 100644 index 0000000..1830dd2 --- /dev/null +++ b/fixtures/fares_v2/routes.txt @@ -0,0 +1 @@ +agency_id,route_id,route_type diff --git a/fixtures/fares_v2/stop_areas.txt b/fixtures/fares_v2/stop_areas.txt new file mode 100644 index 0000000..c3a266a --- /dev/null +++ b/fixtures/fares_v2/stop_areas.txt @@ -0,0 +1,9 @@ +stop_id,area_id +8039,ZN1 +8066,ZN2 +99901,ZN2 +99902,ZN2 +99903,ZN2 +99901,sea_island +99902,sea_island +99903,sea_island \ No newline at end of file diff --git a/fixtures/fares_v2/stop_times.txt b/fixtures/fares_v2/stop_times.txt new file mode 100644 index 0000000..2bf77a9 --- /dev/null +++ b/fixtures/fares_v2/stop_times.txt @@ -0,0 +1 @@ +trip_id,arrival_time,departure_time,stop_id,stop_sequence,stop_time_desc,pickup_type,drop_off_type,timepoint diff --git a/fixtures/fares_v2/stops.txt b/fixtures/fares_v2/stops.txt new file mode 100644 index 0000000..e705822 --- /dev/null +++ b/fixtures/fares_v2/stops.txt @@ -0,0 +1,4 @@ +stop_id,stop_name +8039,Waterfront Station @ Platform 2 +8066,Edmonds Station @ Platform 1 +99901,YVR-Airport Station @ Canada Line \ No newline at end of file diff --git a/fixtures/fares_v2/trips.txt b/fixtures/fares_v2/trips.txt new file mode 100644 index 0000000..357b85f --- /dev/null +++ b/fixtures/fares_v2/trips.txt @@ -0,0 +1 @@ +route_id,service_id,trip_id,trip_headsign,trip_short_name,direction_id,block_id,wheelchair_accessible,bikes_allowed,trip_desc,shape_id diff --git a/src/enums.rs b/src/enums.rs index f862432..516e514 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -610,3 +610,50 @@ pub enum PathwayDirectionType { #[serde(rename = "1")] Bidirectional, } + +/// Defines the type of a [FareMedia] +#[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialEq, Eq)] +pub enum FareMediaType { + /// Used when there is no fare media involved in purchasing or validating a fare product + #[serde(rename = "0")] + None, + /// Physical paper ticket + #[serde(rename = "1")] + PhysicalPaperTicket, + /// Physical transit card + #[serde(rename = "2")] + PhysicalTransitCard, + /// cEMV (contactless Europay, Mastercard and Visa) + #[serde(rename = "3")] + CEmv, + /// Mobile app + #[serde(rename = "4")] + MobileApp, +} + +/// Specifies if an entry in rider_categories.txt should be considered the default category +#[derive(Debug, Serialize, Derivative, Copy, Clone, PartialEq, Eq, Hash)] +pub enum DefaultFareCategory { + /// Category is not considered the default. + NotDefault = 0, + /// Category is considered the default one. + Default = 1, +} + +impl<'de> Deserialize<'de> for DefaultFareCategory { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = <&str>::deserialize(deserializer)?; + Ok(match s { + "" | "0" => DefaultFareCategory::NotDefault, + "1" => DefaultFareCategory::Default, + &_ => { + return Err(serde::de::Error::custom(format!( + "Invalid value `{s}`, expected 0 or 1" + ))) + } + }) + } +} diff --git a/src/gtfs.rs b/src/gtfs.rs index 8d40af1..b7dd345 100644 --- a/src/gtfs.rs +++ b/src/gtfs.rs @@ -41,6 +41,12 @@ pub struct Gtfs { pub fare_attributes: HashMap, /// All fare rules by `fare_id` pub fare_rules: HashMap>, + /// All fare products by `fare_product_id` + pub fare_products: HashMap>, + /// All fare media by `fare_media_id` + pub fare_media: HashMap, + /// All rider categories by `rider_category_id` + pub rider_categories: HashMap, /// All feed information. There is no identifier pub feed_info: Vec, } @@ -63,7 +69,12 @@ impl TryFrom for Gtfs { let mut fare_rules = HashMap::>::new(); for f in raw.fare_rules.unwrap_or_else(|| Ok(Vec::new()))? { - (*fare_rules.entry(f.fare_id.clone()).or_default()).push(f); + fare_rules.entry(f.fare_id.clone()).or_default().push(f); + } + + let mut fare_products = HashMap::>::new(); + for f in raw.fare_products.unwrap_or_else(|| Ok(Vec::new()))? { + fare_products.entry(f.id.clone()).or_default().push(f); } Ok(Gtfs { @@ -74,6 +85,9 @@ impl TryFrom for Gtfs { shapes: to_shape_map(raw.shapes.unwrap_or_else(|| Ok(Vec::new()))?), fare_attributes: to_map(raw.fare_attributes.unwrap_or_else(|| Ok(Vec::new()))?), fare_rules, + fare_products, + fare_media: to_map(raw.fare_media.unwrap_or_else(|| Ok(Vec::new()))?), + rider_categories: to_map(raw.rider_categories.unwrap_or_else(|| Ok(Vec::new()))?), feed_info: raw.feed_info.unwrap_or_else(|| Ok(Vec::new()))?, calendar: to_map(raw.calendar.unwrap_or_else(|| Ok(Vec::new()))?), calendar_dates: to_calendar_dates( diff --git a/src/gtfs_reader.rs b/src/gtfs_reader.rs index 8eb50e4..c519408 100644 --- a/src/gtfs_reader.rs +++ b/src/gtfs_reader.rs @@ -177,6 +177,9 @@ impl RawGtfsReader { shapes: self.read_objs_from_optional_path(p, "shapes.txt"), fare_attributes: self.read_objs_from_optional_path(p, "fare_attributes.txt"), fare_rules: self.read_objs_from_optional_path(p, "fare_rules.txt"), + fare_products: self.read_objs_from_optional_path(p, "fare_products.txt"), + fare_media: self.read_objs_from_optional_path(p, "fare_media.txt"), + rider_categories: self.read_objs_from_optional_path(p, "rider_categories.txt"), frequencies: self.read_objs_from_optional_path(p, "frequencies.txt"), transfers: self.read_objs_from_optional_path(p, "transfers.txt"), pathways: self.read_objs_from_optional_path(p, "pathways.txt"), @@ -265,6 +268,9 @@ impl RawGtfsReader { "trips.txt", "fare_attributes.txt", "fare_rules.txt", + "fare_products.txt", + "fare_media.txt", + "rider_categories.txt", "frequencies.txt", "transfers.txt", "pathways.txt", @@ -301,6 +307,17 @@ impl RawGtfsReader { "fare_attributes.txt", ), fare_rules: self.read_optional_file(&file_mapping, &mut archive, "fare_rules.txt"), + fare_products: self.read_optional_file( + &file_mapping, + &mut archive, + "fare_products.txt", + ), + fare_media: self.read_optional_file(&file_mapping, &mut archive, "fare_media.txt"), + rider_categories: self.read_optional_file( + &file_mapping, + &mut archive, + "rider_categories.txt", + ), frequencies: self.read_optional_file(&file_mapping, &mut archive, "frequencies.txt"), transfers: self.read_optional_file(&file_mapping, &mut archive, "transfers.txt"), pathways: self.read_optional_file(&file_mapping, &mut archive, "pathways.txt"), diff --git a/src/objects.rs b/src/objects.rs index 0cb4f99..6795f95 100644 --- a/src/objects.rs +++ b/src/objects.rs @@ -621,6 +621,96 @@ impl Type for FareAttribute { } } +/// Used to describe the range of fares available for purchase by riders or taken into account +/// when computing the total fare for journeys with multiple legs, such as transfer costs. +/// https://gtfs.org/documentation/schedule/reference/#fare_productstxt +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct FareProduct { + /// Identifies a fare product or set of fare products. + #[serde(rename = "fare_product_id")] + pub id: String, + /// The name of the fare product as displayed to riders. + #[serde(rename = "fare_product_name")] + pub name: Option, + /// Identifies a rider category eligible for the fare product. + pub rider_category_id: Option, + /// Identifies a fare media that can be employed to use the fare product during the trip. + pub fare_media_id: Option, + /// The cost of the fare product. May be negative to represent transfer discounts. May be zero to represent a fare product that is free. + pub amount: String, + /// The currency of the cost of the fare product. + pub currency: String, +} + +impl Id for FareProduct { + fn id(&self) -> &str { + &self.id + } +} + +impl Type for FareProduct { + fn object_type(&self) -> ObjectType { + ObjectType::Fare + } +} + +/// To describe the different fare media that can be employed to use fare products. +/// Fare media are physical or virtual holders used for the representation and/or validation of a fare product. +/// https://gtfs.org/documentation/schedule/reference/#fare_mediatxt +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct FareMedia { + /// Identifies a fare media. + #[serde(rename = "fare_media_id")] + pub id: String, + /// The name of the fare media. + #[serde(rename = "fare_media_name")] + pub name: Option, + /// The type of fare media + #[serde(rename = "fare_media_type")] + pub media_type: FareMediaType, +} + +impl Id for FareMedia { + fn id(&self) -> &str { + &self.id + } +} + +impl Type for FareMedia { + fn object_type(&self) -> ObjectType { + ObjectType::Fare + } +} + +/// Defines categories of riders (e.g. elderly, student). +/// https://gtfs.org/documentation/schedule/reference/#rider_categoriestxt +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RiderCategory { + /// Identifies a rider category. + #[serde(rename = "rider_category_id")] + pub id: String, + /// Rider category name as displayed to the rider. + #[serde(rename = "rider_category_name")] + pub name: String, + /// is_default_fare_category + pub is_default_fare_category: DefaultFareCategory, + /// URL of a web page, usually from the operating agency, that provides + /// detailed information about a specific rider category and/or describes its eligibility criteria. + pub eligibility_url: Option, +} + +impl Id for RiderCategory { + fn id(&self) -> &str { + &self.id + } +} + +impl Type for RiderCategory { + fn object_type(&self) -> ObjectType { + ObjectType::Fare + } +} + /// Defines one possible fare. See #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct FareRule { diff --git a/src/raw_gtfs.rs b/src/raw_gtfs.rs index efa3874..0b3a374 100644 --- a/src/raw_gtfs.rs +++ b/src/raw_gtfs.rs @@ -26,10 +26,16 @@ pub struct RawGtfs { pub agencies: Result, Error>, /// All shapes points, None if the file was absent as it is not mandatory pub shapes: Option, Error>>, - /// All FareAttribates, None if the file was absent as it is not mandatory + /// All FareAttributes, None if the file was absent as it is not mandatory pub fare_attributes: Option, Error>>, /// All FareRules, None if the file was absent as it is not mandatory pub fare_rules: Option, Error>>, + /// All FareProducts, None if the file was absent as it is not mandatory + pub fare_products: Option, Error>>, + /// All FareMedias, None if the file was absent as it is not mandatory + pub fare_media: Option, Error>>, + /// All RiderCategories, None if the file was absent as it is not mandatory + pub rider_categories: Option, Error>>, /// All Frequencies, None if the file was absent as it is not mandatory pub frequencies: Option, Error>>, /// All Transfers, None if the file was absent as it is not mandatory diff --git a/src/tests.rs b/src/tests.rs index 4633255..4c3aa70 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -524,3 +524,40 @@ fn fare_v1() { ); assert_eq!(gtfs.fare_rules, expected_rules); } + +#[test] +fn fares_v2() { + let gtfs = Gtfs::from_path("fixtures/fares_v2").expect("impossible to read gtfs"); + + let expected = vec![FareProduct { + id: "1_zone_fare".to_string(), + name: Some("1-Zone Fare".to_string()), + rider_category_id: None, + fare_media_id: Some("contactless".to_string()), + amount: "3.20".to_string(), + currency: "CAD".to_string(), + }]; + + assert_eq!(gtfs.fare_products.len(), 8); + assert_eq!(gtfs.fare_products["1_zone_fare"], expected); + + let expected = FareMedia { + id: "contactless".to_string(), + name: Some("Contactless".to_string()), + media_type: FareMediaType::CEmv, + }; + assert_eq!(gtfs.fare_media.len(), 5); + assert_eq!(gtfs.fare_media["contactless"], expected); + + let expected = RiderCategory { + id: "concession".to_string(), + name: "Concession".to_string(), + is_default_fare_category: DefaultFareCategory::NotDefault, + eligibility_url: Some( + "https://www.translink.ca/transit-fares/pricing-and-fare-zones#fare-pricing" + .to_string(), + ), + }; + assert_eq!(gtfs.rider_categories.len(), 2); + assert_eq!(gtfs.rider_categories["concession"], expected); +}