@@ -389,26 +389,36 @@ impl MessageHeader {
389389 self . location_str ( ) . split ( '-' )
390390 }
391391
392- /// Message validity duration (Duration)
392+ /// Message validity duration (` Duration` )
393393 ///
394- /// Returns the message validity duration. The message is
395- /// valid until
394+ /// Returns the message validity duration or "purge time."
395+ /// The duration specifies how long, relative to the
396+ /// [issue time](MessageHeader::issue_datetime), that the
397+ /// message is valid.
396398 ///
397- /// ```ignore
398- /// msg.issue_datetime().unwrap() + msg.valid_duration()
399- /// ```
399+ /// The Duration is typically:
400+ ///
401+ /// * increments of **15 minutes** for Durations of
402+ /// **1 hour** or less
403+ ///
404+ /// * increments of **30 minutes** for Durations longer
405+ /// than **1 hour**
406+ ///
407+ /// * no longer than
408+ /// [99.5 hours](https://www.weather.gov/nwr/samealertduration)
400409 ///
401- /// After this time elapses, the message is no longer valid
402- /// and should not be relayed or alerted to anymore.
410+ /// but sameplace does not enforce any of these restrictions.
403411 ///
404- /// This field represents the validity time of the *message*
412+ /// This field represents the validity duration of the *message*
405413 /// and not the expected duration of the severe condition.
406- /// Severe conditions may persist after the message expires!
407- /// (And might be the subject of future messages.)
414+ /// **An expired message may still refer to an ongoing hazard** or
415+ /// event. Expiration merely indicates that the *message* is no
416+ /// longer valid. Clients are encouraged to retain a history of
417+ /// alerts and voice message contents.
408418 ///
409419 /// The valid duration is relative to the
410- /// [`issue_datetime()`](#method. issue_datetime) and *not* the
411- /// current time.
420+ /// [`issue_datetime()`](MessageHeader:: issue_datetime) and
421+ /// *not* the current time.
412422 ///
413423 /// Requires `chrono`.
414424 #[ cfg( feature = "chrono" ) ]
@@ -421,14 +431,33 @@ impl MessageHeader {
421431 ///
422432 /// Returns the message validity duration or "purge time."
423433 /// This is a tuple of (`hours`, `minutes`).
434+ /// The duration specifies how long, relative to the
435+ /// [issue time](MessageHeader::issue_datetime), that the
436+ /// message is valid.
437+ ///
438+ /// The duration is typically:
439+ ///
440+ /// * increments of **15 minutes** for durations of
441+ /// **1 hour** or less
442+ ///
443+ /// * increments of **30 minutes** for durations longer
444+ /// than **1 hour**
445+ ///
446+ /// * no longer than
447+ /// [99.5 hours](https://www.weather.gov/nwr/samealertduration)
424448 ///
425- /// This field represents the validity time of the *message*
449+ /// but sameplace does not enforce any of these restrictions.
450+ ///
451+ /// This field represents the validity duration of the *message*
426452 /// and not the expected duration of the severe condition.
427- /// Severe conditions may persist after the message expires!
428- /// (And might be the subject of future messages.)
453+ /// **An expired message may still refer to an ongoing hazard** or
454+ /// event. Expiration merely indicates that the *message* is no
455+ /// longer valid. Clients are encouraged to retain a history of
456+ /// alerts and voice message contents.
429457 ///
430458 /// The valid duration is relative to the
431- /// [`issue_daytime_fields()`](#method.issue_daytime_fields).
459+ /// [`issue_datetime()`](MessageHeader::issue_datetime) and
460+ /// *not* the current time.
432461 pub fn valid_duration_fields ( & self ) -> ( u8 , u8 ) {
433462 let dur_str = & self . message [ self . offset_time + Self :: OFFSET_FROMPLUS_VALIDTIME
434463 ..self . offset_time + Self :: OFFSET_FROMPLUS_VALIDTIME + 4 ] ;
@@ -471,28 +500,73 @@ impl MessageHeader {
471500 )
472501 }
473502
503+ /// Message purge/expiration datetime (UTC)
504+ ///
505+ /// Compute the datetime that the SAME message should be
506+ /// *purged* or discarded. The caller must provide the time
507+ /// that the message was `received`.
508+ ///
509+ /// The returned timestamp is rounded per NWSI 10-1712:
510+ ///
511+ /// * For [valid durations](MessageHeader::valid_duration) ≤01h00m,
512+ /// the timestamp is rounded to the nearest 15 minutes
513+ ///
514+ /// * For valid durations greater than an hour, the timestamp is
515+ /// rounded to the nearest 30 minutes
516+ ///
517+ /// An error is returned if we are unable to calculate
518+ /// a valid timestamp. This can happen, for example, if we
519+ /// project a message sent on Julian/Ordinal Day 366 into a
520+ /// year that is not a leap year.
521+ ///
522+ /// SAME headers do not include the year of issuance. This makes
523+ /// it impossible to calculate the full datetime of issuance—or
524+ /// purge, for that matter—without a rough idea of the message's
525+ /// true UTC time. It is *unnecessary* for the `received` time
526+ /// to be a precision timestamp. As long as the provided value
527+ /// is within ±90 days of true UTC, the output time will be
528+ /// correct.
529+ ///
530+ /// This field represents the expiration time of the *message*
531+ /// and not the expected duration of the severe condition.
532+ /// **An expired message may still refer to an ongoing hazard** or
533+ /// event. Expiration merely indicates that the *message* is no
534+ /// longer valid. Clients are encouraged to retain a history of
535+ /// alerts and voice message contents.
536+ ///
537+ /// Requires `chrono`.
538+ #[ cfg( feature = "chrono" ) ]
539+ pub fn purge_datetime (
540+ & self ,
541+ received : & DateTime < Utc > ,
542+ ) -> Result < DateTime < Utc > , InvalidDateErr > {
543+ calculate_expire_time ( & self . issue_datetime ( received) ?, & self . valid_duration ( ) )
544+ }
545+
474546 /// Is the message expired?
475547 ///
476548 /// Given the current time, determine if this message has
477- /// expired. It is assumed that `now` is within twelve
478- /// hours of the message issuance time. Twelve hours is
479- /// the maximum [` duration`](#method.valid_duration) of a
480- /// SAME message.
549+ /// expired. It is assumed that `now` is within ±90 days of
550+ /// the message's [ issuance time](MessageHeader::issue_datetime).
551+ /// The [ maximum duration](https://www.weather.gov/nwr/samealertduration)
552+ /// of a SAME message is 99.5 hours .
481553 ///
482- /// An expired message may still refer to an *ongoing hazard*
483- /// or event! Expiration merely indicates that the message
484- /// should not be relayed or alerted to anymore.
554+ /// **An expired message may still refer to an ongoing hazard** or
555+ /// event. Expiration merely indicates that the *message* is no
556+ /// longer valid. Clients are encouraged to retain a history of
557+ /// alerts and voice message contents.
485558 ///
486559 /// Requires `chrono`.
487560 #[ cfg( feature = "chrono" ) ]
488561 pub fn is_expired_at ( & self , now : & DateTime < Utc > ) -> bool {
489- match self . issue_datetime ( now) {
490- Ok ( issue_ts) => issue_ts + self . valid_duration ( ) < * now,
491- Err ( _e) => false ,
562+ if let Ok ( purge) = self . purge_datetime ( & now) {
563+ purge < * now
564+ } else {
565+ false
492566 }
493567 }
494568
495- /// Mesage issuance day/time (fields)
569+ /// Message issuance day/time (fields)
496570 ///
497571 /// Returns the message issue day and time, as the string
498572 /// `JJJHHMM`,
@@ -787,6 +861,31 @@ fn calculate_issue_time(
787861 . ok_or ( InvalidDateErr { } )
788862}
789863
864+ /// Calculate message expiration time
865+ #[ cfg( feature = "chrono" ) ]
866+ fn calculate_expire_time (
867+ issued : & DateTime < Utc > ,
868+ purge : & Duration ,
869+ ) -> Result < DateTime < Utc > , InvalidDateErr > {
870+ use chrono:: DurationRound ;
871+
872+ const FIFTEEN_MINUTES : Duration = Duration :: minutes ( 15 ) ;
873+ const THIRTY_MINUTES : Duration = Duration :: minutes ( 30 ) ;
874+ const ONE_HOUR : Duration = Duration :: hours ( 1 ) ;
875+
876+ issued
877+ . checked_add_signed ( * purge)
878+ . and_then ( |purge_unrounded| {
879+ if * purge <= ONE_HOUR {
880+ purge_unrounded. duration_round ( FIFTEEN_MINUTES )
881+ } else {
882+ purge_unrounded. duration_round ( THIRTY_MINUTES )
883+ }
884+ . ok ( )
885+ } )
886+ . ok_or ( InvalidDateErr { } )
887+ }
888+
790889// Create the latest-possible Utc date from year, ordinal, and HMS
791890#[ cfg( feature = "chrono" ) ]
792891#[ inline]
@@ -863,9 +962,65 @@ mod tests {
863962 calculate_issue_time ( ( 84 , 25 , 59 ) , ( 2021 , 84 ) ) . expect_err ( "should not succeed" ) ;
864963 }
865964
965+ #[ cfg( feature = "chrono" ) ]
966+ #[ test]
967+ fn test_calculate_expire_time_short ( ) {
968+ const FIFTEEN_MINUTES : Duration = Duration :: minutes ( 15 ) ;
969+
970+ let issued = Utc . with_ymd_and_hms ( 2021 , 3 , 24 , 2 , 44 , 0 ) . unwrap ( ) ;
971+ assert_eq ! (
972+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 0 , 0 ) . unwrap( ) ,
973+ calculate_expire_time( & issued, & FIFTEEN_MINUTES ) . unwrap( )
974+ ) ;
975+
976+ let issued = Utc . with_ymd_and_hms ( 2021 , 3 , 24 , 2 , 46 , 0 ) . unwrap ( ) ;
977+ assert_eq ! (
978+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 0 , 0 ) . unwrap( ) ,
979+ calculate_expire_time( & issued, & FIFTEEN_MINUTES ) . unwrap( )
980+ ) ;
981+
982+ let issued = Utc . with_ymd_and_hms ( 2021 , 3 , 24 , 2 , 55 , 0 ) . unwrap ( ) ;
983+ assert_eq ! (
984+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 15 , 0 ) . unwrap( ) ,
985+ calculate_expire_time( & issued, & FIFTEEN_MINUTES ) . unwrap( )
986+ ) ;
987+
988+ let issued = Utc . with_ymd_and_hms ( 2021 , 3 , 24 , 3 , 00 , 0 ) . unwrap ( ) ;
989+ assert_eq ! (
990+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 15 , 0 ) . unwrap( ) ,
991+ calculate_expire_time( & issued, & FIFTEEN_MINUTES ) . unwrap( )
992+ ) ;
993+ }
994+
995+ #[ cfg( feature = "chrono" ) ]
996+ #[ test]
997+ fn test_calculate_expire_time_long ( ) {
998+ let issued = Utc . with_ymd_and_hms ( 2021 , 3 , 24 , 2 , 53 , 0 ) . unwrap ( ) ;
999+
1000+ assert_eq ! (
1001+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 15 , 0 ) . unwrap( ) ,
1002+ calculate_expire_time( & issued, & Duration :: minutes( 15 ) ) . unwrap( )
1003+ ) ;
1004+
1005+ assert_eq ! (
1006+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 30 , 0 ) . unwrap( ) ,
1007+ calculate_expire_time( & issued, & Duration :: minutes( 30 ) ) . unwrap( )
1008+ ) ;
1009+
1010+ assert_eq ! (
1011+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 3 , 45 , 0 ) . unwrap( ) ,
1012+ calculate_expire_time( & issued, & Duration :: minutes( 45 ) ) . unwrap( )
1013+ ) ;
1014+
1015+ assert_eq ! (
1016+ Utc . with_ymd_and_hms( 2021 , 3 , 24 , 4 , 00 , 0 ) . unwrap( ) ,
1017+ calculate_expire_time( & issued, & Duration :: minutes( 60 ) ) . unwrap( )
1018+ ) ;
1019+ }
1020+
8661021 #[ test]
8671022 fn test_message_header ( ) {
868- const THREE_LOCATIONS : & str = "ZCZC-WXR-RWT-012345-567890-888990+0351 -3662322-NOCALL00-@@@" ;
1023+ const THREE_LOCATIONS : & str = "ZCZC-WXR-RWT-012345-567890-888990+0330 -3662322-NOCALL00-@@@" ;
8691024
8701025 let mut errs = vec ! [ 0u8 ; THREE_LOCATIONS . len( ) ] ;
8711026 errs[ 0 ] = 1u8 ;
@@ -885,7 +1040,7 @@ mod tests {
8851040 assert_eq ! ( Originator :: NationalWeatherService , msg. originator( ) ) ;
8861041 assert_eq ! ( msg. event_str( ) , "RWT" ) ;
8871042 assert_eq ! ( msg. event( ) . phenomenon( ) , Phenomenon :: RequiredWeeklyTest ) ;
888- assert_eq ! ( msg. valid_duration_fields( ) , ( 3 , 51 ) ) ;
1043+ assert_eq ! ( msg. valid_duration_fields( ) , ( 3 , 30 ) ) ;
8891044 assert_eq ! ( msg. issue_daytime_fields( ) , ( 366 , 23 , 22 ) ) ;
8901045 assert_eq ! ( msg. callsign( ) , "NOCALL00" ) ;
8911046 assert_eq ! ( msg. parity_error_count( ) , 6 ) ;
@@ -898,19 +1053,25 @@ mod tests {
8981053 // time API checks
8991054 #[ cfg( feature = "chrono" ) ]
9001055 {
1056+ // mock system time that the message was received
1057+ let received = Utc . with_ymd_and_hms ( 2020 , 12 , 31 , 11 , 30 , 34 ) . unwrap ( ) ;
1058+
9011059 assert_eq ! (
9021060 Utc . with_ymd_and_hms( 2020 , 12 , 31 , 23 , 22 , 00 ) . unwrap( ) ,
903- msg. issue_datetime( & Utc . with_ymd_and_hms( 2020 , 12 , 31 , 11 , 30 , 34 ) . unwrap( ) )
904- . unwrap( )
1061+ msg. issue_datetime( & received) . unwrap( )
9051062 ) ;
9061063 assert_eq ! (
9071064 msg. valid_duration( ) ,
908- Duration :: hours( 3 ) + Duration :: minutes( 51 )
1065+ Duration :: hours( 3 ) + Duration :: minutes( 30 )
1066+ ) ;
1067+ assert_eq ! (
1068+ Utc . with_ymd_and_hms( 2021 , 1 , 1 , 3 , 0 , 00 ) . unwrap( ) ,
1069+ msg. purge_datetime( & received) . unwrap( )
9091070 ) ;
9101071 assert ! ( !msg. is_expired_at( & Utc . with_ymd_and_hms( 2020 , 12 , 31 , 23 , 59 , 0 ) . unwrap( ) ) ) ;
9111072 assert ! ( !msg. is_expired_at( & Utc . with_ymd_and_hms( 2021 , 1 , 1 , 1 , 20 , 30 ) . unwrap( ) ) ) ;
912- assert ! ( !msg. is_expired_at( & Utc . with_ymd_and_hms( 2021 , 1 , 1 , 3 , 13 , 00 ) . unwrap( ) ) ) ;
913- assert ! ( msg. is_expired_at( & Utc . with_ymd_and_hms( 2021 , 1 , 1 , 3 , 13 , 01 ) . unwrap( ) ) ) ;
1073+ assert ! ( !msg. is_expired_at( & Utc . with_ymd_and_hms( 2021 , 1 , 1 , 2 , 59 , 59 ) . unwrap( ) ) ) ;
1074+ assert ! ( msg. is_expired_at( & Utc . with_ymd_and_hms( 2021 , 1 , 1 , 3 , 0 , 01 ) . unwrap( ) ) ) ;
9141075 }
9151076
9161077 // try again via Message
0 commit comments