From 10e8d7757917a1c8462b3142561dc4bbff7bacd5 Mon Sep 17 00:00:00 2001 From: Jean SIMARD Date: Thu, 5 Sep 2019 13:18:12 +0200 Subject: [PATCH] Add bank holidays management in TransXChange --- Cargo.toml | 1 - src/transxchange/bank_holidays.rs | 200 +++++++ src/transxchange/mod.rs | 2 + src/transxchange/operating_profile.rs | 545 ++++++++++++++++++ src/transxchange/read.rs | 343 ++++++----- .../transxchange2ntfs/input/bank-holiday.json | 11 + .../input/transxchange/02_simple.xml | 16 +- .../output/ntfs/calendar_dates.txt | 2 + tests/read_transxchange.rs | 2 + 9 files changed, 941 insertions(+), 181 deletions(-) create mode 100644 src/transxchange/bank_holidays.rs create mode 100644 src/transxchange/operating_profile.rs create mode 100644 tests/fixtures/transxchange2ntfs/input/bank-holiday.json create mode 100644 tests/fixtures/transxchange2ntfs/output/ntfs/calendar_dates.txt diff --git a/Cargo.toml b/Cargo.toml index 71e71b4ec..e804c2b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ minidom = "0.9.0" num-traits = "0.2.6" pretty_assertions = "0.6.1" proj = { version = "0.9.3", optional = true } -regex = "1.1.2" rust_decimal = "1.0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/transxchange/bank_holidays.rs b/src/transxchange/bank_holidays.rs new file mode 100644 index 000000000..908e187e4 --- /dev/null +++ b/src/transxchange/bank_holidays.rs @@ -0,0 +1,200 @@ +// copyright 2017 kisio digital and/or its affiliates. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// . + +//! Module to handle Bank Holidays in UK +//! The data structure is based on the JSON provided by the UK government at +//! https://www.gov.uk/bank-holidays.json + +use crate::{ + objects::{Date, ValidityPeriod}, + Result, +}; +use chrono::Datelike; +use serde::Deserialize; +use std::{collections::HashMap, fs::File, path::Path}; + +#[derive(Debug, Deserialize)] +pub struct BankHolidayRegion { + events: Vec, +} + +pub fn date_from_string<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + + Date::parse_from_str(&s, "%Y-%m-%d").map_err(serde::de::Error::custom) +} + +pub fn bank_holiday_from_string<'de, D>( + deserializer: D, +) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let title = String::deserialize(deserializer)?; + use BankHoliday::*; + // All of the following are equivalent and should be `BankHoliday::EarlyMay` + // - "Early May bank holiday" + // - "Early May bank holiday (VE Day)" + // Therefore, we trim anything that is in parenthesis at the end + let parenthesis_offset = title.find('(').unwrap_or_else(|| title.len()); + let day = match title[0..parenthesis_offset].trim() { + "New Year’s Day" => NewYearHoliday, + "2nd January" => JanuarySecondHoliday, + "St Patrick’s Day" => SaintPatrick, + "Good Friday" => GoodFriday, + "Easter Monday" => EasterMonday, + "Early May bank holiday" => EarlyMay, + "Spring bank holiday" => Spring, + "Queen’s Diamond Jubilee" => QueensDiamondJubilee, + "Battle of the Boyne" => BattleOfTheBoyne, + "Summer bank holiday" => Summer, + "St Andrew’s Day" => SaintAndrewsHoliday, + "Christmas Day" => ChristmasHoliday, + "Boxing Day" => BoxingDayHoliday, + title => { + return Err(serde::de::Error::custom(format!( + "Failed to match '{}' with a known bank holiday", + title + ))) + } + }; + Ok(day) +} + +#[derive(Debug, Deserialize)] +struct BankHolidayEvent { + #[serde(deserialize_with = "bank_holiday_from_string")] + title: BankHoliday, + #[serde(deserialize_with = "date_from_string")] + date: Date, +} + +#[derive(Clone, Debug, Ord, PartialOrd, PartialEq, Eq, Hash)] +pub enum BankHoliday { + NewYear, + // Bank Holiday for New Year, not necessarily on the 1st of January + NewYearHoliday, + JanuarySecond, + // Bank Holiday for January Second, not necessarily on the 2nd of January + JanuarySecondHoliday, + SaintPatrick, + GoodFriday, + EasterMonday, + EarlyMay, + Spring, + QueensDiamondJubilee, + BattleOfTheBoyne, + Summer, + SaintAndrews, + // Bank Holiday for Saint Andrews, not necessarily on the 30th of November + SaintAndrewsHoliday, + ChristmasEve, + Christmas, + // Bank Holiday for Christmas, not necessarily on the 25th of December + ChristmasHoliday, + BoxingDay, + // Bank Holiday for Boxing Day, not necessarily on the 26th of December + BoxingDayHoliday, + NewYearEve, +} + +pub fn get_bank_holiday>( + bank_holiday_path: P, +) -> Result>> { + let bank_holidays_file = File::open(bank_holiday_path)?; + let region: BankHolidayRegion = serde_json::from_reader(bank_holidays_file)?; + let mut day_per_bank_holiday: HashMap> = HashMap::new(); + for event in region.events { + day_per_bank_holiday + .entry(event.title) + .or_insert_with(Vec::new) + .push(event.date); + } + Ok(day_per_bank_holiday) +} + +// Generate a list of all fixed dates between two dates. +// For example, let's say you want to generate all the Christmas dates between +// the 1st of January 2000 and the 31st December of 2020 +// ``` +// let validity_period = ValidityPeriod { +// start_date: NaiveDate::from_ymd(2000, 1, 1), +// end_date: NaiveDate::from_ymd(2020, 12, 31), +// }; +// let dates = get_fixed_days(25, 12, &validity_period); +// for year in 2000..=2020 { +// let date = NaiveDate::from_ymd(year, 12, 25); +// assert!(dates.contains(&date)); +// } +// ``` +pub fn get_fixed_days(day: u32, month: u32, validity_period: &ValidityPeriod) -> Vec { + let start_year = if Date::from_ymd(validity_period.start_date.year(), month, day) + >= validity_period.start_date + { + validity_period.start_date.year() + } else { + validity_period.start_date.year() + 1 + }; + let end_year = if Date::from_ymd(validity_period.end_date.year(), month, day) + <= validity_period.end_date + { + validity_period.end_date.year() + } else { + validity_period.end_date.year() - 1 + }; + (start_year..=end_year) + .map(|year| Date::from_ymd(year, month, day)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + mod fixed_years { + use super::*; + use chrono::NaiveDate; + + #[test] + fn included_limits() { + let validity_period = ValidityPeriod { + start_date: NaiveDate::from_ymd(2000, 12, 25), + end_date: NaiveDate::from_ymd(2002, 12, 25), + }; + let dates = get_fixed_days(25, 12, &validity_period); + let date = NaiveDate::from_ymd(2000, 12, 25); + assert!(dates.contains(&date)); + let date = NaiveDate::from_ymd(2001, 12, 25); + assert!(dates.contains(&date)); + let date = NaiveDate::from_ymd(2002, 12, 25); + assert!(dates.contains(&date)); + } + + #[test] + fn excluded_limits() { + let validity_period = ValidityPeriod { + start_date: NaiveDate::from_ymd(2000, 12, 26), + end_date: NaiveDate::from_ymd(2002, 12, 24), + }; + let dates = get_fixed_days(25, 12, &validity_period); + let date = NaiveDate::from_ymd(2001, 12, 25); + assert!(dates.contains(&date)); + } + } +} diff --git a/src/transxchange/mod.rs b/src/transxchange/mod.rs index 4e78425c3..ae8b66157 100644 --- a/src/transxchange/mod.rs +++ b/src/transxchange/mod.rs @@ -16,7 +16,9 @@ //! Module to handle UK Theoric Data Model for Public Transportation called TransXChange +mod bank_holidays; mod naptan; +mod operating_profile; mod read; pub use read::read; diff --git a/src/transxchange/operating_profile.rs b/src/transxchange/operating_profile.rs new file mode 100644 index 000000000..87d38477a --- /dev/null +++ b/src/transxchange/operating_profile.rs @@ -0,0 +1,545 @@ +use crate::{ + minidom_utils::TryOnlyChild, + objects::{Date, ValidityPeriod}, + transxchange::bank_holidays::BankHoliday, +}; +use chrono::{Datelike, Weekday}; +use log::warn; +use minidom::Element; +use std::{ + collections::{HashMap, HashSet}, + convert::From, +}; + +#[derive(Debug, Default)] +struct IncludeExclude +where + T: Default, +{ + include: T, + exclude: T, +} + +pub struct OperatingProfile { + week_pattern: HashSet, + bank_holidays: IncludeExclude>, +} +type BankHolidays = HashMap>; + +impl OperatingProfile { + fn regular_days(days_of_week: &Element) -> HashSet { + let mut regular_days = HashSet::new(); + use chrono::Weekday::*; + if days_of_week.children().count() == 0 { + regular_days.insert(Mon); + regular_days.insert(Tue); + regular_days.insert(Wed); + regular_days.insert(Thu); + regular_days.insert(Fri); + regular_days.insert(Sat); + regular_days.insert(Sun); + } else { + for element in days_of_week.children() { + match element.name() { + "Monday" => { + regular_days.insert(Mon); + } + "Tuesday" => { + regular_days.insert(Tue); + } + "Wednesday" => { + regular_days.insert(Wed); + } + "Thursday" => { + regular_days.insert(Thu); + } + "Friday" => { + regular_days.insert(Fri); + } + "Saturday" => { + regular_days.insert(Sat); + } + "Sunday" => { + regular_days.insert(Sun); + } + "MondayToFriday" => { + regular_days.insert(Mon); + regular_days.insert(Tue); + regular_days.insert(Wed); + regular_days.insert(Thu); + regular_days.insert(Fri); + } + "MondayToSaturday" => { + regular_days.insert(Mon); + regular_days.insert(Tue); + regular_days.insert(Wed); + regular_days.insert(Thu); + regular_days.insert(Fri); + regular_days.insert(Sat); + } + "MondayToSunday" => { + regular_days.insert(Mon); + regular_days.insert(Tue); + regular_days.insert(Wed); + regular_days.insert(Thu); + regular_days.insert(Fri); + regular_days.insert(Sat); + regular_days.insert(Sun); + } + "NotSaturday" => { + regular_days.insert(Mon); + regular_days.insert(Tue); + regular_days.insert(Wed); + regular_days.insert(Thu); + regular_days.insert(Fri); + regular_days.insert(Sun); + } + "Weekend" => { + regular_days.insert(Sat); + regular_days.insert(Sun); + } + unknown_tag => warn!("Tag '{}' is not a valid tag for DaysOfWeek", unknown_tag), + }; + } + } + regular_days + } + + fn bank_holidays(days_operation: &Element) -> HashSet { + let mut bank_holidays = HashSet::new(); + for element in days_operation.children() { + use crate::transxchange::bank_holidays::BankHoliday::*; + match element.name() { + "AllBankHolidays" => { + bank_holidays.insert(NewYear); + bank_holidays.insert(JanuarySecond); + bank_holidays.insert(GoodFriday); + bank_holidays.insert(SaintAndrews); + bank_holidays.insert(EasterMonday); + bank_holidays.insert(EarlyMay); + bank_holidays.insert(Spring); + bank_holidays.insert(Summer); + bank_holidays.insert(Christmas); + bank_holidays.insert(BoxingDay); + bank_holidays.insert(NewYearHoliday); + bank_holidays.insert(JanuarySecondHoliday); + bank_holidays.insert(SaintAndrewsHoliday); + bank_holidays.insert(ChristmasHoliday); + bank_holidays.insert(BoxingDayHoliday); + } + "EarlyRunOff" => { + bank_holidays.insert(ChristmasEve); + bank_holidays.insert(NewYearEve); + } + "AllHolidaysExceptChristmas" => { + bank_holidays.insert(NewYear); + bank_holidays.insert(JanuarySecond); + bank_holidays.insert(GoodFriday); + bank_holidays.insert(SaintAndrews); + bank_holidays.insert(EasterMonday); + bank_holidays.insert(EarlyMay); + bank_holidays.insert(Spring); + bank_holidays.insert(Summer); + } + "Holidays" => { + bank_holidays.insert(NewYear); + bank_holidays.insert(JanuarySecond); + bank_holidays.insert(GoodFriday); + bank_holidays.insert(SaintAndrews); + } + "HolidayMondays" => { + bank_holidays.insert(EasterMonday); + bank_holidays.insert(EarlyMay); + bank_holidays.insert(Spring); + bank_holidays.insert(Summer); + } + "Christmas" => { + bank_holidays.insert(Christmas); + bank_holidays.insert(BoxingDay); + } + "DisplacementHolidays" => { + bank_holidays.insert(NewYearHoliday); + bank_holidays.insert(JanuarySecondHoliday); + bank_holidays.insert(SaintAndrewsHoliday); + bank_holidays.insert(ChristmasHoliday); + bank_holidays.insert(BoxingDayHoliday); + } + "NewYearsDay" => { + bank_holidays.insert(NewYear); + } + "Jan2ndScotland" => { + bank_holidays.insert(JanuarySecond); + } + "GoodFriday" => { + bank_holidays.insert(GoodFriday); + } + "StAndrewsDay" => { + bank_holidays.insert(SaintAndrews); + } + "EasterMonday" => { + bank_holidays.insert(EasterMonday); + } + "MayDay" => { + bank_holidays.insert(EarlyMay); + } + "SpringBank" => { + bank_holidays.insert(Spring); + } + "AugustBankHolidayScotland" | "LateSummerBankHolidayNotScotland" => { + bank_holidays.insert(Summer); + } + "ChristmasDay" => { + bank_holidays.insert(Christmas); + } + "BoxingDay" => { + bank_holidays.insert(BoxingDay); + } + "NewYearsDayHoliday" => { + bank_holidays.insert(NewYearHoliday); + } + "Jan2ndScotlandHoliday" => { + bank_holidays.insert(JanuarySecondHoliday); + } + "StAndrewsDayHoliday" => { + bank_holidays.insert(SaintAndrewsHoliday); + } + "ChristmasDayHoliday" => { + bank_holidays.insert(ChristmasHoliday); + } + "BoxingDayHoliday" => { + bank_holidays.insert(BoxingDayHoliday); + } + unknown_tag => warn!( + "Tag '{}' is not a valid tag BankHolidayOperation", + unknown_tag + ), + } + } + bank_holidays + } +} + +impl From<&Element> for OperatingProfile { + fn from(operating_profile: &Element) -> Self { + let week_pattern = operating_profile + .try_only_child("RegularDayType") + .and_then(|regular_day_type| regular_day_type.try_only_child("DaysOfWeek")) + .map(|days_of_week| OperatingProfile::regular_days(days_of_week)) + .unwrap_or_default(); + let bank_holidays = operating_profile + .try_only_child("BankHolidayOperation") + .and_then(|bank_holiday_operation| { + let include = bank_holiday_operation + .try_only_child("DaysOfOperation") + .map(OperatingProfile::bank_holidays) + .unwrap_or_default(); + let exclude = bank_holiday_operation + .try_only_child("DaysOfNonOperation") + .map(OperatingProfile::bank_holidays) + .unwrap_or_default(); + Ok(IncludeExclude { include, exclude }) + }) + .unwrap_or_default(); + Self { + week_pattern, + bank_holidays, + } + } +} + +pub struct ValidityPatternIterator<'a> { + operating_profile: &'a OperatingProfile, + bank_holidays_dates: IncludeExclude>, + validity_period: &'a ValidityPeriod, + current_date: Date, +} + +impl OperatingProfile { + pub fn iter_with_bank_holidays_between<'a>( + &'a self, + bank_holidays: &'a BankHolidays, + validity_period: &'a ValidityPeriod, + ) -> ValidityPatternIterator<'a> { + let filter_dates = |list_bank_holidays: &HashSet| { + list_bank_holidays + .iter() + .flat_map(|bank_holiday| bank_holidays.get(&bank_holiday)) + .flatten() + .filter(|date| { + **date >= validity_period.start_date && **date <= validity_period.end_date + }) + .cloned() + .collect() + }; + let include_bank_holidays_dates: HashSet = filter_dates(&self.bank_holidays.include); + let exclude_bank_holidays_dates: HashSet = filter_dates(&self.bank_holidays.exclude); + ValidityPatternIterator { + operating_profile: self, + bank_holidays_dates: IncludeExclude { + include: include_bank_holidays_dates, + exclude: exclude_bank_holidays_dates, + }, + validity_period, + current_date: validity_period.start_date.pred(), + } + } +} + +impl Iterator for ValidityPatternIterator<'_> { + type Item = Date; + fn next(&mut self) -> Option { + self.current_date = self.current_date.succ(); + if self.current_date > self.validity_period.end_date { + return None; + } + let is_included = self + .operating_profile + .week_pattern + .contains(&self.current_date.weekday()); + let is_included = if is_included { + // Check if it's excluded as a Bank Holiday + !self + .bank_holidays_dates + .exclude + .contains(&self.current_date) + } else { + // Check if it's included as a Bank holiday + self.bank_holidays_dates + .include + .contains(&self.current_date) + }; + if is_included { + Some(self.current_date) + } else { + self.next() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod operating_profile { + use super::*; + + mod regular_days { + use super::*; + use chrono::Weekday::*; + + #[test] + fn work_week() { + let xml = r#" + + + "#; + let root: Element = xml.parse().unwrap(); + let regular_days = OperatingProfile::regular_days(&root); + assert!(regular_days.contains(&Mon)); + assert!(regular_days.contains(&Tue)); + assert!(regular_days.contains(&Wed)); + assert!(regular_days.contains(&Thu)); + assert!(regular_days.contains(&Fri)); + } + + #[test] + fn default() { + let xml = r#""#; + let root: Element = xml.parse().unwrap(); + let regular_days = OperatingProfile::regular_days(&root); + assert!(regular_days.contains(&Mon)); + assert!(regular_days.contains(&Tue)); + assert!(regular_days.contains(&Wed)); + assert!(regular_days.contains(&Thu)); + assert!(regular_days.contains(&Fri)); + } + } + + mod bank_holidays { + use super::*; + use crate::transxchange::bank_holidays::BankHoliday::*; + + #[test] + fn christmas_and_displacement_holidays() { + let xml = r#" + + + + "#; + let root: Element = xml.parse().unwrap(); + let bank_holidays = OperatingProfile::bank_holidays(&root); + assert!(bank_holidays.contains(&Christmas)); + assert!(bank_holidays.contains(&BoxingDay)); + assert!(bank_holidays.contains(&NewYearHoliday)); + assert!(bank_holidays.contains(&JanuarySecondHoliday)); + assert!(bank_holidays.contains(&SaintAndrewsHoliday)); + assert!(bank_holidays.contains(&ChristmasHoliday)); + assert!(bank_holidays.contains(&BoxingDayHoliday)); + } + } + + mod from { + use super::*; + use crate::transxchange::bank_holidays::BankHoliday::*; + use chrono::Weekday::*; + + #[test] + fn regular_day_type() { + let xml = r#" + + + + + + "#; + let root: Element = xml.parse().unwrap(); + let operating_profile = OperatingProfile::from(&root); + assert!(operating_profile.week_pattern.contains(&Sat)); + assert!(operating_profile.week_pattern.contains(&Sun)); + assert!(operating_profile.bank_holidays.include.is_empty()); + assert!(operating_profile.bank_holidays.exclude.is_empty()); + } + + #[test] + fn with_days_of_operation() { + let xml = r#" + + + + + + + + + + + "#; + let root: Element = xml.parse().unwrap(); + let operating_profile = OperatingProfile::from(&root); + assert!(operating_profile.week_pattern.contains(&Sat)); + assert!(operating_profile.week_pattern.contains(&Sun)); + assert!(operating_profile + .bank_holidays + .include + .contains(&EasterMonday)); + assert!(operating_profile.bank_holidays.exclude.is_empty()); + } + + #[test] + fn with_days_of_non_operation() { + let xml = r#" + + + + + + + + + + + "#; + let root: Element = xml.parse().unwrap(); + let operating_profile = OperatingProfile::from(&root); + assert!(operating_profile.week_pattern.contains(&Sat)); + assert!(operating_profile.week_pattern.contains(&Sun)); + assert!(operating_profile.bank_holidays.include.is_empty()); + assert!(operating_profile + .bank_holidays + .exclude + .contains(&JanuarySecond)); + } + } + } + + mod validity_pattern_iterator { + use super::*; + use crate::transxchange::bank_holidays::BankHoliday::*; + use chrono::Weekday::*; + + #[test] + fn weekend() { + let mut week_pattern = HashSet::new(); + week_pattern.insert(Sat); + week_pattern.insert(Sun); + let include = HashSet::new(); + let exclude = HashSet::new(); + let bank_holidays = IncludeExclude { include, exclude }; + let operating_profile = OperatingProfile { + week_pattern, + bank_holidays, + }; + let bank_holidays = HashMap::new(); + let validity_period = ValidityPeriod { + start_date: Date::from_ymd(2019, 1, 1), + end_date: Date::from_ymd(2019, 1, 8), + }; + let dates: Vec = operating_profile + .iter_with_bank_holidays_between(&bank_holidays, &validity_period) + .collect(); + assert_eq!(dates[0], Date::from_ymd(2019, 1, 5)); + assert_eq!(dates[1], Date::from_ymd(2019, 1, 6)); + } + + #[test] + fn with_bank_holiday() { + let mut week_pattern = HashSet::new(); + week_pattern.insert(Sat); + week_pattern.insert(Sun); + let mut include = HashSet::new(); + include.insert(NewYear); + include.insert(NewYearHoliday); + let exclude = HashSet::new(); + let bank_holidays = IncludeExclude { include, exclude }; + let operating_profile = OperatingProfile { + week_pattern, + bank_holidays, + }; + let mut bank_holidays = HashMap::new(); + bank_holidays.insert(NewYear, vec![Date::from_ymd(2017, 1, 1)]); + bank_holidays.insert(NewYearHoliday, vec![Date::from_ymd(2017, 1, 2)]); + let validity_period = ValidityPeriod { + start_date: Date::from_ymd(2017, 1, 1), + end_date: Date::from_ymd(2017, 1, 8), + }; + let dates: Vec = operating_profile + .iter_with_bank_holidays_between(&bank_holidays, &validity_period) + .collect(); + assert_eq!(dates[0], Date::from_ymd(2017, 1, 1)); + assert_eq!(dates[1], Date::from_ymd(2017, 1, 2)); + assert_eq!(dates[2], Date::from_ymd(2017, 1, 7)); + assert_eq!(dates[3], Date::from_ymd(2017, 1, 8)); + } + + #[test] + fn without_bank_holiday() { + let mut week_pattern = HashSet::new(); + week_pattern.insert(Mon); + week_pattern.insert(Tue); + week_pattern.insert(Wed); + week_pattern.insert(Thu); + week_pattern.insert(Fri); + let include = HashSet::new(); + let mut exclude = HashSet::new(); + exclude.insert(NewYear); + let bank_holidays = IncludeExclude { include, exclude }; + let operating_profile = OperatingProfile { + week_pattern, + bank_holidays, + }; + let mut bank_holidays = HashMap::new(); + bank_holidays.insert(NewYear, vec![Date::from_ymd(2018, 1, 1)]); + let validity_period = ValidityPeriod { + start_date: Date::from_ymd(2018, 1, 1), + end_date: Date::from_ymd(2018, 1, 8), + }; + let dates: Vec = operating_profile + .iter_with_bank_holidays_between(&bank_holidays, &validity_period) + .collect(); + assert_eq!(dates[0], Date::from_ymd(2018, 1, 2)); + assert_eq!(dates[1], Date::from_ymd(2018, 1, 3)); + assert_eq!(dates[2], Date::from_ymd(2018, 1, 4)); + assert_eq!(dates[3], Date::from_ymd(2018, 1, 5)); + } + } +} diff --git a/src/transxchange/read.rs b/src/transxchange/read.rs index dbc3d994d..3d1f3a08d 100644 --- a/src/transxchange/read.rs +++ b/src/transxchange/read.rs @@ -19,19 +19,19 @@ use crate::{ minidom_utils::TryOnlyChild, model::{Collections, Model}, objects::*, - transxchange::naptan, + transxchange::{bank_holidays, bank_holidays::BankHoliday, naptan}, AddPrefix, Result, }; use chrono::{ naive::{NaiveDate, MAX_DATE, MIN_DATE}, - Datelike, Duration, + Duration, }; use failure::{bail, format_err}; use lazy_static::lazy_static; use log::{info, warn}; use minidom::Element; use std::{ - collections::{BTreeMap, BTreeSet, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap}, convert::TryFrom, fs::File, io::Read, @@ -340,137 +340,34 @@ fn create_route( }) } -struct NaiveDateRange { - current: NaiveDate, - end: NaiveDate, -} - -impl NaiveDateRange { - fn new(start: NaiveDate, end: NaiveDate) -> NaiveDateRange { - if start <= end { - NaiveDateRange { - current: start, - end, - } - } else { - // If end date is smaller, then invert start and end - NaiveDateRange { - current: end, - end: start, - } - } - } -} - -impl Iterator for NaiveDateRange { - type Item = NaiveDate; - - fn next(&mut self) -> Option { - if self.current <= self.end { - let date = self.current; - self.current = self.current.succ(); - Some(date) - } else { - None - } - } -} - fn generate_calendar_dates( operating_profile: &Element, - validity_period: ValidityPeriod, + bank_holidays: &HashMap>, + validity_period: &ValidityPeriod, ) -> Result> { - let mut dates = BTreeSet::new(); - let mut days = HashSet::new(); - let regular_day_type = operating_profile.try_only_child("RegularDayType")?; - if let Ok(days_of_week) = regular_day_type.try_only_child("DaysOfWeek") { - use chrono::Weekday::*; - if days_of_week.children().count() == 0 { - days.insert(Mon); - days.insert(Tue); - days.insert(Wed); - days.insert(Thu); - days.insert(Fri); - days.insert(Sat); - days.insert(Sun); - } else { - for element in days_of_week.children() { - match element.name() { - "Monday" => { - days.insert(Mon); - } - "Tuesday" => { - days.insert(Tue); - } - "Wednesday" => { - days.insert(Wed); - } - "Thursday" => { - days.insert(Thu); - } - "Friday" => { - days.insert(Fri); - } - "Saturday" => { - days.insert(Sat); - } - "Sunday" => { - days.insert(Sun); - } - "MondayToFriday" => { - days.insert(Mon); - days.insert(Tue); - days.insert(Wed); - days.insert(Thu); - days.insert(Fri); - } - "MondayToSaturday" => { - days.insert(Mon); - days.insert(Tue); - days.insert(Wed); - days.insert(Thu); - days.insert(Fri); - days.insert(Sat); - } - "MondayToSunday" => { - days.insert(Mon); - days.insert(Tue); - days.insert(Wed); - days.insert(Thu); - days.insert(Fri); - days.insert(Sat); - days.insert(Sun); - } - "NotSaturday" => { - days.insert(Mon); - days.insert(Tue); - days.insert(Wed); - days.insert(Thu); - days.insert(Fri); - days.insert(Sun); - } - "Weekend" => { - days.insert(Sat); - days.insert(Sun); - } - unknown_tag => warn!("Tag '{}' is not a valid tag for DaysOfWeek", unknown_tag), - }; - } - } - } - for date in NaiveDateRange::new(validity_period.start_date, validity_period.end_date) { - if days.contains(&date.weekday()) { - // TODO: Handle exceptions (see SpecialDaysOperation) - // TODO: Handle bank holidays (see BankHolidaysOperation) - dates.insert(date); - } - } + use crate::transxchange::operating_profile::OperatingProfile; + let operating_profile = OperatingProfile::from(operating_profile); + let mut bank_holidays = bank_holidays.clone(); + let new_year_days = bank_holidays::get_fixed_days(1, 1, validity_period); + bank_holidays.insert(BankHoliday::NewYear, new_year_days); + let january_second_days = bank_holidays::get_fixed_days(2, 1, validity_period); + bank_holidays.insert(BankHoliday::JanuarySecond, january_second_days); + let saint_andrews_days = bank_holidays::get_fixed_days(30, 11, validity_period); + bank_holidays.insert(BankHoliday::SaintAndrews, saint_andrews_days); + let christmas_days = bank_holidays::get_fixed_days(25, 12, validity_period); + bank_holidays.insert(BankHoliday::Christmas, christmas_days); + let boxing_days = bank_holidays::get_fixed_days(26, 12, validity_period); + bank_holidays.insert(BankHoliday::BoxingDay, boxing_days); + let dates: BTreeSet = operating_profile + .iter_with_bank_holidays_between(&bank_holidays, validity_period) + .collect(); Ok(dates) } fn create_calendar_dates( transxchange: &Element, vehicle_journey: &Element, + bank_holidays: &HashMap>, max_end_date: NaiveDate, ) -> Result> { let operating_profile = vehicle_journey @@ -482,7 +379,7 @@ fn create_calendar_dates( .try_only_child("OperatingProfile") })?; let validity_period = get_service_validity_period(transxchange, max_end_date)?; - generate_calendar_dates(&operating_profile, validity_period) + generate_calendar_dates(&operating_profile, bank_holidays, &validity_period) } fn find_duplicate_calendar<'a>( @@ -637,6 +534,7 @@ fn calculate_stop_times( fn load_routes_vehicle_journeys_calendars( collections: &Collections, transxchange: &Element, + bank_holidays: &HashMap>, lines: &CollectionWithId, dataset_id: &str, physical_mode_id: &str, @@ -696,7 +594,8 @@ fn load_routes_vehicle_journeys_calendars( } vj_id }; - let dates = create_calendar_dates(transxchange, vehicle_journey, max_end_date)?; + let dates = + create_calendar_dates(transxchange, vehicle_journey, bank_holidays, max_end_date)?; if dates.is_empty() { warn!("No calendar date, skipping Vehicle Journey {}", id); continue; @@ -781,6 +680,7 @@ fn load_routes_vehicle_journeys_calendars( fn read_xml( transxchange: &Element, collections: &mut Collections, + bank_holidays: &HashMap>, dataset_id: &str, max_end_date: NaiveDate, ) -> Result<()> { @@ -791,6 +691,7 @@ fn read_xml( let (routes, vehicle_journeys, calendars) = load_routes_vehicle_journeys_calendars( collections, transxchange, + bank_holidays, &lines, dataset_id, &physical_mode.id, @@ -830,6 +731,7 @@ fn read_xml( fn read_file( file_path: &Path, mut file: F, + bank_holidays: &HashMap>, collections: &mut Collections, dataset_id: &str, max_end_date: NaiveDate, @@ -843,7 +745,13 @@ where let mut file_content = String::new(); file.read_to_string(&mut file_content)?; match file_content.parse::() { - Ok(element) => read_xml(&element, collections, dataset_id, max_end_date)?, + Ok(element) => read_xml( + &element, + collections, + bank_holidays, + dataset_id, + max_end_date, + )?, Err(e) => { warn!("Failed to parse file '{:?}' as DOM: {}", file_path, e); } @@ -856,6 +764,7 @@ where fn read_from_zip

( transxchange_path: P, + bank_holidays: &HashMap>, collections: &mut Collections, dataset_id: &str, max_end_date: NaiveDate, @@ -880,6 +789,7 @@ where read_file( file.sanitized_name().as_path(), file, + bank_holidays, collections, dataset_id, max_end_date, @@ -890,6 +800,7 @@ where fn read_from_path

( transxchange_path: P, + bank_holidays: &HashMap>, collections: &mut Collections, dataset_id: &str, max_end_date: NaiveDate, @@ -907,7 +818,14 @@ where paths.sort(); for path in paths { let file = File::open(&path)?; - read_file(&path, file, collections, dataset_id, max_end_date)?; + read_file( + &path, + file, + bank_holidays, + collections, + dataset_id, + max_end_date, + )?; } Ok(()) } @@ -916,6 +834,7 @@ where pub fn read

( transxchange_path: P, naptan_path: P, + bank_holidays_path: Option

, config_path: Option

, prefix: Option, max_end_date: NaiveDate, @@ -940,9 +859,15 @@ where } else { naptan::read_from_path(naptan_path, &mut collections)?; }; + let bank_holidays = if let Some(bank_holidays_path) = bank_holidays_path { + bank_holidays::get_bank_holiday(bank_holidays_path)? + } else { + Default::default() + }; if transxchange_path.as_ref().is_file() { read_from_zip( transxchange_path, + &bank_holidays, &mut collections, &dataset_id, max_end_date, @@ -950,6 +875,7 @@ where } else if transxchange_path.as_ref().is_dir() { read_from_path( transxchange_path, + &bank_holidays, &mut collections, &dataset_id, max_end_date, @@ -1747,20 +1673,80 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 12); - let end_date = NaiveDate::from_ymd(2019, 08, 20); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 1); + let end_date = NaiveDate::from_ymd(2019, 1, 8); + let validity = ValidityPeriod { + start_date, + end_date, + }; + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 1))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 2))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 3))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 4))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 7))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 8))); + } + + #[test] + fn with_included_bank_holidays() { + let xml = r#" + + + + + + + + + + + "#; + let root: Element = xml.parse().unwrap(); + let bank_holidays = HashMap::new(); + // 1st of January 2017 was a Sunday + let start_date = NaiveDate::from_ymd(2017, 1, 1); + let end_date = NaiveDate::from_ymd(2017, 1, 8); + let validity = ValidityPeriod { + start_date, + end_date, + }; + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 1))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 2))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 3))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 4))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 5))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 6))); + } + + #[test] + fn with_excluded_bank_holidays() { + let xml = r#" + + + + + + + + + + + "#; + let root: Element = xml.parse().unwrap(); + let bank_holidays = HashMap::new(); + // 1st of January 2017 was a Sunday + let start_date = NaiveDate::from_ymd(2017, 1, 1); + let end_date = NaiveDate::from_ymd(2017, 1, 8); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 12))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 13))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 14))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 15))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 16))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 19))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 20))); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 7))); + assert!(dates.contains(&NaiveDate::from_ymd(2017, 1, 8))); } #[test] @@ -1773,15 +1759,16 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 17); - let end_date = NaiveDate::from_ymd(2019, 08, 19); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 4); + let end_date = NaiveDate::from_ymd(2019, 1, 6); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 18))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 19))); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 4))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 6))); } #[test] @@ -1795,16 +1782,17 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 19); - let end_date = NaiveDate::from_ymd(2019, 08, 17); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 1); + let end_date = NaiveDate::from_ymd(2019, 1, 8); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 17))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 18))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 19))); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 5))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 6))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 7))); } #[test] @@ -1819,22 +1807,22 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 12); - let end_date = NaiveDate::from_ymd(2019, 08, 20); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 1); + let end_date = NaiveDate::from_ymd(2019, 1, 8); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 12))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 13))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 14))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 15))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 16))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 17))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 18))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 19))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 20))); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 1))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 2))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 3))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 4))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 5))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 6))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 7))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 8))); } #[test] @@ -1845,22 +1833,22 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 12); - let end_date = NaiveDate::from_ymd(2019, 08, 20); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 1); + let end_date = NaiveDate::from_ymd(2019, 1, 8); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 12))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 13))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 14))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 15))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 16))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 17))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 18))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 19))); - assert!(dates.contains(&NaiveDate::from_ymd(2019, 08, 20))); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 1))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 2))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 3))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 4))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 5))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 6))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 7))); + assert!(dates.contains(&NaiveDate::from_ymd(2019, 1, 8))); } #[test] @@ -1871,13 +1859,14 @@ mod tests { "#; let root: Element = xml.parse().unwrap(); - let start_date = NaiveDate::from_ymd(2019, 08, 12); - let end_date = NaiveDate::from_ymd(2019, 08, 20); + let bank_holidays = HashMap::new(); + let start_date = NaiveDate::from_ymd(2019, 1, 1); + let end_date = NaiveDate::from_ymd(2019, 1, 8); let validity = ValidityPeriod { start_date, end_date, }; - let dates = generate_calendar_dates(&root, validity).unwrap(); + let dates = generate_calendar_dates(&root, &bank_holidays, &validity).unwrap(); assert!(dates.is_empty()); } } diff --git a/tests/fixtures/transxchange2ntfs/input/bank-holiday.json b/tests/fixtures/transxchange2ntfs/input/bank-holiday.json new file mode 100644 index 000000000..b41b298d4 --- /dev/null +++ b/tests/fixtures/transxchange2ntfs/input/bank-holiday.json @@ -0,0 +1,11 @@ +{ + "division": "england-and-wales", + "events": [ + { + "title": "Easter Monday", + "date": "2019-04-22", + "notes": "", + "bunting": true + } + ] +} diff --git a/tests/fixtures/transxchange2ntfs/input/transxchange/02_simple.xml b/tests/fixtures/transxchange2ntfs/input/transxchange/02_simple.xml index b8dd9daab..0372f8594 100644 --- a/tests/fixtures/transxchange2ntfs/input/transxchange/02_simple.xml +++ b/tests/fixtures/transxchange2ntfs/input/transxchange/02_simple.xml @@ -1,5 +1,5 @@ - - + + @@ -108,7 +108,7 @@ JPS1 inboundAndOutbound - + Upper Race, Race Club, opp JPS15 inboundAndOutbound @@ -127,6 +127,11 @@ + + + + + JP1 08:45:00 @@ -155,6 +160,11 @@ + + + + + JP15 18:45:00 diff --git a/tests/fixtures/transxchange2ntfs/output/ntfs/calendar_dates.txt b/tests/fixtures/transxchange2ntfs/output/ntfs/calendar_dates.txt new file mode 100644 index 000000000..07be60343 --- /dev/null +++ b/tests/fixtures/transxchange2ntfs/output/ntfs/calendar_dates.txt @@ -0,0 +1,2 @@ +service_id,date,exception_type +prefix:CD:SCAO812:SL1:VJ1:1,20190422,2 diff --git a/tests/read_transxchange.rs b/tests/read_transxchange.rs index 0378e63eb..e8998293b 100644 --- a/tests/read_transxchange.rs +++ b/tests/read_transxchange.rs @@ -22,6 +22,7 @@ fn test_read_transxchange() { let ntm = transit_model::transxchange::read( "tests/fixtures/transxchange2ntfs/input/transxchange", "tests/fixtures/transxchange2ntfs/input/naptan", + Some("tests/fixtures/transxchange2ntfs/input/bank-holiday.json"), Some("tests/fixtures/transxchange2ntfs/input/config.json"), Some("prefix".into()), chrono::NaiveDate::from_ymd(2021, 12, 31), @@ -33,6 +34,7 @@ fn test_read_transxchange() { &output_dir, Some(vec![ "calendar.txt", + "calendar_dates.txt", "companies.txt", "contributors.txt", "commercial_modes.txt",