Skip to content

Commit 0f4f41a

Browse files
sbryngelsonclaude
andcommitted
Add physics warning infrastructure and auto-documenting constraints
Add warn() method to CaseValidator for non-fatal physics checks (volume fraction sum, alpha-rho consistency, EOS parameter sanity, domain bounds, patch bounds, velocity components). Extend AST analyzer to discover self.warn() calls alongside self.prohibit(), with severity field on Rule dataclass. Auto-generated Doxygen docs now include a Physics Warnings section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21347c1 commit 0f4f41a

5 files changed

Lines changed: 398 additions & 24 deletions

File tree

toolchain/mfc/case_validator.py

Lines changed: 303 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)