Skip to main content

hpr_flightdata/
perfectflite.rs

1//! PerfectFlite's `.pf2` flight logs, as its software writes a Pnut's flights. The StratoLogger and
2//! StratoLoggerCF are expected to write the same layout, as Debrief's reader assumes; no file of
3//! theirs has been read.
4//!
5//! A `.pf2` is text: the logger's name on the first line, a preamble of `Key: value` lines, a
6//! `Data:` line naming the columns, then one row per sample, comma-separated, at about 20 Hz:
7//!
8//! ```text
9//! PerfectFlite Pnut
10//! Firmware: 1.0
11//! Apogee: 1280' AGL
12//! Ground Elevation: 600' MSL
13//! NumSamps: 3
14//! Flight Number: 2
15//! Data: (Time, Altitude, Velocity, Temperature (F), Voltage)
16//! 0.00, 0, 0, 70.00, 4.20
17//! 0.05, 0, 0
18//! 0.10, 1, 8, 70.00, 4.20
19//! ```
20//!
21//! Times are seconds, altitudes feet above the logger's reading on the pad, speeds feet per
22//! second, temperatures degrees Fahrenheit, voltages volts. A row may leave its last columns out
23//! (the temperature and voltage are logged less often); a column left out is a gap, `NaN`. The
24//! velocity is the logger's own, worked out from its barometric altitude: a PerfectFlite has no
25//! accelerometer.
26//!
27//! **Where this comes from.** PerfectFlite publishes no specification of the format. This reader
28//! follows the one in Debrief (`lib/parsers/perfectflite.ts`, MIT, the project owner's own; see
29//! `THIRD-PARTY-NOTICES.md`), which was written from exported files and cites no document; the
30//! units above are its reading, borne out by the Pnut fixture Debrief ships, whose preamble states
31//! its apogee with a foot mark. Where this reader departs from Debrief's: it takes the column order
32//! from the `Data:` line when there is one, where Debrief assumes it; and it refuses what Debrief
33//! passes over, naming the line: an empty or non-numeric cell, a time that doesn't increase, and a
34//! line that isn't a row once the rows have begun. A stated apogee or elevation in a unit other
35//! than feet is refused rather than guessed at. A byte-order mark is dropped, and lines may end
36//! in CR, LF or both.
37
38use crate::error::LogError;
39use crate::log::{FlightLog, LogFormat, Stated};
40
41/// The format's name in errors.
42const FORMAT: &str = ".pf2";
43
44/// Meters in a foot, exactly (the international foot).
45pub const FOOT_M: f64 = 0.3048;
46
47/// The columns a `.pf2` has when it has no `Data:` line: the order Debrief reads and the Pnut
48/// fixture's `Data:` line states.
49const DEFAULT_COLUMNS: [Column; 5] = [
50    Column::Time,
51    Column::Altitude,
52    Column::Velocity,
53    Column::Temperature(TemperatureUnit::Fahrenheit),
54    Column::Voltage,
55];
56
57/// A column of the data rows.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Column {
60    Time,
61    Altitude,
62    Velocity,
63    Temperature(TemperatureUnit),
64    Voltage,
65    /// A column the reader doesn't know, dropped with a note.
66    Unknown,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum TemperatureUnit {
71    Fahrenheit,
72    Celsius,
73}
74
75impl TemperatureUnit {
76    fn kelvin(self, value: f64) -> f64 {
77        match self {
78            Self::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
79            Self::Celsius => value + 273.15,
80        }
81    }
82}
83
84/// Reads a `.pf2` flight log from its text.
85///
86/// # Errors
87///
88/// [`LogError::NotThisFormat`] if the first line doesn't name PerfectFlite;
89/// [`LogError::Syntax`] for a row that isn't numbers, a time that doesn't increase, or a `Data:`
90/// line without a time or an altitude column; [`LogError::Unit`] for a stated height that isn't
91/// in feet; [`LogError::NoData`] if there are no rows.
92pub fn read(text: &str) -> Result<FlightLog, LogError> {
93    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
94    let text = text.replace("\r\n", "\n").replace('\r', "\n");
95    let mut lines = text
96        .lines()
97        .enumerate()
98        .map(|(index, line)| (index + 1, line.trim()));
99    let logger = loop {
100        match lines.next() {
101            Some((_, "")) => {}
102            Some((_, line)) if line.to_ascii_lowercase().contains("perfectflite") => {
103                break line.to_owned();
104            }
105            Some((_, line)) => {
106                return Err(LogError::NotThisFormat {
107                    format: FORMAT,
108                    message: format!(
109                        "its first line, {:?}, doesn't name PerfectFlite",
110                        short(line)
111                    ),
112                });
113            }
114            None => {
115                return Err(LogError::NotThisFormat {
116                    format: FORMAT,
117                    message: "the file is empty".to_owned(),
118                });
119            }
120        }
121    };
122
123    let mut log = FlightLog {
124        format: LogFormat::PerfectFlitePf2,
125        logger,
126        serial_number: None,
127        firmware: None,
128        flight_number: None,
129        stated: Stated::default(),
130        time_s: Vec::new(),
131        altitude_m: Vec::new(),
132        vertical_speed_m_s: None,
133        temperature_k: None,
134        battery_v: None,
135        notes: Vec::new(),
136    };
137    let mut notes = Notes::default();
138    let mut columns: Option<Vec<Column>> = None;
139    let mut rows: Vec<(usize, Vec<f64>)> = Vec::new();
140
141    for (number, line) in lines {
142        if line.is_empty() {
143            continue;
144        }
145        if is_data_row(line) {
146            rows.push((number, data_row(line, number)?));
147            continue;
148        }
149        if !rows.is_empty() {
150            return Err(syntax(
151                number,
152                format!(
153                    "{:?} after the data rows began isn't a row of numbers",
154                    short(line)
155                ),
156            ));
157        }
158        let Some((key, value)) = line.split_once(':') else {
159            notes.push(format!(
160                "line {number}, {:?}, isn't a `Key: value` line and was skipped",
161                short(line)
162            ));
163            continue;
164        };
165        let value = value.trim();
166        match key.trim().to_ascii_lowercase().as_str() {
167            "data" => columns = Some(data_columns(value, number, &mut notes)?),
168            "apogee" => log.stated.apogee_m = stated_feet(value, number, "apogee", &mut notes)?,
169            "ground elevation" => {
170                log.stated.ground_elevation_msl_m =
171                    stated_feet(value, number, "ground elevation", &mut notes)?;
172            }
173            "numsamps" => log.stated.samples = value.parse().ok(),
174            "flight number" => log.flight_number = value.parse().ok(),
175            "serial number" if !value.is_empty() => log.serial_number = Some(value.to_owned()),
176            "firmware" if !value.is_empty() => log.firmware = Some(value.to_owned()),
177            // The software version, comments and anything else describe the file, not the flight.
178            _ => {}
179        }
180    }
181
182    if rows.is_empty() {
183        return Err(LogError::NoData { format: FORMAT });
184    }
185    let columns = columns.unwrap_or_else(|| {
186        log.notes.push(
187            "the file has no `Data:` line; its columns were read as time, altitude, velocity, \
188             temperature (°F) and voltage"
189                .to_owned(),
190        );
191        DEFAULT_COLUMNS.to_vec()
192    });
193    fill(&mut log, &columns, &rows)?;
194    if let Some(stated) = log.stated.samples
195        && stated != log.time_s.len()
196    {
197        log.notes.push(format!(
198            "the file states {stated} samples and holds {}",
199            log.time_s.len()
200        ));
201    }
202    // The file-wide notes above come first, then those about single lines.
203    log.notes.extend(notes.kept);
204    if notes.left_out > 0 {
205        log.notes.push(format!(
206            "and {} more notes about single lines, left out so that a broken file's list stays \
207             short",
208            notes.left_out
209        ));
210    }
211    Ok(log)
212}
213
214/// The most notes about single lines a log keeps; past it, one more note counts the rest.
215const MAX_NOTES: usize = 20;
216
217/// Notes about single lines as the reader meets them: the first [`MAX_NOTES`] kept, the rest
218/// only counted, so a file of many broken lines can't fill memory with them.
219#[derive(Default)]
220struct Notes {
221    kept: Vec<String>,
222    left_out: usize,
223}
224
225impl Notes {
226    fn push(&mut self, note: String) {
227        if self.kept.len() < MAX_NOTES {
228            self.kept.push(note);
229        } else {
230            self.left_out += 1;
231        }
232    }
233}
234
235/// Whether a line is a data row: it starts with a number and has a comma, as Debrief tells them.
236fn is_data_row(line: &str) -> bool {
237    let first = line.trim_start_matches(['-', '+']);
238    first.starts_with(|c: char| c.is_ascii_digit() || c == '.') && line.contains(',')
239}
240
241fn syntax(line: usize, message: String) -> LogError {
242    LogError::Syntax {
243        format: FORMAT,
244        line,
245        message,
246    }
247}
248
249/// A data row's cells as numbers.
250fn data_row(line: &str, number: usize) -> Result<Vec<f64>, LogError> {
251    line.split(',')
252        .map(|cell| {
253            let cell = cell.trim();
254            cell.parse::<f64>()
255                .ok()
256                .filter(|value| value.is_finite())
257                .ok_or_else(|| syntax(number, format!("{:?} isn't a finite number", short(cell))))
258        })
259        .collect()
260}
261
262/// The columns a `Data:` line names, such as `(Time, Altitude, Velocity, Temperature (F), Voltage)`.
263fn data_columns(value: &str, number: usize, notes: &mut Notes) -> Result<Vec<Column>, LogError> {
264    let inner = value.strip_prefix('(').unwrap_or(value);
265    let inner = inner.strip_suffix(')').unwrap_or(inner);
266    let columns: Vec<Column> = inner
267        .split(',')
268        .map(|name| {
269            let name = name.trim().to_ascii_lowercase();
270            let column = if name.starts_with("time") {
271                Column::Time
272            } else if name.starts_with("altitude") {
273                Column::Altitude
274            } else if name.starts_with("velocity") {
275                Column::Velocity
276            } else if name.starts_with("temperature") && name.contains("(f)") {
277                Column::Temperature(TemperatureUnit::Fahrenheit)
278            } else if name.starts_with("temperature") && name.contains("(c)") {
279                Column::Temperature(TemperatureUnit::Celsius)
280            } else if name.starts_with("voltage") {
281                Column::Voltage
282            } else {
283                Column::Unknown
284            };
285            if column == Column::Unknown {
286                notes.push(format!(
287                    "the column {:?} isn't one this reader knows, and was left out",
288                    short(&name)
289                ));
290            }
291            column
292        })
293        .collect();
294    for required in [Column::Time, Column::Altitude] {
295        if columns.iter().filter(|column| **column == required).count() != 1 {
296            return Err(syntax(
297                number,
298                format!(
299                    "the `Data:` line must name one {required:?} column: {:?}",
300                    short(value)
301                ),
302            ));
303        }
304    }
305    Ok(columns)
306}
307
308/// A stated height, such as `1009' AGL`, in meters; `None`, with a note, for one that isn't a
309/// number hpr can read, such as `PWRLOSS`, or that names no unit, such as `1009 AGL`. A number in
310/// a unit other than feet (`'`, `ft`, `feet`) is refused.
311fn stated_feet(
312    value: &str,
313    number: usize,
314    what: &str,
315    notes: &mut Notes,
316) -> Result<Option<f64>, LogError> {
317    let end = value
318        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+')))
319        .unwrap_or(value.len());
320    let (digits, rest) = value.split_at(end);
321    let Some(feet) = digits.parse::<f64>().ok().filter(|feet| feet.is_finite()) else {
322        notes.push(format!(
323            "the file states its {what} as {:?}, which isn't a height",
324            short(value)
325        ));
326        return Ok(None);
327    };
328    let rest = rest.trim_start();
329    let word: String = rest
330        .chars()
331        .take_while(char::is_ascii_alphabetic)
332        .collect::<String>()
333        .to_ascii_lowercase();
334    if rest.starts_with('\'') || word == "ft" || word == "feet" {
335        return Ok(Some(feet * FOOT_M));
336    }
337    // `AGL` and `MSL` say what the height is measured from, not its unit.
338    if word.is_empty() || word == "agl" || word == "msl" {
339        notes.push(format!(
340            "the file states its {what} as {:?}, with no unit hpr can read",
341            short(value)
342        ));
343        return Ok(None);
344    }
345    Err(LogError::Unit {
346        format: FORMAT,
347        line: number,
348        message: format!(
349            "the {what} {:?} is in {:?}, not feet ('), the only unit this reader knows",
350            short(value),
351            short(&word)
352        ),
353    })
354}
355
356/// Text from the file as an error or note quotes it: cut to [`QUOTED_CHARS`] characters, so a
357/// binary file's "first line" doesn't fill the message.
358fn short(text: &str) -> String {
359    if text.chars().count() <= QUOTED_CHARS {
360        text.to_owned()
361    } else {
362        let mut cut: String = text.chars().take(QUOTED_CHARS - 1).collect();
363        cut.push('…');
364        cut
365    }
366}
367
368/// The most characters of the file an error or note quotes.
369const QUOTED_CHARS: usize = 80;
370
371/// Puts the rows' values into the log's channels, in SI.
372fn fill(
373    log: &mut FlightLog,
374    columns: &[Column],
375    rows: &[(usize, Vec<f64>)],
376) -> Result<(), LogError> {
377    let has = |wanted: fn(&Column) -> bool| columns.iter().any(wanted);
378    let mut speed = has(|c| *c == Column::Velocity).then(Vec::new);
379    let mut temperature = has(|c| matches!(c, Column::Temperature(_))).then(Vec::new);
380    let mut battery = has(|c| *c == Column::Voltage).then(Vec::new);
381    for (number, cells) in rows {
382        if cells.len() > columns.len() {
383            return Err(syntax(
384                *number,
385                format!(
386                    "the row has {} values and the columns are {}",
387                    cells.len(),
388                    columns.len()
389                ),
390            ));
391        }
392        let mut time = None;
393        let mut altitude = None;
394        let (mut v, mut t, mut u) = (f64::NAN, f64::NAN, f64::NAN);
395        for (column, value) in columns.iter().zip(cells) {
396            match column {
397                Column::Time => time = Some(*value),
398                Column::Altitude => altitude = Some(*value * FOOT_M),
399                Column::Velocity => v = *value * FOOT_M,
400                Column::Temperature(unit) => t = unit.kelvin(*value),
401                Column::Voltage => u = *value,
402                Column::Unknown => {}
403            }
404        }
405        let (Some(time), Some(altitude)) = (time, altitude) else {
406            return Err(syntax(
407                *number,
408                "the row leaves out its time or its altitude".to_owned(),
409            ));
410        };
411        if let Some(&last) = log.time_s.last()
412            && time <= last
413        {
414            return Err(syntax(
415                *number,
416                format!("the time {time} s doesn't come after the row before's {last} s"),
417            ));
418        }
419        log.time_s.push(time);
420        log.altitude_m.push(altitude);
421        for (channel, value) in [(&mut speed, v), (&mut temperature, t), (&mut battery, u)] {
422            if let Some(channel) = channel {
423                channel.push(value);
424            }
425        }
426    }
427    log.vertical_speed_m_s = speed;
428    log.temperature_k = temperature;
429    log.battery_v = battery;
430    Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    const HEAD: &str = "PerfectFlite Pnut\r\nFirmware: 1.0\r\nSerial Number: 17\r\n\
438                        Apogee: 1280' AGL\r\nGround Elevation: 600' MSL\r\nNumSamps: 3\r\n\
439                        Flight Number: 2\r\nComments: \r\n\r\n\
440                        Data: (Time, Altitude, Velocity, Temperature (F), Voltage)\r\n";
441
442    /// Every value in SI, a short row's missing cells gaps, and the preamble's statements read.
443    #[test]
444    fn reads_the_preamble_and_rows_in_si() {
445        let text =
446            format!("{HEAD}0.00, 0, 0, 32.00, 4.20\r\n0.05, -1, 10\r\n0.10, 10, 20, 212, 4.1\r\n");
447        let log = read(&text).unwrap();
448        assert_eq!(log.format, LogFormat::PerfectFlitePf2);
449        assert_eq!(log.logger, "PerfectFlite Pnut");
450        assert_eq!(log.serial_number.as_deref(), Some("17"));
451        assert_eq!(log.firmware.as_deref(), Some("1.0"));
452        assert_eq!(log.flight_number, Some(2));
453        assert_eq!(log.stated.apogee_m, Some(1280.0 * 0.3048));
454        assert_eq!(log.stated.ground_elevation_msl_m, Some(600.0 * 0.3048));
455        assert_eq!(log.stated.samples, Some(3));
456        assert_eq!(log.time_s, [0.0, 0.05, 0.10]);
457        assert_eq!(log.altitude_m, [0.0, -0.3048, 3.048]);
458        assert_eq!(
459            log.vertical_speed_m_s.as_deref(),
460            Some(&[0.0, 3.048, 6.096][..])
461        );
462        let temperature = log.temperature_k.unwrap();
463        assert_eq!(temperature[0], 273.15);
464        assert!(temperature[1].is_nan());
465        assert!((temperature[2] - 373.15).abs() < 1e-12);
466        let battery = log.battery_v.unwrap();
467        assert_eq!((battery[0], battery[2]), (4.2, 4.1));
468        assert!(battery[1].is_nan());
469        assert!(log.notes.is_empty(), "{:?}", log.notes);
470    }
471
472    /// The columns come from the `Data:` line, in its order; a column it doesn't know is noted
473    /// and left out; without the line, Debrief's order is assumed and noted.
474    #[test]
475    fn columns_follow_the_data_line() {
476        let text = "PerfectFlite StratoLogger\nData: (Altitude, Time, Pressure)\n100, 0.5, 9\n";
477        let log = read(text).unwrap();
478        assert_eq!((log.time_s[0], log.altitude_m[0]), (0.5, 30.48));
479        assert!(log.vertical_speed_m_s.is_none() && log.temperature_k.is_none());
480        assert!(log.notes[0].contains("\"pressure\""), "{:?}", log.notes);
481
482        let log = read("PerfectFlite Pnut\n0.0, 5, 1, 50, 4\n").unwrap();
483        assert_eq!(log.altitude_m, [5.0 * 0.3048]);
484        assert!(log.notes[0].contains("no `Data:` line"), "{:?}", log.notes);
485    }
486
487    /// `PWRLOSS` is no height, and `1009 AGL` names no unit: both noted, not refused. A height in
488    /// another unit is refused.
489    #[test]
490    fn stated_heights_are_feet_or_nothing() {
491        let log = read("PerfectFlite Pnut\nApogee: PWRLOSS\n0, 0, 0\n").unwrap();
492        assert_eq!(log.stated.apogee_m, None);
493        assert!(
494            log.notes.iter().any(|note| note.contains("PWRLOSS")),
495            "{:?}",
496            log.notes
497        );
498        let error = read("PerfectFlite Pnut\nApogee: 390 m AGL\n0, 0, 0\n").unwrap_err();
499        assert!(
500            matches!(&error, LogError::Unit { line: 2, message, .. } if message.contains("\"390 m AGL\" is in \"m\"")),
501            "{error}"
502        ); // `AGL` names the height's zero, not its unit: noted, and the flight still reads.
503        let log = read("PerfectFlite Pnut\nApogee: 1009 AGL\n0, 0, 0\n").unwrap();
504        assert_eq!(log.stated.apogee_m, None);
505        assert!(
506            log.notes.iter().any(|note| note.contains("no unit")),
507            "{:?}",
508            log.notes
509        );
510    }
511
512    /// A header of many unreadable lines keeps a short list of notes, the file-wide one first,
513    /// and counts the 980 it left out of the 1,000 skipped lines.
514    #[test]
515    fn notes_stay_few() {
516        let text = format!("PerfectFlite Pnut\n{}0, 0, 0\n", "x\n".repeat(1000));
517        let log = read(&text).unwrap();
518        assert_eq!(log.notes.len(), MAX_NOTES + 2);
519        assert!(log.notes[0].contains("no `Data:` line"), "{:?}", log.notes);
520        assert!(
521            log.notes[MAX_NOTES + 1].starts_with("and 980 more notes"),
522            "{:?}",
523            log.notes
524        );
525    }
526
527    /// A file that isn't text is refused with a short quote of it, not the whole of it.
528    #[test]
529    fn a_long_line_is_quoted_short() {
530        let junk = "\u{fffd}".repeat(100_000);
531        let error = read(&junk).unwrap_err().to_string();
532        assert!(error.len() < 400, "{} bytes", error.len());
533        assert!(error.contains('…'), "{error}");
534    }
535
536    /// A byte-order mark is dropped; CR-only line ends read as lines; `ft` is feet; a height
537    /// hpr can't read, such as one with a thousands comma, is noted and left out.
538    #[test]
539    fn marks_line_ends_and_odd_heights() {
540        let log = read(
541            "\u{feff}PerfectFlite Pnut\rApogee: 1,009' AGL\rGround Elevation: 600 ft\r0, 0, 0\r",
542        )
543        .unwrap();
544        assert_eq!(log.logger, "PerfectFlite Pnut");
545        assert_eq!(log.time_s.len(), 1);
546        assert_eq!(log.stated.apogee_m, None);
547        assert!(
548            log.notes.iter().any(|note| note.contains("\"1,009' AGL\"")),
549            "{:?}",
550            log.notes
551        );
552        assert_eq!(log.stated.ground_elevation_msl_m, Some(600.0 * FOOT_M));
553    }
554
555    /// Each refusal names its line and what is wrong.
556    #[test]
557    fn malformed_files_are_refused_where_they_go_wrong() {
558        let cases = [
559            ("", "the file is empty"),
560            ("Time, Altitude\n0, 0\n", "doesn't name PerfectFlite"),
561            ("PerfectFlite Pnut\nApogee: 5'\n", "no data rows"),
562            (
563                "PerfectFlite Pnut\n0, 0, 0\n0, 1, 0\n",
564                "line 3: the time 0 s doesn't come after",
565            ),
566            (
567                "PerfectFlite Pnut\n0, 0, x\n",
568                "line 2: \"x\" isn't a finite number",
569            ),
570            (
571                "PerfectFlite Pnut\n0, 0, 0\nEnd\n",
572                "line 3: \"End\" after the data rows",
573            ),
574            (
575                "PerfectFlite Pnut\n0, 0, 0, 1, 2, 3\n",
576                "line 2: the row has 6 values",
577            ),
578            ("PerfectFlite Pnut\n0\n", "no data rows"),
579            (
580                "PerfectFlite Pnut\nData: (Time, Velocity)\n0, 0\n",
581                "line 2: the `Data:` line",
582            ),
583            (
584                "PerfectFlite Pnut\nData: (Time, Altitude)\n0.5\n",
585                "no data rows",
586            ),
587            (
588                "PerfectFlite Pnut\nData: (Time, Altitude)\n0.5,\n",
589                "line 3: \"\" isn't",
590            ),
591            (
592                "PerfectFlite Pnut\n0, inf, 0\n",
593                "line 2: \"inf\" isn't a finite number",
594            ),
595        ];
596        for (text, expected) in cases {
597            let error = read(text).unwrap_err().to_string();
598            assert!(error.contains(expected), "{text:?}: {error}");
599        }
600        // A short row that stops before the altitude's column leaves it out.
601        let error =
602            read("PerfectFlite Pnut\nData: (Time, Velocity, Altitude)\n0, 5\n").unwrap_err();
603        assert!(
604            error.to_string().contains("line 3: the row leaves out"),
605            "{error}"
606        );
607    }
608
609    /// A stated sample count the rows don't match is noted.
610    #[test]
611    fn a_sample_count_that_differs_is_noted() {
612        let log = read("PerfectFlite Pnut\nNumSamps: 3\n0, 0, 0\n0.05, 0, 0\n").unwrap();
613        assert_eq!(
614            log.notes.last().unwrap(),
615            "the file states 3 samples and holds 2"
616        );
617    }
618
619    proptest::proptest! {
620        /// Any text is read or refused, never a panic; what is read has increasing finite times.
621        #[test]
622        fn any_text_is_read_or_refused(text in "(PerfectFlite Pnut\n)?([-0-9., A-Za-z:'()]{0,20}\n){0,12}") {
623            if let Ok(log) = read(&text) {
624                proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
625                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
626            }
627        }
628    }
629}