diff --git a/src/foamai-core/foamai_core/boundary_condition.py b/src/foamai-core/foamai_core/boundary_condition.py index 0c526c0..dd4ebbf 100644 --- a/src/foamai-core/foamai_core/boundary_condition.py +++ b/src/foamai-core/foamai_core/boundary_condition.py @@ -163,6 +163,16 @@ def map_boundary_conditions_to_patches( "walls": ["walls", "top", "bottom"], "sides": ["sides", "front", "back"] }, + GeometryType.NOZZLE: { + "inlet": ["inlet"], + "outlet": ["outlet"], + "nozzle": ["nozzle"], + "top": ["top"], + "bottom": ["bottom"], + "front": ["front"], + "back": ["back"], + "walls": ["top", "bottom", "front", "back"] # Keep for backward compatibility + }, GeometryType.CUSTOM: { # STL geometries - external flow around custom geometry "inlet": ["inlet"], @@ -403,8 +413,12 @@ def generate_boundary_conditions_with_mapping( ) -> Dict[str, Any]: """Generate boundary conditions with intelligent patch mapping.""" + # Get solver type for compressible flow detection + solver_info = state.get("solver_settings", {}) + solver_type = solver_info.get("solver_type") + # First, generate standard boundary conditions - boundary_conditions = generate_boundary_conditions(parsed_params, geometry_info, mesh_config) + boundary_conditions = generate_boundary_conditions(parsed_params, geometry_info, mesh_config, solver_type) # Check if we can read actual mesh patches (post-mesh generation) case_directory = state.get("case_directory") @@ -471,7 +485,7 @@ def merge_ai_boundary_conditions( return existing_conditions -def generate_boundary_conditions(parsed_params: Dict[str, Any], geometry_info: Dict[str, Any], mesh_config: Dict[str, Any]) -> Dict[str, Any]: +def generate_boundary_conditions(parsed_params: Dict[str, Any], geometry_info: Dict[str, Any], mesh_config: Dict[str, Any], solver_type: SolverType = None) -> Dict[str, Any]: """Generate complete boundary condition configuration.""" flow_type = parsed_params.get("flow_type", FlowType.LAMINAR) velocity = parsed_params.get("velocity", 1.0) @@ -481,7 +495,7 @@ def generate_boundary_conditions(parsed_params: Dict[str, Any], geometry_info: D # Generate field files boundary_conditions = { "U": generate_velocity_field(parsed_params, geometry_info, mesh_config), - "p": generate_pressure_field(parsed_params, geometry_info, mesh_config), + "p": generate_pressure_field(parsed_params, geometry_info, mesh_config, solver_type), } # Add turbulence fields if needed @@ -834,21 +848,61 @@ def generate_velocity_field(parsed_params: Dict[str, Any], geometry_info: Dict[s velocity_field["boundaryField"][stl_name] = { "type": "noSlip" # Wall boundary on STL surface } + elif geometry_type == GeometryType.NOZZLE: + # Internal flow through nozzle - using snappyHexMesh + velocity_field["boundaryField"] = { + "inlet": { + "type": "fixedValue", + "value": f"uniform {velocity_vector}" + }, + "outlet": { + "type": "zeroGradient" + }, + "nozzle": { + "type": "noSlip" # Nozzle surface as no-slip wall + }, + "top": { + "type": "slip" # Far field boundary + }, + "bottom": { + "type": "slip" # Far field boundary + }, + "front": { + "type": "slip" # Far field boundary + }, + "back": { + "type": "slip" # Far field boundary + } + } return velocity_field -def generate_pressure_field(parsed_params: Dict[str, Any], geometry_info: Dict[str, Any], mesh_config: Dict[str, Any]) -> Dict[str, Any]: +def generate_pressure_field(parsed_params: Dict[str, Any], geometry_info: Dict[str, Any], mesh_config: Dict[str, Any], solver_type: SolverType = None) -> Dict[str, Any]: """Generate pressure field (p) boundary conditions.""" pressure = parsed_params.get("pressure", 0.0) + # Determine if this is a compressible flow solver + is_compressible_solver = solver_type in [ + SolverType.RHO_PIMPLE_FOAM, + SolverType.SONIC_FOAM, + SolverType.CHT_MULTI_REGION_FOAM, # Can be compressible + SolverType.REACTING_FOAM # Can be compressible + ] + # For incompressible flow simulations, use gauge pressure (0) instead of absolute pressure - # This ensures proper pressure gradients for visualization - # Check if pressure looks like absolute pressure (atmospheric ~101325 Pa) - if pressure > 50000: # Anything above 50kPa is likely absolute pressure - # Convert to gauge pressure for incompressible flow - logger.info(f"Converting absolute pressure ({pressure} Pa) to gauge pressure (0 Pa) for incompressible flow") + # For compressible flow simulations, keep absolute pressure + if pressure > 50000 and not is_compressible_solver: # Anything above 50kPa is likely absolute pressure + # Convert to gauge pressure for incompressible flow only + logger.info(f"Converting absolute pressure ({pressure} Pa) to gauge pressure (0 Pa) for incompressible flow solver {solver_type}") pressure = 0.0 # Use gauge pressure for incompressible flow + elif pressure > 50000 and is_compressible_solver: + # Keep absolute pressure for compressible flows + logger.info(f"Keeping absolute pressure ({pressure} Pa) for compressible flow solver {solver_type}") + elif pressure <= 50000 and is_compressible_solver and pressure <= 0: + # For compressible flows, ensure we don't have zero or negative pressure + logger.warning(f"Compressible flow solver {solver_type} requires positive absolute pressure. Setting to atmospheric pressure (101325 Pa)") + pressure = 101325.0 # Default to atmospheric pressure geometry_type = geometry_info["type"] flow_context = geometry_info.get("flow_context", {}) @@ -1093,6 +1147,32 @@ def generate_pressure_field(parsed_params: Dict[str, Any], geometry_info: Dict[s pressure_field["boundaryField"][stl_name] = { "type": "zeroGradient" # No pressure gradient at STL surface } + elif geometry_type == GeometryType.NOZZLE: + # Internal flow through nozzle - using snappyHexMesh + pressure_field["boundaryField"] = { + "inlet": { + "type": "zeroGradient" # Let velocity drive the flow + }, + "outlet": { + "type": "fixedValue", + "value": f"uniform {pressure}" # Fix pressure at outlet + }, + "nozzle": { + "type": "zeroGradient" # No pressure gradient at nozzle surface + }, + "top": { + "type": "zeroGradient" # Far field boundary + }, + "bottom": { + "type": "zeroGradient" # Far field boundary + }, + "front": { + "type": "zeroGradient" # Far field boundary + }, + "back": { + "type": "zeroGradient" # Far field boundary + } + } return pressure_field @@ -2450,6 +2530,32 @@ def generate_temperature_field(parsed_params: Dict[str, Any], geometry_info: Dic temp_field["boundaryField"][stl_name] = { "type": "zeroGradient" # No temperature gradient at STL surface } + elif geometry_type == GeometryType.NOZZLE: + # Internal flow through nozzle - using snappyHexMesh + temp_field["boundaryField"] = { + "inlet": { + "type": "fixedValue", + "value": f"uniform {temperature}" + }, + "outlet": { + "type": "zeroGradient" + }, + "nozzle": { + "type": "zeroGradient" # No temperature gradient at nozzle surface + }, + "top": { + "type": "zeroGradient" # Far field boundary + }, + "bottom": { + "type": "zeroGradient" # Far field boundary + }, + "front": { + "type": "zeroGradient" # Far field boundary + }, + "back": { + "type": "zeroGradient" # Far field boundary + } + } return temp_field diff --git a/src/foamai-core/foamai_core/case_writer.py b/src/foamai-core/foamai_core/case_writer.py index 6d35d8b..4e3f5b5 100644 --- a/src/foamai-core/foamai_core/case_writer.py +++ b/src/foamai-core/foamai_core/case_writer.py @@ -681,6 +681,8 @@ def get_resolution_for_geometry(geom_type: str, res_str: str) -> Dict[str, int]: return {"circumferential": count, "radial": count//2, "meridional": count//2} elif geom_type == "cube": return {"x": count, "y": count, "z": count} + elif geom_type == "nozzle": + return {"x": count, "y": count, "z": count} else: return {"x": count, "y": count, "z": count} @@ -698,6 +700,8 @@ def get_resolution_for_geometry(geom_type: str, res_str: str) -> Dict[str, int]: return generate_sphere_blockmesh_dict(dimensions, resolution) elif geometry_type == "cube": return generate_cube_blockmesh_dict(dimensions, resolution) + elif geometry_type == "nozzle": + return generate_nozzle_blockmesh_dict(dimensions, resolution) else: raise ValueError(f"Unsupported geometry type for blockMesh: {geometry_type}") @@ -1379,6 +1383,38 @@ def write_solver_files(case_directory: Path, state: CFDState) -> None: # Write transportProperties write_foam_dict(case_directory / "constant" / "transportProperties", solver_settings["transportProperties"]) + # Write GPU-specific configuration files + if state.get("use_gpu", False): + gpu_info = state.get("gpu_info", {}) + if gpu_info.get("gpu_backend") == "amgx": + # Write AmgX configuration file + amgx_config = { + "config_version": 2, + "solver": { + "preconditioner": { + "scope": "precond", + "solver": "BLOCK_JACOBI" + }, + "solver": "PCG", + "print_solve_stats": 1, + "obtain_timings": 1, + "max_iters": 200, + "monitor_residual": 1, + "store_res_history": 1, + "scope": "main", + "convergence": "RELATIVE_INI_CORE", + "tolerance": 1e-03 + } + } + + # Write AmgX configuration as JSON + import json + with open(case_directory / "system" / "amgxOptions", "w") as f: + json.dump(amgx_config, f, indent=4) + + if state["verbose"]: + logger.info("Case Writer: Wrote AmgX configuration file") + # Write solver-specific files # interFoam specific files if "g" in solver_settings: @@ -1452,11 +1488,12 @@ def write_solver_files(case_directory: Path, state: CFDState) -> None: if state["verbose"]: logger.info("Case Writer: Wrote p_rgh field for interFoam") - # rhoPimpleFoam specific files + # Compressible solver files (rhoPimpleFoam, sonicFoam, etc.) if "thermophysicalProperties" in solver_settings: write_foam_dict(case_directory / "constant" / "thermophysicalProperties", solver_settings["thermophysicalProperties"]) if state["verbose"]: - logger.info("Case Writer: Wrote thermophysicalProperties for rhoPimpleFoam") + solver_name = solver_settings.get("solver", "compressible solver") + logger.info(f"Case Writer: Wrote thermophysicalProperties for {solver_name}") if "T" in solver_settings: # Only write temperature field if it doesn't already exist from boundary conditions @@ -1464,7 +1501,8 @@ def write_solver_files(case_directory: Path, state: CFDState) -> None: if not temp_file_path.exists(): write_foam_dict(temp_file_path, solver_settings["T"]) if state["verbose"]: - logger.info("Case Writer: Wrote initial temperature field T for rhoPimpleFoam") + solver_name = solver_settings.get("solver", "compressible solver") + logger.info(f"Case Writer: Wrote initial temperature field T for {solver_name}") else: if state["verbose"]: logger.info("Case Writer: Temperature field T already exists from boundary conditions") @@ -1930,12 +1968,9 @@ def validate_case_structure(case_directory: Path) -> Dict[str, Any]: if not (case_directory / "0" / "alpha.water").exists(): errors.append("Missing alpha.water field for interFoam") - # Check for rhoPimpleFoam specific files - elif solver == "rhoPimpleFoam": - if not (case_directory / "constant" / "thermophysicalProperties").exists(): - errors.append("Missing thermophysicalProperties for rhoPimpleFoam") - if not (case_directory / "0" / "T").exists(): - errors.append("Missing temperature field T for rhoPimpleFoam") + # For compressible solvers, files are written based on solver_settings + # so we don't need to check for file existence here since this validation + # runs before the files are actually written # Check boundary condition files zero_dir = case_directory / "0" @@ -2298,4 +2333,70 @@ def generate_snappyhexmesh_dict(mesh_config: Dict[str, Any], state: CFDState) -> "levels": [(1e15, snappy_settings.get("refinement_levels", {}).get("max", 2) - 1)] } - return snappy_dict \ No newline at end of file + return snappy_dict + + +def generate_nozzle_blockmesh_dict(dimensions: Dict[str, float], resolution: Dict[str, int]) -> Dict[str, Any]: + """Generate blockMeshDict for nozzle geometry.""" + # Extract nozzle dimensions + length = dimensions.get("length", 0.3) + max_diameter = dimensions.get("max_diameter", 0.1) + + # For nozzle, create a rectangular domain that encompasses the nozzle + # The actual nozzle profile will be handled by boundary conditions + domain_length = length + domain_height = max_diameter * 2.0 # 2x max diameter + domain_width = max_diameter * 2.0 # 2x max diameter + + # Cell counts + nx = resolution.get("x", 60) + ny = resolution.get("y", 30) + nz = resolution.get("z", 30) + + # Check if this is 2D or 3D + is_2d = nz == 1 + + return { + "convertToMeters": 1.0, + "vertices": [ + "(0 0 0)", + f"({domain_length} 0 0)", + f"({domain_length} {domain_height} 0)", + f"(0 {domain_height} 0)", + f"(0 0 {domain_width})", + f"({domain_length} 0 {domain_width})", + f"({domain_length} {domain_height} {domain_width})", + f"(0 {domain_height} {domain_width})" + ], + "blocks": [ + f"hex (0 1 2 3 4 5 6 7) ({nx} {ny} {nz}) simpleGrading (1 1 1)" + ], + "edges": [], + "boundary": { + "inlet": { + "type": "patch", + "faces": ["(0 4 7 3)"] + }, + "outlet": { + "type": "patch", + "faces": ["(1 2 6 5)"] + }, + "top": { + "type": "slip", + "faces": ["(3 7 6 2)"] + }, + "bottom": { + "type": "slip", + "faces": ["(0 1 5 4)"] + }, + "front": { + "type": "slip", + "faces": ["(0 3 2 1)"] + }, + "back": { + "type": "slip", + "faces": ["(4 5 6 7)"] + } + }, + "mergePatchPairs": [] + } \ No newline at end of file diff --git a/src/foamai-core/foamai_core/mesh_generator.py b/src/foamai-core/foamai_core/mesh_generator.py index 32aa8d8..7313b6b 100644 --- a/src/foamai-core/foamai_core/mesh_generator.py +++ b/src/foamai-core/foamai_core/mesh_generator.py @@ -63,7 +63,9 @@ def generate_mesh_config(geometry_info: Dict[str, Any], parsed_params: Dict[str, """Generate mesh configuration based on geometry type.""" geometry_type = geometry_info["type"] dimensions = geometry_info["dimensions"] - mesh_resolution = geometry_info.get("mesh_resolution", "medium") + mesh_resolution = geometry_info.get("mesh_resolution") + if mesh_resolution is None: + mesh_resolution = "medium" # Default mesh resolution flow_context = geometry_info.get("flow_context", {}) is_custom_geometry = geometry_info.get("is_custom_geometry", False) stl_file = geometry_info.get("stl_file") @@ -100,6 +102,8 @@ def generate_mesh_config(geometry_info: Dict[str, Any], parsed_params: Dict[str, return generate_sphere_mesh(dimensions, mesh_resolution, is_external_flow, flow_context) elif geometry_type == GeometryType.CUBE: return generate_cube_mesh(dimensions, mesh_resolution, is_external_flow, flow_context) + elif geometry_type == GeometryType.NOZZLE: + return generate_nozzle_mesh(dimensions, mesh_resolution, flow_context, parsed_params) else: raise ValueError(f"Unsupported geometry type: {geometry_type}") @@ -112,14 +116,22 @@ def get_resolution_multiplier(mesh_resolution: str) -> float: "fine": 2.0, "very_fine": 4.0 } + if mesh_resolution is None: + return 1.0 # Default medium resolution return resolution_map.get(mesh_resolution, 1.0) def generate_cylinder_mesh(dimensions: Dict[str, float], resolution: str = "medium", is_external_flow: bool = True, flow_context: Dict[str, Any] = None) -> Dict[str, Any]: """Generate mesh configuration for cylinder geometry.""" - diameter = dimensions.get("diameter", 0.1) # Default 0.1m diameter - length = dimensions.get("length", diameter * 0.1) # Default thin slice for 2D + # Handle None values explicitly + diameter = dimensions.get("diameter") + if diameter is None: + diameter = 0.1 # Default 0.1m diameter + + length = dimensions.get("length") + if length is None: + length = diameter # Default height equal to diameter # Determine if this is a 2D or 3D case based on length # If length is very small compared to diameter, it's 2D @@ -127,7 +139,9 @@ def generate_cylinder_mesh(dimensions: Dict[str, float], resolution: str = "medi if is_external_flow: # External flow around cylinder - create proper O-grid mesh - domain_multiplier = flow_context.get("domain_size_multiplier", 20.0) if flow_context else 20.0 + domain_multiplier = flow_context.get("domain_size_multiplier") if flow_context else None + if domain_multiplier is None: + domain_multiplier = 20.0 # Default domain size multiplier # Domain dimensions domain_length = diameter * domain_multiplier @@ -156,6 +170,9 @@ def generate_cylinder_mesh(dimensions: Dict[str, float], resolution: str = "medi "geometry_type": "cylinder", "is_external_flow": True, "is_2d": is_2d, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "background_mesh": { # Background mesh (will be refined by snappyHexMesh) "type": "blockMesh", @@ -301,6 +318,9 @@ def generate_cylinder_mesh(dimensions: Dict[str, float], resolution: str = "medi "geometry_type": "cylinder_in_channel", "is_external_flow": False, "is_2d": is_2d, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "dimensions": { "cylinder_diameter": diameter, "channel_length": channel_length, @@ -327,16 +347,27 @@ def generate_cylinder_mesh(dimensions: Dict[str, float], resolution: str = "medi def generate_airfoil_mesh(dimensions: Dict[str, float], resolution: str = "medium", is_external_flow: bool = True, flow_context: Dict[str, Any] = None) -> Dict[str, Any]: """Generate mesh configuration for airfoil geometry.""" - chord = dimensions.get("chord", 0.1) - span = dimensions.get("span", chord * 0.1) # Default thin span for 2D - thickness = dimensions.get("thickness", chord * 0.12) # NACA 0012 default + # Handle None values explicitly + chord = dimensions.get("chord") + if chord is None: + chord = 0.1 # Default 10cm chord + + span = dimensions.get("span") + if span is None: + span = chord * 0.1 # Default thin span for 2D + + thickness = dimensions.get("thickness") + if thickness is None: + thickness = chord * 0.12 # NACA 0012 default # Determine if this is 2D or 3D is_2d = span < chord * 0.2 # Less than 20% of chord means 2D if is_external_flow: # External flow around airfoil - domain_multiplier = flow_context.get("domain_size_multiplier", 30.0) if flow_context else 30.0 + domain_multiplier = flow_context.get("domain_size_multiplier") if flow_context else None + if domain_multiplier is None: + domain_multiplier = 30.0 # Default domain size multiplier # Domain dimensions domain_length = chord * domain_multiplier @@ -363,6 +394,9 @@ def generate_airfoil_mesh(dimensions: Dict[str, float], resolution: str = "mediu "geometry_type": "airfoil", "is_external_flow": True, "is_2d": is_2d, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "background_mesh": { "type": "blockMesh", "domain_length": domain_length, @@ -462,15 +496,27 @@ def generate_airfoil_mesh(dimensions: Dict[str, float], resolution: str = "mediu def generate_pipe_mesh(dimensions: Dict[str, float], resolution_multiplier: float, params: Dict[str, Any]) -> Dict[str, Any]: """Generate mesh configuration for pipe geometry.""" - diameter = dimensions.get("diameter", 0.05) - length = dimensions.get("length", 1.0) + # Handle None values explicitly + diameter = dimensions.get("diameter") + if diameter is None: + diameter = 0.05 # Default 5cm pipe + + length = dimensions.get("length") + if length is None: + length = 1.0 # Default 1m length # Base mesh resolution base_resolution = int(30 * resolution_multiplier) mesh_config = { "type": "blockMesh", + "mesh_topology": "structured", "geometry_type": "pipe", + "is_external_flow": False, + "is_2d": False, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "dimensions": { "diameter": diameter, "length": length @@ -496,16 +542,31 @@ def generate_pipe_mesh(dimensions: Dict[str, float], resolution_multiplier: floa def generate_channel_mesh(dimensions: Dict[str, float], resolution_multiplier: float, params: Dict[str, Any]) -> Dict[str, Any]: """Generate mesh configuration for channel geometry.""" - width = dimensions.get("width", 0.1) - height = dimensions.get("height", 0.02) - length = dimensions.get("length", 1.0) + # Handle None values explicitly + width = dimensions.get("width") + if width is None: + width = 0.1 # Default 10cm width + + height = dimensions.get("height") + if height is None: + height = 0.02 # Default 2cm height + + length = dimensions.get("length") + if length is None: + length = 1.0 # Default 1m length # Base mesh resolution base_resolution = int(40 * resolution_multiplier) mesh_config = { "type": "blockMesh", + "mesh_topology": "structured", "geometry_type": "channel", + "is_external_flow": False, + "is_2d": False, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "dimensions": { "width": width, "height": height, @@ -534,11 +595,16 @@ def generate_channel_mesh(dimensions: Dict[str, float], resolution_multiplier: f def generate_sphere_mesh(dimensions: Dict[str, float], resolution: str = "medium", is_external_flow: bool = True, flow_context: Dict[str, Any] = None) -> Dict[str, Any]: """Generate mesh configuration for sphere geometry.""" - diameter = dimensions.get("diameter", 0.1) + # Handle None values explicitly + diameter = dimensions.get("diameter") + if diameter is None: + diameter = 0.1 # Default 10cm sphere if is_external_flow: # External flow around sphere - must be 3D - domain_multiplier = flow_context.get("domain_size_multiplier", 20.0) if flow_context else 20.0 + domain_multiplier = flow_context.get("domain_size_multiplier") if flow_context else None + if domain_multiplier is None: + domain_multiplier = 20.0 # Default domain size multiplier # Domain dimensions - sphere requires 3D domain domain_length = diameter * domain_multiplier @@ -557,6 +623,9 @@ def generate_sphere_mesh(dimensions: Dict[str, float], resolution: str = "medium "geometry_type": "sphere", "is_external_flow": True, "is_2d": False, # Sphere is always 3D + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "background_mesh": { "type": "blockMesh", "domain_length": domain_length, @@ -629,6 +698,9 @@ def generate_sphere_mesh(dimensions: Dict[str, float], resolution: str = "medium "geometry_type": "sphere_in_duct", "is_external_flow": False, "is_2d": False, # Always 3D + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "dimensions": { "sphere_diameter": diameter, "duct_diameter": duct_diameter, @@ -652,14 +724,19 @@ def generate_sphere_mesh(dimensions: Dict[str, float], resolution: str = "medium def generate_cube_mesh(dimensions: Dict[str, float], resolution: str = "medium", is_external_flow: bool = True, flow_context: Dict[str, Any] = None) -> Dict[str, Any]: """Generate mesh configuration for cube geometry.""" - side_length = dimensions.get("side_length", 0.1) + # Handle None values explicitly + side_length = dimensions.get("side_length") + if side_length is None: + side_length = 0.1 # Default 10cm cube # Get resolution count base_resolution = {"coarse": 15, "medium": 30, "fine": 60, "very_fine": 90}.get(resolution, 30) if is_external_flow: # External flow around cube - domain_multiplier = flow_context.get("domain_size_multiplier", 20.0) if flow_context else 20.0 + domain_multiplier = flow_context.get("domain_size_multiplier") if flow_context else None + if domain_multiplier is None: + domain_multiplier = 20.0 # Default domain size multiplier # Domain dimensions domain_length = side_length * domain_multiplier @@ -683,6 +760,9 @@ def generate_cube_mesh(dimensions: Dict[str, float], resolution: str = "medium", "geometry_type": "cube", "is_external_flow": is_external_flow, "is_2d": is_2d, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "background_mesh": { "type": "blockMesh", "domain_length": domain_length, @@ -759,6 +839,9 @@ def generate_cube_mesh(dimensions: Dict[str, float], resolution: str = "medium", "geometry_type": "cube_in_channel", "is_external_flow": False, "is_2d": is_2d, + "is_custom_geometry": False, + "stl_file": None, + "stl_name": None, "dimensions": { "cube_side_length": side_length, "channel_length": channel_length, @@ -781,6 +864,270 @@ def generate_cube_mesh(dimensions: Dict[str, float], resolution: str = "medium", return mesh_config +def generate_nozzle_mesh(dimensions: Dict[str, float], mesh_resolution: str, flow_context: Dict[str, Any], parsed_params: Dict[str, Any]) -> Dict[str, Any]: + """Generate mesh configuration for nozzle geometry using snappyHexMesh with STL.""" + from pathlib import Path + throat_diameter = dimensions.get("throat_diameter", 0.05) + inlet_diameter = dimensions.get("inlet_diameter", throat_diameter * 1.5) + outlet_diameter = dimensions.get("outlet_diameter", throat_diameter * 2.0) + length = dimensions.get("length", throat_diameter * 10) + convergent_length = dimensions.get("convergent_length", length * 0.3) + divergent_length = dimensions.get("divergent_length", length * 0.7) + + # Get base resolution settings + base_resolution = {"coarse": 30, "medium": 50, "fine": 80, "very_fine": 120}.get(mesh_resolution, 50) + + # Domain dimensions - larger for snappyHexMesh background mesh + max_diameter = max(inlet_diameter, outlet_diameter) + domain_height = max_diameter * 4.0 # Extra space for background mesh + domain_width = max_diameter * 4.0 # Extra space for background mesh + domain_length = length * 1.5 # Extra space upstream and downstream + + # Generate nozzle STL file + nozzle_stl_path = generate_nozzle_stl( + throat_diameter=throat_diameter, + inlet_diameter=inlet_diameter, + outlet_diameter=outlet_diameter, + length=length, + convergent_length=convergent_length, + divergent_length=divergent_length + ) + + # Use snappyHexMesh for proper nozzle geometry + mesh_config = { + "type": "snappyHexMesh", + "mesh_topology": "snappy", + "geometry_type": "nozzle", + "is_external_flow": False, + "is_2d": False, + "is_custom_geometry": True, + "stl_file": nozzle_stl_path, + "stl_name": "nozzle", + "background_mesh": { + "type": "blockMesh", + "domain_length": domain_length, + "domain_height": domain_height, + "domain_width": domain_width, + "n_cells_x": int(base_resolution * 1.5), + "n_cells_y": int(base_resolution * 0.75), + "n_cells_z": int(base_resolution * 0.75) + }, + "geometry": { + "nozzle": { + "type": "triSurfaceMesh", + "file": f'"{Path(nozzle_stl_path).name}"' + } + }, + "dimensions": { + "throat_diameter": throat_diameter, + "inlet_diameter": inlet_diameter, + "outlet_diameter": outlet_diameter, + "length": length, + "convergent_length": convergent_length, + "divergent_length": divergent_length, + "domain_height": domain_height, + "domain_width": domain_width, + "domain_length": domain_length, + "characteristic_length": throat_diameter # Use throat diameter as characteristic length + }, + "resolution": { + "base_resolution": base_resolution, + "surface_resolution": base_resolution * 2, # Higher resolution on nozzle surface + "volume_resolution": base_resolution + }, + "snappy_settings": { + "castellated_mesh": True, + "snap": True, + "add_layers": True, + "refinement_levels": { + "min": 1, + "max": 2 + }, + "refinement_region": { + # Refinement box around nozzle + "min": [0, -max_diameter*2, -max_diameter*2], + "max": [length, max_diameter*2, max_diameter*2] + }, + "location_in_mesh": [domain_length/4, domain_height/2, domain_width/2], # Fixed: place within background mesh bounds + "layers": { + "n_layers": 5, + "expansion_ratio": 1.3, + "final_layer_thickness": 0.7, + "min_thickness": 0.1 + } + }, + "boundary_patches": { + "inlet": "patch", + "outlet": "patch", + "nozzle": "wall", # Nozzle surface as wall + "top": "patch", # Background mesh boundaries + "bottom": "patch", + "front": "patch", + "back": "patch" + }, + "total_cells": base_resolution * base_resolution * 6, # Estimate after refinement + "quality_metrics": { + "aspect_ratio": 2.0, # Expected aspect ratio for snappy mesh + "quality_score": 0.80 # Good quality for snappy mesh + } + } + + return mesh_config + + +def generate_nozzle_stl(throat_diameter: float, inlet_diameter: float, outlet_diameter: float, + length: float, convergent_length: float, divergent_length: float) -> str: + """Generate STL file for nozzle geometry.""" + import math + import os + + # Create nozzle STL file + stl_filename = f"nozzle_{throat_diameter:.3f}_{inlet_diameter:.3f}_{outlet_diameter:.3f}.stl" + stl_path = os.path.join("stl", stl_filename) + + # Create stl directory if it doesn't exist + os.makedirs("stl", exist_ok=True) + + # Generate nozzle geometry points + n_axial = 60 # Number of points along axis + n_theta = 24 # Number of points around circumference + + # Axial positions + x_positions = [] + radii = [] + + # Convergent section + for i in range(int(n_axial * 0.3)): + x = i / (n_axial * 0.3) * convergent_length + progress = i / (n_axial * 0.3) + r = (inlet_diameter / 2) - progress * (inlet_diameter / 2 - throat_diameter / 2) + x_positions.append(x) + radii.append(r) + + # Throat section (short constant diameter) + throat_section_length = length - convergent_length - divergent_length + for i in range(int(n_axial * 0.1)): + x = convergent_length + i / (n_axial * 0.1) * throat_section_length + x_positions.append(x) + radii.append(throat_diameter / 2) + + # Divergent section + for i in range(int(n_axial * 0.6)): + x = convergent_length + throat_section_length + i / (n_axial * 0.6) * divergent_length + progress = i / (n_axial * 0.6) + r = (throat_diameter / 2) + progress * (outlet_diameter / 2 - throat_diameter / 2) + x_positions.append(x) + radii.append(r) + + # Generate STL triangles + triangles = [] + + # Generate surface triangles + for i in range(len(x_positions) - 1): + for j in range(n_theta): + # Current ring + theta1 = 2 * math.pi * j / n_theta + theta2 = 2 * math.pi * ((j + 1) % n_theta) / n_theta + + # Current points + x1, r1 = x_positions[i], radii[i] + x2, r2 = x_positions[i + 1], radii[i + 1] + + # Points on current ring + p1 = (x1, r1 * math.cos(theta1), r1 * math.sin(theta1)) + p2 = (x1, r1 * math.cos(theta2), r1 * math.sin(theta2)) + + # Points on next ring + p3 = (x2, r2 * math.cos(theta1), r2 * math.sin(theta1)) + p4 = (x2, r2 * math.cos(theta2), r2 * math.sin(theta2)) + + # Two triangles per quad + triangles.append([p1, p2, p3]) + triangles.append([p2, p4, p3]) + + # Add inlet and outlet caps + # Inlet cap + inlet_center = (0, 0, 0) + for j in range(n_theta): + theta1 = 2 * math.pi * j / n_theta + theta2 = 2 * math.pi * ((j + 1) % n_theta) / n_theta + + p1 = (0, (inlet_diameter / 2) * math.cos(theta1), (inlet_diameter / 2) * math.sin(theta1)) + p2 = (0, (inlet_diameter / 2) * math.cos(theta2), (inlet_diameter / 2) * math.sin(theta2)) + + triangles.append([inlet_center, p2, p1]) # Inward normal + + # Outlet cap + outlet_center = (length, 0, 0) + for j in range(n_theta): + theta1 = 2 * math.pi * j / n_theta + theta2 = 2 * math.pi * ((j + 1) % n_theta) / n_theta + + p1 = (length, (outlet_diameter / 2) * math.cos(theta1), (outlet_diameter / 2) * math.sin(theta1)) + p2 = (length, (outlet_diameter / 2) * math.cos(theta2), (outlet_diameter / 2) * math.sin(theta2)) + + triangles.append([outlet_center, p1, p2]) # Outward normal + + # Write STL file + with open(stl_path, 'w') as f: + f.write("solid nozzle\n") + + for triangle in triangles: + # Calculate normal vector + v1 = [triangle[1][i] - triangle[0][i] for i in range(3)] + v2 = [triangle[2][i] - triangle[0][i] for i in range(3)] + normal = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0] + ] + + # Normalize + length_n = math.sqrt(sum(n**2 for n in normal)) + if length_n > 0: + normal = [n / length_n for n in normal] + + f.write(f" facet normal {normal[0]:.6f} {normal[1]:.6f} {normal[2]:.6f}\n") + f.write(" outer loop\n") + for point in triangle: + f.write(f" vertex {point[0]:.6f} {point[1]:.6f} {point[2]:.6f}\n") + f.write(" endloop\n") + f.write(" endfacet\n") + + f.write("endsolid nozzle\n") + + return stl_path + + +def generate_nozzle_blocks(throat_diameter: float, inlet_diameter: float, outlet_diameter: float, length: float, resolution: int) -> Dict[str, Any]: + """Generate block structure for nozzle mesh.""" + return { + "block_count": 3, # Convergent, throat, divergent sections + "sections": { + "convergent": { + "inlet_diameter": inlet_diameter, + "outlet_diameter": throat_diameter, + "grading": 1.0 + }, + "throat": { + "diameter": throat_diameter, + "grading": 1.0 + }, + "divergent": { + "inlet_diameter": throat_diameter, + "outlet_diameter": outlet_diameter, + "grading": 1.0 + } + }, + "boundary_layer": { + "enabled": True, + "first_layer_thickness": throat_diameter * 1e-4, + "expansion_ratio": 1.15, + "layers": 8 + } + } + + def generate_cylinder_blocks(diameter: float, domain_width: float, domain_height: float, resolution: int) -> Dict[str, Any]: """Generate block structure for cylinder mesh.""" # This is a simplified block structure diff --git a/src/foamai-core/foamai_core/nl_interpreter.py b/src/foamai-core/foamai_core/nl_interpreter.py index 1088925..d1931f1 100644 --- a/src/foamai-core/foamai_core/nl_interpreter.py +++ b/src/foamai-core/foamai_core/nl_interpreter.py @@ -17,7 +17,7 @@ class FlowContext(BaseModel): """Context of the flow problem.""" is_external_flow: bool = Field(description="True if flow around object (external), False if flow through object (internal)") domain_type: str = Field(description="Type of domain: 'unbounded' for external flow, 'channel' for bounded flow") - domain_size_multiplier: float = Field(description="Multiplier for domain size relative to object size (e.g., 20 for 20x object diameter)") + domain_size_multiplier: Optional[float] = Field(None, description="Multiplier for domain size relative to object size (e.g., 20 for 20x object diameter)") class CFDParameters(BaseModel): @@ -111,6 +111,39 @@ def extract_dimensions_from_text(text: str, geometry_type: GeometryType) -> Dict r'(\d+\.?\d*)\s*(?:m|meter|metre)?\s*(?:high|tall)', r'height\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre)?' ], + # Nozzle-specific patterns + 'throat_diameter': [ + r'(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?\s*throat\s*diameter', + r'throat\s*diameter\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?', + r'throat\s*(?:size|width)\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?' + ], + 'inlet_diameter': [ + r'(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?\s*inlet\s*diameter', + r'inlet\s*diameter\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?', + r'inlet\s*(?:size|width)\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?' + ], + 'outlet_diameter': [ + r'(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?\s*outlet\s*diameter', + r'outlet\s*diameter\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?', + r'outlet\s*(?:size|width)\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?', + r'exit\s*diameter\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:m|meter|metre|mm|cm|inch|inches)?' + ], + 'expansion_ratio': [ + r'expansion\s*ratio\s*(?:of|:)?\s*(\d+\.?\d*)', + r'area\s*ratio\s*(?:of|:)?\s*(\d+\.?\d*)', + r'(\d+\.?\d*)\s*(?::|to|-)?\s*1\s*expansion', + r'(\d+\.?\d*)\s*(?::|to|-)?\s*1\s*area\s*ratio' + ], + 'convergence_angle': [ + r'convergence\s*angle\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:deg|degree|degrees)?', + r'(\d+\.?\d*)\s*(?:deg|degree|degrees)?\s*convergence', + r'converging\s*(?:at|angle)\s*(\d+\.?\d*)\s*(?:deg|degree|degrees)?' + ], + 'divergence_angle': [ + r'divergence\s*angle\s*(?:of|:)?\s*(\d+\.?\d*)\s*(?:deg|degree|degrees)?', + r'(\d+\.?\d*)\s*(?:deg|degree|degrees)?\s*divergence', + r'diverging\s*(?:at|angle)\s*(\d+\.?\d*)\s*(?:deg|degree|degrees)?' + ], # Angle of attack 'angle_of_attack': [ r'(\d+\.?\d*)\s*(?:deg|degree)?\s*(?:angle\s*of\s*attack|aoa)', @@ -518,16 +551,16 @@ def infer_flow_context(text: str, geometry_type: GeometryType, user_domain_multi # Determine if it's external or internal flow if any(keyword in text_lower for keyword in ["around", "over", "past", "external", "cylinder", "sphere", "airfoil", "cube"]): - if not any(keyword in text_lower for keyword in ["through", "in", "inside", "internal", "pipe", "channel", "duct"]): + if not any(keyword in text_lower for keyword in ["through", "in", "inside", "internal", "pipe", "channel", "duct", "nozzle"]): is_external = True - elif any(keyword in text_lower for keyword in ["through", "in", "inside", "internal", "pipe", "channel", "duct"]): + elif any(keyword in text_lower for keyword in ["through", "in", "inside", "internal", "pipe", "channel", "duct", "nozzle"]): is_external = False else: # Default based on geometry type if geometry_type in [GeometryType.CYLINDER, GeometryType.SPHERE, GeometryType.AIRFOIL, GeometryType.CUBE]: is_external = True else: - is_external = False # Default to internal flow for pipes/channels + is_external = False # Default to internal flow for pipes/channels/nozzles # Determine domain type and size if is_external: @@ -545,6 +578,8 @@ def infer_flow_context(text: str, geometry_type: GeometryType, user_domain_multi domain_size_multiplier = 20.0 # 20x diameter for sphere elif geometry_type == GeometryType.CUBE: domain_size_multiplier = 20.0 # 20x side length for cube + elif geometry_type == GeometryType.NOZZLE: + domain_size_multiplier = 3.0 # 3x length for nozzle (smaller domain for internal flow) else: domain_size_multiplier = 10.0 # Default else: @@ -577,8 +612,8 @@ def apply_intelligent_defaults(geometry_type: GeometryType, dimensions: Dict[str if 'length' not in dimensions: if flow_context.is_external_flow: - # For 2D external flow, use thin slice - dimensions['length'] = dimensions['diameter'] * 0.1 + # For external flow, use height equal to diameter by default + dimensions['length'] = dimensions['diameter'] else: # For internal flow (unlikely for cylinder), use longer length dimensions['length'] = dimensions['diameter'] * 10 @@ -625,6 +660,51 @@ def apply_intelligent_defaults(geometry_type: GeometryType, dimensions: Dict[str else: dimensions['side_length'] = 0.1 # Default 10cm cube + elif geometry_type == GeometryType.NOZZLE: + # Nozzle geometry: converging-diverging nozzle with throat + if 'throat_diameter' not in dimensions or dimensions.get('throat_diameter') is None: + dimensions['throat_diameter'] = 0.05 # Default 5cm throat + + # If inlet diameter not specified, use 1.5x throat diameter (typical convergent ratio) + if 'inlet_diameter' not in dimensions or dimensions.get('inlet_diameter') is None: + dimensions['inlet_diameter'] = dimensions.get('throat_diameter', 0.05) * 1.5 + + # If outlet diameter not specified, use 2x throat diameter (typical expansion ratio) + if 'outlet_diameter' not in dimensions or dimensions.get('outlet_diameter') is None: + dimensions['outlet_diameter'] = dimensions.get('throat_diameter', 0.05) * 2.0 + + # Calculate expansion ratio if not provided + if 'expansion_ratio' not in dimensions or dimensions.get('expansion_ratio') is None: + throat_area = 3.14159 * (dimensions.get('throat_diameter', 0.05) / 2) ** 2 + outlet_area = 3.14159 * (dimensions.get('outlet_diameter', 0.1) / 2) ** 2 + dimensions['expansion_ratio'] = outlet_area / throat_area + + # Default convergence/divergence angles + if 'convergence_angle' not in dimensions or dimensions.get('convergence_angle') is None: + dimensions['convergence_angle'] = 15.0 # 15 degrees typical + + if 'divergence_angle' not in dimensions or dimensions.get('divergence_angle') is None: + dimensions['divergence_angle'] = 7.0 # 7 degrees typical (smaller than convergence) + + # Calculate nozzle length if not provided + if 'length' not in dimensions or dimensions.get('length') is None: + # Total length based on diameters and angles + inlet_radius = dimensions.get('inlet_diameter', 0.075) / 2 + throat_radius = dimensions.get('throat_diameter', 0.05) / 2 + outlet_radius = dimensions.get('outlet_diameter', 0.1) / 2 + + # Convergent section length + conv_angle_rad = dimensions.get('convergence_angle', 15.0) * 3.14159 / 180 + conv_length = (inlet_radius - throat_radius) / max(0.001, (conv_angle_rad / 2)) + + # Divergent section length + div_angle_rad = dimensions.get('divergence_angle', 7.0) * 3.14159 / 180 + div_length = (outlet_radius - throat_radius) / max(0.001, (div_angle_rad / 2)) + + dimensions['length'] = conv_length + div_length + dimensions['convergent_length'] = conv_length + dimensions['divergent_length'] = div_length + return dimensions @@ -704,13 +784,148 @@ def detect_rotation_request(prompt: str) -> Dict[str, Any]: elif re.search(r'\broll\b', prompt_lower): rotation_info["rotation_axis"] = "x" # Roll is rotation around X - # If rotation detected but no axis specified, default to Z (most common for vehicles) - if rotation_info["rotate"] and not rotation_info["rotation_axis"]: - rotation_info["rotation_axis"] = "z" - return rotation_info +def detect_mesh_convergence_request(prompt: str) -> Dict[str, Any]: + """Detect mesh convergence study request from the prompt.""" + prompt_lower = prompt.lower() + + # Initialize mesh convergence info + mesh_convergence_info = { + "mesh_convergence_active": False, + "mesh_convergence_levels": 4, + "mesh_convergence_target_params": [], + "mesh_convergence_threshold": 1.0 + } + + # Detect mesh convergence request + mesh_convergence_patterns = [ + r'mesh\s+convergence\s+study', + r'mesh\s+convergence\s+analysis', + r'mesh\s+independence\s+study', + r'mesh\s+independence\s+analysis', + r'mesh\s+sensitivity\s+study', + r'mesh\s+sensitivity\s+analysis', + r'grid\s+convergence\s+study', + r'grid\s+independence\s+study', + r'check\s+mesh\s+convergence', + r'verify\s+mesh\s+independence', + r'test\s+mesh\s+sensitivity', + r'perform\s+mesh\s+study', + r'run\s+mesh\s+convergence', + r'mesh\s+refinement\s+study', + r'grid\s+refinement\s+study' + ] + + # Check for mesh convergence request + for pattern in mesh_convergence_patterns: + if re.search(pattern, prompt_lower): + mesh_convergence_info["mesh_convergence_active"] = True + break + + # Extract number of mesh levels + levels_patterns = [ + r'(\d+)\s+mesh\s+levels', + r'(\d+)\s+refinement\s+levels', + r'(\d+)\s+grid\s+levels', + r'with\s+(\d+)\s+levels', + r'using\s+(\d+)\s+levels' + ] + + for pattern in levels_patterns: + match = re.search(pattern, prompt_lower) + if match: + levels = int(match.group(1)) + if 2 <= levels <= 8: # Reasonable range + mesh_convergence_info["mesh_convergence_levels"] = levels + break + + # Extract target parameters + param_patterns = [ + r'monitor\s+([^.]+?)(?:\s+for\s+convergence|$)', + r'check\s+([^.]+?)(?:\s+convergence|$)', + r'target\s+parameters?\s*[:=]?\s*([^.]+?)(?:\s|$)', + r'convergence\s+of\s+([^.]+?)(?:\s|$)' + ] + + for pattern in param_patterns: + match = re.search(pattern, prompt_lower) + if match: + param_text = match.group(1) + # Extract parameter names + params = [p.strip() for p in re.split(r'[,\s]+', param_text) if p.strip()] + if params: + mesh_convergence_info["mesh_convergence_target_params"] = params + break + + # Extract convergence threshold + threshold_patterns = [ + r'threshold\s+of\s+([\d.]+)\s*%', + r'convergence\s+threshold\s+([\d.]+)\s*%', + r'within\s+([\d.]+)\s*%', + r'accuracy\s+of\s+([\d.]+)\s*%' + ] + + for pattern in threshold_patterns: + match = re.search(pattern, prompt_lower) + if match: + threshold = float(match.group(1)) + if 0.1 <= threshold <= 10.0: # Reasonable range + mesh_convergence_info["mesh_convergence_threshold"] = threshold + break + + return mesh_convergence_info + + +def detect_gpu_request(prompt: str) -> Dict[str, Any]: + """Detect explicit GPU acceleration request from the prompt.""" + prompt_lower = prompt.lower() + + # Initialize GPU info + gpu_info = { + "use_gpu": False, + "gpu_explicit": False, + "gpu_backend": "petsc" + } + + # Detect explicit GPU requests - user must explicitly ask for GPU + gpu_patterns = [ + r'use\s+(?:the\s+)?gpu', + r'use\s+my\s+gpu', + r'with\s+gpu', + r'gpu\s+acceleration', + r'accelerate\s+with\s+gpu', + r'run\s+on\s+gpu', + r'enable\s+gpu', + r'use\s+graphics\s+card', + r'leverage\s+gpu', + r'utilize\s+gpu', + r'gpu\s+computing', + r'gpu\s+solver', + r'cuda\s+acceleration', + r'use\s+cuda' + ] + + # Check for explicit GPU request + for pattern in gpu_patterns: + if re.search(pattern, prompt_lower): + gpu_info["use_gpu"] = True + gpu_info["gpu_explicit"] = True + break + + # Detect specific GPU backend preferences + if gpu_info["use_gpu"]: + if re.search(r'petsc', prompt_lower): + gpu_info["gpu_backend"] = "petsc" + elif re.search(r'amgx', prompt_lower): + gpu_info["gpu_backend"] = "amgx" + elif re.search(r'rapidcfd', prompt_lower): + gpu_info["gpu_backend"] = "rapidcfd" + + return gpu_info + + def detect_multiphase_flow(prompt: str) -> Dict[str, Any]: """Detect multiphase flow indicators from the prompt using word boundaries.""" import re @@ -816,7 +1031,26 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: # Modify prompt template to handle STL files stl_instruction = "" if state.get("stl_file"): - stl_instruction = f""" + # Check if this is a nozzle STL file + stl_filename = state.get("stl_file", "").lower() + user_prompt_lower = state["user_prompt"].lower() + + is_nozzle_stl = any(keyword in stl_filename for keyword in ["nozzle", "jet", "rocket", "throat"]) or \ + any(keyword in user_prompt_lower for keyword in ["nozzle", "jet nozzle", "rocket nozzle", "throat"]) + + if is_nozzle_stl: + stl_instruction = f""" +IMPORTANT: The user is providing a nozzle STL file for custom geometry: {state['stl_file']} +- Set geometry_type to "nozzle" (this is a nozzle STL file) +- Set is_custom_geometry to true +- Set is_external_flow to false (nozzles are internal flow through the geometry) +- Set domain_type to "channel" +- Set domain_size_multiplier to 3.0 (smaller domain for internal nozzle flow) +- The STL file defines the nozzle geometry (converging-diverging profile) +- Focus on extracting flow parameters, boundary conditions, and simulation settings from the prompt +""" + else: + stl_instruction = f""" IMPORTANT: The user is providing an STL file for custom geometry: {state['stl_file']} - Set geometry_type to "custom" - Set is_custom_geometry to true @@ -846,10 +1080,10 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: IMPORTANT RULES: 1. For "flow around" objects (cylinder, sphere, airfoil), set is_external_flow=true and domain_type="unbounded" -2. For "flow through" or "flow in" objects (pipe, channel), set is_external_flow=false and domain_type="channel" +2. For "flow through" or "flow in" objects (pipe, channel, nozzle), set is_external_flow=false and domain_type="channel" 3. If neither is specified, use these defaults: - Cylinder, Sphere, Airfoil → external flow (around object) - - Pipe, Channel → internal flow (through object) + - Pipe, Channel, Nozzle → internal flow (through object) 4. Extract ALL numerical dimensions mentioned (with unit conversion to meters) 5. For external flow, set domain_size_multiplier appropriately (typically 20-30x object size) 6. DEFAULT TO UNSTEADY (TRANSIENT) ANALYSIS unless the user explicitly mentions "steady", "steady-state", or "stationary" @@ -860,8 +1094,11 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: Examples of dimension extraction: - "10mm diameter cylinder" → diameter: 0.01 (converted to meters) - "5 inch pipe" → diameter: 0.127 (converted to meters) +- "nozzle with 50mm throat diameter" → throat_diameter: 0.05 (converted to meters) +- "expansion ratio 2.5" → expansion_ratio: 2.5 - "flow around a cylinder" → is_external_flow: true, domain_type: "unbounded" - "flow through a pipe" → is_external_flow: false, domain_type: "channel" +- "flow in a nozzle" → is_external_flow: false, domain_type: "channel" Examples of analysis type extraction: - "flow around a cylinder" → analysis_type: UNSTEADY (default) @@ -910,20 +1147,43 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: # Handle STL file-specific logic if state.get("stl_file"): - # Force custom geometry settings for STL files - parsed_params["geometry_type"] = GeometryType.CUSTOM - parsed_params["is_custom_geometry"] = True - parsed_params["geometry_dimensions"] = {"is_3d": True} # Mark as 3D + # Check if this is a nozzle STL file + stl_filename = state.get("stl_file", "").lower() + user_prompt_lower = state["user_prompt"].lower() - # Set appropriate flow context for STL files (typically external flow) - parsed_params["flow_context"] = { - "is_external_flow": True, - "domain_type": "unbounded", - "domain_size_multiplier": 20.0 - } + is_nozzle_stl = any(keyword in stl_filename for keyword in ["nozzle", "jet", "rocket", "throat"]) or \ + any(keyword in user_prompt_lower for keyword in ["nozzle", "jet nozzle", "rocket nozzle", "throat"]) - if state["verbose"]: - logger.info("NL Interpreter: STL file detected, configured for custom 3D geometry") + if is_nozzle_stl: + # Force nozzle geometry settings for nozzle STL files + parsed_params["geometry_type"] = GeometryType.NOZZLE + parsed_params["is_custom_geometry"] = True + parsed_params["geometry_dimensions"] = {"is_3d": True} # Mark as 3D + + # Set appropriate flow context for nozzle STL files (internal flow) + parsed_params["flow_context"] = { + "is_external_flow": False, + "domain_type": "channel", + "domain_size_multiplier": 3.0 + } + + if state["verbose"]: + logger.info("NL Interpreter: Nozzle STL file detected, configured for internal flow geometry") + else: + # Force custom geometry settings for non-nozzle STL files + parsed_params["geometry_type"] = GeometryType.CUSTOM + parsed_params["is_custom_geometry"] = True + parsed_params["geometry_dimensions"] = {"is_3d": True} # Mark as 3D + + # Set appropriate flow context for STL files (typically external flow) + parsed_params["flow_context"] = { + "is_external_flow": True, + "domain_type": "unbounded", + "domain_size_multiplier": 20.0 + } + + if state["verbose"]: + logger.info("NL Interpreter: STL file detected, configured for custom 3D geometry") else: # Extract dimensions from text (as backup/enhancement) for non-STL cases text_dimensions = extract_dimensions_from_text(state["user_prompt"], parsed_params["geometry_type"]) @@ -1006,6 +1266,44 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: if state["verbose"]: logger.info(f"NL Interpreter: Detected rotation request: {rotation_info}") + # Detect mesh convergence request from prompt + mesh_convergence_info = detect_mesh_convergence_request(state["user_prompt"]) + parsed_params["mesh_convergence_info"] = mesh_convergence_info + + # Log mesh convergence detection if found + if mesh_convergence_info["mesh_convergence_active"]: + if state["verbose"]: + logger.info(f"NL Interpreter: Detected mesh convergence request!") + logger.info(f"NL Interpreter: Mesh levels: {mesh_convergence_info['mesh_convergence_levels']}") + logger.info(f"NL Interpreter: Convergence threshold: {mesh_convergence_info['mesh_convergence_threshold']}%") + if mesh_convergence_info["mesh_convergence_target_params"]: + logger.info(f"NL Interpreter: Target parameters: {mesh_convergence_info['mesh_convergence_target_params']}") + + # Detect GPU request from prompt + gpu_info = detect_gpu_request(state["user_prompt"]) + + # Merge GPU info with existing flag from CLI + # Priority: CLI flag > prompt detection + final_gpu_info = { + "use_gpu": state.get("use_gpu", False) or gpu_info["use_gpu"], + "gpu_explicit": state.get("use_gpu", False) or gpu_info["gpu_explicit"], + "gpu_backend": gpu_info["gpu_backend"] + } + + # Log GPU detection if found + if final_gpu_info["use_gpu"]: + if state["verbose"]: + logger.info(f"NL Interpreter: GPU acceleration requested!") + logger.info(f"NL Interpreter: GPU explicit: {final_gpu_info['gpu_explicit']}") + logger.info(f"NL Interpreter: GPU backend: {final_gpu_info['gpu_backend']}") + if state.get("use_gpu", False): + logger.info(f"NL Interpreter: GPU enabled via --use-gpu flag") + if gpu_info["use_gpu"]: + logger.info(f"NL Interpreter: GPU requested in prompt") + + # Store GPU info in parsed parameters + parsed_params["gpu_info"] = final_gpu_info + # Detect advanced parameters from prompt and check for validation errors advanced_params = detect_advanced_parameters(state["user_prompt"]) all_validation_errors = [] @@ -1042,7 +1340,15 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: "warnings": state["warnings"] + [warning_message], "parsed_parameters": parsed_params, "geometry_info": geometry_info, - "current_step": CFDStep.NL_INTERPRETATION + "current_step": CFDStep.NL_INTERPRETATION, + # Include mesh convergence parameters in state + "mesh_convergence_active": mesh_convergence_info["mesh_convergence_active"], + "mesh_convergence_levels": mesh_convergence_info["mesh_convergence_levels"], + "mesh_convergence_threshold": mesh_convergence_info["mesh_convergence_threshold"], + "mesh_convergence_target_params": mesh_convergence_info["mesh_convergence_target_params"], + # Include GPU parameters in state + "use_gpu": final_gpu_info["use_gpu"], + "gpu_info": final_gpu_info } else: # Normal validation failure - stop execution with helpful error message @@ -1059,7 +1365,15 @@ def nl_interpreter_agent(state: CFDState) -> CFDState: "parsed_parameters": parsed_params, "geometry_info": geometry_info, "original_prompt": state["user_prompt"], # Pass original prompt for AI solver selection - "errors": [] + "errors": [], + # Include mesh convergence parameters in state + "mesh_convergence_active": mesh_convergence_info["mesh_convergence_active"], + "mesh_convergence_levels": mesh_convergence_info["mesh_convergence_levels"], + "mesh_convergence_threshold": mesh_convergence_info["mesh_convergence_threshold"], + "mesh_convergence_target_params": mesh_convergence_info["mesh_convergence_target_params"], + # Include GPU parameters in state + "use_gpu": final_gpu_info["use_gpu"], + "gpu_info": final_gpu_info } except Exception as e: @@ -1119,18 +1433,184 @@ def get_characteristic_length(geometry_info: Dict[str, Any]) -> Optional[float]: return dimensions.get("side_length", 0.1) # Default 0.1m side length elif geometry_type == GeometryType.CHANNEL: return dimensions.get("height", 0.1) # Default 0.1m height + elif geometry_type == GeometryType.NOZZLE: + # For nozzles, characteristic length is the throat diameter + return dimensions.get('throat_diameter', dimensions.get('length', 0.1)) else: return 0.1 # Default characteristic length + +def detect_mars_simulation(prompt: str) -> bool: + """Detect if the user is requesting a Mars simulation.""" + mars_keywords = [ + "mars", "martian", "red planet", "on mars", "mars atmosphere", + "mars surface", "mars conditions", "mars environment" + ] + + prompt_lower = prompt.lower() + return any(keyword in prompt_lower for keyword in mars_keywords) + + +def detect_moon_simulation(prompt: str) -> bool: + """Detect if the user is requesting a Moon simulation.""" + moon_keywords = [ + "moon", "lunar", "on the moon", "moon surface", "moon conditions", + "moon environment", "lunar surface", "lunar conditions", "lunar environment" + ] + + prompt_lower = prompt.lower() + return any(keyword in prompt_lower for keyword in moon_keywords) + + +def detect_custom_environment(prompt: str) -> Dict[str, Any]: + """Use OpenAI to detect and extract custom environmental conditions.""" + try: + # Skip if it's already Mars/Moon/Earth + if detect_mars_simulation(prompt) or detect_moon_simulation(prompt): + return {"has_custom_environment": False} + + # Check for environmental indicators + environmental_keywords = [ + "pluto", "venus", "jupiter", "saturn", "neptune", "uranus", "mercury", + "altitude", "elevation", "sea level", "underwater", "deep ocean", "high altitude", + "mountain", "stratosphere", "atmosphere", "pressure", "vacuum", "space", + "planet", "planetary", "conditions", "environment" + ] + + prompt_lower = prompt.lower() + has_environmental_context = any(keyword in prompt_lower for keyword in environmental_keywords) + + if not has_environmental_context: + return {"has_custom_environment": False} + + # Get settings for API key + import sys + sys.path.append('src') + from foamai.config import get_settings + settings = get_settings() + + if not settings.openai_api_key: + logger.warning("No OpenAI API key found for custom environment detection") + return {"has_custom_environment": False} + + # Use OpenAI to extract environmental parameters + import openai + client = openai.OpenAI(api_key=settings.openai_api_key) + + system_message = """You are an expert in planetary science and atmospheric physics. Analyze the given prompt to determine if it describes a specific environmental or planetary condition that would affect fluid dynamics simulation parameters. + +Your task is to: +1. Identify if there's a specific environment mentioned (planet, altitude, etc.) +2. Determine appropriate physical parameters for that environment +3. Return the parameters in the specified JSON format + +Be accurate with scientific values. If unsure about specific parameters, use reasonable estimates based on known science.""" + + user_message = f"""Analyze this fluid dynamics scenario for custom environmental conditions: + +PROMPT: "{prompt}" + +If this describes a specific environment (planet, altitude, underwater, etc.) that differs from standard Earth sea-level conditions, extract the appropriate physical parameters. + +Respond with ONLY this JSON format: +{{ + "has_custom_environment": true/false, + "environment_name": "name of environment (e.g., 'Pluto', 'High Altitude', 'Underwater')", + "temperature": temperature_in_kelvin, + "pressure": pressure_in_pascals, + "density": density_in_kg_per_m3, + "viscosity": viscosity_in_pa_s, + "gravity": gravity_in_m_per_s2, + "explanation": "brief explanation of the environment and parameter choices" +}} + +Examples: +- "Flow on Pluto" → Pluto conditions (40K, very low pressure/density, 0.62 m/s² gravity) +- "Flow at 10km altitude" → High altitude conditions (reduced pressure/density, same gravity) +- "Flow 100m underwater" → Underwater conditions (high pressure, water density) +- "Flow around cylinder" → has_custom_environment: false (standard conditions) + +If no specific environment is mentioned, return has_custom_environment: false.""" + + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system_message}, + {"role": "user", "content": user_message} + ], + max_tokens=500, + temperature=0.1 + ) + + # Parse JSON response + import json + try: + result = json.loads(response.choices[0].message.content) + if result.get("has_custom_environment", False): + logger.info(f"Detected custom environment: {result.get('environment_name', 'Unknown')}") + logger.info(f"Parameters: T={result.get('temperature')}K, P={result.get('pressure')}Pa, ρ={result.get('density')}kg/m³") + return result + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse OpenAI environment response: {e}") + return {"has_custom_environment": False} + + except Exception as e: + logger.warning(f"Custom environment detection failed: {e}") + return {"has_custom_environment": False} + + def set_default_fluid_properties(params: Dict[str, Any]) -> Dict[str, Any]: """Set default fluid properties if not specified.""" - defaults = { - "density": 1.225, # Air at sea level (kg/m³) - "viscosity": 1.81e-5, # Air at 20°C (Pa·s) - "temperature": 293.15, # 20°C in Kelvin - "pressure": 101325, # Atmospheric pressure (Pa) - } + # Check if this is a Mars, Moon, or custom environment simulation + original_prompt = params.get("original_prompt", "") + is_mars_simulation = detect_mars_simulation(original_prompt) + is_moon_simulation = detect_moon_simulation(original_prompt) + + # Check for custom environment if not Mars/Moon + custom_environment = None + if not is_mars_simulation and not is_moon_simulation: + custom_environment = detect_custom_environment(original_prompt) + + if is_mars_simulation: + # Mars atmospheric conditions + defaults = { + "density": 0.02, # Mars atmosphere density (kg/m³) + "viscosity": 1.0e-5, # Mars atmosphere viscosity (Pa·s) + "temperature": 210.0, # Mars surface temperature (K) - approximately -63°C + "pressure": 610.0, # Mars atmospheric pressure (Pa) - about 0.6% of Earth's + } + logger.info("Using Mars atmospheric conditions for simulation") + elif is_moon_simulation: + # Moon atmospheric conditions (essentially vacuum) + defaults = { + "density": 1e-14, # Moon trace atmosphere density (kg/m³) + "viscosity": 1.0e-5, # Moon atmosphere viscosity (Pa·s) - similar to Mars for numerical stability + "temperature": 250.0, # Moon surface temperature (K) - approximately -23°C (day side) + "pressure": 3e-15, # Moon atmospheric pressure (Pa) - essentially vacuum + } + logger.info("Using Moon atmospheric conditions for simulation") + elif custom_environment and custom_environment.get("has_custom_environment", False): + # Custom environment conditions (detected by OpenAI) + defaults = { + "density": custom_environment.get("density", 1.225), + "viscosity": custom_environment.get("viscosity", 1.81e-5), + "temperature": custom_environment.get("temperature", 293.15), + "pressure": custom_environment.get("pressure", 101325), + } + env_name = custom_environment.get("environment_name", "Unknown") + explanation = custom_environment.get("explanation", "No explanation provided") + logger.info(f"Using custom environment conditions for simulation: {env_name}") + logger.info(f"Environment details: {explanation}") + else: + # Earth atmospheric conditions (default) + defaults = { + "density": 1.225, # Air at sea level (kg/m³) + "viscosity": 1.81e-5, # Air at 20°C (Pa·s) + "temperature": 293.15, # 20°C in Kelvin + "pressure": 101325, # Atmospheric pressure (Pa) + } + for key, value in defaults.items(): if key not in params or params[key] is None: diff --git a/src/foamai-core/foamai_core/orchestrator.py b/src/foamai-core/foamai_core/orchestrator.py index 2087c5c..fd7b377 100644 --- a/src/foamai-core/foamai_core/orchestrator.py +++ b/src/foamai-core/foamai_core/orchestrator.py @@ -521,10 +521,18 @@ def create_initial_state( user_approval_enabled: bool = True, stl_file: Optional[str] = None, force_validation: bool = False, + + mesh_convergence_active: bool = False, + mesh_convergence_levels: int = 4, + mesh_convergence_target_params: List[str] = None, + mesh_convergence_threshold: float = 1.0, + use_gpu: bool = False + # Remote execution parameters execution_mode: str = "local", server_url: Optional[str] = None, project_name: Optional[str] = None + ) -> CFDState: """ Create initial state for the CFD workflow. @@ -585,6 +593,21 @@ def create_initial_state( current_iteration=0, conversation_active=True, previous_results=None, + mesh_convergence_active=mesh_convergence_active, + mesh_convergence_levels=mesh_convergence_levels, + mesh_convergence_target_params=mesh_convergence_target_params or [], + mesh_convergence_threshold=mesh_convergence_threshold, + mesh_convergence_results={}, + mesh_convergence_report={}, + recommended_mesh_level=0, + use_gpu=use_gpu, + gpu_info={ + "use_gpu": use_gpu, + "gpu_explicit": False, + "gpu_backend": "petsc" + } + ) + # Remote execution fields execution_mode=execution_mode, server_url=server_url, @@ -917,4 +940,5 @@ def execute_solver_only(state: CFDState) -> CFDState: **state, "errors": state.get("errors", []) + [f"Solver execution failed: {str(e)}"], "current_step": CFDStep.ERROR - } \ No newline at end of file + } + diff --git a/src/foamai-core/foamai_core/simulation_executor.py b/src/foamai-core/foamai_core/simulation_executor.py index 3d5d87d..6db752f 100644 --- a/src/foamai-core/foamai_core/simulation_executor.py +++ b/src/foamai-core/foamai_core/simulation_executor.py @@ -7,11 +7,11 @@ from pathlib import Path from typing import Dict, Any, Optional, List from loguru import logger - -from .state import CFDState, CFDStep, GeometryType +from .state import CFDState, CFDStep, GeometryType, SolverType from .remote_executor import RemoteExecutor + def simulation_executor_agent(state: CFDState) -> CFDState: """ Simulation Executor Agent. @@ -821,8 +821,32 @@ def run_solver(case_directory: Path, solver: str, state: CFDState) -> Dict[str, from foamai.config import get_settings settings = get_settings() - # Prepare environment - env = prepare_openfoam_env() + # Check if GPU acceleration is requested + gpu_info = state.get("gpu_info", {}) + use_gpu = gpu_info.get("use_gpu", False) + + # Prepare environment with GPU support if requested + env = prepare_openfoam_env(use_gpu=use_gpu) + + if use_gpu: + # Check if GPU libraries are actually available + import os + home_dir = os.path.expanduser("~") + petsc_dir = f"{home_dir}/gpu_libs/petsc-3.20.6" + petsc_arch = "linux-gnu-cuda-opt" + gpu_libs_available = os.path.exists(petsc_dir) and os.path.exists(f"{petsc_dir}/{petsc_arch}") + + if state["verbose"]: + logger.info("=" * 60) + logger.info("GPU ACCELERATION ENABLED") + logger.info(f"GPU Backend: {gpu_info.get('gpu_backend', 'petsc')}") + if gpu_libs_available: + logger.info("✅ GPU libraries detected - using GPU acceleration") + logger.info(f"PETSc Directory: {petsc_dir}") + else: + logger.warning("⚠️ GPU libraries not found - falling back to CPU") + logger.warning("To install GPU support, run: bash setup_gpu_acceleration.sh") + logger.info("=" * 60) # Determine if we need to use WSL if settings.openfoam_path and settings.openfoam_path.startswith("/"): @@ -989,8 +1013,9 @@ def run_solver(case_directory: Path, solver: str, state: CFDState) -> Dict[str, } -def prepare_openfoam_env() -> Dict[str, str]: +def prepare_openfoam_env(use_gpu: bool = False) -> Dict[str, str]: """Prepare OpenFOAM environment variables.""" + import os env = os.environ.copy() # Get configured OpenFOAM path @@ -1019,6 +1044,53 @@ def prepare_openfoam_env() -> Dict[str, str]: else: env["PATH"] = bin_path + # Add GPU environment if requested + if use_gpu: + import os + home_dir = os.path.expanduser("~") + + # PETSc environment + petsc_dir = f"{home_dir}/gpu_libs/petsc-3.20.6" + petsc_arch = "linux-gnu-cuda-opt" + + # Check if GPU libraries are installed + if os.path.exists(petsc_dir) and os.path.exists(f"{petsc_dir}/{petsc_arch}"): + env["PETSC_DIR"] = petsc_dir + env["PETSC_ARCH"] = petsc_arch + + # Add PETSc libraries to LD_LIBRARY_PATH + petsc_lib_path = f"{petsc_dir}/{petsc_arch}/lib" + if "LD_LIBRARY_PATH" in env: + env["LD_LIBRARY_PATH"] = f"{petsc_lib_path}:{env['LD_LIBRARY_PATH']}" + else: + env["LD_LIBRARY_PATH"] = petsc_lib_path + + # Add PETSc binaries to PATH + petsc_bin_path = f"{petsc_dir}/{petsc_arch}/bin" + if "PATH" in env: + env["PATH"] = f"{petsc_bin_path}:{env['PATH']}" + else: + env["PATH"] = petsc_bin_path + + # CUDA environment + cuda_home = "/usr/local/cuda" + if os.path.exists(cuda_home): + env["CUDA_HOME"] = cuda_home + + # Add CUDA libraries to LD_LIBRARY_PATH + cuda_lib_path = f"{cuda_home}/lib64" + if "LD_LIBRARY_PATH" in env: + env["LD_LIBRARY_PATH"] = f"{cuda_lib_path}:{env['LD_LIBRARY_PATH']}" + else: + env["LD_LIBRARY_PATH"] = cuda_lib_path + + # Add CUDA binaries to PATH + cuda_bin_path = f"{cuda_home}/bin" + if "PATH" in env: + env["PATH"] = f"{cuda_bin_path}:{env['PATH']}" + else: + env["PATH"] = cuda_bin_path + return env @@ -1374,6 +1446,8 @@ def get_characteristic_length_from_geometry(geometry_info: Dict[str, Any]) -> fl return dimensions.get("diameter", 0.05) elif geometry_type == GeometryType.CHANNEL: return dimensions.get("height", 0.02) + elif geometry_type == GeometryType.NOZZLE: + return dimensions.get("throat_diameter", dimensions.get("length", 0.1)) else: # Try to find any dimension for key in ["diameter", "length", "width", "height", "chord"]: @@ -1556,10 +1630,54 @@ def remap_boundary_conditions_after_mesh(case_directory: Path, state: CFDState) boundary_conditions, actual_patches, geometry_type, case_directory ) - # For complex solvers, enhance with AI boundary conditions + # Get solver information for compressible flow correction solver_settings = state.get("solver_settings", {}) solver_type = solver_settings.get("solver_type") + # CRITICAL FIX: Correct pressure values for compressible solvers + # This runs after solver selection, so we know the solver type + if solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.SONIC_FOAM, SolverType.CHT_MULTI_REGION_FOAM, SolverType.REACTING_FOAM]: + logger.info(f"Simulation Executor: Correcting pressure values for compressible solver {solver_type}") + + # Fix pressure field for compressible flows + if "p" in mapped_conditions: + p_field = mapped_conditions["p"] + parsed_params = state.get("parsed_parameters", {}) + + # Check if we have gauge pressure (0 Pa) and need absolute pressure + internal_field = p_field.get("internalField", "uniform 0") + + # Extract pressure value from internal field + import re + pressure_match = re.search(r'uniform\s+([\d.-]+)', internal_field) + if pressure_match: + current_pressure = float(pressure_match.group(1)) + + # If pressure is 0 (gauge pressure), convert to absolute pressure + if abs(current_pressure) < 1.0: # Very close to zero (gauge pressure) + absolute_pressure = parsed_params.get("pressure", 101325.0) + if absolute_pressure < 50000: # If parsed pressure is also low, default to atmospheric + absolute_pressure = 101325.0 + + logger.info(f"Correcting pressure from {current_pressure} Pa (gauge) to {absolute_pressure} Pa (absolute) for {solver_type}") + + # Update internal field + p_field["internalField"] = f"uniform {absolute_pressure}" + + # Update dimensions for compressible solver (absolute pressure) + p_field["dimensions"] = "[1 -1 -2 0 0 0 0]" + + # Update boundary field outlet values + if "boundaryField" in p_field: + for patch_name, patch_bc in p_field["boundaryField"].items(): + if patch_bc.get("type") == "fixedValue": + patch_bc["value"] = f"uniform {absolute_pressure}" + + # Mark that we updated the pressure field + result["fields_updated"].append("p_corrected_for_compressible") + + # For complex solvers, enhance with AI boundary conditions + if solver_type and hasattr(solver_type, 'value'): solver_name = solver_type.value else: @@ -1620,57 +1738,106 @@ def remap_boundary_conditions_after_mesh(case_directory: Path, state: CFDState) patches_info = read_mesh_patches_with_types(case_directory) patch_types = {info['name']: info['type'] for info in patches_info} - # Update alpha.water with correct boundary conditions + # Update alpha.water with correct boundary conditions (use mapped U field as basis) if "alpha.water" in solver_settings: alpha_water_file = case_directory / "0" / "alpha.water" if alpha_water_file.exists(): from .case_writer import write_foam_dict - alpha_water_config = solver_settings["alpha.water"] - - # Correct boundary conditions for each patch - if "boundaryField" in alpha_water_config: - corrected_boundary_field = {} - for patch_name, bc_data in alpha_water_config["boundaryField"].items(): - patch_type = patch_types.get(patch_name, "patch") - corrected_bc = adjust_boundary_condition_for_patch_type( - bc_data, patch_type, "alpha.water", patch_name - ) - corrected_boundary_field[patch_name] = corrected_bc + # Use mapped U field boundary conditions as basis for alpha.water + if "U" in mapped_conditions: + u_boundary_field = mapped_conditions["U"]["boundaryField"] + # Create alpha.water boundary conditions based on U field + alpha_boundary_field = {} + for patch_name, patch_config in u_boundary_field.items(): + if patch_name == "inlet": + # For inlet, set alpha.water = 0 (air only) + alpha_boundary_field[patch_name] = { + "type": "fixedValue", + "value": "uniform 0" + } + elif patch_name == "outlet": + alpha_boundary_field[patch_name] = { + "type": "zeroGradient" + } + else: + # For all other patches (walls, top, bottom, etc.) + alpha_boundary_field[patch_name] = { + "type": "zeroGradient" + } + + # Create corrected alpha.water field corrected_alpha_water = { - **alpha_water_config, - "boundaryField": corrected_boundary_field + "dimensions": "[0 0 0 0 0 0 0]", # Dimensionless volume fraction + "internalField": "uniform 0", # Start with air only + "boundaryField": alpha_boundary_field } write_foam_dict(alpha_water_file, corrected_alpha_water) result["fields_updated"].append("alpha.water") + else: + # Fallback to original method if U field not available + alpha_water_config = solver_settings["alpha.water"] + + # Correct boundary conditions for each patch + if "boundaryField" in alpha_water_config: + corrected_boundary_field = {} + for patch_name, bc_data in alpha_water_config["boundaryField"].items(): + patch_type = patch_types.get(patch_name, "patch") + corrected_bc = adjust_boundary_condition_for_patch_type( + bc_data, patch_type, "alpha.water", patch_name + ) + corrected_boundary_field[patch_name] = corrected_bc + + corrected_alpha_water = { + **alpha_water_config, + "boundaryField": corrected_boundary_field + } + + write_foam_dict(alpha_water_file, corrected_alpha_water) + result["fields_updated"].append("alpha.water") - # Update p_rgh with correct boundary conditions + # Update p_rgh with correct boundary conditions (use mapped p field as basis) if "p_rgh" in solver_settings: p_rgh_file = case_directory / "0" / "p_rgh" if p_rgh_file.exists(): from .case_writer import write_foam_dict - p_rgh_config = solver_settings["p_rgh"] - - # Correct boundary conditions for each patch - if "boundaryField" in p_rgh_config: - corrected_boundary_field = {} - for patch_name, bc_data in p_rgh_config["boundaryField"].items(): - patch_type = patch_types.get(patch_name, "patch") - corrected_bc = adjust_boundary_condition_for_patch_type( - bc_data, patch_type, "p_rgh", patch_name - ) - corrected_boundary_field[patch_name] = corrected_bc + # Use mapped p field boundary conditions as basis for p_rgh + if "p" in mapped_conditions: + p_boundary_field = mapped_conditions["p"]["boundaryField"] + # Create p_rgh with same boundary conditions as p field corrected_p_rgh = { - **p_rgh_config, - "boundaryField": corrected_boundary_field + "dimensions": "[1 -1 -2 0 0 0 0]", # Kinematic pressure for interFoam + "internalField": "uniform 0", + "boundaryField": p_boundary_field } write_foam_dict(p_rgh_file, corrected_p_rgh) result["fields_updated"].append("p_rgh") + else: + # Fallback to original method if p field not available + p_rgh_config = solver_settings["p_rgh"] + + # Correct boundary conditions for each patch + if "boundaryField" in p_rgh_config: + corrected_boundary_field = {} + for patch_name, bc_data in p_rgh_config["boundaryField"].items(): + patch_type = patch_types.get(patch_name, "patch") + corrected_bc = adjust_boundary_condition_for_patch_type( + bc_data, patch_type, "p_rgh", patch_name + ) + corrected_boundary_field[patch_name] = corrected_bc + + corrected_p_rgh = { + **p_rgh_config, + "boundaryField": corrected_boundary_field + } + + write_foam_dict(p_rgh_file, corrected_p_rgh) + result["fields_updated"].append("p_rgh") result["success"] = True logger.info(f"Boundary condition remapping completed: {len(result['fields_updated'])} fields updated") diff --git a/src/foamai-core/foamai_core/solver_selector.py b/src/foamai-core/foamai_core/solver_selector.py index 12f163d..905477e 100644 --- a/src/foamai-core/foamai_core/solver_selector.py +++ b/src/foamai-core/foamai_core/solver_selector.py @@ -7,6 +7,910 @@ from .state import CFDState, CFDStep, GeometryType, FlowType, AnalysisType, SolverType +# Enhanced keyword detection with context analysis and weighting +KEYWORD_WEIGHTS = { + "compressible": { + # Core compressible flow keywords + "shock": 3.0, + "supersonic": 2.5, + "transonic": 2.0, + "hypersonic": 2.5, + "mach": 2.0, + "compressible": 2.0, + "gas dynamics": 1.5, + "high-speed": 1.5, + "high speed": 1.5, + "sonic boom": 2.0, + # Applications and phenomena + "nozzle": 1.5, + "jet": 1.5, + "rocket": 1.5, + "blast": 2.0, + "explosion": 2.0, + "expansion": 1.0, + "compression": 1.0, + "rarefaction": 2.0, + "oblique shock": 2.5, + "normal shock": 2.5, + "prandtl-meyer": 2.0, + "fanno": 1.5, + "rayleigh": 1.5, + # Aerospace applications + "aircraft": 1.0, + "airfoil supersonic": 2.0, + "wind tunnel": 1.0, + "ballistics": 2.0, + "projectile": 1.5, + # Negative weights for conflicting contexts + "wave": -1.0, # "shock wave" vs "water wave" + "water": -2.0, # Compressible flows rarely involve water + "liquid": -2.0, + "free surface": -1.5, + "dam break": -2.0, + "sloshing": -2.0, + "marine": -2.0, + "naval": -2.0 + }, + "multiphase": { + # Fluid types + "water": 2.0, + "liquid": 1.5, + "gas": 1.0, + "oil": 2.0, + "steam": 1.5, + "vapor": 1.5, + "mist": 2.0, + "fog": 1.5, + "spray": 2.0, + # Interface phenomena + "interface": 2.0, + "free surface": 2.5, + "meniscus": 2.0, + "contact line": 2.0, + "wetting": 1.5, + "dewetting": 1.5, + "surface tension": 2.5, + "capillary": 2.0, + # Methods and models + "vof": 3.0, + "volume of fluid": 3.0, + "level set": 2.5, + "multiphase": 3.0, + "two-phase": 2.5, + "three-phase": 2.5, + "eulerian": 1.5, + "lagrangian": 1.5, + # Phenomena and applications + "dam break": 2.5, + "wave": 1.5, # Water waves, not shock waves + "tsunami": 2.5, + "breaking wave": 2.5, + "droplet": 2.0, + "bubble": 2.0, + "cavitation": 2.5, + "boiling": 2.0, + "condensation": 2.0, + "evaporation": 2.0, + "splash": 2.0, + "impact": 1.5, + "filling": 1.5, + "draining": 1.5, + "sloshing": 2.0, + "coating": 1.5, + "inkjet": 2.0, + "atomization": 2.0, + # Applications + "marine": 1.5, + "naval": 1.5, + "offshore": 1.5, + "ship": 1.0, + "hull": 1.0, + "tank": 1.0, + "reservoir": 1.0, + "pipeline": 1.0, + "microfluidics": 2.0, + "air": 0.5, # Only when combined with other fluids + # Negative weights for conflicting contexts + "shock": -2.0, # Shock waves are compressible phenomena + "supersonic": -2.0, + "hypersonic": -2.0, + "mach": -2.0, + "compressible": -2.0, + "gas dynamics": -1.5, + "ballistics": -2.0 + }, + "heat_transfer": { + # Core thermal keywords + "heat": 2.0, + "thermal": 2.0, + "temperature": 1.5, + "cooling": 1.5, + "heating": 1.5, + "hot": 1.0, + "cold": 1.0, + "warm": 1.0, + # Heat transfer mechanisms + "heat transfer": 3.0, + "conduction": 2.0, + "convection": 2.0, + "radiation": 1.5, + "natural convection": 2.5, + "forced convection": 2.5, + "mixed convection": 2.5, + "free convection": 2.5, + # Multi-physics coupling + "conjugate": 2.5, + "conjugate heat transfer": 3.0, + "cht": 3.0, + "cfd-cht": 3.0, + "fluid-solid": 2.5, + "solid-fluid": 2.0, + "solid fluid": 2.0, + "coupling": 1.5, + "thermal coupling": 2.5, + "multi-region": 2.5, + "multi region": 2.5, + "multiregion": 2.5, + # Boundaries and conditions + "heat flux": 2.0, + "thermal boundary": 2.0, + "wall temperature": 1.5, + "wall conduction": 2.0, + "solid wall": 1.5, + "thermal contact": 2.0, + "interface temperature": 2.0, + # Applications and equipment + "heat exchanger": 2.5, + "radiator": 2.0, + "heat sink": 2.0, + "cooling system": 2.0, + "thermal management": 2.0, + "hvac": 2.0, + "boiler": 2.0, + "furnace": 2.0, + "oven": 1.5, + "chimney": 1.5, + "stack": 1.0, + "insulation": 1.5, + "thermal insulation": 2.0, + # Phase change + "melting": 2.0, + "solidification": 2.0, + "freezing": 2.0, + "phase change": 2.5, + "latent heat": 2.0, + # Electronics cooling + "electronics": 1.5, + "cpu": 1.5, + "chip": 1.5, + "pcb": 1.5, + "thermal interface": 2.0 + }, + "reactive": { + # Core combustion keywords + "combustion": 3.0, + "burning": 2.5, + "flame": 2.5, + "fire": 2.0, + "ignition": 2.0, + "autoignition": 2.5, + "quenching": 2.0, + "extinction": 2.0, + "flashback": 2.5, + "blowoff": 2.5, + # Chemical processes + "reaction": 2.0, + "chemical": 2.0, + "chemistry": 2.0, + "kinetics": 2.0, + "mechanism": 1.5, + "species": 2.0, + "concentration": 1.5, + "mass fraction": 2.0, + "mole fraction": 2.0, + "mixture fraction": 2.0, + "progress variable": 2.0, + # Fuel types + "fuel": 2.0, + "oxidizer": 2.0, + "methane": 2.0, + "propane": 2.0, + "hydrogen": 2.0, + "ethane": 2.0, + "gasoline": 2.0, + "diesel": 2.0, + "kerosene": 2.0, + "natural gas": 2.0, + "biogas": 2.0, + "syngas": 2.0, + "ammonia": 2.0, + # Combustion modes + "premixed": 2.5, + "non-premixed": 2.5, + "partially premixed": 2.5, + "diffusion flame": 2.5, + "laminar flame": 2.5, + "turbulent flame": 2.5, + "stratified": 2.0, + # Combustion phenomena + "detonation": 2.5, + "deflagration": 2.5, + "knock": 2.0, + "auto-ignition": 2.5, + "flame speed": 2.0, + "flame front": 2.0, + "flame propagation": 2.0, + "flame stabilization": 2.0, + # Applications + "burner": 2.0, + "combustor": 2.0, + "engine": 1.5, + "gas turbine": 1.0, # Reduced score to avoid confusion with MRF + "furnace": 2.0, + "boiler": 1.5, + "incinerator": 2.0, + "flare": 2.0, + "torch": 1.5, + "propulsion": 1.5, + "rocket": 1.5, + "jet engine": 2.0, + "combustion turbine": 2.0, # More specific to avoid MRF confusion + "internal combustion": 2.5, + # Modeling approaches + "reacting": 3.0, + "reactive": 3.0, + "eddy dissipation": 2.0, + "flamelet": 2.5, + "pdf": 1.5, + "les combustion": 2.5, + "rans combustion": 2.5, + # Negative weights to avoid confusion + "shock wave": -2.0, # Compressible, not reactive + "supersonic": -2.0, + "hypersonic": -2.0, + "mach": -2.0, + "compressible": -1.5, + "gas dynamics": -2.0, + "nozzle": -1.5, + "pump": -1.5, # MRF, not reactive + "impeller": -1.5, + "centrifugal": -1.5, + "rotating": -1.5, + "rotation": -1.5, + "mrf": -2.0, + "multiple reference frame": -2.0, + "steady rotating": -2.0, + "free surface": -1.5, # Multiphase, not reactive + "dam break": -1.5, + "multiphase": -1.5, + "vof": -1.5, + "volume of fluid": -1.5 + }, + "steady": { + "steady": 2.0, + "steady-state": 2.5, + "steady state": 2.5, + "equilibrium": 2.0, + "final": 1.5, + "converged": 2.0, + "time-independent": 2.0, + "stationary": 2.0, + "constant": 1.0, + # Performance metrics (often steady-state) + "pressure drop": 2.0, + "drag coefficient": 1.5, + "lift coefficient": 1.5, + "efficiency": 1.0, + "performance": 1.0, + "design point": 1.5, + "operating point": 1.5, + # Negative weights for conflicting contexts + "transient": -2.0, + "time": -1.0, + "unsteady": -2.0, + "vortex": -1.5, + "shedding": -2.0, + "oscillat": -2.0, + "frequency": -2.0, + "startup": -2.0, + "development": -1.5, + "periodic": -2.0, + "pulsating": -2.0, + "fluctuating": -2.0 + }, + "transient": { + "transient": 2.5, + "unsteady": 2.5, + "time": 1.5, + "time-dependent": 2.5, + "time dependent": 2.5, + "temporal": 2.0, + "dynamic": 1.5, + "evolution": 1.5, + # Vortex phenomena + "vortex": 2.0, + "shedding": 2.5, + "vortex shedding": 3.0, + "karman": 2.5, + "von karman": 2.5, + "wake": 1.5, + "instability": 2.0, + # Oscillatory phenomena + "oscillat": 2.0, + "oscillating": 2.0, + "oscillation": 2.0, + "frequency": 2.0, + "periodic": 2.0, + "pulsating": 2.0, + "pulsation": 2.0, + "fluctuating": 2.0, + "fluctuation": 2.0, + "vibration": 1.5, + "resonance": 2.0, + # Flow development + "startup": 2.0, + "start-up": 2.0, + "development": 1.5, + "developing": 1.5, + "transient response": 2.5, + "impulse": 2.0, + "step response": 2.0, + # Turbulence + "turbulent": 1.0, + "turbulence": 1.0, + "eddy": 1.0, + "les": 1.5, # Large Eddy Simulation + "dns": 2.0, # Direct Numerical Simulation + # Dimensionless numbers + "strouhal": 2.0, + "strouhal number": 2.5, + "reduced frequency": 2.0, + # Negative weights for conflicting contexts + "steady": -2.0, + "steady-state": -2.5, + "steady state": -2.5, + "equilibrium": -2.0, + "final": -1.5, + "converged": -2.0, + "stationary": -2.0 + }, + "piso": { + # PISO algorithm preferences + "piso": 3.0, + "accurate": 2.0, + "precision": 2.0, + "accuracy": 2.0, + "high precision": 2.5, + "high accuracy": 2.5, + "temporal accuracy": 2.5, + "time accuracy": 2.5, + "temporal precision": 2.5, + "time precision": 2.5, + # Biomedical applications + "pulsatile": 2.5, + "pulsating": 2.5, + "arterial": 2.0, + "cardiovascular": 2.0, + "biomedical": 2.0, + "blood flow": 2.0, + "medical": 2.0, + "physiological": 2.0, + "heart": 2.0, + "artery": 2.0, + "vein": 2.0, + "cardiac": 2.0, + "vascular": 2.0, + # Oscillatory flows + "oscillatory": 2.0, + "oscillating": 2.0, + "womersley": 2.0, + "periodic": 1.5, + "cyclic": 1.5, + "sinusoidal": 2.0, + "harmonic": 2.0, + # High fidelity simulations + "high fidelity": 2.0, + "detailed": 1.5, + "fine": 1.5, + "resolved": 1.5, + "dns": 2.0, + "direct numerical": 2.0, + "well resolved": 2.0, + "high resolution": 2.0, + # Negative weights for less suitable cases + "coarse": -1.0, + "rough": -1.0, + "approximate": -1.0, + "fast": -1.0, + "quick": -1.0, + "rans": -1.0, + "reynolds averaged": -1.0 + }, + "sonic": { + # Supersonic flow indicators + "sonic": 2.5, + "supersonic": 3.0, + "hypersonic": 3.0, + "trans-sonic": 2.5, + "transonic": 2.5, + "sonic boom": 2.5, + "mach": 2.5, + "high mach": 2.5, + "mach number": 2.5, + # Shock phenomena + "shock": 3.0, + "shockwave": 3.0, + "shock wave": 3.0, + "shock waves": 3.0, + "oblique shock": 2.5, + "normal shock": 2.5, + "bow shock": 2.5, + "detached shock": 2.5, + "attached shock": 2.5, + "shock interaction": 2.5, + "shock reflection": 2.5, + "shock diffraction": 2.5, + # Expansion phenomena + "expansion": 2.0, + "expansion fan": 2.0, + "expansion wave": 2.0, + "prandtl-meyer": 2.0, + "rarefaction": 2.0, + "rarefaction wave": 2.0, + # Compressible flow theory + "fanno flow": 2.0, + "rayleigh flow": 2.0, + "isentropic": 2.0, + "adiabatic": 1.5, + "polytropic": 1.5, + "compressibility": 2.0, + "density variation": 2.0, + "pressure wave": 2.0, + "acoustic": 1.5, + "sound": 1.5, + "sound wave": 1.5, + # Aerospace applications + "aerodynamics": 2.0, + "aerospace": 2.0, + "aircraft": 2.0, + "fighter": 2.0, + "missile": 2.0, + "rocket": 2.0, + "spacecraft": 2.0, + "reentry": 2.0, + "ballistic": 2.0, + "projectile": 2.0, + "bullet": 2.0, + "artillery": 2.0, + # High-speed facilities + "wind tunnel": 2.0, + "shock tunnel": 2.5, + "blow-down": 2.0, + "hypersonic tunnel": 2.5, + "supersonic tunnel": 2.5, + "ludwieg tube": 2.0, + # Nozzle types + "nozzle": 2.0, + "de laval": 2.0, + "convergent-divergent": 2.0, + "cd nozzle": 2.0, + "rocket nozzle": 2.0, + "jet nozzle": 2.0, + "exhaust nozzle": 2.0, + "propelling nozzle": 2.0, + # Negative weights for less suitable cases + "subsonic": -1.0, + "low speed": -1.0, + "low mach": -1.0, + "incompressible": -2.0, + "water": -2.0, + "liquid": -2.0, + "multiphase": -1.0 + }, + "mrf": { + # MRF core keywords + "mrf": 3.0, + "multiple reference frame": 3.0, + "multiple reference frames": 3.0, + "multi reference frame": 3.0, + "rotating reference frame": 2.5, + "reference frame": 2.0, + "rotating frame": 2.5, + "moving reference frame": 2.5, + # Rotation keywords + "rotating": 2.5, + "rotation": 2.5, + "rotational": 2.5, + "rotary": 2.0, + "spinning": 2.0, + "angular": 2.0, + "angular velocity": 2.0, + "angular speed": 2.0, + "omega": 2.0, + "rpm": 2.0, + "revolutions per minute": 2.0, + "rad/s": 2.0, + "radians per second": 2.0, + # Rotating machinery + "rotating machinery": 2.5, + "turbomachinery": 2.5, + "machinery": 2.0, + "rotor": 2.5, + "stator": 2.0, + "rotating equipment": 2.5, + "rotating device": 2.5, + # Pumps + "pump": 2.0, + "centrifugal pump": 2.5, + "axial pump": 2.5, + "mixed flow pump": 2.5, + "radial pump": 2.5, + "impeller": 2.5, + "pump impeller": 2.5, + "centrifugal impeller": 2.5, + "axial impeller": 2.5, + "mixed flow impeller": 2.5, + "circulation pump": 2.0, + "cooling pump": 2.0, + "water pump": 2.0, + "oil pump": 2.0, + "chemical pump": 2.0, + "process pump": 2.0, + "fire pump": 2.0, + "booster pump": 2.0, + "multistage pump": 2.0, + "single stage pump": 2.0, + "double suction pump": 2.0, + "end suction pump": 2.0, + "split case pump": 2.0, + "volute pump": 2.0, + "diffuser pump": 2.0, + # Turbines + "turbine": 2.0, + "gas turbine": 2.0, + "steam turbine": 2.0, + "water turbine": 2.0, + "wind turbine": 2.0, + "hydroelectric turbine": 2.0, + "hydro turbine": 2.0, + "kaplan turbine": 2.0, + "francis turbine": 2.0, + "pelton turbine": 2.0, + "axial turbine": 2.0, + "radial turbine": 2.0, + "mixed flow turbine": 2.0, + "turbine runner": 2.0, + "turbine blade": 2.0, + "turbine vane": 2.0, + "guide vane": 2.0, + "wicket gate": 2.0, + "stay vane": 2.0, + "draft tube": 2.0, + "spiral case": 2.0, + "scroll case": 2.0, + "penstock": 2.0, + # Fans and blowers + "fan": 2.0, + "centrifugal fan": 2.5, + "axial fan": 2.5, + "mixed flow fan": 2.5, + "radial fan": 2.5, + "cooling fan": 2.0, + "exhaust fan": 2.0, + "supply fan": 2.0, + "ventilation fan": 2.0, + "industrial fan": 2.0, + "blower": 2.0, + "centrifugal blower": 2.5, + "axial blower": 2.5, + "roots blower": 2.0, + "screw blower": 2.0, + "air blower": 2.0, + "gas blower": 2.0, + # Compressors + "compressor": 2.0, + "centrifugal compressor": 2.5, + "axial compressor": 2.5, + "mixed flow compressor": 2.5, + "radial compressor": 2.5, + "air compressor": 2.0, + "gas compressor": 2.0, + "refrigeration compressor": 2.0, + "compressor stage": 2.0, + "compressor wheel": 2.0, + "compressor impeller": 2.5, + "compressor rotor": 2.5, + "compressor stator": 2.0, + "compressor blade": 2.0, + "compressor vane": 2.0, + "diffuser": 2.0, + "volute": 2.0, + "scroll": 2.0, + "vaneless diffuser": 2.0, + "vaned diffuser": 2.0, + # Propellers + "propeller": 2.0, + "prop": 2.0, + "screw": 2.0, + "marine propeller": 2.0, + "aircraft propeller": 2.0, + "ship propeller": 2.0, + "propeller blade": 2.0, + "propeller hub": 2.0, + "propeller boss": 2.0, + "ducted propeller": 2.0, + "open propeller": 2.0, + "fixed pitch propeller": 2.0, + "variable pitch propeller": 2.0, + "controllable pitch propeller": 2.0, + # Mixers and agitators + "mixer": 2.0, + "agitator": 2.0, + "impeller mixer": 2.5, + "stirrer": 2.0, + "mixing impeller": 2.5, + "agitator impeller": 2.5, + "rushton turbine": 2.5, + "pitched blade turbine": 2.5, + "axial flow impeller": 2.5, + "radial flow impeller": 2.5, + "marine impeller": 2.5, + "hydrofoil impeller": 2.5, + "anchor impeller": 2.5, + "helical impeller": 2.5, + "ribbon impeller": 2.5, + "paddle impeller": 2.5, + "propeller impeller": 2.5, + # Geometric components + "blade": 2.0, + "vane": 2.0, + "rotor blade": 2.0, + "stator blade": 2.0, + "guide blade": 2.0, + "runner blade": 2.0, + "impeller blade": 2.0, + "rotor vane": 2.0, + "stator vane": 2.0, + "guide vane": 2.0, + "inlet guide vane": 2.0, + "outlet guide vane": 2.0, + "hub": 2.0, + "shroud": 2.0, + "casing": 2.0, + "housing": 2.0, + "volute casing": 2.0, + "spiral casing": 2.0, + "scroll casing": 2.0, + "eye": 1.5, + "inlet eye": 1.5, + "suction eye": 1.5, + "discharge": 1.5, + "outlet": 1.5, + "suction": 1.5, + "inlet": 1.5, + # Performance characteristics + "head": 1.5, + "pressure head": 1.5, + "total head": 1.5, + "dynamic head": 1.5, + "static head": 1.5, + "flow rate": 1.0, + "discharge rate": 1.0, + "capacity": 1.0, + "efficiency": 1.5, + "performance": 1.5, + "characteristic": 1.5, + "performance curve": 1.5, + "characteristic curve": 1.5, + "operating point": 1.5, + "duty point": 1.5, + "best efficiency point": 1.5, + "bep": 1.5, + "npsh": 1.5, + "cavitation": 1.5, + "surge": 1.5, + "stall": 1.5, + "off-design": 1.5, + "part load": 1.5, + "overload": 1.5, + # Forces and moments + "torque": 2.0, + "moment": 2.0, + "power": 1.5, + "work": 1.5, + "shaft power": 1.5, + "hydraulic power": 1.5, + "mechanical power": 1.5, + "brake power": 1.5, + "input power": 1.5, + "output power": 1.5, + "coriolis": 2.0, + "centrifugal force": 2.0, + "centripetal force": 2.0, + "angular momentum": 2.0, + "moment of momentum": 2.0, + "euler equation": 2.0, + "euler turbine equation": 2.0, + # Steady-state indicators + "steady rotating": 2.5, + "steady state rotating": 2.5, + "steady rotation": 2.5, + "constant rotation": 2.5, + "constant angular velocity": 2.5, + "constant rpm": 2.5, + "design speed": 2.0, + "rated speed": 2.0, + "nominal speed": 2.0, + "operating speed": 2.0, + # Negative weights for unsuitable cases + "stationary": -2.0, + "non-rotating": -2.0, + "fixed": -1.0, + "static": -1.0, + "no rotation": -2.0, + "transient": -1.0, # MRF is typically steady-state + "startup": -1.0, + "acceleration": -1.0, + "deceleration": -1.0, + "variable speed": -1.0, + "speed variation": -1.0, + "unsteady rotation": -1.0, + "time dependent rotation": -1.0, + "oscillating rotation": -1.0, + "reciprocating": -2.0, + "linear": -2.0, + "translational": -2.0, + "sliding": -2.0, + "combustion": -1.0, # Avoid confusion with reactive flows + "reacting": -1.0, + "reactive": -1.0, + "burning": -1.0, + "flame": -1.0 + } +} + +# Context-aware phrase detection +CONTEXT_PHRASES = { + "compressible": [ + "shock wave", + "sonic boom", + "gas dynamics", + "high-speed flow", + "high speed flow", + "mach number", + "compressible flow" + ], + "multiphase": [ + "free surface", + "dam break", + "volume of fluid", + "air-water interface", + "liquid-gas interface", + "two-phase flow", + "multiphase flow" + ], + "heat_transfer": [ + "heat transfer", + "conjugate heat transfer", + "thermal boundary", + "heat exchanger", + "heat sink", + "thermal management", + "multi-region", + "solid-fluid coupling" + ] +} + +# Parameter validation requirements for each solver +SOLVER_PARAMETER_REQUIREMENTS = { + SolverType.SIMPLE_FOAM: { + "required": ["reynolds_number", "velocity"], + "optional": ["pressure", "turbulence_intensity"], + "physics_checks": ["incompressible", "steady_state_compatible"] + }, + SolverType.PIMPLE_FOAM: { + "required": ["reynolds_number", "velocity"], + "optional": ["pressure", "turbulence_intensity", "end_time"], + "physics_checks": ["incompressible", "transient_compatible"] + }, + SolverType.INTER_FOAM: { + "required": ["velocity", "phases"], + "optional": ["surface_tension", "gravity", "contact_angle"], + "physics_checks": ["multiphase", "transient_only"] + }, + SolverType.RHO_PIMPLE_FOAM: { + "required": ["velocity", "temperature", "pressure"], + "optional": ["mach_number", "turbulence_intensity"], + "physics_checks": ["compressible", "density_varying"] + }, + SolverType.CHT_MULTI_REGION_FOAM: { + "required": ["velocity", "temperature"], + "optional": ["heat_flux", "thermal_conductivity", "solid_regions"], + "physics_checks": ["heat_transfer", "multi_region", "conjugate"] + }, + SolverType.REACTING_FOAM: { + "required": ["velocity", "temperature", "species"], + "optional": ["reaction_rate", "mixture_fraction", "fuel_composition"], + "physics_checks": ["combustion", "chemical_reactions"] + }, + SolverType.BUOYANT_SIMPLE_FOAM: { + "required": ["temperature", "gravity"], + "optional": ["velocity", "thermal_expansion", "prandtl_number"], + "physics_checks": ["heat_transfer", "buoyancy", "steady_state_compatible"] + }, + SolverType.PISO_FOAM: { + "required": ["reynolds_number", "velocity"], + "optional": ["pressure", "turbulence_intensity", "end_time"], + "physics_checks": ["incompressible", "transient_only"] + }, + SolverType.SONIC_FOAM: { + "required": ["velocity", "temperature", "pressure", "mach_number"], + "optional": ["turbulence_intensity", "gas_properties"], + "physics_checks": ["compressible", "supersonic_compatible", "transient_only"] + }, + SolverType.MRF_SIMPLE_FOAM: { + "required": ["reynolds_number", "velocity", "rotation_rate"], + "optional": ["pressure", "turbulence_intensity", "mrf_zones"], + "physics_checks": ["incompressible", "steady_state_compatible", "rotating_machinery"] + } +} + +# Intelligent defaults for missing parameters +INTELLIGENT_DEFAULTS = { + "reynolds_number": { + "cylinder": {"low": 100, "medium": 1000, "high": 10000}, + "sphere": {"low": 300, "medium": 3000, "high": 30000}, + "airfoil": {"low": 100000, "medium": 1000000, "high": 10000000} + }, + "velocity": { + "low_speed": 1.0, + "medium_speed": 10.0, + "high_speed": 100.0 + }, + "temperature": { + "ambient": 293.15, # 20°C + "cold": 273.15, # 0°C + "hot": 373.15, # 100°C + "mars": 210.0, # Mars surface temperature (-63°C) + "moon": 250.0 # Moon surface temperature (day side, -23°C) + }, + "pressure": { + "atmospheric": 101325.0, + "low": 50000.0, + "high": 200000.0, + "mars": 610.0, # Mars atmospheric pressure (0.6% of Earth's) + "moon": 3e-15 # Moon atmospheric pressure (essentially vacuum) + }, + "gravity": { + "earth": 9.81, # m/s² + "mars": 3.71, # m/s² + "moon": 1.62 # m/s² + }, + "density": { + "earth": 1.225, # kg/m³ (Earth atmosphere at sea level) + "mars": 0.02, # kg/m³ (Mars atmosphere) + "moon": 1e-14 # kg/m³ (Moon trace atmosphere) + }, + "viscosity": { + "earth": 1.81e-5, # Pa·s (Earth atmosphere) + "mars": 1.0e-5, # Pa·s (Mars atmosphere) + "moon": 1.0e-5 # Pa·s (Moon trace atmosphere - similar to Mars) + }, + "thermal_expansion": { + "air": 3.43e-3, # 1/K for air at 20°C + "water": 2.1e-4, # 1/K for water at 20°C + "typical": 3.0e-3 # 1/K typical for gases + }, + "rotation_rate": { + "low": 100.0, # rad/s (about 950 RPM) + "medium": 500.0, # rad/s (about 4775 RPM) + "high": 1000.0, # rad/s (about 9550 RPM) + "fan": 314.0, # rad/s (about 3000 RPM - typical fan) + "pump": 157.0, # rad/s (about 1500 RPM - typical pump) + "turbine": 628.0 # rad/s (about 6000 RPM - typical turbine) + } +} + # Solver Registry - defines available solvers and their characteristics SOLVER_REGISTRY = { SolverType.SIMPLE_FOAM: { @@ -325,6 +1229,8 @@ def extract_problem_features(state: CFDState) -> Dict[str, Any]: char_length = geometry.get("diameter", 0.1) elif geometry_type == GeometryType.CHANNEL: char_length = geometry.get("height", 0.1) + elif geometry_type == GeometryType.NOZZLE: + char_length = geometry.get("throat_diameter", geometry.get("length", 0.1)) else: char_length = 0.1 # Default @@ -553,23 +1459,267 @@ def extract_keywords(prompt: str) -> List[str]: r"\bpropane\b", r"\bhydrogen\b", r"\bethane\b", r"\bgasoline\b"] found_keywords = [] + + for category, score in physics_scores.items(): + if score > 0.5: # Threshold for keyword detection + found_keywords.append(f"{category}:{score:.1f}") - for keyword in steady_keywords: - if re.search(keyword, prompt_lower): - found_keywords.append(f"steady:{keyword}") + return found_keywords + + +def validate_solver_parameters(solver_type: SolverType, params: Dict[str, Any], + geometry_info: Dict[str, Any]) -> Tuple[List[str], Dict[str, Any]]: + """ + Validate and suggest missing parameters for solver type. + Returns (missing_params, suggested_defaults). + """ + requirements = SOLVER_PARAMETER_REQUIREMENTS.get(solver_type, {}) + required_params = requirements.get("required", []) - for keyword in transient_keywords: - if re.search(keyword, prompt_lower): - found_keywords.append(f"transient:{keyword}") + missing_params = [] + suggested_defaults = {} - # Special handling for "air" in multiphase context - air_pattern = r"\bair\b" - if re.search(air_pattern, prompt_lower): - # Only include as multiphase if there's clear multiphase context - # Include compound words like "underwater" - other_fluids = [r"\bwater\b", r"water", r"\bliquid\b", r"liquid", r"\boil\b"] - if any(re.search(fluid, prompt_lower) for fluid in other_fluids): - found_keywords.append("multiphase:air") + for param in required_params: + if param not in params or params[param] is None: + missing_params.append(param) + default_value = get_intelligent_default(param, solver_type, params, geometry_info) + if default_value is not None: + suggested_defaults[param] = default_value + + return missing_params, suggested_defaults + + +def detect_mars_simulation(prompt: str) -> bool: + """Detect if the user is requesting a Mars simulation.""" + mars_keywords = [ + "mars", "martian", "red planet", "on mars", "mars atmosphere", + "mars surface", "mars conditions", "mars environment" + ] + + prompt_lower = prompt.lower() + return any(keyword in prompt_lower for keyword in mars_keywords) + + +def detect_moon_simulation(prompt: str) -> bool: + """Detect if the user is requesting a Moon simulation.""" + moon_keywords = [ + "moon", "lunar", "on the moon", "moon surface", "moon conditions", + "moon environment", "lunar surface", "lunar conditions", "lunar environment" + ] + + prompt_lower = prompt.lower() + return any(keyword in prompt_lower for keyword in moon_keywords) + + +def detect_custom_environment(prompt: str) -> Dict[str, Any]: + """Use OpenAI to detect and extract custom environmental conditions.""" + try: + # Skip if it's already Mars/Moon/Earth + if detect_mars_simulation(prompt) or detect_moon_simulation(prompt): + return {"has_custom_environment": False} + + # Check for environmental indicators + environmental_keywords = [ + "pluto", "venus", "jupiter", "saturn", "neptune", "uranus", "mercury", + "altitude", "elevation", "sea level", "underwater", "deep ocean", "high altitude", + "mountain", "stratosphere", "atmosphere", "pressure", "vacuum", "space", + "planet", "planetary", "conditions", "environment" + ] + + prompt_lower = prompt.lower() + has_environmental_context = any(keyword in prompt_lower for keyword in environmental_keywords) + + if not has_environmental_context: + return {"has_custom_environment": False} + + # Get settings for API key + import sys + sys.path.append('src') + from foamai.config import get_settings + settings = get_settings() + + if not settings.openai_api_key: + logger.warning("No OpenAI API key found for custom environment detection") + return {"has_custom_environment": False} + + # Use OpenAI to extract environmental parameters + import openai + client = openai.OpenAI(api_key=settings.openai_api_key) + + system_message = """You are an expert in planetary science and atmospheric physics. Analyze the given prompt to determine if it describes a specific environmental or planetary condition that would affect fluid dynamics simulation parameters. + +Your task is to: +1. Identify if there's a specific environment mentioned (planet, altitude, etc.) +2. Determine appropriate physical parameters for that environment +3. Return the parameters in the specified JSON format + +Be accurate with scientific values. If unsure about specific parameters, use reasonable estimates based on known science.""" + + user_message = f"""Analyze this fluid dynamics scenario for custom environmental conditions: + +PROMPT: "{prompt}" + +If this describes a specific environment (planet, altitude, underwater, etc.) that differs from standard Earth sea-level conditions, extract the appropriate physical parameters. + +Respond with ONLY this JSON format: +{{ + "has_custom_environment": true/false, + "environment_name": "name of environment (e.g., 'Pluto', 'High Altitude', 'Underwater')", + "temperature": temperature_in_kelvin, + "pressure": pressure_in_pascals, + "density": density_in_kg_per_m3, + "viscosity": viscosity_in_pa_s, + "gravity": gravity_in_m_per_s2, + "explanation": "brief explanation of the environment and parameter choices" +}} + +Examples: +- "Flow on Pluto" → Pluto conditions (40K, very low pressure/density, 0.62 m/s² gravity) +- "Flow at 10km altitude" → High altitude conditions (reduced pressure/density, same gravity) +- "Flow 100m underwater" → Underwater conditions (high pressure, water density) +- "Flow around cylinder" → has_custom_environment: false (standard conditions) + +If no specific environment is mentioned, return has_custom_environment: false.""" + + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system_message}, + {"role": "user", "content": user_message} + ], + max_tokens=500, + temperature=0.1 + ) + + # Parse JSON response + import json + try: + result = json.loads(response.choices[0].message.content) + if result.get("has_custom_environment", False): + logger.info(f"Detected custom environment: {result.get('environment_name', 'Unknown')}") + logger.info(f"Parameters: T={result.get('temperature')}K, P={result.get('pressure')}Pa, ρ={result.get('density')}kg/m³") + return result + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse OpenAI environment response: {e}") + return {"has_custom_environment": False} + + except Exception as e: + logger.warning(f"Custom environment detection failed: {e}") + return {"has_custom_environment": False} + + +def get_intelligent_default(param: str, solver_type: SolverType, params: Dict[str, Any], + geometry_info: Dict[str, Any]) -> Any: + """ + Generate intelligent default values for missing parameters. + """ + geometry_type = geometry_info.get("type", GeometryType.CYLINDER) + + # Check if this is a Mars, Moon, or custom environment simulation + original_prompt = params.get("original_prompt", "") + is_mars_simulation = detect_mars_simulation(original_prompt) + is_moon_simulation = detect_moon_simulation(original_prompt) + + # Check for custom environment if not Mars/Moon + custom_environment = None + if not is_mars_simulation and not is_moon_simulation: + custom_environment = detect_custom_environment(original_prompt) + if custom_environment.get("has_custom_environment", False): + logger.info(f"Using custom environment parameters: {custom_environment.get('environment_name', 'Unknown')}") + + if param == "reynolds_number": + # Base Reynolds number on geometry type and flow regime + geometry_name = geometry_type.value if hasattr(geometry_type, 'value') else str(geometry_type).lower() + + if geometry_name in INTELLIGENT_DEFAULTS["reynolds_number"]: + defaults = INTELLIGENT_DEFAULTS["reynolds_number"][geometry_name] + # Choose based on solver type + if solver_type == SolverType.SIMPLE_FOAM: + return defaults["low"] # Conservative for steady-state + elif solver_type in [SolverType.PIMPLE_FOAM, SolverType.INTER_FOAM]: + return defaults["medium"] # Moderate for transient + else: + return defaults["high"] # Higher for complex physics + else: + return 1000 # Generic default + + elif param == "velocity": + # Base velocity on flow regime and solver + if solver_type == SolverType.RHO_PIMPLE_FOAM: + return INTELLIGENT_DEFAULTS["velocity"]["high_speed"] # Compressible flows + elif solver_type == SolverType.INTER_FOAM: + return INTELLIGENT_DEFAULTS["velocity"]["low_speed"] # Multiphase flows + else: + return INTELLIGENT_DEFAULTS["velocity"]["medium_speed"] # General flows + + elif param == "temperature": + # Base temperature on solver type and application + if solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.CHT_MULTI_REGION_FOAM, SolverType.REACTING_FOAM, SolverType.BUOYANT_SIMPLE_FOAM, SolverType.SONIC_FOAM]: + if is_mars_simulation: + return INTELLIGENT_DEFAULTS["temperature"]["mars"] + elif is_moon_simulation: + return INTELLIGENT_DEFAULTS["temperature"]["moon"] + elif custom_environment and custom_environment.get("has_custom_environment", False): + return custom_environment.get("temperature", INTELLIGENT_DEFAULTS["temperature"]["ambient"]) + else: + return INTELLIGENT_DEFAULTS["temperature"]["ambient"] + else: + return None # Not required for incompressible solvers + + elif param == "pressure": + # Base pressure on solver type + if solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.SONIC_FOAM]: + if is_mars_simulation: + return INTELLIGENT_DEFAULTS["pressure"]["mars"] + elif is_moon_simulation: + return INTELLIGENT_DEFAULTS["pressure"]["moon"] + elif custom_environment and custom_environment.get("has_custom_environment", False): + return custom_environment.get("pressure", INTELLIGENT_DEFAULTS["pressure"]["atmospheric"]) + else: + return INTELLIGENT_DEFAULTS["pressure"]["atmospheric"] + else: + return None # Usually relative pressure for incompressible + + elif param == "gravity": + # Base gravity on simulation environment + if is_mars_simulation: + return INTELLIGENT_DEFAULTS["gravity"]["mars"] + elif is_moon_simulation: + return INTELLIGENT_DEFAULTS["gravity"]["moon"] + elif custom_environment and custom_environment.get("has_custom_environment", False): + return custom_environment.get("gravity", INTELLIGENT_DEFAULTS["gravity"]["earth"]) + else: + return INTELLIGENT_DEFAULTS["gravity"]["earth"] + + elif param == "density": + # Base density on simulation environment + if is_mars_simulation: + return INTELLIGENT_DEFAULTS["density"]["mars"] + elif is_moon_simulation: + return INTELLIGENT_DEFAULTS["density"]["moon"] + elif custom_environment and custom_environment.get("has_custom_environment", False): + return custom_environment.get("density", INTELLIGENT_DEFAULTS["density"]["earth"]) + else: + return INTELLIGENT_DEFAULTS["density"]["earth"] + + elif param == "viscosity": + # Base viscosity on simulation environment + if is_mars_simulation: + return INTELLIGENT_DEFAULTS["viscosity"]["mars"] + elif is_moon_simulation: + return INTELLIGENT_DEFAULTS["viscosity"]["moon"] + elif custom_environment and custom_environment.get("has_custom_environment", False): + return custom_environment.get("viscosity", INTELLIGENT_DEFAULTS["viscosity"]["earth"]) + else: + return INTELLIGENT_DEFAULTS["viscosity"]["earth"] + + elif param == "thermal_expansion": + # Base thermal expansion on fluid type + if solver_type == SolverType.BUOYANT_SIMPLE_FOAM: + return INTELLIGENT_DEFAULTS["thermal_expansion"]["typical"] + else: + return None + for keyword in multiphase_keywords: if re.search(keyword, prompt_lower): @@ -924,6 +2074,7 @@ def generate_solver_config(solver_settings: Dict[str, Any], parsed_params: Dict[ p_field = state.get("boundary_conditions", {}).get("p", {}) if state else {} p_rgh_boundary_field = p_field.get("boundaryField", {}) + # Create p_rgh with same boundary conditions as p, but will be remapped to actual mesh patches later solver_config["p_rgh"] = { "dimensions": "[1 -1 -2 0 0 0 0]", # Kinematic pressure for interFoam "internalField": "uniform 0", @@ -950,6 +2101,27 @@ def generate_solver_config(solver_settings: Dict[str, Any], parsed_params: Dict[ "internalField": f"uniform {parsed_params.get('temperature', 293.15)}", # Default 20°C "boundaryField": {} } + + if solver_settings.get("solver_type") == SolverType.SONIC_FOAM: + # Add compressible flow properties for sonicFoam + solver_config["thermophysicalProperties"] = generate_thermophysical_properties(solver_settings, parsed_params) + + # Only generate temperature field if it doesn't already exist with proper boundary conditions + if state and "boundary_conditions" in state and "T" in state["boundary_conditions"]: + # Use existing temperature field from boundary condition agent (it has correct boundary conditions) + existing_temp_field = state["boundary_conditions"]["T"] + solver_config["T"] = { + "dimensions": "[0 0 0 1 0 0 0]", # Temperature in Kelvin + "internalField": f"uniform {parsed_params.get('temperature', 293.15)}", # Default 20°C + "boundaryField": existing_temp_field["boundaryField"] + } + else: + # Fallback - create basic temperature field (shouldn't happen with good boundary conditions) + solver_config["T"] = { + "dimensions": "[0 0 0 1 0 0 0]", # Temperature in Kelvin + "internalField": f"uniform {parsed_params.get('temperature', 293.15)}", # Default 20°C + "boundaryField": {} + } if solver_settings.get("solver_type") == SolverType.CHT_MULTI_REGION_FOAM: # Add multi-region heat transfer properties @@ -1033,6 +2205,8 @@ def generate_control_dict(solver: str, analysis_type: AnalysisType, parsed_param char_length = geometry_info.get("diameter", 0.1) elif geometry_info["type"] == GeometryType.CHANNEL: char_length = geometry_info.get("height", 0.1) + elif geometry_info["type"] == GeometryType.NOZZLE: + char_length = geometry_info.get("throat_diameter", geometry_info.get("length", 0.1)) else: char_length = 0.1 # Default @@ -1324,14 +2498,15 @@ def generate_fv_schemes(solver_settings: Dict[str, Any], parsed_params: Dict[str "pcorr": "", "alpha.water": "" } - elif solver_type == SolverType.RHO_PIMPLE_FOAM: - # rhoPimpleFoam specific schemes for compressible flow + elif solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.SONIC_FOAM]: + # Compressible solver specific schemes (rhoPimpleFoam, sonicFoam) fv_schemes["divSchemes"].update({ "div(phi,U)": "Gauss linearUpwindV grad(U)", "div(phi,K)": "Gauss upwind", "div(phi,h)": "Gauss upwind", "div(phi,e)": "Gauss upwind", "div(phiv,p)": "Gauss upwind", + "div(phid,p)": "Gauss upwind", # sonicFoam specific scheme "div(phi,k)": "Gauss upwind", "div(phi,omega)": "Gauss upwind", "div(phi,epsilon)": "Gauss upwind", @@ -1387,6 +2562,11 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st flow_type = solver_settings.get("flow_type", FlowType.LAMINAR) solver_type = solver_settings.get("solver_type", SolverType.PIMPLE_FOAM) + # Check if GPU acceleration is requested + gpu_info = parsed_params.get("gpu_info", {}) + use_gpu = gpu_info.get("use_gpu", False) + gpu_backend = gpu_info.get("gpu_backend", "petsc") + # Base solution settings fv_solution = { "solvers": {}, @@ -1396,6 +2576,12 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st "residualControl": {} } + # GPU-specific library loading + if use_gpu: + fv_solution["libs"] = ["libpetscFoam.so"] + if gpu_backend == "amgx": + fv_solution["libs"].append("libamgxFoam.so") + # Solver-specific pressure and velocity solvers if solver_type == SolverType.INTER_FOAM: # interFoam uses p_rgh (pressure minus hydrostatic component) @@ -1434,8 +2620,8 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st "tolerance": 1e-08, "relTol": 0 } - elif solver_type == SolverType.RHO_PIMPLE_FOAM: - # rhoPimpleFoam pressure solver + elif solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.SONIC_FOAM]: + # Compressible solver pressure solver (rhoPimpleFoam, sonicFoam) fv_solution["solvers"]["p"] = { "solver": "GAMG", "tolerance": 1e-06, @@ -1451,7 +2637,7 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st fv_solution["solvers"]["pFinal"] = fv_solution["solvers"]["p"].copy() fv_solution["solvers"]["pFinal"]["relTol"] = 0 - # Density solver for compressible flows + # Density solver for compressible flows (required for both rhoPimpleFoam and sonicFoam) fv_solution["solvers"]["rho"] = { "solver": "smoothSolver", "smoother": "GaussSeidel", @@ -1535,18 +2721,40 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st } else: # Standard pressure solver for incompressible flows - fv_solution["solvers"]["p"] = { - "solver": "GAMG", - "tolerance": 1e-06, - "relTol": 0.1, - "smoother": "GaussSeidel", - "nPreSweeps": 0, - "nPostSweeps": 2, - "cacheAgglomeration": "true", - "nCellsInCoarsestLevel": 10, - "agglomerator": "faceAreaPair", - "mergeLevels": 1 - } + if use_gpu and gpu_backend == "petsc": + fv_solution["solvers"]["p"] = { + "solver": "petsc", + "petsc": { + "options": { + "ksp_type": "cg", + "mat_type": "aijcusparse", + "pc_type": "gamg" + } + }, + "tolerance": 1e-06, + "relTol": 0.1 + } + elif use_gpu and gpu_backend == "amgx": + fv_solution["solvers"]["p"] = { + "solver": "amgx", + "amgx": {}, + "tolerance": 1e-06, + "relTol": 0.1 + } + else: + # Standard CPU solver + fv_solution["solvers"]["p"] = { + "solver": "GAMG", + "tolerance": 1e-06, + "relTol": 0.1, + "smoother": "GaussSeidel", + "nPreSweeps": 0, + "nPostSweeps": 2, + "cacheAgglomeration": "true", + "nCellsInCoarsestLevel": 10, + "agglomerator": "faceAreaPair", + "mergeLevels": 1 + } # Velocity solver (common for all) fv_solution["solvers"]["U"] = { @@ -1565,18 +2773,41 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st "relTol": 0 } if solver_type != SolverType.INTER_FOAM and "pFinal" not in fv_solution["solvers"]: - fv_solution["solvers"]["pFinal"] = { - "solver": "GAMG", - "tolerance": 1e-06, - "relTol": 0, - "smoother": "GaussSeidel", - "nPreSweeps": 0, - "nPostSweeps": 2, - "cacheAgglomeration": "true", - "nCellsInCoarsestLevel": 10, - "agglomerator": "faceAreaPair", - "mergeLevels": 1 - } + # Apply same GPU settings to pFinal solver + if use_gpu and gpu_backend == "petsc": + fv_solution["solvers"]["pFinal"] = { + "solver": "petsc", + "petsc": { + "options": { + "ksp_type": "cg", + "mat_type": "aijcusparse", + "pc_type": "gamg" + } + }, + "tolerance": 1e-06, + "relTol": 0 + } + elif use_gpu and gpu_backend == "amgx": + fv_solution["solvers"]["pFinal"] = { + "solver": "amgx", + "amgx": {}, + "tolerance": 1e-06, + "relTol": 0 + } + else: + # Standard CPU solver + fv_solution["solvers"]["pFinal"] = { + "solver": "GAMG", + "tolerance": 1e-06, + "relTol": 0, + "smoother": "GaussSeidel", + "nPreSweeps": 0, + "nPostSweeps": 2, + "cacheAgglomeration": "true", + "nCellsInCoarsestLevel": 10, + "agglomerator": "faceAreaPair", + "mergeLevels": 1 + } # Turbulence field solvers if flow_type == FlowType.TURBULENT: @@ -1675,8 +2906,8 @@ def generate_fv_solution(solver_settings: Dict[str, Any], parsed_params: Dict[st "pRefCell": 0, "pRefValue": 0 } - elif solver_type == SolverType.RHO_PIMPLE_FOAM: - # rhoPimpleFoam specific settings + elif solver_type in [SolverType.RHO_PIMPLE_FOAM, SolverType.SONIC_FOAM]: + # Compressible solver specific settings (rhoPimpleFoam, sonicFoam) fv_solution["PIMPLE"] = { "nOuterCorrectors": 2, "nCorrectors": 2, @@ -1714,7 +2945,7 @@ def generate_turbulence_properties(solver_settings: Dict[str, Any], parsed_param flow_type = solver_settings.get("flow_type", FlowType.LAMINAR) turbulence_model = solver_settings.get("turbulence_model", "laminar") - if flow_type == FlowType.LAMINAR: + if flow_type == FlowType.LAMINAR or turbulence_model == "laminar": return { "simulationType": "laminar" } @@ -1786,9 +3017,32 @@ def generate_transport_properties(solver_settings: Dict[str, Any], parsed_params def generate_thermophysical_properties(solver_settings: Dict[str, Any], parsed_params: Dict[str, Any]) -> Dict[str, Any]: """Generate thermophysicalProperties file for compressible solvers.""" + + # Check if this is a Mars, Moon, or custom environment simulation + original_prompt = parsed_params.get("original_prompt", "") + is_mars_simulation = detect_mars_simulation(original_prompt) + is_moon_simulation = detect_moon_simulation(original_prompt) + + # Check for custom environment + custom_environment = None + if not is_mars_simulation and not is_moon_simulation: + custom_environment = detect_custom_environment(original_prompt) + # Default temperature and pressure if not specified - temperature = parsed_params.get("temperature", 293.15) # 20°C in Kelvin - pressure = parsed_params.get("pressure", 101325) # 1 atm in Pa + if is_mars_simulation: + temperature = parsed_params.get("temperature", 210.0) # Mars surface temperature + pressure = parsed_params.get("pressure", 610.0) # Mars atmospheric pressure + elif is_moon_simulation: + temperature = parsed_params.get("temperature", 250.0) # Moon surface temperature + pressure = parsed_params.get("pressure", 3e-15) # Moon atmospheric pressure (vacuum) + elif custom_environment and custom_environment.get("has_custom_environment", False): + temperature = parsed_params.get("temperature", custom_environment.get("temperature", 293.15)) + pressure = parsed_params.get("pressure", custom_environment.get("pressure", 101325)) + logger.info(f"Using custom environment thermophysical properties: {custom_environment.get('environment_name', 'Unknown')}") + else: + temperature = parsed_params.get("temperature", 293.15) # 20°C in Kelvin + pressure = parsed_params.get("pressure", 101325) # 1 atm in Pa + # Gas properties (default to air) cp = parsed_params.get("specific_heat", 1005) # J/(kg·K) for air @@ -1799,15 +3053,18 @@ def generate_thermophysical_properties(solver_settings: Dict[str, Any], parsed_p mu = parsed_params.get("viscosity", 1.81e-5) # Pa·s pr = parsed_params.get("prandtl_number", 0.72) # Prandtl number for air + # Get properties from solver_settings if available + properties = solver_settings.get("properties", {}) + return { "thermoType": { - "type": solver_settings.get("thermo_type", "hePsiThermo"), - "mixture": solver_settings.get("mixture", "pureMixture"), - "transport": solver_settings.get("transport_model", "const"), + "type": properties.get("thermo_type", "hePsiThermo"), + "mixture": properties.get("mixture", "pureMixture"), + "transport": properties.get("transport_model", "const"), "thermo": "hConst", - "equationOfState": solver_settings.get("equation_of_state", "perfectGas"), - "specie": solver_settings.get("specie", "specie"), - "energy": solver_settings.get("energy", "sensibleInternalEnergy") + "equationOfState": properties.get("equation_of_state", "perfectGas"), + "specie": properties.get("specie", "specie"), + "energy": properties.get("energy", "sensibleInternalEnergy") }, "mixture": { "specie": { @@ -1828,9 +3085,32 @@ def generate_thermophysical_properties(solver_settings: Dict[str, Any], parsed_p def generate_reactive_thermophysical_properties(solver_settings: Dict[str, Any], parsed_params: Dict[str, Any]) -> Dict[str, Any]: """Generate thermophysicalProperties file for reactive flow solvers.""" + + # Check if this is a Mars, Moon, or custom environment simulation + original_prompt = parsed_params.get("original_prompt", "") + is_mars_simulation = detect_mars_simulation(original_prompt) + is_moon_simulation = detect_moon_simulation(original_prompt) + + # Check for custom environment + custom_environment = None + if not is_mars_simulation and not is_moon_simulation: + custom_environment = detect_custom_environment(original_prompt) + # Default temperature and pressure if not specified - temperature = parsed_params.get("temperature", 300) # 300K for combustion - pressure = parsed_params.get("pressure", 101325) # 1 atm in Pa + if is_mars_simulation: + temperature = parsed_params.get("temperature", 210.0) # Mars surface temperature + pressure = parsed_params.get("pressure", 610.0) # Mars atmospheric pressure + elif is_moon_simulation: + temperature = parsed_params.get("temperature", 250.0) # Moon surface temperature + pressure = parsed_params.get("pressure", 3e-15) # Moon atmospheric pressure (vacuum) + elif custom_environment and custom_environment.get("has_custom_environment", False): + temperature = parsed_params.get("temperature", custom_environment.get("temperature", 300)) + pressure = parsed_params.get("pressure", custom_environment.get("pressure", 101325)) + logger.info(f"Using custom environment reactive properties: {custom_environment.get('environment_name', 'Unknown')}") + else: + temperature = parsed_params.get("temperature", 300) # 300K for combustion + pressure = parsed_params.get("pressure", 101325) # 1 atm in Pa + # Get species list species = solver_settings.get("species", ["CH4", "O2", "CO2", "H2O", "N2"]) @@ -1891,27 +3171,339 @@ def validate_solver_config(solver_config: Dict[str, Any], parsed_params: Dict[st if solver_config.get("analysis_type") == AnalysisType.STEADY: errors.append("interFoam does not support steady-state analysis") - elif solver == "rhoPimpleFoam": - # Check for required compressible properties - if "thermophysicalProperties" not in solver_config: - errors.append("Missing thermophysicalProperties for compressible solver") - if "T" not in solver_config: - warnings.append("Temperature field 'T' not initialized for rhoPimpleFoam") - # Check Mach number - mach_number = parsed_params.get("mach_number", 0) - if mach_number is not None and isinstance(mach_number, (int, float)) and mach_number < 0.3: + + if "sigma" not in fields and "sigma" not in solver_config: + errors.append("Surface tension 'sigma' not specified for interFoam") + suggestions.append("Add surface tension value (typical: 0.07 N/m for water-air)") + + # Check phases + phases = properties.get("phases", solver_config.get("phases", [])) + if not phases or len(phases) < 2: + errors.append("interFoam requires at least 2 phases") + suggestions.append("Specify phases like ['water', 'air']") + + # Check phase properties + phase_props = properties.get("phase_properties", {}) + for phase in phases: + if phase not in phase_props: + warnings.append(f"Missing properties for phase '{phase}'") + suggestions.append(f"Add density and viscosity for phase '{phase}'") + + # interFoam cannot be steady state + if solver_config.get("analysis_type") == AnalysisType.STEADY: + errors.append("interFoam does not support steady-state analysis") + suggestions.append("Use transient analysis for multiphase flows") + + +def _validate_rhopimplefoam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate rhoPimpleFoam-specific configuration.""" + # Check for required compressible properties + # Note: For compressible solvers, boundary conditions and thermophysical properties + # are handled by other agents in the workflow, so we skip these validation checks + + # Check thermophysical properties - this should be generated by the solver selector + if "thermophysicalProperties" not in solver_config: + # This is expected to be generated by the solver selector based on solver type + # The case writer will actually write the file, so we don't need to error here + pass # Remove the error for now as it's handled in the workflow + + # Check Mach number consistency + mach_number = parsed_params.get("mach_number", 0) + if mach_number is not None and isinstance(mach_number, (int, float)): + if mach_number < 0.3: warnings.append(f"Low Mach number ({mach_number:.2f}) - consider using incompressible solver") + suggestions.append("Use pimpleFoam or simpleFoam for Mach < 0.3") + elif mach_number > 5.0: + warnings.append(f"Very high Mach number ({mach_number:.2f}) - ensure proper shock capturing") + suggestions.append("Consider specialized high-Mach solvers or adjust numerical schemes") + + # Check thermophysical model consistency + thermo_model = properties.get("thermophysical_model") + if thermo_model and thermo_model not in ["perfectGas", "incompressiblePerfectGas", "rhoConst"]: + warnings.append(f"Unusual thermophysical model: {thermo_model}") + + +def _validate_chtmultiregion_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], errors: List[str], + warnings: List[str], suggestions: List[str]) -> None: + """Validate chtMultiRegionFoam-specific configuration.""" + # Check for required multi-region properties + if not properties.get("multi_region", False): + errors.append("Multi-region flag not set for chtMultiRegionFoam") + + regions = properties.get("regions", []) + if len(regions) < 2: + errors.append("chtMultiRegionFoam requires at least 2 regions (fluid and solid)") + suggestions.append("Define regions like ['fluid', 'solid']") + + # Check for typical region types + fluid_regions = properties.get("fluidRegions", []) + solid_regions = properties.get("solidRegions", []) + + if not fluid_regions: + warnings.append("No fluid regions detected - ensure proper region naming") + if not solid_regions: + warnings.append("No solid regions detected - ensure proper region naming") + + # Check thermal coupling + if not properties.get("thermal_coupling", False): + warnings.append("Thermal coupling not enabled - check if intended") + suggestions.append("Enable thermal coupling for conjugate heat transfer") + + # Check temperature field + if "T" not in fields and "T" not in solver_config: + errors.append("Temperature field 'T' required for chtMultiRegionFoam") + suggestions.append("Initialize temperature field for all regions") + + +def _validate_reactingfoam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], errors: List[str], + warnings: List[str], suggestions: List[str]) -> None: + """Validate reactingFoam-specific configuration.""" + # Check for required reactive flow properties + if not properties.get("chemistry", False): + errors.append("Chemistry not enabled for reactingFoam") + suggestions.append("Enable chemistry for reactive flows") + + species = properties.get("species", []) + if not species or len(species) < 2: + errors.append("reactingFoam requires chemical species") + suggestions.append("Define species list (e.g., ['CH4', 'O2', 'CO2', 'H2O', 'N2'])") + + # Check combustion model + combustion_model = properties.get("combustion_model") + if not combustion_model: + warnings.append("No combustion model specified - will use default") + suggestions.append("Specify combustion model (e.g., 'PaSR', 'EDC', 'laminar')") + + # Check required fields + if "T" not in fields and "T" not in solver_config: + errors.append("Temperature field 'T' required for reactingFoam") + + # Check species fields - they should now be in the fields dict + for species_name in species: + if species_name not in fields: + warnings.append(f"Species field '{species_name}' not initialized") + suggestions.append(f"Initialize species field '{species_name}' with appropriate mass fraction") + + # reactingFoam is always transient + if solver_config.get("analysis_type") == AnalysisType.STEADY: + errors.append("reactingFoam does not support steady-state analysis") + suggestions.append("Use transient analysis for reactive flows") + + +def _validate_buoyant_simple_foam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate buoyantSimpleFoam-specific configuration.""" + # Check for required temperature field + if "T" not in fields and "T" not in solver_config: + errors.append("Temperature field 'T' required for buoyantSimpleFoam") + suggestions.append("Initialize temperature field (typical: 293.15 K)") + + # Check for gravity vector + if "g" not in fields and "g" not in solver_config and "gravity" not in parsed_params: + errors.append("Gravity vector 'g' required for buoyantSimpleFoam") + suggestions.append("Set gravity vector (typical: (0 -9.81 0) m/s²)") + + # Check for thermal expansion coefficient + if "beta" not in fields and "beta" not in solver_config and "thermal_expansion" not in parsed_params: + warnings.append("Thermal expansion coefficient 'beta' not specified") + suggestions.append("Set thermal expansion coefficient (typical: 3.43e-3 1/K for air)") + + # Check for transport properties + if "transportProperties" not in solver_config: + warnings.append("Transport properties not specified for buoyantSimpleFoam") + suggestions.append("Add transport properties with density, viscosity, and thermal properties") + + # Check Prandtl number + prandtl_number = parsed_params.get("prandtl_number") + if prandtl_number is not None and (prandtl_number < 0.1 or prandtl_number > 100): + warnings.append(f"Unusual Prandtl number ({prandtl_number}) - typical range is 0.1-100") + suggestions.append("Check Prandtl number value (typical: 0.71 for air, 7.0 for water)") + + # Check reference temperature + ref_temp = parsed_params.get("reference_temperature") + if ref_temp is not None and (ref_temp < 200 or ref_temp > 600): + warnings.append(f"Reference temperature ({ref_temp} K) outside typical range") + suggestions.append("Check reference temperature (typical: 293.15 K)") + + # buoyantSimpleFoam is steady-state only + if solver_config.get("analysis_type") == AnalysisType.UNSTEADY: + errors.append("buoyantSimpleFoam only supports steady-state analysis") + suggestions.append("Use buoyantPimpleFoam for transient natural convection") + + # Check for heat transfer consistency + if not properties.get("heat_transfer", False): + warnings.append("Heat transfer not enabled - this may not be appropriate for buoyantSimpleFoam") + suggestions.append("Enable heat transfer for natural convection simulations") + + +def _validate_piso_foam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate pisoFoam-specific configuration.""" + # Check required fields for incompressible flow + if "U" not in fields and "U" not in solver_config: + errors.append("Velocity field 'U' required for pisoFoam") + suggestions.append("Initialize velocity field (typical: (1 0 0) m/s)") + + if "p" not in fields and "p" not in solver_config: + errors.append("Pressure field 'p' required for pisoFoam") + suggestions.append("Initialize pressure field (typical: 0 Pa relative)") + + # Check for transient compatibility + if solver_config.get("analysis_type") == AnalysisType.STEADY: + errors.append("pisoFoam only supports transient analysis") + suggestions.append("Use simpleFoam for steady-state analysis") + + # Check Reynolds number for appropriateness + reynolds_number = parsed_params.get("reynolds_number") + if reynolds_number is not None and reynolds_number > 100000: + warnings.append(f"High Reynolds number ({reynolds_number}) - consider pimpleFoam for better stability") + suggestions.append("pimpleFoam may be more stable for high Re flows") + + # Check time step settings + control_dict = solver_config.get("controlDict", {}) + delta_t = control_dict.get("deltaT", 0) + if delta_t is not None and delta_t <= 0: + errors.append("Invalid time step for pisoFoam") + suggestions.append("Set positive time step (typical: 0.001 s)") + + # Suggest temporal accuracy considerations + suggestions.append("pisoFoam provides good temporal accuracy - ensure CFL < 1 for stability") + + +def _validate_sonic_foam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate sonicFoam-specific configuration.""" + # Check required fields for compressible flow + # Note: For sonicFoam, boundary conditions should be provided by the boundary condition agent + # and thermophysical properties should be provided by the case writer + # So we skip these validation checks as they are handled by other agents + + # Check thermophysical properties - this should be generated by the solver selector + if "thermophysicalProperties" not in solver_config: + # This is expected to be generated by the solver selector based on solver type + # The case writer will actually write the file, so we don't need to error here + pass # Remove the error for now as it's handled in the workflow + + # Validate Mach number + mach_number = parsed_params.get("mach_number") + if mach_number is None: + warnings.append("Mach number not specified - assuming supersonic flow") + suggestions.append("Specify Mach number for better solver configuration") + elif mach_number < 0.8: + warnings.append(f"Low Mach number ({mach_number}) for sonicFoam - consider rhoPimpleFoam") + suggestions.append("sonicFoam is optimized for trans-sonic/supersonic flows") + elif mach_number > 5.0: + warnings.append(f"Very high Mach number ({mach_number}) - ensure proper shock capturing") + suggestions.append("Use specialized high-Mach schemes and fine mesh near shocks") + + # Check for transient compatibility + if solver_config.get("analysis_type") == AnalysisType.STEADY: + errors.append("sonicFoam only supports transient analysis") + suggestions.append("Use appropriate steady compressible solver for steady-state") + + # Check pressure and temperature consistency + pressure = parsed_params.get("pressure") + temperature = parsed_params.get("temperature") + if pressure is not None and temperature is not None: + if pressure <= 0: + errors.append("Pressure must be positive for compressible flows") + if temperature <= 0: + errors.append("Temperature must be positive") + + # Suggest appropriate numerical schemes + suggestions.append("Use appropriate shock-capturing schemes (e.g., Kurganov) for supersonic flows") + suggestions.append("Consider adaptive time stepping for stability") + + +def _validate_mrf_simple_foam_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate MRFSimpleFoam-specific configuration.""" + # Check required fields for incompressible flow + if "U" not in fields and "U" not in solver_config: + errors.append("Velocity field 'U' required for MRFSimpleFoam") + suggestions.append("Initialize velocity field (typical: (1 0 0) m/s)") + + if "p" not in fields and "p" not in solver_config: + errors.append("Pressure field 'p' required for MRFSimpleFoam") + suggestions.append("Initialize pressure field (typical: 0 Pa relative)") + + # Check for MRF properties + if "MRFProperties" not in solver_config: + errors.append("MRFProperties required for MRFSimpleFoam") + suggestions.append("Add MRFProperties file defining rotating zones") + + # Validate rotation rate + rotation_rate = parsed_params.get("rotation_rate") + if rotation_rate is None: + warnings.append("Rotation rate not specified - using default value") + suggestions.append("Specify rotation rate in rad/s (e.g., 314 rad/s = 3000 RPM)") + elif rotation_rate <= 0: + errors.append("Rotation rate must be positive") + elif rotation_rate > 10000: + warnings.append(f"Very high rotation rate ({rotation_rate} rad/s) - check units and stability") + suggestions.append("Ensure rotation rate is in rad/s, not RPM") + + # Check for steady-state compatibility + if solver_config.get("analysis_type") == AnalysisType.UNSTEADY: + errors.append("MRFSimpleFoam only supports steady-state analysis") + suggestions.append("Use pimpleFoam with MRF for transient rotating flows") + + # Check Reynolds number for rotating machinery + reynolds_number = parsed_params.get("reynolds_number") + if reynolds_number is not None: + if reynolds_number < 1000: + warnings.append(f"Low Reynolds number ({reynolds_number}) for rotating machinery - check flow regime") + suggestions.append("Rotating machinery typically operates at high Reynolds numbers") + + # Check for turbulence modeling + turbulence_props = solver_config.get("turbulenceProperties", {}) + if turbulence_props.get("simulationType") == "laminar": + warnings.append("Laminar simulation for rotating machinery - consider turbulent modeling") + suggestions.append("Rotating machinery flows are typically turbulent") + + # Suggest MRF configuration + suggestions.append("Define MRF zones carefully - ensure rotating regions are properly specified") + suggestions.append("Consider mesh refinement in high-gradient regions near rotating zones") + suggestions.append("Use appropriate wall functions for rotating walls") + + +def _validate_incompressible_config(solver_config: Dict[str, Any], fields: Dict[str, Any], + properties: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate incompressible solver configuration.""" + solver = solver_config.get("solver") - elif solver == "chtMultiRegionFoam": - # Check for required multi-region properties - if "regionProperties" not in solver_config: - errors.append("Missing regionProperties for chtMultiRegionFoam") - else: - regions = solver_config.get("regionProperties", {}).get("regions", []) - if len(regions) < 2: - errors.append("chtMultiRegionFoam requires at least 2 regions (fluid and solid)") - if "thermophysicalProperties" not in solver_config: - errors.append("Missing thermophysicalProperties for chtMultiRegionFoam") + # Check for compressible properties in incompressible solver + if "thermophysicalProperties" in solver_config: + warnings.append(f"Thermophysical properties specified for incompressible solver {solver}") + suggestions.append("Remove thermophysical properties or use compressible solver") + + # Check Reynolds number vs solver choice + reynolds_number = parsed_params.get("reynolds_number", 0) + if reynolds_number and reynolds_number > 100000: + if solver == "simpleFoam": + warnings.append("Very high Reynolds number with steady solver - consider transient") + suggestions.append("Use pimpleFoam for high Re flows to capture unsteady effects") + + +def _validate_physics_consistency(solver_config: Dict[str, Any], parsed_params: Dict[str, Any], + errors: List[str], warnings: List[str], suggestions: List[str]) -> None: + """Validate physics consistency across configuration.""" + solver = solver_config.get("solver") + + # Check Reynolds number vs turbulence model + reynolds_number = parsed_params.get("reynolds_number", 0) + turbulence_props = solver_config.get("turbulenceProperties", {}) + simulation_type = turbulence_props.get("simulationType", "") + elif solver == "reactingFoam": # Check for required reactive flow properties diff --git a/src/foamai-core/foamai_core/state.py b/src/foamai-core/foamai_core/state.py index 4114ece..7be98cb 100644 --- a/src/foamai-core/foamai_core/state.py +++ b/src/foamai-core/foamai_core/state.py @@ -29,6 +29,7 @@ class GeometryType(str, Enum): CHANNEL = "channel" SPHERE = "sphere" CUBE = "cube" + NOZZLE = "nozzle" CUSTOM = "custom" @@ -112,6 +113,20 @@ class CFDState(TypedDict): conversation_active: bool # Whether to continue the conversation or exit previous_results: Optional[Dict[str, Any]] # Results from previous iteration for comparison + + # Mesh convergence study + mesh_convergence_active: bool = False + mesh_convergence_levels: int = 4 + mesh_convergence_threshold: float = 1.0 + mesh_convergence_target_params: List[str] = [] + mesh_convergence_results: Dict[str, Any] = {} + mesh_convergence_report: Dict[str, Any] = {} + recommended_mesh_level: int = 0 + + # GPU acceleration + use_gpu: bool = False + gpu_info: Dict[str, Any] = {} + # Remote execution configuration execution_mode: str # "local" or "remote" server_url: Optional[str] # URL of remote OpenFOAM server @@ -121,4 +136,5 @@ class CFDState(TypedDict): awaiting_user_approval: bool # True when workflow is paused for user approval workflow_paused: bool # True when workflow is paused waiting for external input config_summary: Optional[Dict[str, Any]] # Configuration summary for UI display - config_only_mode: Optional[bool] # True when running configuration phase only (no solver execution) \ No newline at end of file + config_only_mode: Optional[bool] # True when running configuration phase only (no solver execution) +