bem/core/io/
nc_format.rs

1//! Parser for C++ NC.inp format
2//!
3//! This module provides backwards compatibility with the NumCalc input format.
4//!
5//! ## Format Overview
6//!
7//! NC.inp files are text-based with sections marked by keywords:
8//! - Comments start with `#` or `##`
9//! - Sections: NODES, ELEMENTS, BOUNDARY, PLANE WAVES, POINT SOURCES, etc.
10//! - END marker signals end of file
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use ndarray::{Array1, Array2};
16use num_complex::Complex64;
17
18use crate::core::types::{BoundaryCondition, Element, ElementProperty, ElementType, PhysicsParams};
19
20/// Parsed NC.inp configuration
21#[derive(Debug, Clone)]
22pub struct NcInputConfig {
23    /// Version string (e.g., "Mesh2HRTF 1.0.0")
24    pub version: String,
25    /// Description/title
26    pub description: String,
27    /// Control parameters I: [output_format, ...]
28    pub control_params_i: Vec<i32>,
29    /// Control parameters II: solver settings
30    pub control_params_ii: Vec<f64>,
31    /// Frequency curve: [(time, freq, phase), ...]
32    pub frequency_curve: Vec<(f64, f64, f64)>,
33    /// Main parameters I: mesh info
34    pub main_params_i: MainParamsI,
35    /// Main parameters II: solver options
36    pub main_params_ii: MainParamsII,
37    /// Main parameters III: symmetry options
38    pub main_params_iii: Vec<i32>,
39    /// Main parameters IV: physical constants
40    pub main_params_iv: MainParamsIV,
41    /// Node file paths
42    pub node_files: Vec<PathBuf>,
43    /// Element file paths
44    pub element_files: Vec<PathBuf>,
45    /// Symmetry configuration (if present)
46    pub symmetry: Option<SymmetryConfig>,
47    /// Boundary conditions
48    pub boundary_conditions: Vec<BoundarySpec>,
49    /// Plane wave sources
50    pub plane_waves: Vec<PlaneWaveSource>,
51    /// Point sources
52    pub point_sources: Vec<PointSource>,
53    /// Base directory for resolving relative paths
54    pub base_dir: PathBuf,
55}
56
57/// Main parameters I (mesh configuration)
58#[derive(Debug, Clone, Default)]
59pub struct MainParamsI {
60    /// Element type (2 = mixed, etc.)
61    pub element_type: i32,
62    /// Total number of nodes
63    pub num_nodes: usize,
64    /// Total number of elements
65    pub num_elements: usize,
66    /// Number of object mesh files
67    pub num_object_files: i32,
68    /// Number of evaluation grid files
69    pub num_eval_files: i32,
70    /// Boundary condition type
71    pub bc_type: i32,
72    /// Solver method (0=TBEM, 1=SLFMM, 2/3=MLFMM)
73    pub solver_method: i32,
74    /// FMM method variant
75    pub fmm_method: i32,
76    /// Parallel processing flag
77    pub parallel: i32,
78}
79
80/// Main parameters II (solver options)
81#[derive(Debug, Clone, Default)]
82pub struct MainParamsII {
83    /// Preconditioner type (0=ILU, 1=row scaling)
84    pub preconditioner: i32,
85    /// Iterative solver (0=CGS, 1=BiCGSTAB)
86    pub iterative_solver: i32,
87    /// Reserved
88    pub reserved1: i32,
89    /// Reserved
90    pub reserved2: f64,
91    /// Output level
92    pub output_level: i32,
93    /// Reserved
94    pub reserved3: i32,
95    /// Reserved
96    pub reserved4: i32,
97}
98
99/// Main parameters IV (physical constants)
100#[derive(Debug, Clone)]
101pub struct MainParamsIV {
102    /// Speed of sound (m/s)
103    pub speed_of_sound: f64,
104    /// Air density (kg/m³)
105    pub density: f64,
106    /// Reference pressure (Pa)
107    pub reference_pressure: f64,
108    /// Reserved values
109    pub reserved: Vec<f64>,
110}
111
112impl Default for MainParamsIV {
113    fn default() -> Self {
114        Self {
115            speed_of_sound: 343.0,
116            density: 1.21,
117            reference_pressure: 1.0,
118            reserved: vec![0.0; 4],
119        }
120    }
121}
122
123/// Symmetry configuration
124#[derive(Debug, Clone)]
125pub struct SymmetryConfig {
126    /// Symmetry flags [x, y, z]
127    pub flags: [bool; 3],
128    /// Symmetry plane origin
129    pub origin: [f64; 3],
130}
131
132/// Boundary condition specification
133#[derive(Debug, Clone)]
134pub struct BoundarySpec {
135    /// Element range start
136    pub elem_start: usize,
137    /// Element range end
138    pub elem_end: usize,
139    /// Boundary condition type ("VELO", "PRES", "ADMI")
140    pub bc_type: String,
141    /// Real part of BC value
142    pub value_re: f64,
143    /// Curve index for real part (-1 = constant)
144    pub curve_re: i32,
145    /// Imaginary part of BC value
146    pub value_im: f64,
147    /// Curve index for imaginary part (-1 = constant)
148    pub curve_im: i32,
149}
150
151/// Plane wave source
152#[derive(Debug, Clone)]
153pub struct PlaneWaveSource {
154    /// Direction vector [x, y, z]
155    pub direction: [f64; 3],
156    /// Amplitude (real part)
157    pub amplitude_re: f64,
158    /// Curve index for amplitude (-1 = constant)
159    pub curve_re: i32,
160    /// Amplitude (imaginary part)
161    pub amplitude_im: f64,
162    /// Curve index for imaginary (-1 = constant)
163    pub curve_im: i32,
164}
165
166/// Point source
167#[derive(Debug, Clone)]
168pub struct PointSource {
169    /// Position [x, y, z]
170    pub position: [f64; 3],
171    /// Amplitude (real part)
172    pub amplitude_re: f64,
173    /// Curve index for amplitude
174    pub curve_re: i32,
175    /// Amplitude (imaginary part)
176    pub amplitude_im: f64,
177    /// Curve index for imaginary
178    pub curve_im: i32,
179}
180
181/// Parser error types
182#[derive(Debug, thiserror::Error)]
183pub enum NcParseError {
184    /// IO error during file reading
185    #[error("IO error: {0}")]
186    Io(#[from] std::io::Error),
187    /// Parse error at a specific line
188    #[error("Parse error at line {line}: {message}")]
189    Parse {
190        /// Line number where error occurred
191        line: usize,
192        /// Error message
193        message: String,
194    },
195    /// Required section is missing from the input file
196    #[error("Missing required section: {0}")]
197    MissingSection(String),
198    /// Input file format is invalid
199    #[error("Invalid format: {0}")]
200    InvalidFormat(String),
201}
202
203/// Parse an NC.inp file
204pub fn parse_nc_input<P: AsRef<Path>>(path: P) -> Result<NcInputConfig, NcParseError> {
205    let path = path.as_ref();
206    let base_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
207    let content = fs::read_to_string(path)?;
208
209    parse_nc_input_string(&content, base_dir)
210}
211
212/// Parse NC.inp content from a string
213pub fn parse_nc_input_string(
214    content: &str,
215    base_dir: PathBuf,
216) -> Result<NcInputConfig, NcParseError> {
217    let mut config = NcInputConfig {
218        version: String::new(),
219        description: String::new(),
220        control_params_i: Vec::new(),
221        control_params_ii: Vec::new(),
222        frequency_curve: Vec::new(),
223        main_params_i: MainParamsI::default(),
224        main_params_ii: MainParamsII::default(),
225        main_params_iii: Vec::new(),
226        main_params_iv: MainParamsIV::default(),
227        node_files: Vec::new(),
228        element_files: Vec::new(),
229        symmetry: None,
230        boundary_conditions: Vec::new(),
231        plane_waves: Vec::new(),
232        point_sources: Vec::new(),
233        base_dir,
234    };
235
236    let lines: Vec<&str> = content.lines().collect();
237    let mut i = 0;
238
239    while i < lines.len() {
240        let line = lines[i].trim();
241
242        // Skip empty lines and comments
243        if line.is_empty() || line.starts_with("##") {
244            i += 1;
245            continue;
246        }
247
248        // Skip single-# comments (but process # at start of keywords)
249        if line.starts_with('#') && !line.starts_with("##") {
250            // Check if it's a commented-out section
251            i += 1;
252            continue;
253        }
254
255        // Parse version line
256        if line.starts_with("Mesh2HRTF") {
257            config.version = line.to_string();
258            i += 1;
259            continue;
260        }
261
262        // Parse description (line after version)
263        if config.version.is_empty() {
264            i += 1;
265            continue;
266        }
267
268        // Control parameters I (exclude II)
269        if i > 0
270            && lines.get(i.saturating_sub(1)).is_some_and(|l| {
271                l.contains("Controlparameter I") && !l.contains("Controlparameter II")
272            })
273        {
274            config.control_params_i = parse_int_line(line);
275            i += 1;
276            continue;
277        }
278
279        // Control parameters II
280        if i > 0
281            && lines
282                .get(i.saturating_sub(1))
283                .is_some_and(|l| l.contains("Controlparameter II"))
284        {
285            config.control_params_ii = parse_float_line(line);
286            i += 1;
287            continue;
288        }
289
290        // Frequency curve
291        if i > 0
292            && lines
293                .get(i.saturating_sub(1))
294                .is_some_and(|l| l.contains("Frequency Curve"))
295        {
296            let header = parse_int_line(line);
297            let num_points = header.get(1).copied().unwrap_or(0) as usize;
298            i += 1;
299
300            for _ in 0..num_points {
301                if i < lines.len() {
302                    let values = parse_float_line(lines[i]);
303                    if values.len() >= 3 {
304                        config
305                            .frequency_curve
306                            .push((values[0], values[1], values[2]));
307                    }
308                    i += 1;
309                }
310            }
311            continue;
312        }
313
314        // Main Parameters I (exclude II, III, IV)
315        if i > 0
316            && lines.get(i.saturating_sub(1)).is_some_and(|l| {
317                l.contains("Main Parameters I")
318                    && !l.contains("Main Parameters II")
319                    && !l.contains("Main Parameters III")
320                    && !l.contains("Main Parameters IV")
321            })
322        {
323            let values = parse_int_line(line);
324            config.main_params_i = MainParamsI {
325                element_type: values.first().copied().unwrap_or(0),
326                num_nodes: values.get(1).copied().unwrap_or(0) as usize,
327                num_elements: values.get(2).copied().unwrap_or(0) as usize,
328                num_object_files: values.get(3).copied().unwrap_or(0),
329                num_eval_files: values.get(4).copied().unwrap_or(0),
330                bc_type: values.get(5).copied().unwrap_or(0),
331                solver_method: values.get(6).copied().unwrap_or(0),
332                fmm_method: values.get(7).copied().unwrap_or(0),
333                parallel: values.get(8).copied().unwrap_or(0),
334            };
335            i += 1;
336            continue;
337        }
338
339        // Main Parameters II (exclude III, IV)
340        if i > 0
341            && lines.get(i.saturating_sub(1)).is_some_and(|l| {
342                l.contains("Main Parameters II")
343                    && !l.contains("Main Parameters III")
344                    && !l.contains("Main Parameters IV")
345            })
346        {
347            let values = parse_mixed_line(line);
348            config.main_params_ii = MainParamsII {
349                preconditioner: values.first().map(|v| *v as i32).unwrap_or(0),
350                iterative_solver: values.get(1).map(|v| *v as i32).unwrap_or(0),
351                reserved1: values.get(2).map(|v| *v as i32).unwrap_or(0),
352                reserved2: values.get(3).copied().unwrap_or(0.0),
353                output_level: values.get(4).map(|v| *v as i32).unwrap_or(0),
354                reserved3: values.get(5).map(|v| *v as i32).unwrap_or(0),
355                reserved4: values.get(6).map(|v| *v as i32).unwrap_or(0),
356            };
357            i += 1;
358            continue;
359        }
360
361        // Main Parameters III (exclude IV)
362        if i > 0
363            && lines.get(i.saturating_sub(1)).is_some_and(|l| {
364                l.contains("Main Parameters III") && !l.contains("Main Parameters IV")
365            })
366        {
367            config.main_params_iii = parse_int_line(line);
368            i += 1;
369            continue;
370        }
371
372        // Main Parameters IV
373        if i > 0
374            && lines
375                .get(i.saturating_sub(1))
376                .is_some_and(|l| l.contains("Main Parameters IV"))
377        {
378            let values = parse_float_line(line);
379            config.main_params_iv = MainParamsIV {
380                speed_of_sound: values.first().copied().unwrap_or(343.0),
381                density: values.get(1).copied().unwrap_or(1.21),
382                reference_pressure: values.get(2).copied().unwrap_or(1.0),
383                reserved: values.get(3..).map(|s| s.to_vec()).unwrap_or_default(),
384            };
385            i += 1;
386            continue;
387        }
388
389        // NODES section
390        if line == "NODES" {
391            i += 1;
392            while i < lines.len() {
393                let node_line = lines[i].trim();
394                if node_line.starts_with("##") || node_line.is_empty() {
395                    break;
396                }
397                if !node_line.starts_with('#') {
398                    let path = config.base_dir.join(node_line);
399                    config.node_files.push(path);
400                }
401                i += 1;
402            }
403            continue;
404        }
405
406        // ELEMENTS section
407        if line == "ELEMENTS" {
408            i += 1;
409            while i < lines.len() {
410                let elem_line = lines[i].trim();
411                if elem_line.starts_with("##") || elem_line.is_empty() {
412                    break;
413                }
414                if !elem_line.starts_with('#') {
415                    let path = config.base_dir.join(elem_line);
416                    config.element_files.push(path);
417                }
418                i += 1;
419            }
420            continue;
421        }
422
423        // SYMMETRY section
424        if line == "SYMMETRY" {
425            i += 1;
426            if i < lines.len() {
427                let flags_line = lines[i].trim();
428                if !flags_line.starts_with('#') {
429                    let flags = parse_int_line(flags_line);
430                    i += 1;
431                    if i < lines.len() {
432                        let origin = parse_float_line(lines[i].trim());
433                        config.symmetry = Some(SymmetryConfig {
434                            flags: [
435                                flags.first().copied().unwrap_or(0) != 0,
436                                flags.get(1).copied().unwrap_or(0) != 0,
437                                flags.get(2).copied().unwrap_or(0) != 0,
438                            ],
439                            origin: [
440                                origin.first().copied().unwrap_or(0.0),
441                                origin.get(1).copied().unwrap_or(0.0),
442                                origin.get(2).copied().unwrap_or(0.0),
443                            ],
444                        });
445                        i += 1;
446                    }
447                }
448            }
449            continue;
450        }
451
452        // BOUNDARY section
453        if line == "BOUNDARY" {
454            i += 1;
455            while i < lines.len() {
456                let bc_line = lines[i].trim();
457                if bc_line.starts_with("##") || bc_line == "RETU" {
458                    i += 1;
459                    break;
460                }
461                if bc_line.starts_with('#') || bc_line.is_empty() {
462                    i += 1;
463                    continue;
464                }
465
466                if let Some(bc) = parse_boundary_line(bc_line) {
467                    config.boundary_conditions.push(bc);
468                }
469                i += 1;
470            }
471            continue;
472        }
473
474        // PLANE WAVES section
475        if line == "PLANE WAVES" {
476            i += 1;
477            while i < lines.len() {
478                let pw_line = lines[i].trim();
479                if pw_line.starts_with("##") || pw_line.is_empty() {
480                    break;
481                }
482                if !pw_line.starts_with('#')
483                    && let Some(pw) = parse_plane_wave_line(pw_line)
484                {
485                    config.plane_waves.push(pw);
486                }
487                i += 1;
488            }
489            continue;
490        }
491
492        // POINT SOURCES section
493        if line == "POINT SOURCES" {
494            i += 1;
495            while i < lines.len() {
496                let ps_line = lines[i].trim();
497                if ps_line.starts_with("##") || ps_line.is_empty() {
498                    break;
499                }
500                if !ps_line.starts_with('#')
501                    && let Some(ps) = parse_point_source_line(ps_line)
502                {
503                    config.point_sources.push(ps);
504                }
505                i += 1;
506            }
507            continue;
508        }
509
510        // END marker
511        if line == "END" {
512            break;
513        }
514
515        i += 1;
516    }
517
518    Ok(config)
519}
520
521/// Parse a line of integers
522fn parse_int_line(line: &str) -> Vec<i32> {
523    line.split_whitespace()
524        .filter_map(|s| s.parse::<i32>().ok())
525        .collect()
526}
527
528/// Parse a line of floats
529fn parse_float_line(line: &str) -> Vec<f64> {
530    line.split_whitespace()
531        .filter_map(|s| {
532            // Handle scientific notation with space (e.g., "0.0 e+00")
533            let s = s.replace(" ", "");
534            s.parse::<f64>().ok()
535        })
536        .collect()
537}
538
539/// Parse a line with mixed int/float values
540fn parse_mixed_line(line: &str) -> Vec<f64> {
541    parse_float_line(line)
542}
543
544/// Parse a boundary condition line
545fn parse_boundary_line(line: &str) -> Option<BoundarySpec> {
546    let parts: Vec<&str> = line.split_whitespace().collect();
547
548    // ELEM start TO end TYPE value curve value curve
549    if parts.len() >= 9 && parts[0] == "ELEM" && parts[2] == "TO" {
550        let elem_start = parts[1].parse().ok()?;
551        let elem_end = parts[3].parse().ok()?;
552        let bc_type = parts[4].to_string();
553        let value_re = parts[5].parse().ok()?;
554        let curve_re = parts[6].parse().ok()?;
555        let value_im = parts[7].parse().ok()?;
556        let curve_im = parts[8].parse().ok()?;
557
558        return Some(BoundarySpec {
559            elem_start,
560            elem_end,
561            bc_type,
562            value_re,
563            curve_re,
564            value_im,
565            curve_im,
566        });
567    }
568
569    None
570}
571
572/// Parse a plane wave line
573fn parse_plane_wave_line(line: &str) -> Option<PlaneWaveSource> {
574    let values = parse_float_line(line);
575    if values.len() >= 8 {
576        Some(PlaneWaveSource {
577            direction: [values[1], values[2], values[3]],
578            amplitude_re: values[4],
579            curve_re: values[5] as i32,
580            amplitude_im: values[6],
581            curve_im: values[7] as i32,
582        })
583    } else {
584        None
585    }
586}
587
588/// Parse a point source line
589fn parse_point_source_line(line: &str) -> Option<PointSource> {
590    let values = parse_float_line(line);
591    if values.len() >= 8 {
592        Some(PointSource {
593            position: [values[1], values[2], values[3]],
594            amplitude_re: values[4],
595            curve_re: values[5] as i32,
596            amplitude_im: values[6],
597            curve_im: values[7] as i32,
598        })
599    } else {
600        None
601    }
602}
603
604/// Load nodes from a NumCalc nodes file
605pub fn load_nc_nodes<P: AsRef<Path>>(path: P) -> Result<Array2<f64>, NcParseError> {
606    let content = fs::read_to_string(path)?;
607    let lines: Vec<&str> = content.lines().collect();
608
609    if lines.is_empty() {
610        return Ok(Array2::zeros((0, 3)));
611    }
612
613    // First line may contain node count
614    let start_line = if lines[0].trim().parse::<usize>().is_ok() {
615        1
616    } else {
617        0
618    };
619
620    let mut nodes = Vec::new();
621    for line in &lines[start_line..] {
622        let values = parse_float_line(line);
623        if values.len() >= 4 {
624            // Format: node_id x y z
625            nodes.push([values[1], values[2], values[3]]);
626        } else if values.len() >= 3 {
627            // Format: x y z
628            nodes.push([values[0], values[1], values[2]]);
629        }
630    }
631
632    let n = nodes.len();
633    let flat: Vec<f64> = nodes.into_iter().flatten().collect();
634    Array2::from_shape_vec((n, 3), flat).map_err(|e| NcParseError::InvalidFormat(e.to_string()))
635}
636
637/// Load elements from a NumCalc elements file
638pub fn load_nc_elements<P: AsRef<Path>>(
639    path: P,
640    property: ElementProperty,
641) -> Result<Vec<Element>, NcParseError> {
642    let content = fs::read_to_string(path)?;
643    let lines: Vec<&str> = content.lines().collect();
644
645    if lines.is_empty() {
646        return Ok(Vec::new());
647    }
648
649    let start_line = if lines[0].trim().parse::<usize>().is_ok() {
650        1
651    } else {
652        0
653    };
654
655    let mut elements = Vec::new();
656    for (idx, line) in lines[start_line..].iter().enumerate() {
657        let values: Vec<i32> = line
658            .split_whitespace()
659            .filter_map(|s| s.parse().ok())
660            .collect();
661
662        if values.len() >= 4 {
663            // Format: elem_id n1 n2 n3 [n4]
664            let connectivity: Vec<usize> = values[1..]
665                .iter()
666                .take_while(|&&v| v >= 0)
667                .map(|&v| v as usize)
668                .collect();
669
670            let element_type = if connectivity.len() == 3 {
671                ElementType::Tri3
672            } else {
673                ElementType::Quad4
674            };
675
676            let elem = Element {
677                connectivity,
678                element_type,
679                property,
680                normal: Array1::zeros(3),
681                node_normals: Array2::zeros((element_type.num_nodes(), 3)),
682                center: Array1::zeros(3),
683                area: 0.0,
684                boundary_condition: BoundaryCondition::Velocity(vec![Complex64::new(0.0, 0.0)]),
685                group: 0,
686                dof_addresses: vec![idx],
687            };
688            elements.push(elem);
689        }
690    }
691
692    Ok(elements)
693}
694
695/// Convert NcInputConfig to PhysicsParams
696impl NcInputConfig {
697    /// Create PhysicsParams from the configuration
698    pub fn to_physics_params(&self, frequency: f64) -> PhysicsParams {
699        PhysicsParams::new(
700            frequency,
701            self.main_params_iv.speed_of_sound,
702            self.main_params_iv.density,
703            false, // external problem
704        )
705    }
706
707    /// Get the BEM method from configuration
708    pub fn bem_method(&self) -> crate::core::types::BemMethod {
709        match self.main_params_i.solver_method {
710            0 => crate::core::types::BemMethod::Traditional,
711            1 => crate::core::types::BemMethod::SingleLevelFmm,
712            2 | 3 => crate::core::types::BemMethod::MultiLevelFmm,
713            _ => crate::core::types::BemMethod::Traditional,
714        }
715    }
716
717    /// Get solver method from configuration
718    pub fn solver_method(&self) -> crate::core::types::SolverMethod {
719        match self.main_params_ii.iterative_solver {
720            0 => crate::core::types::SolverMethod::Cgs,
721            1 => crate::core::types::SolverMethod::BiCgstab,
722            _ => crate::core::types::SolverMethod::Cgs,
723        }
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    const SAMPLE_NC_INP: &str = r#"##-------------------------------------------
732## This file was created by mesh2input
733##-------------------------------------------
734Mesh2HRTF 1.0.0
735##
736Test Description
737##
738## Controlparameter I
7390 0 0 0 7 0
740##
741## Controlparameter II
7421 1 0.000001 0.00e+00 1 0 0
743##
744## Load Frequency Curve
7450 2
7460.000000 0.000000e+00 0.0
7470.000001 0.400000e+04 0.0
748##
749## 1. Main Parameters I
7502 100 50 0 0 2 1 0 0
751##
752## 2. Main Parameters II
7531 0 0 0.0000e+00 0 0 0
754##
755## 3. Main Parameters III
7560 0 0 0
757##
758## 4. Main Parameters IV
759343 1.21 1.0 0.0 0.0 0.0 0.0
760##
761NODES
762nodes.txt
763##
764ELEMENTS
765elements.txt
766##
767BOUNDARY
768ELEM 0 TO 49 VELO 1.0 -1 0.0 -1
769RETU
770##
771PLANE WAVES
7721 0.0 -1.0 0.0 1.0 -1 0.0 -1
773##
774END
775"#;
776
777    #[test]
778    fn test_parse_nc_input() {
779        let config = parse_nc_input_string(SAMPLE_NC_INP, PathBuf::from(".")).unwrap();
780
781        assert!(config.version.contains("Mesh2HRTF"));
782        assert_eq!(config.main_params_i.num_nodes, 100);
783        assert_eq!(config.main_params_i.num_elements, 50);
784        assert_eq!(config.main_params_i.solver_method, 1);
785        assert!((config.main_params_iv.speed_of_sound - 343.0).abs() < 0.01);
786        assert!((config.main_params_iv.density - 1.21).abs() < 0.01);
787        assert_eq!(config.node_files.len(), 1);
788        assert_eq!(config.element_files.len(), 1);
789        assert_eq!(config.boundary_conditions.len(), 1);
790        assert_eq!(config.plane_waves.len(), 1);
791    }
792
793    #[test]
794    fn test_parse_boundary_line() {
795        let bc = parse_boundary_line("ELEM 0 TO 100 VELO 1.0 -1 0.0 -1").unwrap();
796        assert_eq!(bc.elem_start, 0);
797        assert_eq!(bc.elem_end, 100);
798        assert_eq!(bc.bc_type, "VELO");
799        assert!((bc.value_re - 1.0).abs() < 0.001);
800    }
801
802    #[test]
803    fn test_parse_plane_wave() {
804        let pw = parse_plane_wave_line("1 0.0 -1.0 0.0 1.0 -1 0.0 -1").unwrap();
805        assert!((pw.direction[1] - (-1.0)).abs() < 0.001);
806        assert!((pw.amplitude_re - 1.0).abs() < 0.001);
807    }
808
809    #[test]
810    fn test_config_to_physics() {
811        let config = parse_nc_input_string(SAMPLE_NC_INP, PathBuf::from(".")).unwrap();
812        let physics = config.to_physics_params(1000.0);
813
814        assert!((physics.speed_of_sound - 343.0).abs() < 0.01);
815        assert!((physics.density - 1.21).abs() < 0.01);
816        assert!((physics.frequency - 1000.0).abs() < 0.01);
817    }
818}