@@ -51,6 +51,7 @@ class CaseValidator: # pylint: disable=too-many-public-methods
5151 def __init__ (self , params : Dict [str , Any ]):
5252 self .params = params
5353 self .errors : List [str ] = []
54+ self .warnings : List [str ] = []
5455
5556 def get (self , key : str , default = None ):
5657 """Get parameter value with default"""
@@ -65,6 +66,11 @@ def prohibit(self, condition: bool, message: str):
6566 if condition :
6667 self .errors .append (message )
6768
69+ def warn (self , condition : bool , message : str ):
70+ """Add a non-fatal warning if condition is True"""
71+ if condition :
72+ self .warnings .append (message )
73+
6874 def _validate_logical (self , key : str ):
6975 """Validate that a parameter is a valid Fortran logical ('T' or 'F')."""
7076 val = self .get (key )
@@ -1807,6 +1813,288 @@ def check_no_flow_variables(self): # pylint: disable=too-many-locals
18071813 self .prohibit (not has_output ,
18081814 "None of the flow variables have been selected for post-process" )
18091815
1816+ # ===================================================================
1817+ # Cross-Cutting Physics Checks
1818+ # ===================================================================
1819+
1820+ def check_domain_bounds (self ):
1821+ """Checks that domain end > domain begin for each active dimension"""
1822+ x_beg = self .get ('x_domain%beg' )
1823+ x_end = self .get ('x_domain%end' )
1824+ if x_beg is not None and x_end is not None :
1825+ self .prohibit (x_end <= x_beg ,
1826+ f"x_domain%end ({ x_end } ) must be greater than x_domain%beg ({ x_beg } )" )
1827+
1828+ n = self .get ('n' , 0 )
1829+ if n is not None and n > 0 :
1830+ y_beg = self .get ('y_domain%beg' )
1831+ y_end = self .get ('y_domain%end' )
1832+ if y_beg is not None and y_end is not None :
1833+ self .prohibit (y_end <= y_beg ,
1834+ f"y_domain%end ({ y_end } ) must be greater than y_domain%beg ({ y_beg } )" )
1835+
1836+ p = self .get ('p' , 0 )
1837+ if p is not None and p > 0 :
1838+ z_beg = self .get ('z_domain%beg' )
1839+ z_end = self .get ('z_domain%end' )
1840+ if z_beg is not None and z_end is not None :
1841+ self .prohibit (z_end <= z_beg ,
1842+ f"z_domain%end ({ z_end } ) must be greater than z_domain%beg ({ z_beg } )" )
1843+
1844+ def check_volume_fraction_sum (self ): # pylint: disable=too-many-locals
1845+ """Warns if volume fractions do not sum to 1 for multi-component models.
1846+
1847+ For model_eqns in [2, 3, 4], the mixture constraint sum(alpha_j) = 1
1848+ must hold. Skips patches with analytical expressions, alter_patch,
1849+ hcid, bubbles_euler single-fluid cases (where alpha represents
1850+ the void fraction, not a partition of unity), and bubbles_lagrange
1851+ cases (where the Lagrangian phase is not tracked on the Euler grid).
1852+ """
1853+ model_eqns = self .get ('model_eqns' )
1854+ if model_eqns not in [2 , 3 , 4 ]:
1855+ return
1856+
1857+ num_patches = self .get ('num_patches' , 0 )
1858+ num_fluids = self .get ('num_fluids' , 1 )
1859+ bubbles_euler = self .get ('bubbles_euler' , 'F' ) == 'T'
1860+ bubbles_lagrange = self .get ('bubbles_lagrange' , 'F' ) == 'T'
1861+
1862+ # For bubbles_euler with single fluid, alpha is the void fraction
1863+ # and does not need to sum to 1
1864+ if bubbles_euler and num_fluids == 1 :
1865+ return
1866+
1867+ # For bubbles_lagrange, the Lagrangian phase volume is not
1868+ # represented in the Eulerian volume fractions
1869+ if bubbles_lagrange :
1870+ return
1871+
1872+ # IBM cases use alpha as a level-set indicator, not a physical
1873+ # volume fraction, so the sum-to-1 constraint does not apply
1874+ num_ibs = self .get ('num_ibs' , 0 ) or 0
1875+ if num_ibs > 0 :
1876+ return
1877+
1878+ if num_patches is None or num_patches <= 0 or num_fluids is None :
1879+ return
1880+
1881+ for i in range (1 , num_patches + 1 ):
1882+ geometry = self .get (f'patch_icpp({ i } )%geometry' )
1883+ if geometry is None :
1884+ continue
1885+
1886+ # Skip special patches
1887+ hcid = self .get (f'patch_icpp({ i } )%hcid' )
1888+ if hcid is not None :
1889+ continue
1890+ alter_patches = [self .get (f'patch_icpp({ i } )%alter_patch({ j } )' ) == 'T'
1891+ for j in range (1 , num_patches + 1 )]
1892+ if any (alter_patches ):
1893+ continue
1894+
1895+ # Collect alpha values, skip if any are analytical expressions
1896+ alphas = []
1897+ has_expression = False
1898+ for j in range (1 , num_fluids + 1 ):
1899+ alpha = self .get (f'patch_icpp({ i } )%alpha({ j } )' )
1900+ if alpha is None :
1901+ has_expression = True
1902+ break
1903+ if not self ._is_numeric (alpha ):
1904+ has_expression = True
1905+ break
1906+ alphas .append (alpha )
1907+
1908+ if has_expression or len (alphas ) != num_fluids :
1909+ continue
1910+
1911+ alpha_sum = sum (alphas )
1912+ self .warn (abs (alpha_sum - 1.0 ) > 1e-6 ,
1913+ f"patch_icpp({ i } ): volume fractions sum to { alpha_sum :.8g} , expected 1.0" )
1914+
1915+ def check_alpha_rho_consistency (self ):
1916+ """Warns about inconsistent alpha/alpha_rho pairs.
1917+
1918+ Catches common mistakes:
1919+ - alpha(j) = 0 but alpha_rho(j) != 0 (density in absent phase)
1920+ - alpha(j) > 0 but alpha_rho(j) = 0 (present phase has zero density)
1921+ """
1922+ num_patches = self .get ('num_patches' , 0 )
1923+ num_fluids = self .get ('num_fluids' , 1 )
1924+
1925+ if num_patches is None or num_patches <= 0 or num_fluids is None :
1926+ return
1927+
1928+ for i in range (1 , num_patches + 1 ):
1929+ geometry = self .get (f'patch_icpp({ i } )%geometry' )
1930+ if geometry is None :
1931+ continue
1932+
1933+ # Skip special patches
1934+ hcid = self .get (f'patch_icpp({ i } )%hcid' )
1935+ if hcid is not None :
1936+ continue
1937+ alter_patches = [self .get (f'patch_icpp({ i } )%alter_patch({ j } )' ) == 'T'
1938+ for j in range (1 , num_patches + 1 )]
1939+ if any (alter_patches ):
1940+ continue
1941+
1942+ for j in range (1 , num_fluids + 1 ):
1943+ alpha = self .get (f'patch_icpp({ i } )%alpha({ j } )' )
1944+ alpha_rho = self .get (f'patch_icpp({ i } )%alpha_rho({ j } )' )
1945+
1946+ if alpha is None or alpha_rho is None :
1947+ continue
1948+ if not self ._is_numeric (alpha ) or not self ._is_numeric (alpha_rho ):
1949+ continue
1950+
1951+ self .warn (alpha == 0 and alpha_rho != 0 ,
1952+ f"patch_icpp({ i } ): alpha({ j } ) = 0 but alpha_rho({ j } ) = { alpha_rho } "
1953+ f"(density in absent phase)" )
1954+ self .warn (alpha > 1e-10 and alpha_rho == 0 ,
1955+ f"patch_icpp({ i } ): alpha({ j } ) = { alpha } but alpha_rho({ j } ) = 0 "
1956+ f"(present phase has zero density)" )
1957+
1958+ def check_patch_within_domain (self ): # pylint: disable=too-many-locals
1959+ """Checks that centroid+length patches are not entirely outside the domain.
1960+
1961+ Only applies to geometry types whose bounding box is fully determined
1962+ by centroid and length (line segments=1, rectangles=3, cuboids=9).
1963+ Skipped when grid stretching is active because the physical domain
1964+ extents are transformed and the domain bounds are not directly comparable.
1965+ """
1966+ num_patches = self .get ('num_patches' , 0 )
1967+ if num_patches is None or num_patches <= 0 :
1968+ return
1969+
1970+ # Skip when any grid stretching is active — domain bounds don't map
1971+ # directly to physical coordinates in stretched grids
1972+ if (self .get ('stretch_x' , 'F' ) == 'T' or
1973+ self .get ('stretch_y' , 'F' ) == 'T' or
1974+ self .get ('stretch_z' , 'F' ) == 'T' ):
1975+ return
1976+
1977+ x_beg = self .get ('x_domain%beg' )
1978+ x_end = self .get ('x_domain%end' )
1979+ n = self .get ('n' , 0 )
1980+ p = self .get ('p' , 0 )
1981+ y_beg = self .get ('y_domain%beg' ) if n and n > 0 else None
1982+ y_end = self .get ('y_domain%end' ) if n and n > 0 else None
1983+ z_beg = self .get ('z_domain%beg' ) if p and p > 0 else None
1984+ z_end = self .get ('z_domain%end' ) if p and p > 0 else None
1985+
1986+ for i in range (1 , num_patches + 1 ):
1987+ geometry = self .get (f'patch_icpp({ i } )%geometry' )
1988+ if geometry is None :
1989+ continue
1990+
1991+ # Only check patches whose bounding box is fully determined
1992+ # by centroid + length (line segments=1, rectangles=3, cuboids=9)
1993+ if geometry not in [1 , 3 , 9 ]:
1994+ continue
1995+
1996+ xc = self .get (f'patch_icpp({ i } )%x_centroid' )
1997+ lx = self .get (f'patch_icpp({ i } )%length_x' )
1998+
1999+ has_x = (xc is not None and lx is not None
2000+ and x_beg is not None and x_end is not None )
2001+ if has_x and self ._is_numeric (xc ) and self ._is_numeric (lx ):
2002+ patch_x_lo = xc - lx / 2.0
2003+ patch_x_hi = xc + lx / 2.0
2004+ self .prohibit (patch_x_hi < x_beg or patch_x_lo > x_end ,
2005+ f"patch_icpp({ i } ): x-extent [{ patch_x_lo } , { patch_x_hi } ] "
2006+ f"is entirely outside domain [{ x_beg } , { x_end } ]" )
2007+
2008+ if geometry in [3 , 9 ] and y_beg is not None and y_end is not None :
2009+ yc = self .get (f'patch_icpp({ i } )%y_centroid' )
2010+ ly = self .get (f'patch_icpp({ i } )%length_y' )
2011+ if (yc is not None and ly is not None
2012+ and self ._is_numeric (yc ) and self ._is_numeric (ly )):
2013+ patch_y_lo = yc - ly / 2.0
2014+ patch_y_hi = yc + ly / 2.0
2015+ self .prohibit (patch_y_hi < y_beg or patch_y_lo > y_end ,
2016+ f"patch_icpp({ i } ): y-extent [{ patch_y_lo } , { patch_y_hi } ] "
2017+ f"is entirely outside domain [{ y_beg } , { y_end } ]" )
2018+
2019+ if geometry == 9 and z_beg is not None and z_end is not None :
2020+ zc = self .get (f'patch_icpp({ i } )%z_centroid' )
2021+ lz = self .get (f'patch_icpp({ i } )%length_z' )
2022+ if (zc is not None and lz is not None
2023+ and self ._is_numeric (zc ) and self ._is_numeric (lz )):
2024+ patch_z_lo = zc - lz / 2.0
2025+ patch_z_hi = zc + lz / 2.0
2026+ self .prohibit (patch_z_hi < z_beg or patch_z_lo > z_end ,
2027+ f"patch_icpp({ i } ): z-extent [{ patch_z_lo } , { patch_z_hi } ] "
2028+ f"is entirely outside domain [{ z_beg } , { z_end } ]" )
2029+
2030+ def check_eos_parameter_sanity (self ):
2031+ """Warns if EOS gamma parameter looks like raw physical gamma.
2032+
2033+ MFC uses the transformed parameter Gamma = 1/(gamma-1), not the
2034+ physical specific heat ratio gamma directly. Common mistake:
2035+ entering gamma=1.4 (physical) instead of gamma=1/(1.4-1)=2.5.
2036+
2037+ - gamma < 0.1 implies physical gamma > 11 (unusual)
2038+ - gamma > 1000 implies physical gamma ≈ 1.001 (unusual)
2039+ """
2040+ num_fluids = self .get ('num_fluids' )
2041+ model_eqns = self .get ('model_eqns' )
2042+
2043+ if num_fluids is None or model_eqns == 1 :
2044+ return
2045+
2046+ for i in range (1 , num_fluids + 1 ):
2047+ gamma = self .get (f'fluid_pp({ i } )%gamma' )
2048+ if gamma is None or not self ._is_numeric (gamma ) or gamma <= 0 :
2049+ continue
2050+
2051+ # gamma = 1/(physical_gamma - 1), so physical_gamma = 1/gamma + 1
2052+ physical_gamma = 1.0 / gamma + 1.0
2053+
2054+ self .warn (gamma < 0.1 ,
2055+ f"fluid_pp({ i } )%gamma = { gamma } implies physical gamma = { physical_gamma :.2f} "
2056+ f"(unusually high). MFC uses the transformed parameter Gamma = 1/(gamma-1)" )
2057+ self .warn (gamma > 1000 ,
2058+ f"fluid_pp({ i } )%gamma = { gamma } implies physical gamma = { physical_gamma :.6f} "
2059+ f"(very close to 1). Did you enter the physical gamma instead of 1/(gamma-1)?" )
2060+
2061+ def check_velocity_components (self ):
2062+ """Checks that velocity components are not set in inactive dimensions.
2063+
2064+ vel(2) must be zero in 1D (n=0), vel(3) must be zero in 1D/2D (p=0).
2065+ Exempts MHD simulations where transverse velocity components are
2066+ physically meaningful even in 1D (they carry transverse momentum
2067+ coupled to the magnetic field).
2068+ """
2069+ n = self .get ('n' , 0 )
2070+ p = self .get ('p' , 0 )
2071+ num_patches = self .get ('num_patches' , 0 )
2072+ mhd = self .get ('mhd' , 'F' ) == 'T'
2073+
2074+ if num_patches is None or num_patches <= 0 :
2075+ return
2076+
2077+ # MHD simulations legitimately use transverse velocities in 1D
2078+ if mhd :
2079+ return
2080+
2081+ for i in range (1 , num_patches + 1 ):
2082+ geometry = self .get (f'patch_icpp({ i } )%geometry' )
2083+ if geometry is None :
2084+ continue
2085+
2086+ if n is not None and n == 0 :
2087+ vel2 = self .get (f'patch_icpp({ i } )%vel(2)' )
2088+ if vel2 is not None and self ._is_numeric (vel2 ) and vel2 != 0 :
2089+ self .prohibit (True ,
2090+ f"patch_icpp({ i } )%vel(2) = { vel2 } but n = 0 (1D simulation)" )
2091+
2092+ if p is not None and p == 0 :
2093+ vel3 = self .get (f'patch_icpp({ i } )%vel(3)' )
2094+ if vel3 is not None and self ._is_numeric (vel3 ) and vel3 != 0 :
2095+ self .prohibit (True ,
2096+ f"patch_icpp({ i } )%vel(3) = { vel3 } but p = 0 (1D/2D simulation)" )
2097+
18102098 # ===================================================================
18112099 # Main Validation Entry Points
18122100 # ===================================================================
@@ -1815,6 +2103,7 @@ def validate_common(self):
18152103 """Validate parameters common to all stages"""
18162104 self .check_parameter_types () # Type validation first
18172105 self .check_simulation_domain ()
2106+ self .check_domain_bounds ()
18182107 self .check_model_eqns_and_num_fluids ()
18192108 self .check_igr ()
18202109 self .check_weno ()
@@ -1828,6 +2117,7 @@ def validate_common(self):
18282117 self .check_phase_change ()
18292118 self .check_ibm ()
18302119 self .check_stiffened_eos ()
2120+ self .check_eos_parameter_sanity ()
18312121 self .check_surface_tension ()
18322122 self .check_mhd ()
18332123
@@ -1864,6 +2154,10 @@ def validate_pre_process(self):
18642154 self .check_chemistry ()
18652155 self .check_misc_pre_process ()
18662156 self .check_patch_physics ()
2157+ self .check_volume_fraction_sum ()
2158+ self .check_alpha_rho_consistency ()
2159+ self .check_patch_within_domain ()
2160+ self .check_velocity_components ()
18672161 self .check_bc_patches ()
18682162
18692163 def validate_post_process (self ):
@@ -1897,6 +2191,7 @@ def validate(self, stage: str = 'simulation'):
18972191 CaseConstraintError: If any constraint violations are found
18982192 """
18992193 self .errors = []
2194+ self .warnings = []
19002195
19012196 if stage == 'simulation' :
19022197 self .validate_simulation ()
@@ -1907,12 +2202,14 @@ def validate(self, stage: str = 'simulation'):
19072202 else :
19082203 # No stage-specific constraints for auxiliary targets like 'syscheck'.
19092204 # Silently skip validation rather than treating this as an error.
1910- return
2205+ return []
19112206
19122207 if self .errors :
19132208 error_msg = self ._format_errors ()
19142209 raise CaseConstraintError (error_msg )
19152210
2211+ return self .warnings
2212+
19162213 def _format_errors (self ) -> str :
19172214 """Format errors with enhanced context and suggestions."""
19182215 lines = ["[bold red]Case parameter constraint violations:[/bold red]\n " ]
@@ -1952,15 +2249,18 @@ def _format_errors(self) -> str:
19522249 return "\n " .join (lines )
19532250
19542251
1955- def validate_case_constraints (params : Dict [str , Any ], stage : str = 'simulation' ):
2252+ def validate_case_constraints (params : Dict [str , Any ], stage : str = 'simulation' ) -> List [ str ] :
19562253 """Convenience function to validate case parameters
19572254
19582255 Args:
19592256 params: Dictionary of case parameters
19602257 stage: One of 'simulation', 'pre_process', or 'post_process'
19612258
2259+ Returns:
2260+ List of warning messages (non-fatal issues)
2261+
19622262 Raises:
19632263 CaseConstraintError: If any constraint violations are found
19642264 """
19652265 validator = CaseValidator (params )
1966- validator .validate (stage )
2266+ return validator .validate (stage ) or []
0 commit comments