Skip to content

Commit 0931b8a

Browse files
committed
Merge branch 'feature/purge_time_15m' into develop
sameold,samedec: round purge times to 15 or 30 minutes Previous versions of sameplace erroneously stated that the expiration time of a SAME message was issue datetime + purge duration This is *almost* correct, but there's a catch: * the expiration time is rounded to the nearest 15 minute increment for durations ≤1 hour * the expiration time is rounded to the nearest 30 minute increment for longer-duration messages * the maximum duration of a SAME message is 99.5 hours [1] Add `MessageHeader::purge_datetime()` to calculate this time, and fix the incorrect `Message::is_expired_at()` implementation to match. Documentation is given a touch-up. This is an API-expanding change for sameold and will change samedec's `SAMEDEC_PURGETIME` values. [1]: https://www.weather.gov/nwr/samealertduration
2 parents 199fccb + b3d8d66 commit 0931b8a

File tree

4 files changed

+207
-45
lines changed

4 files changed

+207
-45
lines changed

crates/samedec/src/spawner.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ where
3131
B: AsRef<OsStr>,
3232
A: IntoIterator<Item = B>,
3333
{
34-
let (issue_ts, purge_ts) = match header.issue_datetime(&Utc::now()) {
35-
Ok(issue_ts) => (
36-
time_to_unix_str(issue_ts),
37-
time_to_unix_str(issue_ts + header.valid_duration()),
38-
),
39-
Err(_e) => ("".to_owned(), "".to_owned()),
40-
};
41-
34+
let now = Utc::now();
35+
let issue_ts = header
36+
.issue_datetime(&now)
37+
.map(|tm| time_to_unix_str(tm))
38+
.unwrap_or_default();
39+
let purge_ts = header
40+
.purge_datetime(&now)
41+
.map(|tm| time_to_unix_str(tm))
42+
.unwrap_or_default();
4243
let locations: Vec<&str> = header.location_str_iter().collect();
4344
let evt = header.event();
4445

crates/sameplace/src/message.rs

Lines changed: 196 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

sample/npt.22050.s16le.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ exec 0>/dev/null
1313
[ "$SAMEDEC_IS_NATIONAL" = "Y" ]
1414

1515
lifetime=$(( SAMEDEC_PURGETIME - SAMEDEC_ISSUETIME))
16-
[ "$lifetime" -eq $(( 30*60 )) ]
16+
[ "$lifetime" -eq $(( 25*60 )) ]
1717

1818
echo "+OK"

sample/two_and_two.22050.s16le.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ exec 0>/dev/null
1212
[ "$SAMEDEC_IS_NATIONAL" = "" ]
1313

1414
lifetime=$(( SAMEDEC_PURGETIME - SAMEDEC_ISSUETIME))
15-
[ "$lifetime" -eq $(( 1*60*60 + 30*60 )) ]
15+
[ "$lifetime" -eq $(( 1*60*60 + 36*60 )) ]
1616

1717
echo "+OK"

0 commit comments

Comments
 (0)