spec / app /services /cfd_simulation.py
ronanroam
fix: add proper libraries
a9c06c5
"""
CFD Simulation Service
Provides fluid flow analysis using the fluids library for validated calculations.
OpenFOAM is too large (~2GB) for Hugging Face Spaces, so we use analytical methods.
External dependencies:
- fluids: Industry-standard fluid dynamics library
- numpy: Numerical operations
"""
import math
import uuid
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
import numpy as np
import fluids
from fluids import Reynolds, friction_factor, K_from_f, dP_from_K
@dataclass
class CFDInput:
"""Input parameters for CFD simulation."""
pipe_diameter_mm: float
pipe_length_mm: float
flow_rate_lpm: float
oil_viscosity_pa_s: float = 0.046 # ISO VG46 at 40°C
oil_density_kg_m3: float = 870
inlet_temperature_c: float = 40.0
mesh_resolution: str = "medium" # Kept for API compatibility
@dataclass
class CFDResult:
"""Results from CFD simulation."""
success: bool
message: str
job_id: str
# Flow results
flow_regime: str
reynolds_number: float
average_velocity_ms: float
max_velocity_ms: float
pressure_drop_mpa: float
# Comparison with analytical
analytical_pressure_drop_mpa: float
error_percent: float
# Mesh info (zero for analytical)
num_cells: int
# Visualization data
velocity_field: Optional[Dict] = None
pressure_field: Optional[Dict] = None
def calculate_hagen_poiseuille(
pipe_diameter_mm: float,
pipe_length_mm: float,
flow_rate_lpm: float,
oil_viscosity_pa_s: float = 0.046,
oil_density_kg_m3: float = 870
) -> Dict[str, float]:
"""
Pipe flow analysis using the fluids library.
Uses fluids.Reynolds and fluids.friction_factor for validated calculations.
Handles laminar, transition, and turbulent flow regimes.
Args:
pipe_diameter_mm: Pipe inner diameter
pipe_length_mm: Pipe length
flow_rate_lpm: Flow rate in L/min
oil_viscosity_pa_s: Dynamic viscosity
oil_density_kg_m3: Oil density
Returns:
Dictionary with flow solution
"""
# Convert units
d = pipe_diameter_mm / 1000 # m
L = pipe_length_mm / 1000 # m
Q = flow_rate_lpm / 60000 # m³/s
# Cross-sectional area
A = math.pi * (d/2)**2
# Average velocity
v_avg = Q / A if A > 0 else 0
# Use fluids library for Reynolds number calculation
# fluids.Reynolds(V, D, rho, mu) - returns Reynolds number
if d > 0 and oil_viscosity_pa_s > 0:
Re = Reynolds(V=v_avg, D=d, rho=oil_density_kg_m3, mu=oil_viscosity_pa_s)
else:
Re = 0
# Determine flow regime
if Re < 2300:
flow_regime = "laminar"
v_max = 2 * v_avg # Parabolic profile for laminar
elif Re < 4000:
flow_regime = "transition"
v_max = 1.5 * v_avg # Approximate
else:
flow_regime = "turbulent"
v_max = 1.22 * v_avg # Power-law profile
# Use fluids library for friction factor
# friction_factor(Re, eD) - eD is relative roughness (0 for smooth pipe)
if Re > 0:
f = friction_factor(Re=Re, eD=0) # Smooth pipe
else:
f = 0
# Pressure drop using Darcy-Weisbach equation
# ΔP = f × (L/D) × (ρV²/2)
if d > 0 and L > 0:
delta_P = f * (L / d) * (0.5 * oil_density_kg_m3 * v_avg**2)
else:
delta_P = 0
# Wall shear stress
tau_wall = delta_P * d / (4 * L) if L > 0 else 0
# Entry length (distance for flow to become fully developed)
if flow_regime == "laminar":
entry_length = 0.06 * Re * d
else:
entry_length = 4.4 * d * Re**(1/6) if Re > 0 else 0
return {
"flow_regime": flow_regime,
"reynolds_number": round(Re, 0),
"average_velocity_ms": round(v_avg, 4),
"max_velocity_ms": round(v_max, 4),
"pressure_drop_pa": round(delta_P, 2),
"pressure_drop_mpa": round(delta_P / 1e6, 6),
"friction_factor": round(f, 6),
"wall_shear_stress_pa": round(tau_wall, 2),
"entry_length_mm": round(entry_length * 1000, 1),
}
def _generate_velocity_profile(
pipe_diameter_mm: float,
pipe_length_mm: float,
flow_rate_lpm: float,
flow_regime: str
) -> Dict[str, Any]:
"""
Generate velocity profile data for visualization.
"""
import numpy as np
d = pipe_diameter_mm / 1000 # m
R = d / 2
Q = flow_rate_lpm / 60000 # m³/s
A = math.pi * R**2
v_avg = Q / A if A > 0 else 0
# Generate radial positions and velocities
n_radial = 20
n_axial = 10
r_vals = np.linspace(0, R, n_radial)
z_vals = np.linspace(0, pipe_length_mm, n_axial)
positions = []
velocities = []
magnitudes = []
for z in z_vals:
for r in r_vals:
for theta in [0, math.pi/2, math.pi, 3*math.pi/2]:
x = r * math.cos(theta) * 1000 # Convert back to mm
y = r * math.sin(theta) * 1000
positions.extend([x, y, z])
# Velocity profile (parabolic for laminar)
if flow_regime == "laminar":
v = 2 * v_avg * (1 - (r/R)**2) if R > 0 else 0
else:
# Power-law profile for turbulent (1/7 power law)
v = v_avg * 1.22 * (1 - r/R)**(1/7) if R > 0 and r < R else 0
# Velocity is axial (z-direction)
velocities.extend([0, 0, v])
magnitudes.append(v)
return {
"positions": positions,
"vectors": velocities,
"magnitudes": magnitudes,
}
def _generate_pressure_field(
pipe_diameter_mm: float,
pipe_length_mm: float,
pressure_drop_pa: float
) -> Dict[str, Any]:
"""
Generate pressure field data for visualization.
Pressure varies linearly along the pipe.
"""
import numpy as np
n_axial = 20
z_vals = np.linspace(0, pipe_length_mm, n_axial)
positions = []
values = []
inlet_pressure = pressure_drop_pa # Gauge pressure
for z in z_vals:
# Linear pressure drop
p = inlet_pressure * (1 - z / pipe_length_mm)
# Add points at center
positions.extend([0, 0, z])
values.append(p)
return {
"positions": positions,
"values": values,
"range": {
"min": 0,
"max": inlet_pressure,
}
}
def run_cfd_simulation(params: CFDInput) -> CFDResult:
"""
Run CFD analysis using analytical Hagen-Poiseuille solution.
Args:
params: CFD input parameters
Returns:
CFDResult with flow analysis results
"""
job_id = str(uuid.uuid4())[:8]
# Calculate analytical solution
analytical = calculate_hagen_poiseuille(
params.pipe_diameter_mm,
params.pipe_length_mm,
params.flow_rate_lpm,
params.oil_viscosity_pa_s,
params.oil_density_kg_m3
)
# Generate visualization data
velocity_field = _generate_velocity_profile(
params.pipe_diameter_mm,
params.pipe_length_mm,
params.flow_rate_lpm,
analytical["flow_regime"]
)
pressure_field = _generate_pressure_field(
params.pipe_diameter_mm,
params.pipe_length_mm,
analytical["pressure_drop_pa"]
)
return CFDResult(
success=True,
message=f"Flow analysis completed using analytical {analytical['flow_regime']} solution",
job_id=job_id,
flow_regime=analytical["flow_regime"],
reynolds_number=analytical["reynolds_number"],
average_velocity_ms=analytical["average_velocity_ms"],
max_velocity_ms=analytical["max_velocity_ms"],
pressure_drop_mpa=analytical["pressure_drop_mpa"],
analytical_pressure_drop_mpa=analytical["pressure_drop_mpa"],
error_percent=0, # No error since using analytical directly
num_cells=0, # No mesh for analytical
velocity_field=velocity_field,
pressure_field=pressure_field,
)
def cfd_result_to_dict(result: CFDResult) -> Dict[str, Any]:
"""Convert CFDResult to dictionary for JSON response."""
return {
"success": result.success,
"message": result.message,
"job_id": result.job_id,
"flow_analysis": {
"flow_regime": result.flow_regime,
"reynolds_number": int(result.reynolds_number),
"average_velocity_ms": round(result.average_velocity_ms, 4),
"max_velocity_ms": round(result.max_velocity_ms, 4),
"pressure_drop_mpa": round(result.pressure_drop_mpa, 6),
},
"analytical_comparison": {
"analytical_pressure_drop_mpa": round(result.analytical_pressure_drop_mpa, 6),
"cfd_vs_analytical_error_percent": round(result.error_percent, 2),
},
"mesh_info": {
"num_cells": result.num_cells,
},
"visualization": {
"velocity_field": result.velocity_field,
"pressure_field": result.pressure_field,
} if result.velocity_field else None,
}
def calculate_hydraulic_loss_coefficient(
fitting_type: str,
pipe_diameter_mm: float,
flow_rate_lpm: float,
oil_density_kg_m3: float = 870
) -> Dict[str, float]:
"""
Calculate pressure loss for common hydraulic fittings.
Uses fluids.fittings library where possible for validated K values,
with fallback to standard handbook values.
Args:
fitting_type: Type of fitting ("elbow_90", "tee", "valve", etc.)
pipe_diameter_mm: Pipe diameter
flow_rate_lpm: Flow rate
oil_density_kg_m3: Oil density
Returns:
Dictionary with loss coefficient and pressure drop
"""
d = pipe_diameter_mm / 1000 # m
Q = flow_rate_lpm / 60000 # m³/s
A = math.pi * (d/2)**2
v = Q / A if A > 0 else 0
# Try fluids library fittings first
K = None
source = "fluids"
try:
if fitting_type == "elbow_90":
K = fluids.fittings.bend_rounded(Di=d, angle=90, fd=0.02)
elif fitting_type == "elbow_90_long":
K = fluids.fittings.bend_rounded(Di=d, angle=90, fd=0.02, bend_diameters=1.5)
elif fitting_type == "elbow_45":
K = fluids.fittings.bend_rounded(Di=d, angle=45, fd=0.02)
elif fitting_type == "elbow_180":
K = fluids.fittings.bend_rounded(Di=d, angle=180, fd=0.02)
elif fitting_type == "sudden_expansion":
K = fluids.fittings.contraction_sharp(Di1=d*0.8, Di2=d, fd=0.02)
elif fitting_type == "sudden_contraction":
K = fluids.fittings.contraction_sharp(Di1=d, Di2=d*0.8, fd=0.02)
elif fitting_type == "pipe_entrance_sharp":
K = fluids.fittings.entrance_sharp()
elif fitting_type == "pipe_exit":
K = fluids.fittings.exit_normal()
except Exception:
K = None
# Fallback to handbook values if fluids library doesn't have the fitting
if K is None:
source = "handbook"
K_VALUES = {
"elbow_90": 0.9,
"elbow_90_long": 0.6,
"elbow_45": 0.4,
"elbow_180": 1.5,
"tee_through": 0.3,
"tee_branch": 1.0,
"gate_valve_open": 0.2,
"gate_valve_half": 5.6,
"ball_valve_open": 0.1,
"ball_valve_half": 5.5,
"check_valve": 2.5,
"filter": 3.0,
"strainer": 2.0,
"sudden_expansion": 1.0,
"sudden_contraction": 0.5,
"gradual_expansion": 0.3,
"gradual_contraction": 0.2,
"pipe_entrance_sharp": 0.5,
"pipe_entrance_rounded": 0.04,
"pipe_exit": 1.0,
}
K = K_VALUES.get(fitting_type, 1.0)
# Use fluids library for pressure drop calculation
# dP_from_K(K, rho, V) - returns pressure drop in Pa
if v > 0:
delta_P = dP_from_K(K=K, rho=oil_density_kg_m3, V=v)
else:
delta_P = 0
return {
"fitting_type": fitting_type,
"loss_coefficient_K": round(K, 4),
"velocity_ms": round(v, 4),
"pressure_loss_pa": round(delta_P, 2),
"pressure_loss_mpa": round(delta_P / 1e6, 6),
"source": source,
}
def calculate_system_pressure_drop(
pipes: List[Dict],
fittings: List[Dict],
flow_rate_lpm: float,
oil_viscosity_pa_s: float = 0.046,
oil_density_kg_m3: float = 870
) -> Dict[str, Any]:
"""
Calculate total pressure drop for a hydraulic system.
Args:
pipes: List of pipe segments [{diameter_mm, length_mm}, ...]
fittings: List of fittings [{type, diameter_mm}, ...]
flow_rate_lpm: System flow rate
oil_viscosity_pa_s: Oil viscosity
oil_density_kg_m3: Oil density
Returns:
Dictionary with total pressure drop and breakdown
"""
total_pipe_loss = 0
total_fitting_loss = 0
pipe_details = []
fitting_details = []
# Calculate pipe losses
for pipe in pipes:
result = calculate_hagen_poiseuille(
pipe["diameter_mm"],
pipe["length_mm"],
flow_rate_lpm,
oil_viscosity_pa_s,
oil_density_kg_m3
)
total_pipe_loss += result["pressure_drop_pa"]
pipe_details.append({
"diameter_mm": pipe["diameter_mm"],
"length_mm": pipe["length_mm"],
"pressure_drop_mpa": result["pressure_drop_mpa"],
"flow_regime": result["flow_regime"],
})
# Calculate fitting losses
for fitting in fittings:
result = calculate_hydraulic_loss_coefficient(
fitting["type"],
fitting["diameter_mm"],
flow_rate_lpm,
oil_density_kg_m3
)
total_fitting_loss += result["pressure_loss_pa"]
fitting_details.append({
"type": fitting["type"],
"diameter_mm": fitting["diameter_mm"],
"pressure_drop_mpa": result["pressure_loss_mpa"],
"K_factor": result["loss_coefficient_K"],
})
total_loss = total_pipe_loss + total_fitting_loss
return {
"total_pressure_drop_mpa": round(total_loss / 1e6, 4),
"pipe_losses_mpa": round(total_pipe_loss / 1e6, 4),
"fitting_losses_mpa": round(total_fitting_loss / 1e6, 4),
"pipe_loss_fraction": round(total_pipe_loss / total_loss, 2) if total_loss > 0 else 0,
"fitting_loss_fraction": round(total_fitting_loss / total_loss, 2) if total_loss > 0 else 0,
"pipes": pipe_details,
"fittings": fitting_details,
}