diff --git a/toolchain/mfc/case_validator.py b/toolchain/mfc/case_validator.py index 36fce10a0..476009fbd 100644 --- a/toolchain/mfc/case_validator.py +++ b/toolchain/mfc/case_validator.py @@ -19,9 +19,9 @@ from . import eos from .common import MFCException -from .params.definitions import CONSTRAINTS +from .params.definitions import CONSTRAINTS, NAF, NB, NIB, NSM, NTS, NUM_PATCHES_MAX from .params.eos_families import EOS_FAMILIES -from .params.namelist_parser import get_fortran_constants, get_fortran_real_constants +from .params.namelist_parser import get_fortran_real_constants from .state import CFG # Above this the Ensemble-Averaged Bubble Model's O(alpha) expansion, which enters the closure as @@ -790,7 +790,7 @@ def check_ibm(self): ib and num_ibs <= 0 and not has_particle_clouds, "num_ibs must be >= 1 when ib is enabled (or specify at least one particle_cloud with num_particles > 0)", ) - num_ib_patches_max = get_fortran_constants().get("num_ib_patches_max_namelist", 50000) + num_ib_patches_max = NIB self.prohibit( ib and num_ibs > num_ib_patches_max, f"num_ibs must be <= {num_ib_patches_max} (num_ib_patches_max_namelist in m_constants.fpp)", @@ -996,8 +996,8 @@ def check_ibm(self): f"particle_cloud({i}) hemisphere shell {name}-extent must lie within {name}_domain", ) - num_ib_airfoils_max = get_fortran_constants().get("num_ib_airfoils_max", 5) - num_stl_models_max = get_fortran_constants().get("num_stl_models_max", 10) + num_ib_airfoils_max = NAF + num_stl_models_max = NSM num_stl_models = self.get("num_stl_models", 0) self.prohibit( num_stl_models < 0, @@ -1436,7 +1436,7 @@ def check_synthetic_turbulence(self): num_turbulent_sources = self.get("num_turbulent_sources", 0) or 0 self.prohibit(num_turbulent_sources <= 0, "num_turbulent_sources must be > 0 when synthetic_turbulence is enabled") - num_turb_sources_max = get_fortran_constants().get("num_turb_sources_max", 10) + num_turb_sources_max = NTS self.prohibit( num_turbulent_sources > num_turb_sources_max, f"num_turbulent_sources must be <= {num_turb_sources_max} (num_turb_sources_max in m_constants.fpp)", @@ -2011,7 +2011,7 @@ def check_restart(self): self.prohibit(old_grid and t_step_old is None, "old_grid requires t_step_old to be set") self.prohibit(num_patches < 0, "num_patches must be non-negative") self.prohibit(num_patches == 0 and t_step_old is None, "num_patches must be positive for the non-restart case") - num_patches_max = get_fortran_constants().get("num_patches_max", 1000) + num_patches_max = NUM_PATCHES_MAX self.prohibit( num_patches > num_patches_max, f"num_patches must be <= {num_patches_max} (num_patches_max in m_constants.fpp)", @@ -2407,7 +2407,7 @@ def check_patch_physics(self): def check_bc_patches(self): """Checks boundary condition patch geometry (pre-process)""" num_bc_patches = self.get("num_bc_patches", 0) - num_bc_patches_max = get_fortran_constants().get("num_bc_patches_max", 10) + num_bc_patches_max = NB if num_bc_patches <= 0: return diff --git a/toolchain/mfc/params/definitions.py b/toolchain/mfc/params/definitions.py index 159dd9aae..ba5613db2 100644 --- a/toolchain/mfc/params/definitions.py +++ b/toolchain/mfc/params/definitions.py @@ -35,6 +35,7 @@ def _fc(name: str, default: int) -> int: NAF = _fc("num_ib_airfoils_max", 5) # ib_airfoil (Fortran array bound) NSM = _fc("num_stl_models_max", 10) # stl_models (Fortran array bound) NPB = _fc("num_particle_clouds_max", 10) # particle_cloud (Fortran array bound) +NTS = _fc("num_turb_sources_max", 10) # turb_pos, synth_L (Fortran array bound) # Enumeration limits for families not yet converted to IndexedFamily. # These are smaller than the Fortran array bounds to keep the registry compact. # The CONSTRAINTS dict below uses the Fortran constants for validation. @@ -859,7 +860,6 @@ def _load(): _r("num_turbulent_sources", INT, {"synthetic_turbulence"}) _r("synth_U_inf", REAL, {"synthetic_turbulence"}, math=r"\f$U_\infty\f$") NSS = _fc("num_synth_shells_max", 50) - NTS = _fc("num_turb_sources_max", 10) for i in range(1, NSS + 1): _r(f"synth_n_waves_per_shell({i})", INT, {"synthetic_turbulence"}) _r(f"synth_k_shell({i})", REAL, {"synthetic_turbulence"}, math=r"\f$k_s\f$") diff --git a/toolchain/mfc/params_tests/test_definitions.py b/toolchain/mfc/params_tests/test_definitions.py index 3c4cc913b..bb0c20b57 100644 --- a/toolchain/mfc/params_tests/test_definitions.py +++ b/toolchain/mfc/params_tests/test_definitions.py @@ -6,6 +6,8 @@ import ast import inspect +import pathlib +import re import unittest from ..params import REGISTRY, definitions @@ -194,3 +196,49 @@ def test_fallbacks_match_m_constants(self): for call in calls: name, default = (ast.literal_eval(arg) for arg in call.args) self.assertEqual(default, constants[name], f"_fc('{name}', {default}) fallback differs from m_constants.fpp") + + def test_each_constant_is_resolved_once(self): + """No constant should be resolved by two _fc() calls. + + A second _fc() for the same name is a second place to edit. _load() + had its own NTS = _fc("num_turb_sources_max", 10) shadowing the + module-level one, so importing NTS elsewhere would not have given a + single source of truth. + """ + calls = [node for node in ast.walk(ast.parse(inspect.getsource(definitions))) if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_fc"] + names = [ast.literal_eval(call.args[0]) for call in calls] + duplicated = sorted({name for name in names if names.count(name) > 1}) + self.assertEqual(duplicated, [], f"resolved by more than one _fc() call: {duplicated}") + + +class TestFortranConstantGetFallbacks(unittest.TestCase): + """The same rule for get_fortran_constants().get(name, default) anywhere in the toolchain. + + definitions.py resolves its constants through _fc(), which the test above + guards. Callers that re-resolve a constant with their own .get() default + are a second, unguarded copy of the same number: case_validator.py capped + num_ibs at 50000 while m_constants.fpp said 810000, and allowed 1000 + num_patches while the Fortran array holds 10 -- too strict in one + direction and, worse, too permissive in the other. Those defaults are only + reached when src/ is absent, as in a Homebrew layout, so nothing at + development time notices they have drifted. + + Prefer importing the already-resolved constant from definitions.py. This + test allows a .get() default only when it still matches the Fortran. + """ + + def test_get_fallbacks_match_m_constants(self): + constants = get_fortran_constants() + self.assertTrue(constants, "m_constants.fpp could not be parsed") + + toolchain = pathlib.Path(__file__).resolve().parents[2] + pattern = re.compile(r"""get_fortran_constants\(\)\.get\(\s*["'](\w+)["']\s*,\s*(\d+)\s*\)""") + + stale = [] + for path in sorted(toolchain.rglob("*.py")): + for name, default in pattern.findall(path.read_text(encoding="utf-8", errors="ignore")): + if name in constants and int(default) != constants[name]: + rel = path.relative_to(toolchain) + stale.append(f"{rel}: .get('{name}', {default}) != {constants[name]} in m_constants.fpp") + + self.assertEqual(stale, [], "stale Fortran-constant fallbacks:\n " + "\n ".join(stale))