From 004f7f99c68d664724be215b701a9598ad75967b Mon Sep 17 00:00:00 2001 From: Michael Freeborn <31806808+mfreeborn@users.noreply.github.com> Date: Sun, 28 Jun 2020 16:54:55 +0100 Subject: [PATCH] add support for more events (#18) * add support for more events * fix clippy lints * create EventTime type * bump version * update README * update cargo * add new RuntimeErrorKind * improve error handling --- Cargo.lock | 18 +-- Cargo.toml | 2 +- README.md | 61 ++++++-- src/bin/heliocron.rs | 2 +- src/config.rs | 24 ++- src/enums.rs | 19 +++ src/errors.rs | 31 +++- src/report.rs | 358 +++++++++++++++++++++++++++++++++++++------ src/structs.rs | 78 ++++++++-- src/subcommands.rs | 28 +++- src/traits.rs | 7 +- src/utils.rs | 17 +- tests/test_wait.rs | 19 +++ 13 files changed, 549 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d19e11..0890737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "heliocron" -version = "0.3.3" +version = "0.4.0" dependencies = [ "assert_cmd", "chrono", @@ -513,9 +513,9 @@ dependencies = [ [[package]] name = "proc-macro-error" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e9e4b82e0ef281812565ea4751049f1bdcdfccda7d3f459f2e138a40c08678" +checksum = "fc175e9777c3116627248584e8f8b3e2987405cabe1c0adf7d1dd28f09dc7880" dependencies = [ "proc-macro-error-attr", "proc-macro2", @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5444ead4e9935abd7f27dc51f7e852a0569ac888096d5ec2499470794e2e53" +checksum = "3cc9795ca17eb581285ec44936da7fc2335a3f34f2ddd13118b6f4d515435c50" dependencies = [ "proc-macro2", "quote", @@ -831,9 +831,9 @@ checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" [[package]] name = "unicode-xid" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" [[package]] name = "vec_map" @@ -939,9 +939,9 @@ dependencies = [ [[package]] name = "winapi" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", diff --git a/Cargo.toml b/Cargo.toml index d5deaa7..fd7a057 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heliocron" -version = "0.3.4" +version = "0.4.0" authors = ["Michael Freeborn "] description = """ Heliocron is a command line application written in Rust capable of delaying execution of other diff --git a/README.md b/README.md index 07ef04f..4e1b130 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ $ cargo install heliocron . . $ heliocron --version -heliocron 0.3.3 +heliocron 0.4.0 ``` #### 3. Build from source @@ -27,7 +27,7 @@ $ git clone https://github.com/mfreeborn/heliocron $ cd heliocron $ cargo build --release $ ./target/release/heliocron --version -heliocron 0.3.3 +heliocron 0.4.0 ``` ## Usage Examples @@ -57,11 +57,20 @@ DATE ---- 2065-05-07 12:00:00 +01:00 -Sunrise is at: 05:14:52 -Solar noon is at: 13:09:19 -Sunset is at: 21:04:53 +Solar noon is at: 2065-05-07 13:09:19 +01:00 +The day length is: 15h 49m 51s -The day length is: 15:50:01 +Sunrise is at: 2065-05-07 05:14:24 +01:00 +Sunset is at: 2065-05-07 21:04:15 +01:00 + +Civil dawn is at: 2065-05-07 04:27:31 +01:00 +Civil dusk is at: 2065-05-07 21:51:08 +01:00 + +Nautical dawn is at: 2065-05-07 03:19:56 +01:00 +Nautical dusk is at: 2065-05-07 22:58:43 +01:00 + +Astronomical dawn is at: Never +Astronomical dusk is at: Never ``` ### Configuration @@ -77,18 +86,27 @@ Now, using Heliocron without providing specific coordinates will yield the follo $ heliocron -d 2020-03-08 report LOCATION -------- -Latitude: 51.5014N -Longitude: 0.1419W +Latitude: 51.4769N +Longitude: 0.0005W DATE ---- 2020-03-08 12:00:00 +00:00 -Sunrise is at: 06:29:01 -Solar noon is at: 12:11:12 -Sunset is at: 17:53:23 +Solar noon is at: 2020-03-08 12:10:38 +00:00 +The day length is: 11h 24m 24s + +Sunrise is at: 2020-03-08 06:28:26 +00:00 +Sunset is at: 2020-03-08 17:52:50 +00:00 + +Civil dawn is at: 2020-03-08 05:55:11 +00:00 +Civil dusk is at: 2020-03-08 18:26:05 +00:00 -The day length is: 11:24:22 +Nautical dawn is at: 2020-03-08 05:16:32 +00:00 +Nautical dusk is at: 2020-03-08 19:04:44 +00:00 + +Astronomical dawn is at: 2020-03-08 04:37:08 +00:00 +Astronomical dusk is at: 2020-03-08 19:44:08 +00:00 ``` Observe that the location is set according to the contents of the configuration file. @@ -97,16 +115,25 @@ Arguments passed in via the command line will override those set in the configur $ heliocron -d 2020-03-08 -l 51.4839N -o 0.6044W report LOCATION -------- -Latitude: 51.4839N +Latitude: 51.4839N Longitude: 0.6044W DATE ---- 2020-03-08 12:00:00 +00:00 -Sunrise is at: 06:30:51 -Solar noon is at: 12:13:03 -Sunset is at: 17:55:15 +Solar noon is at: 2020-03-08 12:13:03 +00:00 +The day length is: 11h 24m 24s + +Sunrise is at: 2020-03-08 06:30:51 +00:00 +Sunset is at: 2020-03-08 17:55:15 +00:00 + +Civil dawn is at: 2020-03-08 05:57:36 +00:00 +Civil dusk is at: 2020-03-08 18:28:30 +00:00 + +Nautical dawn is at: 2020-03-08 05:18:56 +00:00 +Nautical dusk is at: 2020-03-08 19:07:10 +00:00 -The day length is: 11:24:24 +Astronomical dawn is at: 2020-03-08 04:39:32 +00:00 +Astronomical dusk is at: 2020-03-08 19:46:34 +00:00 ``` diff --git a/src/bin/heliocron.rs b/src/bin/heliocron.rs index c41187a..0452132 100644 --- a/src/bin/heliocron.rs +++ b/src/bin/heliocron.rs @@ -10,7 +10,7 @@ fn run_heliocron() -> Result<(), errors::HeliocronError> { match config.subcommand { Some(config::Subcommand::Report {}) => subcommands::display_report(report), Some(config::Subcommand::Wait { offset, event }) => { - subcommands::wait(offset?, report, event?) + subcommands::wait(offset?, report, event?)? } // will never match None as this is caught earlier by StructOpt None => println!("No subcommand provided!"), diff --git a/src/config.rs b/src/config.rs index 00106c8..6598e2d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::{fs, path::Path}; +use std::{fs, path::Path, result}; use chrono::{DateTime, Duration, FixedOffset, Local, TimeZone}; use dirs; @@ -11,11 +11,17 @@ use super::{ parsers, structs, }; -type Result = std::result::Result; +type Result = result::Result; #[derive(Debug, StructOpt)] #[structopt( - about = "A simple utility for finding out what time sunrise/sunset is, and executing programs relative to these events." + about = "A simple utility for finding out what time various solar events occur, such as sunrise and \ + sunset, at a given location on a given date. It can be integrated into cron commands to \ + trigger program execution relative to these events.\n\n\ + For example, to execute a script 'turn-on-lights.sh' at sunrise, make a Crontab entry to trigger \ + at a time that will always be before the chosen event (say, 2am) and use heliocron to calculate \ + and perform the appropriate delay:\n\n\ + \t0 2 * * * heliocron --latitude 51.47N --longitude 3.1W wait --event sunrise && turn-on-lights.sh" )] struct Cli { #[structopt(subcommand)] @@ -24,10 +30,12 @@ struct Cli { #[structopt(flatten)] date_args: DateArgs, + // the default values for latitude and longitude are handled differently to enable the user to set the values + // either on the command line, in a config file or have a default provided by the program #[structopt( short = "l", long = "latitude", - help = "Set the latitude in decimal degrees. The default is \"51.4769N\" unless overridden in ~/.config/heliocron.toml", + help = "Set the latitude in decimal degrees. Can also be set in ~/.config/heliocron.toml. [default: 51.4769N]", requires = "longitude" )] latitude: Option, @@ -35,7 +43,7 @@ struct Cli { #[structopt( short = "o", long = "longitude", - help = "Set the longitude in decimal degrees. The default is \"0.0005W\" unless overridden in ~/.config/heliocron.toml", + help = "Set the longitude in decimal degrees. Can also be set in ~/.config/heliocron.toml. [default: 0.0005W]", requires = "latitude" )] longitude: Option, @@ -57,11 +65,11 @@ pub enum Subcommand { offset: Result, #[structopt( - help = "Choose one of {sunrise | sunset} from which to base your delay.", + help = "Choose an event from which to base your delay.", short = "e", long = "event", parse(from_str=parsers::parse_event), - possible_values = &["sunrise", "sunset"] + possible_values = &["sunrise", "sunset", "civil_dawn", "civil_dusk", "nautical_dawn", "nautical_dusk", "astronomical_dawn", "astronomical_dusk"] )] event: Result, }, @@ -93,7 +101,7 @@ impl TomlConfig { } } - fn from_toml(config: std::result::Result) -> TomlConfig { + fn from_toml(config: result::Result) -> TomlConfig { match config { Ok(conf) => conf, _ => TomlConfig::new(), diff --git a/src/enums.rs b/src/enums.rs index 1e31449..6d212b1 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -8,6 +8,12 @@ type Result = result::Result; pub enum Event { Sunrise, Sunset, + CivilDawn, + CivilDusk, + NauticalDawn, + NauticalDusk, + AstronomicalDawn, + AstronomicalDusk, } impl Event { @@ -16,7 +22,20 @@ impl Event { match event.as_str() { "sunrise" => Ok(Event::Sunrise), "sunset" => Ok(Event::Sunset), + "civil_dawn" => Ok(Event::CivilDawn), + "civil_dusk" => Ok(Event::CivilDusk), + "nautical_dawn" => Ok(Event::NauticalDawn), + "nautical_dusk" => Ok(Event::NauticalDusk), + "astronomical_dawn" => Ok(Event::AstronomicalDawn), + "astronomical_dusk" => Ok(Event::AstronomicalDusk), _ => Err(HeliocronError::Config(ConfigErrorKind::InvalidEvent)), } } } + +#[derive(Debug)] +pub enum TwilightType { + Civil, + Nautical, + Astronomical, +} diff --git a/src/errors.rs b/src/errors.rs index fb80140..b9cb0a5 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,6 +5,7 @@ use chrono; #[derive(Debug)] pub enum HeliocronError { Config(ConfigErrorKind), + Runtime(RuntimeErrorKind), } #[derive(Debug)] @@ -20,13 +21,28 @@ impl ConfigErrorKind { match *self { ConfigErrorKind::InvalidCoordindates(msg) => msg, ConfigErrorKind::InvalidTomlFile => { - "Error parsing .toml file. Ensure that it is of the correct format." + "Error parsing TOML file. Ensure that it is of the correct format." } ConfigErrorKind::ParseDate => { "Error parsing date. Ensure the date and timezone formats are correct." } - ConfigErrorKind::InvalidEvent => { - "Error parsing event. Expected one of {'sunrise' | 'sunset'}" + ConfigErrorKind::InvalidEvent => "Error parsing event.", + } + } +} + +#[derive(Debug)] +pub enum RuntimeErrorKind { + NonOccurringEvent, + PastEvent, +} + +impl RuntimeErrorKind { + fn as_str(&self) -> &str { + match *self { + RuntimeErrorKind::NonOccurringEvent => "The chosen event does not occur on this day.", + RuntimeErrorKind::PastEvent => { + "The chosen event occurred in the past; cannot wait a negative amount of time." } } } @@ -46,6 +62,14 @@ impl std::fmt::Display for HeliocronError { ConfigErrorKind::InvalidEvent => err.as_str().to_string(), } ), + HeliocronError::Runtime(ref err) => write!( + f, + "Runtime error: {}", + match err { + RuntimeErrorKind::NonOccurringEvent => err.as_str().to_string(), + RuntimeErrorKind::PastEvent => err.as_str().to_string(), + } + ), } } } @@ -54,6 +78,7 @@ impl error::Error for HeliocronError { fn description(&self) -> &str { match *self { HeliocronError::Config(ref err) => err.as_str(), + HeliocronError::Runtime(ref err) => err.as_str(), } } } diff --git a/src/report.rs b/src/report.rs index 4dde9f3..9ac4757 100644 --- a/src/report.rs +++ b/src/report.rs @@ -5,18 +5,36 @@ use std::fmt; use chrono::{DateTime, Duration, FixedOffset, Local, NaiveTime, Offset, TimeZone, Timelike}; -use super::{structs, structs::Coordinate}; +use super::{ + enums, structs, + structs::{Coordinate, EventTime}, +}; use traits::DateTimeExt; #[derive(Debug)] pub struct SolarReport { - pub solar_noon: DateTime, - pub sunrise: DateTime, - pub sunset: DateTime, - pub day_length: NaiveTime, - + // required parameters pub date: DateTime, pub coordinates: structs::Coordinates, + + // these attributes are always calculable + pub solar_noon: DateTime, + pub day_length: Duration, + + // these attributes are sometimes not valid i.e. at high latitudes or + // mid-summer when there may never be a particular dawn or dusk on a + // given day + pub sunrise: EventTime, + pub sunset: EventTime, + + pub civil_dawn: EventTime, + pub civil_dusk: EventTime, + + pub nautical_dawn: EventTime, + pub nautical_dusk: EventTime, + + pub astronomical_dawn: EventTime, + pub astronomical_dusk: EventTime, } impl Default for SolarReport { @@ -24,11 +42,17 @@ impl Default for SolarReport { let local_time = Local::now(); let default_datetime = local_time.with_timezone(&FixedOffset::from_offset(local_time.offset())); - let default_day_length = NaiveTime::from_hms(0, 0, 0); + let default_day_length = Duration::seconds(0); SolarReport { solar_noon: default_datetime, - sunrise: default_datetime, - sunset: default_datetime, + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), day_length: default_day_length, date: local_time.with_timezone(&FixedOffset::from_offset(local_time.offset())), coordinates: structs::Coordinates::from_decimal_degrees("0.0N", "0.0W").unwrap(), @@ -67,30 +91,55 @@ impl SolarReport { DATE\n\ ----\n\ {}\n\n\ - Sunrise is at: {}\n\ - Solar noon is at: {}\n\ - Sunset is at: {}\n\n\ - The day length is: {}", + Solar noon is at: {}\n\ + The day length is: {}\n\n\ + Sunrise is at: {}\n\ + Sunset is at: {}\n\n\ + Civil dawn is at: {}\n\ + Civil dusk is at: {}\n\n\ + Nautical dawn is at: {}\n\ + Nautical dusk is at: {}\n\n\ + Astronomical dawn is at: {}\n\ + Astronomical dusk is at: {} + ", self.coordinates.latitude, self.coordinates.longitude, self.date, - self.sunrise, self.solar_noon, + self.day_length_hms(), + self.sunrise, self.sunset, - self.day_length, + self.civil_dawn, + self.civil_dusk, + self.nautical_dawn, + self.nautical_dusk, + self.astronomical_dawn, + self.astronomical_dusk ) } - fn calculate_day_length(&self) -> NaiveTime { - NaiveTime::from_num_seconds_from_midnight( - (self.sunset - self.sunrise).num_seconds() as u32, - 0, - ) + fn calculate_day_length(&self) -> Duration { + if self.sunrise.is_some() & self.sunset.is_some() { + self.sunset.datetime.unwrap() - self.sunrise.datetime.unwrap() + } else { + // 24 hours if sunrise and sunset don't occur + Duration::hours(24) + } + } + + fn day_length_hms(&self) -> String { + let day_length = self.day_length.num_seconds(); + let hours = (day_length / 60) / 60; + let minutes = (day_length / 60) % 60; + let seconds = day_length % 60; + + format!("{}h {}m {}s", hours, minutes, seconds) } fn day_fraction_to_datetime(&self, mut day_fraction: f64) -> DateTime { let mut date = self.date; + // correct the date if the event rolls over to the next day, or happens on the previous day if day_fraction < 0.0 { date = date - Duration::days(1); day_fraction = day_fraction.abs(); @@ -117,6 +166,47 @@ impl SolarReport { .unwrap() } + fn calculate_hour_angle( + &self, + event: Option, + solar_declination: f64, + ) -> f64 { + let event_angle: f64 = match event { + None => 90.833, + Some(enums::TwilightType::Civil) => 96.0, + Some(enums::TwilightType::Nautical) => 102.0, + Some(enums::TwilightType::Astronomical) => 108.0, + }; + + (((event_angle.to_radians().cos() + / (self.coordinates.latitude.to_radians().cos() + * solar_declination.to_radians().cos())) + - self.coordinates.latitude.to_radians().tan() * solar_declination.to_radians().tan()) + .acos()) + .to_degrees() + } + + fn calculate_event_start_and_end( + &self, + twilight_type: Option, + solar_noon: f64, + solar_declination: f64, + ) -> (EventTime, EventTime) { + let hour_angle = self.calculate_hour_angle(twilight_type, solar_declination); + + if hour_angle.is_nan() { + return (None.into(), None.into()); + } + + let start_fraction = solar_noon - (hour_angle * 4.0) / 1440.0; + let end_fraction = solar_noon + (hour_angle * 4.0) / 1440.0; + + let start_time = self.day_fraction_to_datetime(start_fraction); + let end_time = self.day_fraction_to_datetime(end_fraction); + + (Some(start_time).into(), Some(end_time).into()) + } + fn run(&mut self) { let time_zone = self.date.offset().fix().local_minus_utc() as f64 / 3600.0; @@ -178,23 +268,50 @@ impl SolarReport { * (solar_mean_anomaly.to_radians() * 2.0).sin()) .to_degrees(); - let hour_angle = (((90.833f64.to_radians().cos() - / (self.coordinates.latitude.to_radians().cos() - * solar_declination.to_radians().cos())) - - self.coordinates.latitude.to_radians().tan() * solar_declination.to_radians().tan()) - .acos()) - .to_degrees(); - let solar_noon = (720.0 - 4.0 * self.coordinates.longitude.value - equation_of_time + time_zone * 60.0) / 1440.0; - let sunrise_fraction = solar_noon - (hour_angle * 4.0) / 1440.0; - let sunset_fraction = solar_noon + (hour_angle * 4.0) / 1440.0; + // plain sunrise/sunset + let (sunrise, sunset) = + self.calculate_event_start_and_end(None, solar_noon, solar_declination); + + // civil twilight + let (civil_twilight_start, civil_twilight_end) = self.calculate_event_start_and_end( + Some(enums::TwilightType::Civil), + solar_noon, + solar_declination, + ); + + // nautical twilight + let (nautical_twilight_start, nautical_twilight_end) = self.calculate_event_start_and_end( + Some(enums::TwilightType::Nautical), + solar_noon, + solar_declination, + ); + + // astronomical twilight + let (astronomical_twilight_start, astronomical_twilight_end) = self + .calculate_event_start_and_end( + Some(enums::TwilightType::Astronomical), + solar_noon, + solar_declination, + ); - self.sunrise = self.day_fraction_to_datetime(sunrise_fraction); - self.sunset = self.day_fraction_to_datetime(sunset_fraction); self.solar_noon = self.day_fraction_to_datetime(solar_noon); + + self.sunrise = sunrise; + self.sunset = sunset; + + self.civil_dawn = civil_twilight_start; + self.civil_dusk = civil_twilight_end; + + self.nautical_dawn = nautical_twilight_start; + self.nautical_dusk = nautical_twilight_end; + + self.astronomical_dawn = astronomical_twilight_start; + self.astronomical_dusk = astronomical_twilight_end; + self.day_length = self.calculate_day_length(); } } @@ -205,18 +322,19 @@ mod tests { #[test] fn test_solar_report_new() { + // check that a 'new' method is defined and takes a coordinate and a date as parameters let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); let coordinates = structs::Coordinates { latitude: structs::Latitude { value: 0.0 }, longitude: structs::Longitude { value: 0.0 }, }; - // Default trait should handle the rest + // Default trait should handle the rest of the parameters let _new_report = SolarReport::new(date, coordinates); } #[test] fn test_report_content() { - // checks that the report contains all the corrent metrics + // check that the report contains all the correct metrics let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); let coordinates = structs::Coordinates { latitude: structs::Latitude { value: 0.0 }, @@ -241,13 +359,14 @@ mod tests { let sunset_str = format!("{}", report.sunset); assert!(report_str.contains(&sunset_str)); - let day_length_str = format!("{}", report.day_length); + let day_length_str = format!("{}", report.day_length_hms()); assert!(report_str.contains(&day_length_str)); } #[test] fn test_sunrise_sunset() { // validated against NOAA calculations https://www.esrl.noaa.gov/gmd/grad/solcalc/calcdetails.html + // ~Springtime let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W").unwrap(); @@ -255,15 +374,69 @@ mod tests { date, coordinates, solar_noon: date, - sunrise: date, - sunset: date, - day_length: NaiveTime::from_hms(0, 0, 0), + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), + day_length: Duration::seconds(0), }; report.run(); - assert_eq!("06:00:07", report.sunrise.time().to_string()); - assert_eq!("18:36:59", report.sunset.time().to_string()); + assert_eq!("06:00:07", report.sunrise.time().unwrap().to_string()); + assert_eq!("18:36:59", report.sunset.time().unwrap().to_string()); + assert_eq!("12:18:33", report.solar_noon.time().to_string()); + assert_eq!("05:22:43", report.civil_dawn.time().unwrap().to_string()); + assert_eq!("19:14:23", report.civil_dusk.time().unwrap().to_string()); + assert_eq!("04:37:42", report.nautical_dawn.time().unwrap().to_string()); + assert_eq!("19:59:24", report.nautical_dusk.time().unwrap().to_string()); + assert_eq!( + "03:49:09", + report.astronomical_dawn.time().unwrap().to_string() + ); + assert_eq!( + "20:47:57", + report.astronomical_dusk.time().unwrap().to_string() + ); + // mid-summer (there is no true night; it stays astronomical twilight) + let date = DateTime::parse_from_rfc3339("2020-06-21T12:00:00+01:00").unwrap(); + let coordinates = + structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W").unwrap(); + let mut report = SolarReport { + date, + coordinates, + solar_noon: date, + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), + day_length: Duration::seconds(0), + }; + + report.run(); + assert_eq!("04:26:26", report.sunrise.time().unwrap().to_string()); + assert_eq!("22:02:52", report.sunset.time().unwrap().to_string()); + assert_eq!("13:14:39", report.solar_noon.time().to_string()); + assert_eq!("03:23:57", report.civil_dawn.time().unwrap().to_string()); + assert_eq!("23:05:20", report.civil_dusk.time().unwrap().to_string()); + assert_eq!(None, report.nautical_dawn.datetime); + assert_eq!("Never".to_string(), format!("{}", report.nautical_dawn)); + assert_eq!(None, report.nautical_dusk.datetime); + assert_eq!("Never".to_string(), format!("{}", report.nautical_dusk)); + assert_eq!(None, report.astronomical_dawn.datetime); + assert_eq!("Never".to_string(), format!("{}", report.astronomical_dawn)); + assert_eq!(None, report.astronomical_dusk.datetime); + assert_eq!("Never".to_string(), format!("{}", report.astronomical_dusk)); + + // now try with a non-zero time zone let date = DateTime::parse_from_rfc3339("2020-03-30T12:00:00+01:00").unwrap(); let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "3.1883W").unwrap(); @@ -271,29 +444,116 @@ mod tests { date, coordinates, solar_noon: date, - sunrise: date, - sunset: date, - day_length: NaiveTime::from_hms(0, 0, 0), + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), + day_length: Duration::seconds(0), }; report.run(); - assert_eq!("06:47:03", report.sunrise.time().to_string()); - assert_eq!("19:47:03", report.sunset.time().to_string()); + assert_eq!("06:47:03", report.sunrise.time().unwrap().to_string()); + assert_eq!("19:47:03", report.sunset.time().unwrap().to_string()); + assert_eq!("13:17:03", report.solar_noon.time().to_string()); + assert_eq!("06:09:13", report.civil_dawn.time().unwrap().to_string()); + assert_eq!("20:24:53", report.civil_dusk.time().unwrap().to_string()); + assert_eq!("05:23:09", report.nautical_dawn.time().unwrap().to_string()); + assert_eq!("21:10:57", report.nautical_dusk.time().unwrap().to_string()); + assert_eq!( + "04:32:31", + report.astronomical_dawn.time().unwrap().to_string() + ); + assert_eq!( + "22:01:36", + report.astronomical_dusk.time().unwrap().to_string() + ); + // at an extreme longitude with a very non-local timezone let date = DateTime::parse_from_rfc3339("2020-03-25T12:00:00+00:00").unwrap(); let coordinates = structs::Coordinates::from_decimal_degrees("55.9533N", "174.0W").unwrap(); let mut report = SolarReport { date, coordinates, solar_noon: date, - sunrise: date, - sunset: date, - day_length: NaiveTime::from_hms(0, 0, 0), + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), + day_length: Duration::seconds(0), + }; + + report.run(); + assert_eq!( + "2020-03-25 17:23:21 +00:00", + report.sunrise.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-26 06:00:14 +00:00", + report.sunset.datetime.unwrap().to_string() + ); + assert_eq!("2020-03-25 23:41:48 +00:00", report.solar_noon.to_string()); + assert_eq!( + "2020-03-25 16:45:58 +00:00", + report.civil_dawn.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-26 06:37:37 +00:00", + report.civil_dusk.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-25 16:00:57 +00:00", + report.nautical_dawn.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-26 07:22:39 +00:00", + report.nautical_dusk.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-25 15:12:24 +00:00", + report.astronomical_dawn.datetime.unwrap().to_string() + ); + assert_eq!( + "2020-03-26 08:11:12 +00:00", + report.astronomical_dusk.datetime.unwrap().to_string() + ); + + // an extreme northern latitude during the summer + let date = DateTime::parse_from_rfc3339("2020-06-21T12:00:00+02:00").unwrap(); + let coordinates = structs::Coordinates::from_decimal_degrees("78.22N", "15.635E").unwrap(); + let mut report = SolarReport { + date, + coordinates, + solar_noon: date, + sunrise: EventTime::from(None), + sunset: EventTime::from(None), + civil_dawn: EventTime::from(None), + civil_dusk: EventTime::from(None), + nautical_dawn: EventTime::from(None), + nautical_dusk: EventTime::from(None), + astronomical_dawn: EventTime::from(None), + astronomical_dusk: EventTime::from(None), + day_length: Duration::seconds(0), }; report.run(); - assert_eq!("2020-03-25 17:23:21 +00:00", report.sunrise.to_string()); - assert_eq!("2020-03-26 06:00:14 +00:00", report.sunset.to_string()); + assert_eq!(None, report.sunrise.datetime); + assert_eq!(None, report.sunset.datetime); + assert_eq!("12:59:21", report.solar_noon.time().to_string()); + assert_eq!(None, report.civil_dawn.datetime); + assert_eq!(None, report.civil_dusk.datetime); + assert_eq!(None, report.nautical_dawn.datetime); + assert_eq!(None, report.nautical_dusk.datetime); + assert_eq!(None, report.astronomical_dawn.datetime); + assert_eq!(None, report.astronomical_dusk.datetime); + assert_eq!("24h 0m 0s", report.day_length_hms()); } #[test] diff --git a/src/structs.rs b/src/structs.rs index ad5318e..ece1f71 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,11 +1,57 @@ use std::{fmt, result}; +use chrono::{DateTime, FixedOffset, NaiveTime}; use serde::Deserialize; use super::errors::{ConfigErrorKind, HeliocronError}; type Result = result::Result; +#[derive(Debug)] +pub struct EventTime { + pub datetime: Option>, +} + +impl EventTime { + pub fn new(datetime: Option>) -> EventTime { + EventTime { datetime } + } + + pub fn is_some(&self) -> bool { + if self.datetime.is_some() { + true + } else { + false + } + } + + pub fn time(&self) -> Option { + match self.datetime { + Some(datetime) => Some(datetime.time()), + None => None, + } + } +} + +impl fmt::Display for EventTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self.datetime { + Some(datetime) => datetime.to_string(), + None => "Never".to_string(), + } + ) + } +} + +impl From>> for EventTime { + fn from(datetime: Option>) -> EventTime { + EventTime::new(datetime) + } +} + fn invalid_coordinates_error(msg: &'static str) -> HeliocronError { HeliocronError::Config(ConfigErrorKind::InvalidCoordindates(msg)) } @@ -28,9 +74,10 @@ pub struct Longitude { impl fmt::Display for Latitude { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let compass_direction = match self.value.is_sign_positive() { - true => "N", - false => "S", + let compass_direction = if self.value.is_sign_positive() { + "N" + } else { + "S" }; write!(f, "Latitude: {:.4}{}", self.value.abs(), compass_direction) } @@ -38,9 +85,10 @@ impl fmt::Display for Latitude { impl fmt::Display for Longitude { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let compass_direction = match self.value.is_sign_positive() { - true => "E", - false => "W", + let compass_direction = if self.value.is_sign_positive() { + "E" + } else { + "W" }; write!(f, "Longitude: {:.4}{}", self.value.abs(), compass_direction) } @@ -75,11 +123,11 @@ impl Coordinate for Latitude { .chars() .last() .ok_or(HeliocronError::Config( - ConfigErrorKind::InvalidCoordindates("No coordinates found."), + ConfigErrorKind::InvalidCoordindates("No coordinates found"), ))? { c if c == 'n' || c == 's' => Ok(c), _ => Err(invalid_coordinates_error( - "Latitude must end with 'N' or 'S'.", + "Latitude must end with 'N' or 'S'", )), } } @@ -87,13 +135,13 @@ impl Coordinate for Latitude { fn parse_decimal_degrees(latitude: &str) -> Result { latitude[..latitude.len() - 1] .parse() - .map_err(|_| invalid_coordinates_error("Latitude must be a positive value followed by a compass direction ('N' or 'S').")) + .map_err(|_| invalid_coordinates_error("Latitude must be a positive value followed by a compass direction ('N' or 'S')")) .and_then(|n: f64| match n { n if n.is_sign_positive() => match n { n if (0.0..=90.0).contains(&n) => Ok(n), _ => Err(invalid_coordinates_error("Latitude must be a positive value between 0.0 and 90.0")), }, - _ => Err(invalid_coordinates_error("Latitude must be a positive value between 0.0 and 90.0.")), + _ => Err(invalid_coordinates_error("Latitude must be a positive value between 0.0 and 90.0")), }) } @@ -102,7 +150,7 @@ impl Coordinate for Latitude { 'n' => Ok(1.0), 's' => Ok(-1.0), _ => Err(invalid_coordinates_error( - "Latitude must be a positive value followed by a compass direction ('N' or 'S').", + "Latitude must be a positive value followed by a compass direction ('N' or 'S')", )), } } @@ -131,7 +179,7 @@ impl Coordinate for Longitude { .chars() .last() .ok_or(HeliocronError::Config( - ConfigErrorKind::InvalidCoordindates("No coordinates found."), + ConfigErrorKind::InvalidCoordindates("No coordinates found"), ))? { c if c == 'w' || c == 'e' => Ok(c), _ => Err(invalid_coordinates_error( @@ -143,13 +191,13 @@ impl Coordinate for Longitude { fn parse_decimal_degrees(longitude: &str) -> Result { longitude[..longitude.len() - 1] .parse() - .map_err(|_| invalid_coordinates_error("Longitude must be a positive value followed by a compass direction ('W' or 'E').")) + .map_err(|_| invalid_coordinates_error("Longitude must be a positive value followed by a compass direction ('W' or 'E')")) .and_then(|n: f64| match n { n if n.is_sign_positive() => match n { n if (0.0..=180.0).contains(&n) => Ok(n), _ => Err(invalid_coordinates_error("Longitude must be a positive value between 0.0 and 180.0")), }, - _ => Err(invalid_coordinates_error("Longitude must be a positive value between 0.0 and 180.0.")), + _ => Err(invalid_coordinates_error("Longitude must be a positive value between 0.0 and 180.0")), }) } @@ -158,7 +206,7 @@ impl Coordinate for Longitude { 'e' => Ok(1.0), 'w' => Ok(-1.0), _ => Err(invalid_coordinates_error( - "Longitude must be a positive value followed by a compass direction ('W' or 'E').", + "Longitude must be a positive value followed by a compass direction ('W' or 'E')", )), } } diff --git a/src/subcommands.rs b/src/subcommands.rs index 5603d81..67e88a0 100644 --- a/src/subcommands.rs +++ b/src/subcommands.rs @@ -1,23 +1,43 @@ +use std::result; + use chrono::{Duration, FixedOffset, Local, TimeZone}; -use super::{enums, report, utils}; +use super::{ + enums, + errors::{HeliocronError, RuntimeErrorKind}, + report, utils, +}; + +type Result = result::Result; pub fn display_report(report: report::SolarReport) { println!("{}", report); } -pub fn wait(offset: Duration, report: report::SolarReport, event: enums::Event) { +pub fn wait(offset: Duration, report: report::SolarReport, event: enums::Event) -> Result<()> { let event_time = match event { enums::Event::Sunrise => report.sunrise, enums::Event::Sunset => report.sunset, + enums::Event::CivilDawn => report.civil_dawn, + enums::Event::CivilDusk => report.civil_dusk, + enums::Event::NauticalDawn => report.nautical_dawn, + enums::Event::NauticalDusk => report.nautical_dusk, + enums::Event::AstronomicalDawn => report.astronomical_dawn, + enums::Event::AstronomicalDusk => report.astronomical_dusk, }; - let wait_until = event_time + offset; + // handle the case when the chosen event doesn't occur on this day + if event_time.to_string() == "Never" { + Err(HeliocronError::Runtime(RuntimeErrorKind::NonOccurringEvent))?; + } + + let wait_until = event_time.datetime.unwrap() + offset; let local_time = Local::now(); let local_time = local_time.with_timezone(&FixedOffset::from_offset(local_time.offset())); let duration_to_wait = wait_until - local_time; - utils::wait(duration_to_wait, wait_until); + utils::wait(duration_to_wait, wait_until)?; + Ok(()) } diff --git a/src/traits.rs b/src/traits.rs index 2f107cb..6be4f80 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -18,9 +18,10 @@ impl DateTimeExt for DateTime { let utc_datetime = self.naive_utc(); // adjust for the epoch starting at 12:00 UTC - let hour_part = match utc_datetime.hour() >= 12 { - true => (utc_datetime.hour() - 12) as f64 / 24.0, - false => (utc_datetime.hour() as f64 / 24.0) - 0.5, + let hour_part = if utc_datetime.hour() >= 12 { + (utc_datetime.hour() - 12) as f64 / 24.0 + } else { + (utc_datetime.hour() as f64 / 24.0) - 0.5 }; let time_part = hour_part diff --git a/src/utils.rs b/src/utils.rs index bff0eb3..de1b4c8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,11 @@ +use std::result; + use chrono::{DateTime, Duration, FixedOffset}; +use super::errors::{HeliocronError, RuntimeErrorKind}; + +type Result = result::Result; + fn sleep(dur: std::time::Duration) { if cfg!(feature = "integration-test") || cfg!(test) { println!("Fake sleep for {}s.", dur.as_secs()); @@ -8,11 +14,11 @@ fn sleep(dur: std::time::Duration) { }; } -pub fn wait(duration: Duration, wait_until: DateTime) { +pub fn wait(duration: Duration, wait_until: DateTime) -> Result<()> { let duration_to_wait = match duration.to_std() { - Ok(dur) => dur, - Err(_) => panic!("This event has already passed! Must pick a time in the future."), - }; + Ok(dur) => Ok(dur), + Err(_) => Err(HeliocronError::Runtime(RuntimeErrorKind::PastEvent)), + }?; println!( "Thread going to sleep for {} seconds until {}. Press ctrl+C to cancel.", @@ -20,6 +26,7 @@ pub fn wait(duration: Duration, wait_until: DateTime) { wait_until ); sleep(duration_to_wait); + Ok(()) } #[cfg(test)] @@ -30,6 +37,6 @@ mod tests { fn test_wait() { let duration_to_wait = Duration::seconds(5); let wait_until = FixedOffset::west(0).timestamp(9999999999, 0); - wait(duration_to_wait, wait_until); + wait(duration_to_wait, wait_until).unwrap(); } } diff --git a/tests/test_wait.rs b/tests/test_wait.rs index e677df7..f2b1969 100644 --- a/tests/test_wait.rs +++ b/tests/test_wait.rs @@ -5,6 +5,25 @@ use assert_cmd::prelude::*; // run these tests with `cargo test --test test_wait --features integration-test` in order to // override the default sleep function +#[test] +fn test_wait_panics_with_event_non_occurrence() { + let mut cmd = Command::cargo_bin("heliocron").unwrap(); + + let wait = cmd + .args(&[ + "-d", + "2099-06-21", + "-t", + "+00:00", + "wait", + "--event", + "astronomical_dusk", + ]) + .assert(); + + wait.failure(); +} + #[test] fn test_wait_no_offset() { // assert that the heliocron will put the thread to sleep