1use std::path::Path;
6
7use crate::metadata::{Parameter, ParameterTableSource};
8use crate::{Error, Result};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct LocalParameterEntry<'a> {
17 pub center_id: u16,
18 pub subcenter_id: Option<u16>,
19 pub local_table_version: Option<u8>,
20 pub discipline: u8,
21 pub category: u8,
22 pub number: u8,
23 pub short_name: &'a str,
24 pub description: &'a str,
25}
26
27pub const LOCAL_PARAMETER_TABLE_CSV_HEADER: &str =
29 "center_id,subcenter_id,local_table_version,discipline,category,number,short_name,description";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct OwnedLocalParameterEntry {
37 pub center_id: u16,
38 pub subcenter_id: Option<u16>,
39 pub local_table_version: Option<u8>,
40 pub discipline: u8,
41 pub category: u8,
42 pub number: u8,
43 pub short_name: String,
44 pub description: String,
45}
46
47impl OwnedLocalParameterEntry {
48 #[allow(clippy::too_many_arguments)]
49 pub fn new(
50 center_id: u16,
51 subcenter_id: Option<u16>,
52 local_table_version: Option<u8>,
53 discipline: u8,
54 category: u8,
55 number: u8,
56 short_name: impl Into<String>,
57 description: impl Into<String>,
58 ) -> Result<Self> {
59 let entry = Self {
60 center_id,
61 subcenter_id,
62 local_table_version,
63 discipline,
64 category,
65 number,
66 short_name: short_name.into(),
67 description: description.into(),
68 };
69 entry.validate()?;
70 Ok(entry)
71 }
72
73 pub fn as_entry(&self) -> LocalParameterEntry<'_> {
74 LocalParameterEntry {
75 center_id: self.center_id,
76 subcenter_id: self.subcenter_id,
77 local_table_version: self.local_table_version,
78 discipline: self.discipline,
79 category: self.category,
80 number: self.number,
81 short_name: &self.short_name,
82 description: &self.description,
83 }
84 }
85
86 fn validate(&self) -> Result<()> {
87 if !is_local_parameter_code(self.category) && !is_local_parameter_code(self.number) {
88 return Err(Error::Other(format!(
89 "local parameter entry {}.{}.{} must use a local category or parameter number (192..=254)",
90 self.discipline, self.category, self.number
91 )));
92 }
93 validate_local_parameter_text("short_name", &self.short_name)?;
94 validate_local_parameter_text("description", &self.description)?;
95 if self.short_name.contains(',') {
96 return Err(Error::Other(
97 "local parameter short_name cannot contain a comma".into(),
98 ));
99 }
100 Ok(())
101 }
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct LocalParameterTable {
107 entries: Vec<OwnedLocalParameterEntry>,
108}
109
110impl LocalParameterTable {
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn from_entries(
116 entries: impl IntoIterator<Item = OwnedLocalParameterEntry>,
117 ) -> Result<Self> {
118 let mut table = Self::new();
119 for entry in entries {
120 table.push(entry)?;
121 }
122 Ok(table)
123 }
124
125 pub fn from_csv_str(input: &str) -> Result<Self> {
126 let mut table = Self::new();
127 for (line_index, raw_line) in input.lines().enumerate() {
128 let line_number = line_index + 1;
129 let line = raw_line.trim();
130 if line.is_empty() || line.starts_with('#') {
131 continue;
132 }
133 if is_local_parameter_csv_header(line) {
134 continue;
135 }
136 table.push(parse_local_parameter_csv_record(line, line_number)?)?;
137 }
138 Ok(table)
139 }
140
141 pub fn from_csv_path(path: impl AsRef<Path>) -> Result<Self> {
142 let path = path.as_ref();
143 let input = std::fs::read_to_string(path)
144 .map_err(|err| Error::Io(err, path.display().to_string()))?;
145 Self::from_csv_str(&input)
146 }
147
148 pub fn push(&mut self, entry: OwnedLocalParameterEntry) -> Result<()> {
149 entry.validate()?;
150 if self
151 .entries
152 .iter()
153 .any(|existing| local_parameter_keys_overlap(existing, &entry))
154 {
155 return Err(Error::Other(format!(
156 "duplicate local parameter entry for center {} discipline {} category {} number {}",
157 entry.center_id, entry.discipline, entry.category, entry.number
158 )));
159 }
160 self.entries.push(entry);
161 Ok(())
162 }
163
164 pub fn authored_entries(&self) -> &[OwnedLocalParameterEntry] {
165 &self.entries
166 }
167
168 pub fn entries(&self) -> Vec<LocalParameterEntry<'_>> {
169 let mut entries = self
170 .entries
171 .iter()
172 .enumerate()
173 .map(|(index, entry)| (index, entry.as_entry()))
174 .collect::<Vec<_>>();
175 entries.sort_by_key(|(index, entry)| {
176 (
177 std::cmp::Reverse(local_parameter_entry_specificity(*entry)),
178 *index,
179 )
180 });
181 entries.into_iter().map(|(_index, entry)| entry).collect()
182 }
183
184 pub fn to_csv_string(&self) -> String {
185 let mut out = String::from(LOCAL_PARAMETER_TABLE_CSV_HEADER);
186 out.push('\n');
187 for entry in &self.entries {
188 out.push_str(&entry.center_id.to_string());
189 out.push(',');
190 if let Some(subcenter_id) = entry.subcenter_id {
191 out.push_str(&subcenter_id.to_string());
192 }
193 out.push(',');
194 if let Some(version) = entry.local_table_version {
195 out.push_str(&version.to_string());
196 }
197 out.push(',');
198 out.push_str(&entry.discipline.to_string());
199 out.push(',');
200 out.push_str(&entry.category.to_string());
201 out.push(',');
202 out.push_str(&entry.number.to_string());
203 out.push(',');
204 out.push_str(&entry.short_name);
205 out.push(',');
206 out.push_str(&entry.description);
207 out.push('\n');
208 }
209 out
210 }
211}
212
213impl LocalParameterEntry<'_> {
214 fn matches(
215 &self,
216 discipline: u8,
217 category: u8,
218 number: u8,
219 center_id: u16,
220 subcenter_id: u16,
221 local_table_version: u8,
222 ) -> bool {
223 self.center_id == center_id
224 && self
225 .subcenter_id
226 .map_or(true, |expected| expected == subcenter_id)
227 && self
228 .local_table_version
229 .map_or(true, |expected| expected == local_table_version)
230 && self.discipline == discipline
231 && self.category == category
232 && self.number == number
233 }
234}
235
236fn parse_local_parameter_csv_record(
237 line: &str,
238 line_number: usize,
239) -> Result<OwnedLocalParameterEntry> {
240 let fields = line.splitn(8, ',').map(str::trim).collect::<Vec<_>>();
241 if fields.len() != 8 {
242 return Err(Error::Other(format!(
243 "local parameter CSV line {line_number} has {} fields, expected 8",
244 fields.len()
245 )));
246 }
247
248 OwnedLocalParameterEntry::new(
249 parse_required_u16_field(fields[0], "center_id", line_number)?,
250 parse_optional_u16_field(fields[1], "subcenter_id", line_number)?,
251 parse_optional_u8_field(fields[2], "local_table_version", line_number)?,
252 parse_required_u8_field(fields[3], "discipline", line_number)?,
253 parse_required_u8_field(fields[4], "category", line_number)?,
254 parse_required_u8_field(fields[5], "number", line_number)?,
255 fields[6],
256 fields[7],
257 )
258}
259
260fn is_local_parameter_csv_header(line: &str) -> bool {
261 line.split(',').map(str::trim).collect::<Vec<_>>().join(",") == LOCAL_PARAMETER_TABLE_CSV_HEADER
262}
263
264fn parse_required_u16_field(value: &str, name: &str, line_number: usize) -> Result<u16> {
265 if value.is_empty() {
266 return Err(Error::Other(format!(
267 "local parameter CSV line {line_number} missing {name}"
268 )));
269 }
270 value.parse::<u16>().map_err(|err| {
271 Error::Other(format!(
272 "local parameter CSV line {line_number} invalid {name}: {err}"
273 ))
274 })
275}
276
277fn parse_required_u8_field(value: &str, name: &str, line_number: usize) -> Result<u8> {
278 if value.is_empty() {
279 return Err(Error::Other(format!(
280 "local parameter CSV line {line_number} missing {name}"
281 )));
282 }
283 value.parse::<u8>().map_err(|err| {
284 Error::Other(format!(
285 "local parameter CSV line {line_number} invalid {name}: {err}"
286 ))
287 })
288}
289
290fn parse_optional_u16_field(value: &str, name: &str, line_number: usize) -> Result<Option<u16>> {
291 if is_wildcard_local_parameter_field(value) {
292 Ok(None)
293 } else {
294 parse_required_u16_field(value, name, line_number).map(Some)
295 }
296}
297
298fn parse_optional_u8_field(value: &str, name: &str, line_number: usize) -> Result<Option<u8>> {
299 if is_wildcard_local_parameter_field(value) {
300 Ok(None)
301 } else {
302 parse_required_u8_field(value, name, line_number).map(Some)
303 }
304}
305
306fn is_wildcard_local_parameter_field(value: &str) -> bool {
307 value.is_empty() || value == "*" || value.eq_ignore_ascii_case("any")
308}
309
310fn validate_local_parameter_text(field_name: &str, value: &str) -> Result<()> {
311 if value.trim().is_empty() {
312 return Err(Error::Other(format!(
313 "local parameter {field_name} cannot be empty"
314 )));
315 }
316 if value.chars().any(|ch| ch == '\n' || ch == '\r') {
317 return Err(Error::Other(format!(
318 "local parameter {field_name} cannot contain newlines"
319 )));
320 }
321 Ok(())
322}
323
324fn local_parameter_keys_overlap(
325 left: &OwnedLocalParameterEntry,
326 right: &OwnedLocalParameterEntry,
327) -> bool {
328 left.center_id == right.center_id
329 && optional_u16_matches(left.subcenter_id, right.subcenter_id)
330 && optional_u8_matches(left.local_table_version, right.local_table_version)
331 && left.discipline == right.discipline
332 && left.category == right.category
333 && left.number == right.number
334}
335
336fn optional_u16_matches(left: Option<u16>, right: Option<u16>) -> bool {
337 match (left, right) {
338 (Some(left), Some(right)) => left == right,
339 _ => true,
340 }
341}
342
343fn optional_u8_matches(left: Option<u8>, right: Option<u8>) -> bool {
344 match (left, right) {
345 (Some(left), Some(right)) => left == right,
346 _ => true,
347 }
348}
349
350fn local_parameter_entry_specificity(entry: LocalParameterEntry<'_>) -> u8 {
351 u8::from(entry.subcenter_id.is_some()) + u8::from(entry.local_table_version.is_some())
352}
353
354pub const BUILTIN_LOCAL_PARAMETERS: &[LocalParameterEntry<'static>] = &[LocalParameterEntry {
356 center_id: 7,
357 subcenter_id: None,
358 local_table_version: Some(1),
359 discipline: 0,
360 category: 16,
361 number: 196,
362 short_name: "REFC",
363 description: "Maximum/Composite radar reflectivity",
364}];
365
366pub fn grib1_parameter_name(table_version: u8, number: u8) -> &'static str {
368 match (table_version, number) {
369 (_, 1) => "PRES",
370 (_, 2) => "PRMSL",
371 (_, 7) => "HGT",
372 (_, 11) => "TMP",
373 (_, 17) => "DPT",
374 (_, 33) => "UGRD",
375 (_, 34) => "VGRD",
376 (_, 39) => "VVEL",
377 (_, 52) => "RH",
378 (_, 54) => "PWAT",
379 (_, 61) => "APCP",
380 (_, 71) => "TCDC",
381 _ => "unknown",
382 }
383}
384
385pub fn grib1_parameter_description(table_version: u8, number: u8) -> &'static str {
387 match (table_version, number) {
388 (_, 1) => "Pressure",
389 (_, 2) => "Pressure reduced to mean sea level",
390 (_, 7) => "Geopotential height",
391 (_, 11) => "Temperature",
392 (_, 17) => "Dew point temperature",
393 (_, 33) => "U-component of wind",
394 (_, 34) => "V-component of wind",
395 (_, 39) => "Vertical velocity",
396 (_, 52) => "Relative humidity",
397 (_, 54) => "Precipitable water",
398 (_, 61) => "Total precipitation",
399 (_, 71) => "Total cloud cover",
400 _ => "Unknown parameter",
401 }
402}
403
404pub fn parameter_name(discipline: u8, category: u8, number: u8) -> &'static str {
406 wmo_parameter_definition(discipline, category, number)
407 .map(|(short_name, _description)| short_name)
408 .unwrap_or("unknown")
409}
410
411pub fn parameter_description(discipline: u8, category: u8, number: u8) -> &'static str {
413 wmo_parameter_definition(discipline, category, number)
414 .map(|(_short_name, description)| description)
415 .unwrap_or("Unknown parameter")
416}
417
418pub fn lookup_parameter(
420 discipline: u8,
421 category: u8,
422 number: u8,
423 center_id: u16,
424 subcenter_id: u16,
425 local_table_version: u8,
426) -> Parameter {
427 lookup_parameter_with_local_entries(
428 discipline,
429 category,
430 number,
431 center_id,
432 subcenter_id,
433 local_table_version,
434 &[],
435 )
436}
437
438pub fn lookup_parameter_with_local_entries(
445 discipline: u8,
446 category: u8,
447 number: u8,
448 center_id: u16,
449 subcenter_id: u16,
450 local_table_version: u8,
451 local_entries: &[LocalParameterEntry<'_>],
452) -> Parameter {
453 if let Some((short_name, description)) = wmo_parameter_definition(discipline, category, number)
454 {
455 return Parameter::new_grib2_with_source(
456 discipline,
457 category,
458 number,
459 short_name,
460 description,
461 ParameterTableSource::Wmo,
462 );
463 }
464
465 if is_local_parameter_code(category) || is_local_parameter_code(number) {
466 if local_table_version != 0 {
467 if let Some(entry) = local_entries
468 .iter()
469 .chain(BUILTIN_LOCAL_PARAMETERS.iter())
470 .find(|entry| {
471 entry.matches(
472 discipline,
473 category,
474 number,
475 center_id,
476 subcenter_id,
477 local_table_version,
478 )
479 })
480 {
481 return Parameter::new_grib2_with_source(
482 discipline,
483 category,
484 number,
485 entry.short_name.to_owned(),
486 entry.description.to_owned(),
487 ParameterTableSource::Local {
488 center_id,
489 subcenter_id,
490 local_table_version,
491 },
492 );
493 }
494 }
495
496 return Parameter::new_grib2_with_source(
497 discipline,
498 category,
499 number,
500 "unknown",
501 "Unknown parameter",
502 ParameterTableSource::UnknownLocal {
503 center_id,
504 subcenter_id,
505 local_table_version,
506 },
507 );
508 }
509
510 Parameter::new_grib2_with_source(
511 discipline,
512 category,
513 number,
514 "unknown",
515 "Unknown parameter",
516 ParameterTableSource::Unknown,
517 )
518}
519
520fn is_local_parameter_code(code: u8) -> bool {
521 (192..=254).contains(&code)
522}
523
524fn wmo_parameter_definition(
525 discipline: u8,
526 category: u8,
527 number: u8,
528) -> Option<(&'static str, &'static str)> {
529 match (discipline, category, number) {
530 (0, 0, 0) => Some(("TMP", "Temperature")),
533 (0, 0, 1) => Some(("VTMP", "Virtual temperature")),
534 (0, 0, 2) => Some(("POT", "Potential temperature")),
535 (0, 0, 4) => Some(("TMAX", "Maximum temperature")),
536 (0, 0, 5) => Some(("TMIN", "Minimum temperature")),
537 (0, 0, 6) => Some(("DPT", "Dew point temperature")),
538 (0, 1, 0) => Some(("SPFH", "Specific humidity")),
540 (0, 1, 1) => Some(("RH", "Relative humidity")),
541 (0, 1, 3) => Some(("PWAT", "Precipitable water")),
542 (0, 1, 8) => Some(("APCP", "Total precipitation")),
543 (0, 2, 0) => Some(("WDIR", "Wind direction")),
545 (0, 2, 1) => Some(("WIND", "Wind speed")),
546 (0, 2, 2) => Some(("UGRD", "U-component of wind")),
547 (0, 2, 3) => Some(("VGRD", "V-component of wind")),
548 (0, 2, 22) => Some(("GUST", "Wind gust")),
549 (0, 3, 0) => Some(("PRES", "Pressure")),
551 (0, 3, 1) => Some(("PRMSL", "Pressure reduced to MSL")),
552 (0, 3, 5) => Some(("HGT", "Geopotential height")),
553 (0, 4, 7) => Some(("DSWRF", "Downward short-wave radiation flux")),
555 (0, 5, 3) => Some(("DLWRF", "Downward long-wave radiation flux")),
557 (0, 6, 1) => Some(("TCDC", "Total cloud cover")),
559 (0, 7, 6) => Some(("CAPE", "Convective available potential energy")),
561 (0, 7, 7) => Some(("CIN", "Convective inhibition")),
562 (0, 16, 5) => Some(("REFC", "Composite reflectivity")),
564 (0, 19, 1) => Some(("ALBDO", "Albedo")),
566
567 (10, 0, 3) => Some((
570 "HTSGW",
571 "Significant height of combined wind waves and swell",
572 )),
573 (10, 0, 4) => Some(("WVDIR", "Direction of wind waves")),
574 (10, 0, 5) => Some(("WVPER", "Mean period of wind waves")),
575 (10, 3, 0) => Some(("WTMP", "Water temperature")),
577
578 _ => None,
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn known_parameters() {
588 assert_eq!(grib1_parameter_name(2, 11), "TMP");
589 assert_eq!(grib1_parameter_name(2, 33), "UGRD");
590 assert_eq!(parameter_name(0, 0, 0), "TMP");
591 assert_eq!(parameter_name(0, 2, 2), "UGRD");
592 assert_eq!(parameter_name(0, 3, 5), "HGT");
593 assert_eq!(parameter_name(0, 16, 5), "REFC");
594 assert_eq!(parameter_name(0, 19, 1), "ALBDO");
595 assert_eq!(parameter_name(10, 0, 3), "HTSGW");
596 assert_eq!(parameter_description(0, 0, 2), "Potential temperature");
597 assert_eq!(parameter_description(0, 16, 5), "Composite reflectivity");
598 assert_eq!(parameter_description(0, 19, 1), "Albedo");
599 assert_eq!(parameter_description(10, 3, 0), "Water temperature");
600 }
601
602 #[test]
603 fn unknown_parameter() {
604 assert_eq!(parameter_name(255, 255, 255), "unknown");
605 assert_eq!(parameter_name(0, 16, 196), "unknown");
606 assert_eq!(parameter_description(0, 16, 196), "Unknown parameter");
607 }
608
609 #[test]
610 fn ncep_refc_uses_local_table_context() {
611 let parameter = lookup_parameter(0, 16, 196, 7, 0, 1);
612
613 assert_eq!(parameter.short_name, "REFC");
614 assert_eq!(
615 parameter.description,
616 "Maximum/Composite radar reflectivity"
617 );
618 assert_eq!(
619 parameter.source,
620 ParameterTableSource::Local {
621 center_id: 7,
622 subcenter_id: 0,
623 local_table_version: 1,
624 }
625 );
626 }
627
628 #[test]
629 fn unknown_local_parameter_remains_local_unknown() {
630 let parameter = lookup_parameter(0, 16, 196, 8, 0, 1);
631
632 assert_eq!(parameter.short_name, "unknown");
633 assert_eq!(parameter.description, "Unknown parameter");
634 assert_eq!(
635 parameter.source,
636 ParameterTableSource::UnknownLocal {
637 center_id: 8,
638 subcenter_id: 0,
639 local_table_version: 1,
640 }
641 );
642 }
643
644 #[test]
645 fn local_table_version_must_match_local_entry() {
646 let parameter = lookup_parameter(0, 16, 196, 7, 0, 2);
647
648 assert_eq!(parameter.short_name, "unknown");
649 assert_eq!(parameter.description, "Unknown parameter");
650 assert_eq!(
651 parameter.source,
652 ParameterTableSource::UnknownLocal {
653 center_id: 7,
654 subcenter_id: 0,
655 local_table_version: 2,
656 }
657 );
658 }
659
660 #[test]
661 fn user_local_entries_resolve_local_use_codes() {
662 let entries = [LocalParameterEntry {
663 center_id: 42,
664 subcenter_id: Some(5),
665 local_table_version: Some(3),
666 discipline: 0,
667 category: 192,
668 number: 1,
669 short_name: "XFOO",
670 description: "Example local parameter",
671 }];
672
673 let parameter = lookup_parameter_with_local_entries(0, 192, 1, 42, 5, 3, &entries);
674
675 assert_eq!(parameter.short_name, "XFOO");
676 assert_eq!(parameter.description, "Example local parameter");
677 assert_eq!(
678 parameter.source,
679 ParameterTableSource::Local {
680 center_id: 42,
681 subcenter_id: 5,
682 local_table_version: 3,
683 }
684 );
685 }
686
687 #[test]
688 fn wmo_parameters_win_over_local_entries() {
689 let entries = [LocalParameterEntry {
690 center_id: 7,
691 subcenter_id: None,
692 local_table_version: Some(1),
693 discipline: 0,
694 category: 0,
695 number: 0,
696 short_name: "BADTMP",
697 description: "Bad local temperature",
698 }];
699
700 let parameter = lookup_parameter_with_local_entries(0, 0, 0, 7, 0, 1, &entries);
701
702 assert_eq!(parameter.short_name, "TMP");
703 assert_eq!(parameter.description, "Temperature");
704 assert_eq!(parameter.source, ParameterTableSource::Wmo);
705 }
706
707 #[test]
708 fn authored_local_table_csv_resolves_local_use_codes() {
709 let table = LocalParameterTable::from_csv_str(
710 r#"
711 # center-defined local GRIB2 table
712 center_id,subcenter_id,local_table_version,discipline,category,number,short_name,description
713 42,5,3,0,192,1,XFOO,Example local parameter
714 42,,3,0,16,196,LREFC,Local composite reflectivity
715 "#,
716 )
717 .unwrap();
718 let entries = table.entries();
719
720 let parameter = lookup_parameter_with_local_entries(0, 192, 1, 42, 5, 3, &entries);
721 assert_eq!(parameter.short_name, "XFOO");
722 assert_eq!(parameter.description, "Example local parameter");
723
724 let parameter = lookup_parameter_with_local_entries(0, 16, 196, 42, 99, 3, &entries);
725 assert_eq!(parameter.short_name, "LREFC");
726 assert_eq!(parameter.description, "Local composite reflectivity");
727 }
728
729 #[test]
730 fn authored_local_table_csv_roundtrips() {
731 let entry = OwnedLocalParameterEntry::new(
732 42,
733 None,
734 Some(3),
735 0,
736 16,
737 196,
738 "LREFC",
739 "Local composite reflectivity, experimental",
740 )
741 .unwrap();
742 let table = LocalParameterTable::from_entries([entry]).unwrap();
743
744 let encoded = table.to_csv_string();
745 assert!(encoded.starts_with(LOCAL_PARAMETER_TABLE_CSV_HEADER));
746
747 let reparsed = LocalParameterTable::from_csv_str(&encoded).unwrap();
748 assert_eq!(reparsed.authored_entries(), table.authored_entries());
749 }
750
751 #[test]
752 fn authored_local_table_rejects_ambiguous_overlaps() {
753 let first = OwnedLocalParameterEntry::new(
754 42,
755 None,
756 Some(3),
757 0,
758 16,
759 196,
760 "LREFC",
761 "Local composite reflectivity",
762 )
763 .unwrap();
764 let second = OwnedLocalParameterEntry::new(
765 42,
766 Some(5),
767 Some(3),
768 0,
769 16,
770 196,
771 "LREFC5",
772 "Subcenter local composite reflectivity",
773 )
774 .unwrap();
775
776 let err = LocalParameterTable::from_entries([first, second]).unwrap_err();
777 assert!(matches!(err, Error::Other(message) if message.contains("duplicate")));
778 }
779
780 #[test]
781 fn authored_local_table_rejects_standard_only_codes() {
782 let err = OwnedLocalParameterEntry::new(
783 42,
784 Some(5),
785 Some(3),
786 0,
787 0,
788 0,
789 "BADTMP",
790 "Bad local temperature",
791 )
792 .unwrap_err();
793
794 assert!(
795 matches!(err, Error::Other(message) if message.contains("local category or parameter number"))
796 );
797 }
798}