From 16126c66fcf0cb6942cd489524e12792fba3e9ce Mon Sep 17 00:00:00 2001 From: sbryngelson Date: Sat, 12 Sep 2026 23:01:04 -0400 Subject: [PATCH 1/2] Require the relaxation targets that grcbc_out and grcbc_vel_out read m_cbc.fpp relaxes a GRCBC outflow toward a prescribed pressure: L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir) and grcbc_vel_out reads vel_out through dir_idx, so which component is the normal one depends on the direction. Neither target was required. Setting grcbc_out without pres_out validated cleanly, then relaxed the boundary toward an undefined value. The failure mode is silent and expensive to chase. On a uniform quiescent 2D field with no body and no disturbance, density in the last cell of the outflow direction walked away linearly at 0.032 per step from step 1, crossed zero near step 31, and the run aborted on ICFL at step 57 with nothing in the output naming the boundary condition. ICFL sat at exactly 0.4000 for 56 steps and then jumped to 1.3e15 in one step, so it did not read as a boundary problem either; only dumping the field every step and finding every deviation pinned to cell i = m identified it. check_grcbc now requires bc_%pres_out when grcbc_out is set, and bc_%vel_out(1..num_dims) when grcbc_vel_out is set. Requiring every component rather than guessing the normal one follows check_synthetic_turbulence, and avoids the direction-blindness that #1854 corrects on the inflow side, where vel_in(1) is not the normal component for a y or z boundary. Verified it rejects nothing in tree: all 181 example cases generate and validate, 9 of which use grcbc_out or grcbc_vel_out, and the full validator suite passes (72 tests). Three regression tests cover the rejection, the acceptance once targets are given, and that the third velocity component is required only in 3D. Made with Claude Code. --- toolchain/mfc/case_validator.py | 16 ++++++++++++++++ toolchain/mfc/test_case_validator.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/toolchain/mfc/case_validator.py b/toolchain/mfc/case_validator.py index 1da790a90..43632134b 100644 --- a/toolchain/mfc/case_validator.py +++ b/toolchain/mfc/case_validator.py @@ -1783,6 +1783,7 @@ def check_continuum_damage(self): def check_grcbc(self): """Checks Generalized Relaxation Characteristics BC (simulation)""" + num_dims = 3 if (self.get("p", 0) or 0) > 0 else (2 if (self.get("n", 0) or 0) > 0 else 1) for dir in ["x", "y", "z"]: grcbc_in = self.get(f"bc_{dir}%grcbc_in", "F") == "T" grcbc_out = self.get(f"bc_{dir}%grcbc_out", "F") == "T" @@ -1796,8 +1797,23 @@ def check_grcbc(self): if grcbc_out: # Check if EITHER beg OR end is set to -8 self.prohibit(bc_beg != -8 and bc_end != -8, f"Subsonic Outflow (grcbc_out) requires bc_{dir}%beg = -8 or bc_{dir}%end = -8") + # m_cbc.fpp relaxes the outflow toward this pressure: + # L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir) + # Left unset it relaxes toward an undefined target, which is silent: the boundary cell simply + # walks away, and the run aborts on ICFL tens of steps later with nothing pointing at the BC. + self.prohibit( + not self.is_set(f"bc_{dir}%pres_out"), + f"bc_{dir}%pres_out must be specified when bc_{dir}%grcbc_out is enabled", + ) if grcbc_vel_out: self.prohibit(bc_beg != -8 and bc_end != -8, f"Subsonic Outflow Velocity (grcbc_vel_out) requires bc_{dir}%beg = -8 or bc_{dir}%end = -8") + # The kernel reads vel_out through dir_idx, so which component is the normal one depends on the + # direction. Require all num_dims components rather than guess, matching check_synthetic_turbulence. + for d in range(1, num_dims + 1): + self.prohibit( + not self.is_set(f"bc_{dir}%vel_out({d})"), + f"bc_{dir}%vel_out({d}) must be specified for all num_dims when bc_{dir}%grcbc_vel_out is enabled", + ) def check_probe_output(self): """Checks probe output requirements (simulation)""" diff --git a/toolchain/mfc/test_case_validator.py b/toolchain/mfc/test_case_validator.py index d6f6d585f..f6a2a88e6 100644 --- a/toolchain/mfc/test_case_validator.py +++ b/toolchain/mfc/test_case_validator.py @@ -529,3 +529,31 @@ def test_accepts_and_requires(self): self.assertRejects({**BASE, **self.VINET, "fluid_pp(1)%mg_s2": 0.1}, "fluid_pp(1)%mg_* are only read when") for k in ("gamma", "pi_inf"): self.assertRejects({**BASE, **self.VINET, f"fluid_pp(1)%{k}": 1.0}, f"fluid_pp(1)%{k} is not read with eos = 'vinet'") + + +class TestGrcbcOutflowTargets(ConstraintTestCase): + """grcbc_out and grcbc_vel_out relax toward targets that must actually be given. + + m_cbc.fpp computes L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir), and reads + vel_out through dir_idx so which component is the normal one depends on direction. Left + unset, the relaxation pulls toward an undefined target and the boundary cell walks away: + the failure surfaces tens of steps later as an ICFL abort with nothing naming the BC. + """ + + OUT = {"bc_x%beg": -7, "bc_x%end": -8, "bc_x%grcbc_in": "T", "bc_x%grcbc_out": "T"} + + def test_grcbc_out_requires_pres_out(self): + self.assertRejects({**BASE_2D, **self.OUT}, "bc_x%pres_out must be specified") + self.assertAccepts({**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0}) + + def test_grcbc_vel_out_requires_every_component(self): + p = {**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0, "bc_x%grcbc_vel_out": "T"} + self.assertRejects(p, "bc_x%vel_out(1) must be specified") + self.assertRejects({**p, "bc_x%vel_out(1)": 1.0}, "bc_x%vel_out(2) must be specified") + self.assertAccepts({**p, "bc_x%vel_out(1)": 1.0, "bc_x%vel_out(2)": 0.0}) + + def test_third_component_only_required_in_3d(self): + p2 = {**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0, "bc_x%grcbc_vel_out": "T", "bc_x%vel_out(1)": 1.0, "bc_x%vel_out(2)": 0.0} + self.assertAccepts(p2) + p3 = {**p2, "p": 50, "bc_z%beg": -1, "bc_z%end": -1, "z_domain%beg": 0.0, "z_domain%end": 1.0, "patch_icpp(1)%z_centroid": 0.5, "patch_icpp(1)%length_z": 1.0, "patch_icpp(1)%vel(3)": 0.0} + self.assertRejects(p3, "bc_x%vel_out(3) must be specified") From a2a6eee0c44a534c04de5c20a0fb609f418fc940 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 12 Sep 2026 22:41:28 -0500 Subject: [PATCH 2/2] Require only the normal velocity component that the outflow branch reads --- toolchain/mfc/case_validator.py | 22 ++++++++++------- toolchain/mfc/test_case_validator.py | 35 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/toolchain/mfc/case_validator.py b/toolchain/mfc/case_validator.py index 43632134b..aef17f41a 100644 --- a/toolchain/mfc/case_validator.py +++ b/toolchain/mfc/case_validator.py @@ -1783,7 +1783,6 @@ def check_continuum_damage(self): def check_grcbc(self): """Checks Generalized Relaxation Characteristics BC (simulation)""" - num_dims = 3 if (self.get("p", 0) or 0) > 0 else (2 if (self.get("n", 0) or 0) > 0 else 1) for dir in ["x", "y", "z"]: grcbc_in = self.get(f"bc_{dir}%grcbc_in", "F") == "T" grcbc_out = self.get(f"bc_{dir}%grcbc_out", "F") == "T" @@ -1797,7 +1796,9 @@ def check_grcbc(self): if grcbc_out: # Check if EITHER beg OR end is set to -8 self.prohibit(bc_beg != -8 and bc_end != -8, f"Subsonic Outflow (grcbc_out) requires bc_{dir}%beg = -8 or bc_{dir}%end = -8") - # m_cbc.fpp relaxes the outflow toward this pressure: + # m_cbc.fpp relaxes the outflow toward this pressure. One branch serves the beg and end + # sides alike -- both write L(adv%end), and sign(1, cbc_loc) picks the side -- so this is + # required whichever side carries the -8: # L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir) # Left unset it relaxes toward an undefined target, which is silent: the boundary cell simply # walks away, and the run aborts on ICFL tens of steps later with nothing pointing at the BC. @@ -1807,13 +1808,16 @@ def check_grcbc(self): ) if grcbc_vel_out: self.prohibit(bc_beg != -8 and bc_end != -8, f"Subsonic Outflow Velocity (grcbc_vel_out) requires bc_{dir}%beg = -8 or bc_{dir}%end = -8") - # The kernel reads vel_out through dir_idx, so which component is the normal one depends on the - # direction. Require all num_dims components rather than guess, matching check_synthetic_turbulence. - for d in range(1, num_dims + 1): - self.prohibit( - not self.is_set(f"bc_{dir}%vel_out({d})"), - f"bc_{dir}%vel_out({d}) must be specified for all num_dims when bc_{dir}%grcbc_vel_out is enabled", - ) + # Only the NORMAL component is read here: + # L(adv%end) += rho*c^2*(1 - Ma)*(vel(dir_idx(1)) + vel_out(dir, dir_idx(1))*sign(1, cbc_loc))/Del_out(dir) + # and dir_idx(1) is 1 for x, 2 for y, 3 for z (m_cbc.fpp:942-948). The transverse components + # are read by grcbc_in's inflow branch, not by this one, so requiring them here would reject + # configurations that run correctly. + normal = {"x": 1, "y": 2, "z": 3}[dir] + self.prohibit( + not self.is_set(f"bc_{dir}%vel_out({normal})"), + f"bc_{dir}%vel_out({normal}) must be specified when bc_{dir}%grcbc_vel_out is enabled", + ) def check_probe_output(self): """Checks probe output requirements (simulation)""" diff --git a/toolchain/mfc/test_case_validator.py b/toolchain/mfc/test_case_validator.py index f6a2a88e6..253a510e1 100644 --- a/toolchain/mfc/test_case_validator.py +++ b/toolchain/mfc/test_case_validator.py @@ -534,10 +534,11 @@ def test_accepts_and_requires(self): class TestGrcbcOutflowTargets(ConstraintTestCase): """grcbc_out and grcbc_vel_out relax toward targets that must actually be given. - m_cbc.fpp computes L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir), and reads - vel_out through dir_idx so which component is the normal one depends on direction. Left - unset, the relaxation pulls toward an undefined target and the boundary cell walks away: - the failure surfaces tens of steps later as an ICFL abort with nothing naming the BC. + m_cbc.fpp computes L(adv%end) = c*(1 - Ma)*(pres - pres_out(dir))/Del_out(dir), on the beg and + end sides alike, and adds only the NORMAL velocity, vel_out(dir, dir_idx(1)), where dir_idx(1) + is 1 for x, 2 for y and 3 for z. Left unset, the relaxation pulls toward an undefined target and + the boundary cell walks away: the failure surfaces tens of steps later as an ICFL abort with + nothing naming the BC. """ OUT = {"bc_x%beg": -7, "bc_x%end": -8, "bc_x%grcbc_in": "T", "bc_x%grcbc_out": "T"} @@ -546,14 +547,22 @@ def test_grcbc_out_requires_pres_out(self): self.assertRejects({**BASE_2D, **self.OUT}, "bc_x%pres_out must be specified") self.assertAccepts({**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0}) - def test_grcbc_vel_out_requires_every_component(self): + def test_grcbc_vel_out_requires_the_normal_component(self): p = {**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0, "bc_x%grcbc_vel_out": "T"} self.assertRejects(p, "bc_x%vel_out(1) must be specified") - self.assertRejects({**p, "bc_x%vel_out(1)": 1.0}, "bc_x%vel_out(2) must be specified") - self.assertAccepts({**p, "bc_x%vel_out(1)": 1.0, "bc_x%vel_out(2)": 0.0}) - - def test_third_component_only_required_in_3d(self): - p2 = {**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0, "bc_x%grcbc_vel_out": "T", "bc_x%vel_out(1)": 1.0, "bc_x%vel_out(2)": 0.0} - self.assertAccepts(p2) - p3 = {**p2, "p": 50, "bc_z%beg": -1, "bc_z%end": -1, "z_domain%beg": 0.0, "z_domain%end": 1.0, "patch_icpp(1)%z_centroid": 0.5, "patch_icpp(1)%length_z": 1.0, "patch_icpp(1)%vel(3)": 0.0} - self.assertRejects(p3, "bc_x%vel_out(3) must be specified") + self.assertAccepts({**p, "bc_x%vel_out(1)": 1.0}) + + def test_transverse_components_are_not_required(self): + """vel_out(2) is transverse at an x boundary; only grcbc_in's branch reads it, so demanding + it here would reject a case that runs correctly.""" + p = {**BASE_2D, **self.OUT, "bc_x%pres_out": 1.0, "bc_x%grcbc_vel_out": "T", "bc_x%vel_out(1)": 1.0} + self.assertAccepts(p) + self.assertAccepts( + {**p, "p": 50, "bc_z%beg": -1, "bc_z%end": -1, "z_domain%beg": 0.0, "z_domain%end": 1.0, "patch_icpp(1)%z_centroid": 0.5, "patch_icpp(1)%length_z": 1.0, "patch_icpp(1)%vel(3)": 0.0} + ) + + def test_the_required_component_follows_the_direction(self): + """dir_idx(1) is 2 at a y boundary, so it is vel_out(2) that must be given, not vel_out(1).""" + y = {**BASE_2D, "bc_y%beg": -7, "bc_y%end": -8, "bc_y%grcbc_in": "T", "bc_y%grcbc_out": "T", "bc_y%pres_out": 1.0, "bc_y%grcbc_vel_out": "T"} + self.assertRejects(y, "bc_y%vel_out(2) must be specified") + self.assertAccepts({**y, "bc_y%vel_out(2)": 0.0})