Skip to main content

oxirs_physics/
fluid_dynamics.rs

1//! Basic computational fluid dynamics calculations.
2//!
3//! Provides pipe flow analysis, Bernoulli equation, heat transfer coefficients,
4//! and drag force calculations for common fluid dynamics problems.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Physical constants
10// ---------------------------------------------------------------------------
11
12/// Water density at 20 °C in kg/m³
13pub const WATER_DENSITY_KG_M3: f64 = 998.2;
14/// Air density at 20 °C, sea level, in kg/m³
15pub const AIR_DENSITY_KG_M3: f64 = 1.204;
16/// Dynamic viscosity of water at 20 °C in Pa·s
17pub const WATER_VISCOSITY_PA_S: f64 = 1.002e-3;
18/// Dynamic viscosity of air at 20 °C in Pa·s
19pub const AIR_VISCOSITY_PA_S: f64 = 1.825e-5;
20/// Standard gravity in m/s²
21pub const GRAVITY_M_S2: f64 = 9.81;
22
23// ---------------------------------------------------------------------------
24// FluidProperties
25// ---------------------------------------------------------------------------
26
27/// Thermophysical properties of a fluid.
28#[derive(Debug, Clone)]
29pub struct FluidProperties {
30    /// Human-readable name
31    pub name: String,
32    /// Density in kg/m³
33    pub density: f64,
34    /// Dynamic viscosity in Pa·s
35    pub dynamic_viscosity: f64,
36    /// Kinematic viscosity in m²/s (= dynamic / density)
37    pub kinematic_viscosity: f64,
38    /// Specific heat capacity in J/(kg·K)
39    pub specific_heat: f64,
40    /// Thermal conductivity in W/(m·K)
41    pub thermal_conductivity: f64,
42}
43
44impl FluidProperties {
45    /// Water properties at 20 °C.
46    pub fn water() -> Self {
47        let density = WATER_DENSITY_KG_M3;
48        let dynamic_viscosity = WATER_VISCOSITY_PA_S;
49        Self {
50            name: "Water".to_string(),
51            density,
52            dynamic_viscosity,
53            kinematic_viscosity: dynamic_viscosity / density,
54            specific_heat: 4182.0,
55            thermal_conductivity: 0.598,
56        }
57    }
58
59    /// Air properties at 20 °C and sea level.
60    pub fn air() -> Self {
61        let density = AIR_DENSITY_KG_M3;
62        let dynamic_viscosity = AIR_VISCOSITY_PA_S;
63        Self {
64            name: "Air".to_string(),
65            density,
66            dynamic_viscosity,
67            kinematic_viscosity: dynamic_viscosity / density,
68            specific_heat: 1005.0,
69            thermal_conductivity: 0.0257,
70        }
71    }
72
73    /// Generic oil with user-specified density (kg/m³) and dynamic viscosity (Pa·s).
74    pub fn oil(density: f64, viscosity: f64) -> Self {
75        Self {
76            name: "Oil".to_string(),
77            density,
78            dynamic_viscosity: viscosity,
79            kinematic_viscosity: viscosity / density,
80            specific_heat: 1900.0,
81            thermal_conductivity: 0.145,
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// FlowRegime
88// ---------------------------------------------------------------------------
89
90/// Flow regime determined by the Reynolds number.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum FlowRegime {
93    /// Re < 2300
94    Laminar,
95    /// 2300 ≤ Re < 4000
96    Transitional,
97    /// Re ≥ 4000
98    Turbulent,
99}
100
101// ---------------------------------------------------------------------------
102// PipeFlowResult
103// ---------------------------------------------------------------------------
104
105/// Results of a complete pipe-flow analysis.
106#[derive(Debug, Clone)]
107pub struct PipeFlowResult {
108    /// Reynolds number (dimensionless)
109    pub reynolds_number: f64,
110    /// Flow regime (Laminar / Transitional / Turbulent)
111    pub regime: FlowRegime,
112    /// Darcy-Weisbach friction factor (dimensionless)
113    pub friction_factor: f64,
114    /// Pressure drop over the pipe length in Pa
115    pub pressure_drop_pa: f64,
116    /// Mean flow velocity in m/s
117    pub flow_velocity_m_s: f64,
118    /// Head loss in m (= pressure_drop / (ρ g))
119    pub head_loss_m: f64,
120}
121
122// ---------------------------------------------------------------------------
123// FluidError
124// ---------------------------------------------------------------------------
125
126/// Errors produced by fluid-dynamics calculations.
127#[derive(Debug)]
128pub enum FluidError {
129    /// An input parameter has an illegal value.
130    InvalidInput(String),
131    /// An iterative solver did not converge.
132    ConvergenceFailure(String),
133    /// The requested state is physically impossible.
134    PhysicallyImpossible(String),
135}
136
137impl std::fmt::Display for FluidError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            FluidError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
141            FluidError::ConvergenceFailure(msg) => write!(f, "Convergence failure: {}", msg),
142            FluidError::PhysicallyImpossible(msg) => write!(f, "Physically impossible: {}", msg),
143        }
144    }
145}
146
147impl std::error::Error for FluidError {}
148
149// ---------------------------------------------------------------------------
150// FluidDynamics — main calculation engine
151// ---------------------------------------------------------------------------
152
153/// Stateless namespace for fluid-dynamics calculations.
154pub struct FluidDynamics;
155
156impl FluidDynamics {
157    // -----------------------------------------------------------------------
158    // Core dimensionless numbers
159    // -----------------------------------------------------------------------
160
161    /// Reynolds number: Re = ρ · v · L / μ.
162    ///
163    /// * `density`   – fluid density in kg/m³
164    /// * `velocity`  – flow velocity in m/s
165    /// * `length`    – characteristic length (e.g. pipe diameter) in m
166    /// * `viscosity` – dynamic viscosity in Pa·s
167    pub fn reynolds_number(density: f64, velocity: f64, length: f64, viscosity: f64) -> f64 {
168        density * velocity * length / viscosity
169    }
170
171    /// Classify a Reynolds number into a `FlowRegime`.
172    pub fn flow_regime(re: f64) -> FlowRegime {
173        if re < 2300.0 {
174            FlowRegime::Laminar
175        } else if re < 4000.0 {
176            FlowRegime::Transitional
177        } else {
178            FlowRegime::Turbulent
179        }
180    }
181
182    // -----------------------------------------------------------------------
183    // Friction factor
184    // -----------------------------------------------------------------------
185
186    /// Darcy-Weisbach friction factor using the Moody-chart approximation.
187    ///
188    /// * Laminar  → f = 64 / Re
189    /// * Turbulent / Transitional → Swamee-Jain explicit approximation:
190    ///   f = 0.25 / (log₁₀(ε/(3.7·D) + 5.74/Re^0.9))²
191    ///
192    /// # Errors
193    /// Returns `FluidError::InvalidInput` if `re` ≤ 0, `diameter` ≤ 0, or
194    /// `roughness` < 0.
195    pub fn darcy_friction_factor(
196        re: f64,
197        roughness: f64,
198        diameter: f64,
199    ) -> Result<f64, FluidError> {
200        if re <= 0.0 {
201            return Err(FluidError::InvalidInput(
202                "Reynolds number must be positive".to_string(),
203            ));
204        }
205        if diameter <= 0.0 {
206            return Err(FluidError::InvalidInput(
207                "Pipe diameter must be positive".to_string(),
208            ));
209        }
210        if roughness < 0.0 {
211            return Err(FluidError::InvalidInput(
212                "Surface roughness must be non-negative".to_string(),
213            ));
214        }
215
216        if re < 2300.0 {
217            // Laminar region: exact solution
218            return Ok(64.0 / re);
219        }
220
221        // Swamee-Jain approximation (turbulent and transitional)
222        let relative_roughness = roughness / diameter;
223        let arg = relative_roughness / 3.7 + 5.74 / re.powf(0.9);
224        if arg <= 0.0 {
225            return Err(FluidError::ConvergenceFailure(
226                "Swamee-Jain argument is non-positive".to_string(),
227            ));
228        }
229        let log_val = arg.log10();
230        Ok(0.25 / (log_val * log_val))
231    }
232
233    // -----------------------------------------------------------------------
234    // Pressure drop
235    // -----------------------------------------------------------------------
236
237    /// Darcy-Weisbach pressure drop: ΔP = f · (L/D) · (ρ · v² / 2).
238    ///
239    /// * `friction_factor` – Darcy friction factor (dimensionless)
240    /// * `length`          – pipe length in m
241    /// * `diameter`        – pipe diameter in m
242    /// * `density`         – fluid density in kg/m³
243    /// * `velocity`        – mean flow velocity in m/s
244    pub fn pressure_drop(
245        friction_factor: f64,
246        length: f64,
247        diameter: f64,
248        density: f64,
249        velocity: f64,
250    ) -> f64 {
251        friction_factor * (length / diameter) * (density * velocity * velocity / 2.0)
252    }
253
254    // -----------------------------------------------------------------------
255    // Full pipe-flow analysis
256    // -----------------------------------------------------------------------
257
258    /// Perform a complete pipe-flow analysis.
259    ///
260    /// # Errors
261    /// Propagates `FluidError` from `darcy_friction_factor` or returns
262    /// `InvalidInput` for non-positive `density`.
263    pub fn analyze_pipe_flow(
264        fluid: &FluidProperties,
265        diameter: f64,
266        length: f64,
267        velocity: f64,
268        roughness: f64,
269    ) -> Result<PipeFlowResult, FluidError> {
270        if fluid.density <= 0.0 {
271            return Err(FluidError::InvalidInput(
272                "Fluid density must be positive".to_string(),
273            ));
274        }
275        if diameter <= 0.0 {
276            return Err(FluidError::InvalidInput(
277                "Diameter must be positive".to_string(),
278            ));
279        }
280        if length <= 0.0 {
281            return Err(FluidError::InvalidInput(
282                "Pipe length must be positive".to_string(),
283            ));
284        }
285
286        let re = Self::reynolds_number(fluid.density, velocity, diameter, fluid.dynamic_viscosity);
287        let regime = Self::flow_regime(re);
288        let friction_factor = Self::darcy_friction_factor(re, roughness, diameter)?;
289        let pressure_drop_pa =
290            Self::pressure_drop(friction_factor, length, diameter, fluid.density, velocity);
291        let head_loss_m = pressure_drop_pa / (fluid.density * GRAVITY_M_S2);
292
293        Ok(PipeFlowResult {
294            reynolds_number: re,
295            regime,
296            friction_factor,
297            pressure_drop_pa,
298            flow_velocity_m_s: velocity,
299            head_loss_m,
300        })
301    }
302
303    // -----------------------------------------------------------------------
304    // Bernoulli
305    // -----------------------------------------------------------------------
306
307    /// Solve for p₂ using the Bernoulli equation:
308    ///
309    /// p₁ + ½ρv₁² + ρgh₁ = p₂ + ½ρv₂² + ρgh₂
310    ///
311    /// All pressures in Pa, velocities in m/s, heights in m.
312    pub fn bernoulli_p2(p1: f64, v1: f64, h1: f64, v2: f64, h2: f64, density: f64) -> f64 {
313        let dynamic_1 = 0.5 * density * v1 * v1;
314        let dynamic_2 = 0.5 * density * v2 * v2;
315        let potential_1 = density * GRAVITY_M_S2 * h1;
316        let potential_2 = density * GRAVITY_M_S2 * h2;
317        p1 + dynamic_1 + potential_1 - dynamic_2 - potential_2
318    }
319
320    // -----------------------------------------------------------------------
321    // Continuity
322    // -----------------------------------------------------------------------
323
324    /// Continuity equation: v₂ = v₁ · (d₁ / d₂)².
325    ///
326    /// # Errors
327    /// Returns `FluidError::InvalidInput` if either diameter is ≤ 0.
328    pub fn continuity_velocity(v1: f64, d1: f64, d2: f64) -> Result<f64, FluidError> {
329        if d1 <= 0.0 {
330            return Err(FluidError::InvalidInput(
331                "Upstream diameter must be positive".to_string(),
332            ));
333        }
334        if d2 <= 0.0 {
335            return Err(FluidError::InvalidInput(
336                "Downstream diameter must be positive".to_string(),
337            ));
338        }
339        Ok(v1 * (d1 / d2) * (d1 / d2))
340    }
341
342    // -----------------------------------------------------------------------
343    // Hydraulic diameter
344    // -----------------------------------------------------------------------
345
346    /// Hydraulic diameter for non-circular cross-sections: D_h = 4A / P.
347    ///
348    /// # Errors
349    /// Returns `FluidError::InvalidInput` if `area` or `perimeter` is ≤ 0.
350    pub fn hydraulic_diameter(area: f64, perimeter: f64) -> Result<f64, FluidError> {
351        if area <= 0.0 {
352            return Err(FluidError::InvalidInput(
353                "Cross-sectional area must be positive".to_string(),
354            ));
355        }
356        if perimeter <= 0.0 {
357            return Err(FluidError::InvalidInput(
358                "Perimeter must be positive".to_string(),
359            ));
360        }
361        Ok(4.0 * area / perimeter)
362    }
363
364    // -----------------------------------------------------------------------
365    // Heat transfer
366    // -----------------------------------------------------------------------
367
368    /// Nusselt number for turbulent pipe flow (Dittus-Boelter, heating):
369    /// Nu = 0.023 · Re^0.8 · Pr^0.4.
370    pub fn nusselt_turbulent(re: f64, prandtl: f64) -> f64 {
371        0.023 * re.powf(0.8) * prandtl.powf(0.4)
372    }
373
374    /// Prandtl number: Pr = μ · Cp / k.
375    pub fn prandtl_number(viscosity: f64, specific_heat: f64, thermal_conductivity: f64) -> f64 {
376        viscosity * specific_heat / thermal_conductivity
377    }
378
379    /// Convective heat transfer coefficient: h = Nu · k / D.
380    ///
381    /// # Errors
382    /// Returns `FluidError::InvalidInput` if `diameter` ≤ 0.
383    pub fn convective_htc(
384        nusselt: f64,
385        conductivity: f64,
386        diameter: f64,
387    ) -> Result<f64, FluidError> {
388        if diameter <= 0.0 {
389            return Err(FluidError::InvalidInput(
390                "Diameter must be positive".to_string(),
391            ));
392        }
393        Ok(nusselt * conductivity / diameter)
394    }
395
396    // -----------------------------------------------------------------------
397    // Drag
398    // -----------------------------------------------------------------------
399
400    /// Drag force on a sphere: F = Cd · ½ρv² · A, where A = π r².
401    pub fn sphere_drag_force(cd: f64, density: f64, velocity: f64, radius: f64) -> f64 {
402        let area = std::f64::consts::PI * radius * radius;
403        cd * 0.5 * density * velocity * velocity * area
404    }
405
406    /// Drag coefficient for a sphere.
407    ///
408    /// * Stokes (Re ≪ 1):  Cd = 24 / Re
409    /// * Otherwise:        three-region approximation
410    pub fn sphere_drag_coefficient(re: f64) -> f64 {
411        if re <= 0.0 {
412            return f64::INFINITY;
413        }
414        if re < 1.0 {
415            // Stokes law
416            24.0 / re
417        } else if re < 1000.0 {
418            // Intermediate region (Schiller-Naumann)
419            24.0 / re * (1.0 + 0.15 * re.powf(0.687))
420        } else {
421            // Newton's law region
422            0.44
423        }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// ChannelProperties — auxiliary helper (bonus public type)
429// ---------------------------------------------------------------------------
430
431/// Named-channel registry for quick lookup by identifier.
432#[derive(Debug, Default)]
433pub struct FluidRegistry {
434    fluids: HashMap<String, FluidProperties>,
435}
436
437impl FluidRegistry {
438    /// Create an empty registry.
439    pub fn new() -> Self {
440        Self {
441            fluids: HashMap::new(),
442        }
443    }
444
445    /// Register a fluid by name.
446    pub fn register(&mut self, fluid: FluidProperties) {
447        self.fluids.insert(fluid.name.clone(), fluid);
448    }
449
450    /// Look up a fluid by name.
451    pub fn get(&self, name: &str) -> Option<&FluidProperties> {
452        self.fluids.get(name)
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Tests
458// ---------------------------------------------------------------------------
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    const EPS: f64 = 1e-6;
465
466    // --- FluidProperties ---------------------------------------------------
467
468    #[test]
469    fn test_water_properties() {
470        let w = FluidProperties::water();
471        assert_eq!(w.name, "Water");
472        assert!((w.density - 998.2).abs() < EPS);
473        assert!((w.dynamic_viscosity - 1.002e-3).abs() < 1e-10);
474        let expected_kv = 1.002e-3 / 998.2;
475        assert!((w.kinematic_viscosity - expected_kv).abs() < 1e-12);
476    }
477
478    #[test]
479    fn test_air_properties() {
480        let a = FluidProperties::air();
481        assert_eq!(a.name, "Air");
482        assert!((a.density - 1.204).abs() < EPS);
483        assert!((a.dynamic_viscosity - 1.825e-5).abs() < 1e-10);
484    }
485
486    #[test]
487    fn test_oil_properties() {
488        let o = FluidProperties::oil(870.0, 0.05);
489        assert_eq!(o.name, "Oil");
490        assert!((o.density - 870.0).abs() < EPS);
491        assert!((o.kinematic_viscosity - 0.05 / 870.0).abs() < 1e-10);
492    }
493
494    // --- Reynolds number ---------------------------------------------------
495
496    #[test]
497    fn test_reynolds_number_basic() {
498        // Water at 1 m/s through 0.05 m diameter pipe
499        let re = FluidDynamics::reynolds_number(998.2, 1.0, 0.05, 1.002e-3);
500        // Expected ≈ 49,800
501        assert!((re - 49800.0).abs() < 50.0, "re = {}", re);
502    }
503
504    #[test]
505    fn test_reynolds_number_air() {
506        let re = FluidDynamics::reynolds_number(1.204, 5.0, 0.1, 1.825e-5);
507        let expected = 1.204 * 5.0 * 0.1 / 1.825e-5;
508        assert!((re - expected).abs() / expected < 1e-9);
509    }
510
511    #[test]
512    fn test_reynolds_number_formula() {
513        let re = FluidDynamics::reynolds_number(1000.0, 2.0, 0.02, 0.001);
514        assert!((re - 40_000.0).abs() < EPS);
515    }
516
517    // --- Flow regime -------------------------------------------------------
518
519    #[test]
520    fn test_flow_regime_laminar_boundary() {
521        assert_eq!(FluidDynamics::flow_regime(2299.9), FlowRegime::Laminar);
522    }
523
524    #[test]
525    fn test_flow_regime_laminar_low() {
526        assert_eq!(FluidDynamics::flow_regime(100.0), FlowRegime::Laminar);
527    }
528
529    #[test]
530    fn test_flow_regime_transitional_lower() {
531        assert_eq!(FluidDynamics::flow_regime(2300.0), FlowRegime::Transitional);
532    }
533
534    #[test]
535    fn test_flow_regime_transitional_upper() {
536        assert_eq!(FluidDynamics::flow_regime(3999.9), FlowRegime::Transitional);
537    }
538
539    #[test]
540    fn test_flow_regime_turbulent_boundary() {
541        assert_eq!(FluidDynamics::flow_regime(4000.0), FlowRegime::Turbulent);
542    }
543
544    #[test]
545    fn test_flow_regime_turbulent_high() {
546        assert_eq!(FluidDynamics::flow_regime(100_000.0), FlowRegime::Turbulent);
547    }
548
549    // --- Friction factor ---------------------------------------------------
550
551    #[test]
552    fn test_laminar_friction_factor_64_over_re() {
553        let re = 1000.0;
554        let f = FluidDynamics::darcy_friction_factor(re, 0.0, 0.05).expect("ok");
555        assert!((f - 64.0 / re).abs() < EPS, "f = {}", f);
556    }
557
558    #[test]
559    fn test_laminar_friction_factor_re_100() {
560        let f = FluidDynamics::darcy_friction_factor(100.0, 0.0, 0.05).expect("ok");
561        assert!((f - 0.64).abs() < EPS, "f = {}", f);
562    }
563
564    #[test]
565    fn test_turbulent_friction_swamee_jain() {
566        // Smooth pipe, Re = 100_000
567        let re = 100_000.0;
568        let diameter = 0.1;
569        let roughness = 0.0; // smooth
570        let f = FluidDynamics::darcy_friction_factor(re, roughness, diameter).expect("ok");
571        // Swamee-Jain with ε/D = 0: f = 0.25/(log10(5.74/Re^0.9))^2
572        let arg = 5.74 / re.powf(0.9);
573        let expected = 0.25 / (arg.log10() * arg.log10());
574        assert!((f - expected).abs() / expected < 1e-9, "f = {}", f);
575    }
576
577    #[test]
578    fn test_turbulent_rough_pipe() {
579        let f = FluidDynamics::darcy_friction_factor(50_000.0, 4.6e-5, 0.05).expect("ok");
580        // Sanity: turbulent f for commercial steel is roughly 0.02–0.04
581        assert!(f > 0.01 && f < 0.1, "f out of reasonable range: {}", f);
582    }
583
584    #[test]
585    fn test_friction_factor_invalid_re() {
586        assert!(FluidDynamics::darcy_friction_factor(0.0, 0.0, 0.05).is_err());
587        assert!(FluidDynamics::darcy_friction_factor(-1.0, 0.0, 0.05).is_err());
588    }
589
590    #[test]
591    fn test_friction_factor_invalid_diameter() {
592        assert!(FluidDynamics::darcy_friction_factor(1000.0, 0.0, 0.0).is_err());
593        assert!(FluidDynamics::darcy_friction_factor(1000.0, 0.0, -0.1).is_err());
594    }
595
596    #[test]
597    fn test_friction_factor_negative_roughness() {
598        assert!(FluidDynamics::darcy_friction_factor(10_000.0, -0.001, 0.05).is_err());
599    }
600
601    // --- Pressure drop -----------------------------------------------------
602
603    #[test]
604    fn test_pressure_drop_formula() {
605        // ΔP = f * (L/D) * (ρ v²/2)
606        let dp = FluidDynamics::pressure_drop(0.02, 10.0, 0.05, 1000.0, 1.0);
607        let expected = 0.02 * (10.0 / 0.05) * (1000.0 * 1.0 / 2.0);
608        assert!((dp - expected).abs() < EPS, "dp = {}", dp);
609    }
610
611    #[test]
612    fn test_pressure_drop_zero_velocity() {
613        let dp = FluidDynamics::pressure_drop(0.03, 5.0, 0.1, 1000.0, 0.0);
614        assert!((dp).abs() < EPS);
615    }
616
617    // --- Full pipe-flow analysis -------------------------------------------
618
619    #[test]
620    fn test_analyze_pipe_flow_water_laminar() {
621        let water = FluidProperties::water();
622        // Low velocity → laminar
623        let result = FluidDynamics::analyze_pipe_flow(&water, 0.05, 10.0, 0.01, 0.0)
624            .expect("should succeed");
625        assert_eq!(result.regime, FlowRegime::Laminar);
626        assert!(result.reynolds_number < 2300.0);
627        let expected_f = 64.0 / result.reynolds_number;
628        assert!((result.friction_factor - expected_f).abs() < EPS);
629    }
630
631    #[test]
632    fn test_analyze_pipe_flow_water_turbulent() {
633        let water = FluidProperties::water();
634        let result = FluidDynamics::analyze_pipe_flow(&water, 0.05, 10.0, 2.0, 4.6e-5)
635            .expect("should succeed");
636        assert_eq!(result.regime, FlowRegime::Turbulent);
637        assert!(result.pressure_drop_pa > 0.0);
638        assert!(result.head_loss_m > 0.0);
639        // head_loss = pressure_drop / (density * g)
640        let expected_hl = result.pressure_drop_pa / (water.density * GRAVITY_M_S2);
641        assert!((result.head_loss_m - expected_hl).abs() < 1e-6);
642    }
643
644    #[test]
645    fn test_analyze_pipe_flow_air() {
646        let air = FluidProperties::air();
647        let result =
648            FluidDynamics::analyze_pipe_flow(&air, 0.1, 5.0, 10.0, 0.0).expect("should succeed");
649        assert!(result.pressure_drop_pa > 0.0);
650    }
651
652    #[test]
653    fn test_analyze_pipe_flow_invalid_density() {
654        let mut bad_fluid = FluidProperties::water();
655        bad_fluid.density = -1.0;
656        assert!(FluidDynamics::analyze_pipe_flow(&bad_fluid, 0.05, 10.0, 1.0, 0.0).is_err());
657    }
658
659    #[test]
660    fn test_analyze_pipe_flow_invalid_diameter() {
661        let water = FluidProperties::water();
662        assert!(FluidDynamics::analyze_pipe_flow(&water, 0.0, 10.0, 1.0, 0.0).is_err());
663    }
664
665    #[test]
666    fn test_analyze_pipe_flow_invalid_length() {
667        let water = FluidProperties::water();
668        assert!(FluidDynamics::analyze_pipe_flow(&water, 0.05, 0.0, 1.0, 0.0).is_err());
669    }
670
671    // --- Bernoulli ----------------------------------------------------------
672
673    #[test]
674    fn test_bernoulli_p2_same_velocity_height() {
675        // v1 = v2, h1 = h2 → p2 = p1
676        let p2 = FluidDynamics::bernoulli_p2(101_325.0, 2.0, 0.0, 2.0, 0.0, 1000.0);
677        assert!((p2 - 101_325.0).abs() < EPS);
678    }
679
680    #[test]
681    fn test_bernoulli_p2_speed_increase() {
682        // Faster downstream → lower pressure
683        let p2 = FluidDynamics::bernoulli_p2(200_000.0, 1.0, 0.0, 3.0, 0.0, 1000.0);
684        assert!(p2 < 200_000.0);
685    }
686
687    #[test]
688    fn test_bernoulli_p2_height_increase() {
689        // Higher elevation → lower pressure (same velocity)
690        let p2 = FluidDynamics::bernoulli_p2(200_000.0, 1.0, 0.0, 1.0, 5.0, 1000.0);
691        let expected = 200_000.0 - 1000.0 * GRAVITY_M_S2 * 5.0;
692        assert!((p2 - expected).abs() < EPS);
693    }
694
695    #[test]
696    fn test_bernoulli_p2_formula() {
697        let rho = 998.2;
698        let p2 = FluidDynamics::bernoulli_p2(100_000.0, 2.0, 1.0, 4.0, 2.0, rho);
699        let expected = 100_000.0
700            + 0.5 * rho * (2.0_f64.powi(2) - 4.0_f64.powi(2))
701            + rho * GRAVITY_M_S2 * (1.0 - 2.0);
702        assert!((p2 - expected).abs() < 1e-3);
703    }
704
705    // --- Continuity --------------------------------------------------------
706
707    #[test]
708    fn test_continuity_velocity_same_diameter() {
709        let v2 = FluidDynamics::continuity_velocity(5.0, 0.1, 0.1).expect("ok");
710        assert!((v2 - 5.0).abs() < EPS);
711    }
712
713    #[test]
714    fn test_continuity_velocity_halved_diameter() {
715        // d2 = d1/2 → A ratio = 4 → v2 = 4 * v1
716        let v2 = FluidDynamics::continuity_velocity(2.0, 0.1, 0.05).expect("ok");
717        assert!((v2 - 8.0).abs() < EPS, "v2 = {}", v2);
718    }
719
720    #[test]
721    fn test_continuity_velocity_area_ratio() {
722        let v1 = 3.0;
723        let d1 = 0.2;
724        let d2 = 0.1;
725        let v2 = FluidDynamics::continuity_velocity(v1, d1, d2).expect("ok");
726        assert!((v2 - v1 * (d1 / d2).powi(2)).abs() < EPS);
727    }
728
729    #[test]
730    fn test_continuity_velocity_zero_d1() {
731        assert!(FluidDynamics::continuity_velocity(1.0, 0.0, 0.05).is_err());
732    }
733
734    #[test]
735    fn test_continuity_velocity_zero_d2() {
736        assert!(FluidDynamics::continuity_velocity(1.0, 0.05, 0.0).is_err());
737    }
738
739    // --- Hydraulic diameter ------------------------------------------------
740
741    #[test]
742    fn test_hydraulic_diameter_circular() {
743        // Circle: A = π r², P = 2π r → D_h = 4·π r²/(2π r) = 2r = diameter
744        let r = 0.05_f64;
745        let area = std::f64::consts::PI * r * r;
746        let perimeter = 2.0 * std::f64::consts::PI * r;
747        let dh = FluidDynamics::hydraulic_diameter(area, perimeter).expect("ok");
748        assert!((dh - 2.0 * r).abs() < 1e-10, "dh = {}", dh);
749    }
750
751    #[test]
752    fn test_hydraulic_diameter_square() {
753        // Square with side a: A = a², P = 4a → D_h = 4a²/4a = a
754        let a = 0.1_f64;
755        let dh = FluidDynamics::hydraulic_diameter(a * a, 4.0 * a).expect("ok");
756        assert!((dh - a).abs() < EPS, "dh = {}", dh);
757    }
758
759    #[test]
760    fn test_hydraulic_diameter_invalid_area() {
761        assert!(FluidDynamics::hydraulic_diameter(0.0, 0.5).is_err());
762    }
763
764    #[test]
765    fn test_hydraulic_diameter_invalid_perimeter() {
766        assert!(FluidDynamics::hydraulic_diameter(0.01, 0.0).is_err());
767    }
768
769    // --- Nusselt number ----------------------------------------------------
770
771    #[test]
772    fn test_nusselt_turbulent_formula() {
773        let re = 10_000.0;
774        let pr = 7.0;
775        let nu = FluidDynamics::nusselt_turbulent(re, pr);
776        let expected = 0.023 * re.powf(0.8) * pr.powf(0.4);
777        assert!((nu - expected).abs() / expected < 1e-9);
778    }
779
780    #[test]
781    fn test_nusselt_turbulent_water() {
782        // Typical water pipe flow
783        let nu = FluidDynamics::nusselt_turbulent(50_000.0, 6.99);
784        assert!(nu > 100.0, "nu = {}", nu);
785    }
786
787    // --- Prandtl number ----------------------------------------------------
788
789    #[test]
790    fn test_prandtl_number_water() {
791        let pr = FluidDynamics::prandtl_number(1.002e-3, 4182.0, 0.598);
792        // Expected ≈ 7.0
793        assert!((pr - 7.0).abs() < 0.1, "Pr = {}", pr);
794    }
795
796    #[test]
797    fn test_prandtl_number_air() {
798        let pr = FluidDynamics::prandtl_number(1.825e-5, 1005.0, 0.0257);
799        // Expected ≈ 0.71
800        assert!((pr - 0.71).abs() < 0.01, "Pr = {}", pr);
801    }
802
803    #[test]
804    fn test_prandtl_number_formula() {
805        let mu = 0.002;
806        let cp = 2000.0;
807        let k = 0.5;
808        let pr = FluidDynamics::prandtl_number(mu, cp, k);
809        assert!((pr - mu * cp / k).abs() < EPS);
810    }
811
812    // --- Convective HTC ----------------------------------------------------
813
814    #[test]
815    fn test_convective_htc_basic() {
816        let h = FluidDynamics::convective_htc(200.0, 0.598, 0.05).expect("ok");
817        let expected = 200.0 * 0.598 / 0.05;
818        assert!((h - expected).abs() < EPS);
819    }
820
821    #[test]
822    fn test_convective_htc_invalid_diameter() {
823        assert!(FluidDynamics::convective_htc(100.0, 0.5, 0.0).is_err());
824        assert!(FluidDynamics::convective_htc(100.0, 0.5, -0.01).is_err());
825    }
826
827    // --- Drag ---------------------------------------------------------------
828
829    #[test]
830    fn test_sphere_drag_force_formula() {
831        let cd = 0.44;
832        let rho = 1.204;
833        let v = 10.0;
834        let r = 0.05;
835        let f = FluidDynamics::sphere_drag_force(cd, rho, v, r);
836        let area = std::f64::consts::PI * r * r;
837        let expected = cd * 0.5 * rho * v * v * area;
838        assert!((f - expected).abs() / expected < 1e-9);
839    }
840
841    #[test]
842    fn test_sphere_drag_force_zero_velocity() {
843        let f = FluidDynamics::sphere_drag_force(0.44, 1.204, 0.0, 0.05);
844        assert!(f.abs() < EPS);
845    }
846
847    #[test]
848    fn test_sphere_drag_coefficient_stokes() {
849        let re = 0.5;
850        let cd = FluidDynamics::sphere_drag_coefficient(re);
851        let expected = 24.0 / re;
852        assert!((cd - expected).abs() < EPS, "cd = {}", cd);
853    }
854
855    #[test]
856    fn test_sphere_drag_coefficient_newton_region() {
857        let cd = FluidDynamics::sphere_drag_coefficient(100_000.0);
858        assert!((cd - 0.44).abs() < EPS);
859    }
860
861    #[test]
862    fn test_sphere_drag_coefficient_intermediate() {
863        let re = 100.0;
864        let cd = FluidDynamics::sphere_drag_coefficient(re);
865        // Should be larger than Newton value but smaller than Stokes for this Re
866        assert!(cd > 0.44 && cd < 24.0 / re + 1.0);
867    }
868
869    #[test]
870    fn test_sphere_drag_coefficient_zero_re() {
871        let cd = FluidDynamics::sphere_drag_coefficient(0.0);
872        assert!(cd.is_infinite());
873    }
874
875    // --- Registry ----------------------------------------------------------
876
877    #[test]
878    fn test_fluid_registry() {
879        let mut reg = FluidRegistry::new();
880        reg.register(FluidProperties::water());
881        reg.register(FluidProperties::air());
882        assert!(reg.get("Water").is_some());
883        assert!(reg.get("Air").is_some());
884        assert!(reg.get("Ethanol").is_none());
885    }
886
887    // --- Error display -----------------------------------------------------
888
889    #[test]
890    fn test_fluid_error_display() {
891        let e = FluidError::InvalidInput("bad value".to_string());
892        assert!(e.to_string().contains("bad value"));
893        let e2 = FluidError::ConvergenceFailure("too slow".to_string());
894        assert!(e2.to_string().contains("too slow"));
895        let e3 = FluidError::PhysicallyImpossible("negative density".to_string());
896        assert!(e3.to_string().contains("negative density"));
897    }
898
899    // --- Additional edge-case tests ----------------------------------------
900
901    #[test]
902    fn test_pressure_drop_scaling_with_velocity() {
903        // Doubling velocity quadruples pressure drop (ΔP ∝ v²)
904        let dp1 = FluidDynamics::pressure_drop(0.025, 10.0, 0.05, 1000.0, 2.0);
905        let dp2 = FluidDynamics::pressure_drop(0.025, 10.0, 0.05, 1000.0, 4.0);
906        assert!((dp2 / dp1 - 4.0).abs() < 1e-9, "ratio = {}", dp2 / dp1);
907    }
908
909    #[test]
910    fn test_bernoulli_venturi_tube() {
911        // Venturi tube: constriction d2 = 0.5 d1 → v2 = 4 v1
912        let rho = 1000.0;
913        let v1 = 1.0;
914        let v2 = FluidDynamics::continuity_velocity(v1, 0.1, 0.05).expect("ok");
915        assert!((v2 - 4.0 * v1).abs() < EPS);
916        let p2 = FluidDynamics::bernoulli_p2(200_000.0, v1, 0.0, v2, 0.0, rho);
917        // Pressure must drop in the throat
918        assert!(p2 < 200_000.0);
919    }
920
921    #[test]
922    fn test_head_loss_nondimensional_consistency() {
923        let water = FluidProperties::water();
924        let result =
925            FluidDynamics::analyze_pipe_flow(&water, 0.025, 100.0, 1.5, 4.6e-5).expect("ok");
926        // Verify head_loss from formula
927        let hl_check = result.pressure_drop_pa / (water.density * GRAVITY_M_S2);
928        assert!((result.head_loss_m - hl_check).abs() < 1e-6);
929    }
930}