Skip to main content

grib_core/
parameter.rs

1//! WMO parameter tables (Code Table 4.2) and local table overlays for GRIB2.
2//!
3//! Maps (discipline, parameter_category, parameter_number) to human-readable names.
4
5use std::path::Path;
6
7use crate::metadata::{Parameter, ParameterTableSource};
8use crate::{Error, Result};
9
10/// A user-authored or built-in GRIB2 local table entry.
11///
12/// `subcenter_id` and `local_table_version` may be `None` to match any value
13/// for a center, but correctness-sensitive built-in entries should prefer exact
14/// local table versions when known.
15#[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
27/// Header for the line-oriented local parameter table CSV format.
28pub const LOCAL_PARAMETER_TABLE_CSV_HEADER: &str =
29    "center_id,subcenter_id,local_table_version,discipline,category,number,short_name,description";
30
31/// Owned local table entry for authoring or loading table files.
32///
33/// Convert a [`LocalParameterTable`] into borrowed [`LocalParameterEntry`]
34/// overlays with [`LocalParameterTable::entries`].
35#[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/// Owned GRIB2 local parameter table with file-authoring helpers.
105#[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
354/// Built-in local table entries shipped with the crate.
355pub 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
366/// Look up a GRIB1 parameter short name.
367pub 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
385/// Look up a GRIB1 parameter description.
386pub 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
404/// Look up a parameter short name from WMO code tables.
405pub 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
411/// Look up a human-readable parameter description from WMO code tables.
412pub 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
418/// Look up a GRIB2 parameter using WMO tables plus built-in local tables.
419pub 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
438/// Look up a GRIB2 parameter using WMO tables plus user-provided local entries.
439///
440/// The WMO table is always checked first for standard parameters. User-provided
441/// local entries are checked before built-in local entries, allowing callers to
442/// add or override center-specific local-use definitions without changing the
443/// global WMO table.
444pub 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        // Discipline 0: Meteorological products
531        // Category 0: Temperature
532        (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        // Category 1: Moisture
539        (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        // Category 2: Momentum
544        (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        // Category 3: Mass
550        (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        // Category 4: Short-wave radiation
554        (0, 4, 7) => Some(("DSWRF", "Downward short-wave radiation flux")),
555        // Category 5: Long-wave radiation
556        (0, 5, 3) => Some(("DLWRF", "Downward long-wave radiation flux")),
557        // Category 6: Cloud
558        (0, 6, 1) => Some(("TCDC", "Total cloud cover")),
559        // Category 7: Thermodynamic stability
560        (0, 7, 6) => Some(("CAPE", "Convective available potential energy")),
561        (0, 7, 7) => Some(("CIN", "Convective inhibition")),
562        // Category 16: Forecast radar imagery
563        (0, 16, 5) => Some(("REFC", "Composite reflectivity")),
564        // Category 19: Physical atmospheric properties
565        (0, 19, 1) => Some(("ALBDO", "Albedo")),
566
567        // Discipline 10: Oceanographic products
568        // Category 0: Waves
569        (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        // Category 3: Surface properties
576        (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}