Skip to content

Commit 2017a1a

Browse files
jbeezleyclaude
andauthored
Add PDT 107 support for anomaly products with reference to normal (#139)
PDT 107 (Derived Forecasts with Reference to a Normal) was silently dropped, causing 16 anomaly messages in S2S statistical files to be missing. This adds a new template with the correct byte layout (PDT 8 time-interval offsets + derived forecast at byte 58), an `is_anomaly` trait method, and `:anom` key suffix to prevent collisions with PDT 12 messages sharing the same variable/level/stat/derived type. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cc5d098 commit 2017a1a

10 files changed

Lines changed: 293 additions & 2 deletions

File tree

gribberish/src/message.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,14 @@ impl<'a> Message<'a> {
229229
None => "".to_string(),
230230
};
231231

232+
let anomaly = if self.is_anomaly().unwrap_or(false) {
233+
":anom"
234+
} else {
235+
""
236+
};
237+
232238
Ok(format!(
233-
"{var}{time}{first_level}{second_level}{perturbation}{percentile}{probability}:{statistical_process}{generating_process}{derived_forecast_type}"
239+
"{var}{time}{first_level}{second_level}{perturbation}{percentile}{probability}{anomaly}:{statistical_process}{generating_process}{derived_forecast_type}"
234240
))
235241
}
236242

@@ -532,6 +538,16 @@ impl<'a> Message<'a> {
532538
}
533539
}
534540

541+
pub fn is_anomaly(&self) -> Result<bool, GribberishError> {
542+
match self {
543+
Message::Grib1 { .. } => Ok(false),
544+
Message::Grib2 { .. } => {
545+
let product_template = self.product_template()?;
546+
Ok(product_template.is_anomaly())
547+
}
548+
}
549+
}
550+
535551
pub fn perturbation_number(&self) -> Result<Option<u8>, GribberishError> {
536552
match self {
537553
Message::Grib1 { .. } => Ok(None),

gribberish/src/message_metadata.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ pub struct MessageMetadata {
5353
pub forecast_probability_number: Option<u8>,
5454
pub probability_lower_limit: Option<f64>,
5555
pub probability_upper_limit: Option<f64>,
56+
pub is_anomaly: bool,
5657
}
5758

5859
impl MessageMetadata {
@@ -175,6 +176,7 @@ impl<'a> TryFrom<&Message<'a>> for MessageMetadata {
175176
forecast_probability_number: message.forecast_probability_number()?,
176177
probability_lower_limit: message.probability_lower_limit()?,
177178
probability_upper_limit: message.probability_upper_limit()?,
179+
is_anomaly: message.is_anomaly()?,
178180
})
179181
}
180182
}

gribberish/src/parser.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ fn grib1_to_metadata(message: &Grib1Message) -> Result<MessageMetadata, Gribberi
329329
forecast_probability_number: None,
330330
probability_lower_limit: None,
331331
probability_upper_limit: None,
332+
is_anomaly: false,
332333
})
333334
}
334335

gribberish/src/sections/product_definition.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::grib_section::GribSection;
22
use crate::{
33
templates::product::{
4+
derived_ensemble_forecast_time_interval_reference_template::DerivedEnsembleForecastTimeIntervalReferenceTemplate,
45
derived_ensemble_horizontal_forecast_time_interval_template::DerivedEnsembleHorizontalForecastTimeIntervalTemplate,
56
product_template::ProductTemplate,
67
AverageAccumulationExtremeHorizontalAnalysisForecastTemplate,
@@ -73,6 +74,12 @@ impl<'a> ProductDefinitionSection<'a> {
7374
discipline,
7475
),
7576
)),
77+
107 => Some(Box::new(
78+
DerivedEnsembleForecastTimeIntervalReferenceTemplate::new(
79+
self.data.to_vec(),
80+
discipline,
81+
),
82+
)),
7683
_ => None,
7784
}
7885
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
use crate::templates::template::{Template, TemplateType};
2+
use crate::utils::{read_u16_from_bytes, read_u32_from_bytes};
3+
use chrono::{prelude::*, Duration};
4+
5+
use super::product_template::ProductTemplate;
6+
use super::tables::{
7+
DerivedForecastType, FixedSurfaceType, GeneratingProcess, TimeUnit,
8+
TypeOfStatisticalProcessing, TypeOfTimeInterval,
9+
};
10+
use super::HorizontalAnalysisForecastTemplate;
11+
12+
pub struct DerivedEnsembleForecastTimeIntervalReferenceTemplate {
13+
data: Vec<u8>,
14+
discipline: u8,
15+
}
16+
17+
impl Template for DerivedEnsembleForecastTimeIntervalReferenceTemplate {
18+
fn data(&self) -> &[u8] {
19+
&self.data
20+
}
21+
22+
fn template_number(&self) -> u16 {
23+
107
24+
}
25+
26+
fn template_type(&self) -> TemplateType {
27+
TemplateType::Product
28+
}
29+
30+
fn template_name(&self) -> &str {
31+
"Derived forecasts based on all ensemble members at a horizontal level
32+
or in a horizontal layer in a continuous or non-continuous time interval
33+
with reference to a normal"
34+
}
35+
}
36+
37+
impl DerivedEnsembleForecastTimeIntervalReferenceTemplate {
38+
pub fn new(data: Vec<u8>, discipline: u8) -> Self {
39+
Self { data, discipline }
40+
}
41+
42+
pub fn first_fixed_surface_scale_factor(&self) -> i8 {
43+
as_signed!(self.data[23], 8, i8)
44+
}
45+
46+
pub fn first_fixed_surface_scaled_value(&self) -> i32 {
47+
as_signed!(read_u32_from_bytes(&self.data, 24).unwrap_or(0), 32, i32)
48+
}
49+
50+
pub fn second_fixed_surface_scale_factor(&self) -> i8 {
51+
as_signed!(self.data[29], 8, i8)
52+
}
53+
54+
pub fn second_fixed_surface_scaled_value(&self) -> i32 {
55+
as_signed!(read_u32_from_bytes(&self.data, 30).unwrap_or(0), 32, i32)
56+
}
57+
58+
pub fn valid_end_date(&self) -> DateTime<Utc> {
59+
let data = self.data();
60+
let year = read_u16_from_bytes(data, 34).unwrap_or(0) as i32;
61+
let month = data[36] as u32;
62+
let day = data[37] as u32;
63+
let hour = data[38] as u32;
64+
let minute = data[39] as u32;
65+
let second = data[40] as u32;
66+
67+
Utc.with_ymd_and_hms(year as i32, month, day, hour, minute, second)
68+
.unwrap()
69+
}
70+
71+
pub fn number_of_time_ranges(&self) -> u8 {
72+
self.data()[41]
73+
}
74+
75+
pub fn number_of_values_missing_from_stats(&self) -> u32 {
76+
read_u32_from_bytes(self.data(), 42).unwrap_or(0)
77+
}
78+
79+
pub fn type_of_time_interval(&self) -> TypeOfTimeInterval {
80+
self.data()[47].into()
81+
}
82+
83+
pub fn statistical_process_time_unit(&self) -> TimeUnit {
84+
self.data()[48].into()
85+
}
86+
87+
pub fn statistical_process_time_interval(&self) -> u32 {
88+
read_u32_from_bytes(self.data(), 49).unwrap_or(0)
89+
}
90+
91+
pub fn number_of_forecasts_in_ensemble(&self) -> u8 {
92+
self.data[59]
93+
}
94+
}
95+
96+
impl ProductTemplate for DerivedEnsembleForecastTimeIntervalReferenceTemplate {
97+
fn discipline(&self) -> u8 {
98+
self.discipline
99+
}
100+
101+
fn category_value(&self) -> u8 {
102+
self.data[9]
103+
}
104+
105+
fn parameter_value(&self) -> u8 {
106+
self.data[10]
107+
}
108+
109+
fn generating_process(&self) -> GeneratingProcess {
110+
self.data[11].into()
111+
}
112+
113+
fn time_unit(&self) -> TimeUnit {
114+
self.data[17].into()
115+
}
116+
117+
fn time_increment_unit(&self) -> Option<TimeUnit> {
118+
Some(self.data()[53].into())
119+
}
120+
121+
fn time_interval(&self) -> u32 {
122+
read_u32_from_bytes(&self.data, 18).unwrap_or(0)
123+
}
124+
125+
fn time_increment_interval(&self) -> Option<u32> {
126+
Some(read_u32_from_bytes(self.data(), 54).unwrap_or(0))
127+
}
128+
129+
fn forecast_datetime(&self, reference_date: DateTime<Utc>) -> DateTime<Utc> {
130+
let offset_duration: Duration = self.time_interval_duration();
131+
reference_date + offset_duration
132+
}
133+
134+
fn forecast_end_datetime(&self, _reference_date: DateTime<Utc>) -> Option<DateTime<Utc>> {
135+
Some(self.valid_end_date())
136+
}
137+
138+
fn first_fixed_surface_type(&self) -> FixedSurfaceType {
139+
self.data[22].into()
140+
}
141+
142+
fn first_fixed_surface_value(&self) -> Option<f64> {
143+
HorizontalAnalysisForecastTemplate::scale_value(
144+
self.first_fixed_surface_scale_factor(),
145+
self.first_fixed_surface_scaled_value(),
146+
)
147+
}
148+
149+
fn second_fixed_surface_type(&self) -> FixedSurfaceType {
150+
self.data[28].into()
151+
}
152+
153+
fn second_fixed_surface_value(&self) -> Option<f64> {
154+
HorizontalAnalysisForecastTemplate::scale_value(
155+
self.second_fixed_surface_scale_factor(),
156+
self.second_fixed_surface_scaled_value(),
157+
)
158+
}
159+
160+
fn derived_forecast_type(&self) -> Option<DerivedForecastType> {
161+
Some(self.data[58].into())
162+
}
163+
164+
fn statistical_process_type(&self) -> Option<TypeOfStatisticalProcessing> {
165+
Some(self.data()[46].into())
166+
}
167+
168+
fn is_anomaly(&self) -> bool {
169+
true
170+
}
171+
}

gribberish/src/templates/product/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod average_accumulation_extreme_horizontal_analysis_template;
2+
pub mod derived_ensemble_forecast_time_interval_reference_template;
23
pub mod derived_ensemble_horizontal_analysis_template;
34
pub mod derived_ensemble_horizontal_forecast_time_interval_template;
45
pub mod ensemble_forecast_time_interval_template;
@@ -12,6 +13,7 @@ pub mod product_template;
1213
pub mod tables;
1314

1415
pub use average_accumulation_extreme_horizontal_analysis_template::AverageAccumulationExtremeHorizontalAnalysisForecastTemplate;
16+
pub use derived_ensemble_forecast_time_interval_reference_template::DerivedEnsembleForecastTimeIntervalReferenceTemplate;
1517
pub use derived_ensemble_horizontal_analysis_template::DerivedEnsembleHorizontalAnalysisForecastTemplate;
1618
pub use ensemble_forecast_time_interval_template::EnsembleForecastTimeIntervalTemplate;
1719
pub use horizontal_analysis_template::HorizontalAnalysisForecastTemplate;

gribberish/src/templates/product/product_template.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ pub trait ProductTemplate {
5959
None
6060
}
6161

62+
fn is_anomaly(&self) -> bool {
63+
false
64+
}
65+
6266
fn category(&self) -> &'static str {
6367
category(self.discipline(), self.category_value())
6468
}

gribberish/tests/read.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
extern crate gribberish;
22

33
use gribberish::message::{read_messages, Message};
4+
use gribberish::templates::product::tables::{DerivedForecastType, TypeOfStatisticalProcessing};
45
use std::time::Instant;
56
use std::vec::Vec;
67

@@ -977,3 +978,89 @@ fn read_percentile_and_probability_templates() {
977978
dups
978979
);
979980
}
981+
982+
#[test]
983+
fn read_pdt107_anomaly_with_reference() {
984+
// Fixture: 4 messages with zeroed data, 0.5° global grid.
985+
// Messages 0-1: PDT 12 (derived ensemble time interval)
986+
// Messages 2-3: PDT 107 (same, with reference to normal — anomaly)
987+
// Messages 0 and 2 share the same variable/level/stat/derived type
988+
// and would collide without the :anom key suffix.
989+
let grib_data = read_grib_messages("../test-data/s2s-pdt12-pdt107-anomaly.grib2");
990+
let messages = read_messages(grib_data.as_slice()).collect::<Vec<Message>>();
991+
992+
assert_eq!(messages.len(), 4, "Expected 4 messages in fixture");
993+
994+
// --- PDT 12: TMP 2m Max UnweightedMean ---
995+
let pdt12_max = &messages[0];
996+
assert_eq!(pdt12_max.product_template_id().unwrap(), 12);
997+
assert_eq!(pdt12_max.variable_abbrev().unwrap(), "TMP");
998+
assert!(!pdt12_max.is_anomaly().unwrap());
999+
1000+
// --- PDT 12: TMP 2m Avg UnweightedMean ---
1001+
let pdt12_avg = &messages[1];
1002+
assert_eq!(pdt12_avg.product_template_id().unwrap(), 12);
1003+
assert_eq!(pdt12_avg.variable_abbrev().unwrap(), "TMP");
1004+
assert!(!pdt12_avg.is_anomaly().unwrap());
1005+
1006+
// --- PDT 107: TMP 2m Max UnweightedMean (anomaly) ---
1007+
let pdt107_max = &messages[2];
1008+
assert_eq!(pdt107_max.product_template_id().unwrap(), 107);
1009+
assert_eq!(pdt107_max.variable_abbrev().unwrap(), "TMP");
1010+
assert!(pdt107_max.is_anomaly().unwrap());
1011+
assert_eq!(
1012+
pdt107_max.derived_forecast_type().unwrap(),
1013+
Some(DerivedForecastType::UnweightedMean)
1014+
);
1015+
assert_eq!(
1016+
pdt107_max.statistical_process_type().unwrap(),
1017+
Some(TypeOfStatisticalProcessing::Maximum)
1018+
);
1019+
let data = pdt107_max.data().unwrap();
1020+
assert_eq!(data.len(), 259920);
1021+
1022+
// --- PDT 107: TMP 1000hPa Avg UnweightedMean (anomaly) ---
1023+
let pdt107_avg = &messages[3];
1024+
assert_eq!(pdt107_avg.product_template_id().unwrap(), 107);
1025+
assert_eq!(pdt107_avg.variable_abbrev().unwrap(), "TMP");
1026+
assert!(pdt107_avg.is_anomaly().unwrap());
1027+
assert_eq!(
1028+
pdt107_avg.derived_forecast_type().unwrap(),
1029+
Some(DerivedForecastType::UnweightedMean)
1030+
);
1031+
assert_eq!(
1032+
pdt107_avg.statistical_process_type().unwrap(),
1033+
Some(TypeOfStatisticalProcessing::Average)
1034+
);
1035+
1036+
// --- All 4 messages should have unique keys ---
1037+
let mut keys = Vec::new();
1038+
let mut dups = Vec::new();
1039+
for message in &messages {
1040+
let key = message.key().unwrap();
1041+
if keys.contains(&key) {
1042+
dups.push(key);
1043+
} else {
1044+
keys.push(key);
1045+
}
1046+
}
1047+
assert_eq!(
1048+
dups.len(),
1049+
0,
1050+
"Found {} duplicate keys: {:?}",
1051+
dups.len(),
1052+
dups
1053+
);
1054+
1055+
// Verify the anomaly key contains :anom and the non-anomaly does not
1056+
let pdt12_key = pdt12_max.key().unwrap();
1057+
let pdt107_key = pdt107_max.key().unwrap();
1058+
assert!(
1059+
!pdt12_key.contains(":anom"),
1060+
"PDT 12 key should not contain :anom"
1061+
);
1062+
assert!(
1063+
pdt107_key.contains(":anom"),
1064+
"PDT 107 key should contain :anom"
1065+
);
1066+
}

python/src/dataset.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ pub fn parse_grib_dataset<'py>(
156156
}
157157
_ => "".to_string(),
158158
};
159+
let anom = if v.2.is_anomaly { "_anom" } else { "" };
159160
let hash = format!(
160-
"{surf}_{stat}{gen}{accum_period}{derived}{prob_type}{prob_limits}",
161+
"{surf}_{stat}{gen}{accum_period}{derived}{prob_type}{prob_limits}{anom}",
161162
surf = v.2.first_fixed_surface_type.coordinate_name(),
162163
stat =
163164
v.2.statistical_process
862 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)