diff --git a/CLAUDE.md b/CLAUDE.md index 47a188d100..627757fa56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ All commands run from the repo root via `./mfc.sh`. # Verification (pre-commit CI checks) ./mfc.sh precheck -j 8 # Run all 5 lint checks (same as CI gate) ./mfc.sh format -j 8 # Auto-format Fortran (.fpp/.f90) + Python -./mfc.sh lint # Pylint + Python unit tests +./mfc.sh lint # Ruff lint + Python unit tests ./mfc.sh spelling # Spell check # Module loading (HPC clusters only — must use `source`) diff --git a/benchmarks/5eq_rk3_weno3_hllc/case.py b/benchmarks/5eq_rk3_weno3_hllc/case.py index fa09426ffe..29bb6fca94 100644 --- a/benchmarks/5eq_rk3_weno3_hllc/case.py +++ b/benchmarks/5eq_rk3_weno3_hllc/case.py @@ -6,9 +6,9 @@ # - weno_order : 3 # - riemann_solver : 2 +import argparse import json import math -import argparse parser = argparse.ArgumentParser(prog="Benchmarking Case 1", description="This MFC case was created for the purposes of benchmarking MFC.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/benchmarks/hypo_hll/case.py b/benchmarks/hypo_hll/case.py index f8d0928a01..6a92ee3a3d 100644 --- a/benchmarks/hypo_hll/case.py +++ b/benchmarks/hypo_hll/case.py @@ -4,9 +4,9 @@ # - hypoelasticity : T # - riemann_solver : 1 +import argparse import json import math -import argparse parser = argparse.ArgumentParser(prog="Benchmarkin Case 3", description="This MFC case was created for the purposes of benchmarking MFC.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/benchmarks/ibm/case.py b/benchmarks/ibm/case.py index 303cf7fcaf..697973b8ed 100644 --- a/benchmarks/ibm/case.py +++ b/benchmarks/ibm/case.py @@ -3,9 +3,9 @@ # Additional Benchmarked Features # - ibm : T +import argparse import json import math -import argparse parser = argparse.ArgumentParser(prog="Benchmarking Case 4", description="This MFC case was created for the purposes of benchmarking MFC.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/benchmarks/igr/case.py b/benchmarks/igr/case.py index 4ceed76257..52331297e2 100644 --- a/benchmarks/igr/case.py +++ b/benchmarks/igr/case.py @@ -5,9 +5,9 @@ # - viscous : T # - igr_order : 5 +import argparse import json import math -import argparse parser = argparse.ArgumentParser(prog="Benchmarking Case 5", description="This MFC case was created for the purposes of benchmarking MFC.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/benchmarks/viscous_weno5_sgb_acoustic/case.py b/benchmarks/viscous_weno5_sgb_acoustic/case.py index 83bdc43e9c..ebc13e6161 100644 --- a/benchmarks/viscous_weno5_sgb_acoustic/case.py +++ b/benchmarks/viscous_weno5_sgb_acoustic/case.py @@ -8,9 +8,9 @@ # - bubble_model : 3 # - acoustic_source : T +import argparse import json import math -import argparse parser = argparse.ArgumentParser(prog="Benchmarking Case 2", description="This MFC case was created for the purposes of benchmarking MFC.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/docs/documentation/contributing.md b/docs/documentation/contributing.md index da97488dde..9811bcc3c3 100644 --- a/docs/documentation/contributing.md +++ b/docs/documentation/contributing.md @@ -715,14 +715,15 @@ Every push to a PR triggers CI. Understanding the pipeline helps you fix failure ### Lint Gate (runs first, blocks all other jobs) -All four checks must pass before any builds start: +All five checks must pass before any builds start: 1. **Formatting** — `./mfc.sh format` (auto-handled by pre-commit hook) 2. **Spelling** — `./mfc.sh spelling` -3. **Toolchain lint** — `./mfc.sh lint` (Python code quality) +3. **Toolchain lint** — `./mfc.sh lint` (ruff + Python unit tests) 4. **Source lint** — checks for: - Raw `!$acc` or `!$omp` directives (must use Fypp GPU macros) - Double-precision intrinsics (`dsqrt`, `dexp`, `dble`, etc.) +5. **Doc references** — validates documentation cross-references ### Build and Test Matrix diff --git a/examples/0D_bubblecollapse_adap/case.py b/examples/0D_bubblecollapse_adap/case.py index 70ffb11a81..ac79d69275 100644 --- a/examples/0D_bubblecollapse_adap/case.py +++ b/examples/0D_bubblecollapse_adap/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -import math import json +import math # FLUID PROPERTIES # Water diff --git a/examples/1D_bubblescreen/case.py b/examples/1D_bubblescreen/case.py index fec53c55e1..f5d6011946 100755 --- a/examples/1D_bubblescreen/case.py +++ b/examples/1D_bubblescreen/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # FLUID PROPERTIES R_uni = 8314.0 # [J/kmol/K] @@ -53,7 +53,6 @@ u0 = math.sqrt(p0 / rho0) # [m/s] t0 = x0 / u0 # [s] -# cfl = 0.1 Nx = 100 Ldomain = 20.0e-03 diff --git a/examples/1D_convergence/case.py b/examples/1D_convergence/case.py index fe1ed3c976..7c5858bed4 100755 --- a/examples/1D_convergence/case.py +++ b/examples/1D_convergence/case.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import math -import json import argparse +import json +import math # Parsing command line arguments parser = argparse.ArgumentParser(description="Generate JSON case configuration for two-fluid convergence simulation.") @@ -67,10 +67,10 @@ "patch_icpp(1)%length_x": 1.0, "patch_icpp(1)%vel(1)": 1.0, "patch_icpp(1)%pres": 1.0, - "patch_icpp(1)%alpha_rho(1)": f"0.5 - 0.5*sin(2*pi*x)", - "patch_icpp(1)%alpha(1)": f"0.5 - 0.5*sin(2*pi*x)", - "patch_icpp(1)%alpha_rho(2)": f"0.5 + 0.5*sin(2*pi*x)", - "patch_icpp(1)%alpha(2)": f"0.5 + 0.5*sin(2*pi*x)", + "patch_icpp(1)%alpha_rho(1)": "0.5 - 0.5*sin(2*pi*x)", + "patch_icpp(1)%alpha(1)": "0.5 - 0.5*sin(2*pi*x)", + "patch_icpp(1)%alpha_rho(2)": "0.5 + 0.5*sin(2*pi*x)", + "patch_icpp(1)%alpha(2)": "0.5 + 0.5*sin(2*pi*x)", # Fluids Physical Parameters "fluid_pp(1)%gamma": 1.0e00 / (1.4 - 1.0e00), "fluid_pp(1)%pi_inf": 0.0, diff --git a/examples/1D_convergence/plot.py b/examples/1D_convergence/plot.py index 6af7e5c1f9..2f004425ac 100644 --- a/examples/1D_convergence/plot.py +++ b/examples/1D_convergence/plot.py @@ -11,7 +11,6 @@ for i in range(len(N)): for j in range(len(Ord)): - sim_a1 = pd.read_csv(f"N{N[i]}_O{Ord[j]}/D/cons.5.00.{TEND}.dat", sep=r"\s+", header=None, names=["x", "y"]) sim_a2 = pd.read_csv(f"N{N[i]}_O{Ord[j]}/D/cons.6.00.{TEND}.dat", sep=r"\s+", header=None, names=["x", "y"]) diff --git a/examples/1D_exp_bubscreen/case.py b/examples/1D_exp_bubscreen/case.py index d8590436c6..212e18611d 100755 --- a/examples/1D_exp_bubscreen/case.py +++ b/examples/1D_exp_bubscreen/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # FLUID PROPERTIES R_uni = 8314.0 # [J/kmol/K] diff --git a/examples/1D_exp_tube_phasechange/case.py b/examples/1D_exp_tube_phasechange/case.py index 18bd4c72d9..03808cd78d 100644 --- a/examples/1D_exp_tube_phasechange/case.py +++ b/examples/1D_exp_tube_phasechange/case.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import math -import json import argparse +import json +import math parser = argparse.ArgumentParser(prog="phasechange", description="phase change considering both 5 and 6 equation models.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--mfc", type=json.loads, default="{}", metavar="DICT", help="MFC's toolchain's internal state.") diff --git a/examples/1D_hypo_2materials/case.py b/examples/1D_hypo_2materials/case.py index 137fb8824c..cd4316fc41 100755 --- a/examples/1D_hypo_2materials/case.py +++ b/examples/1D_hypo_2materials/case.py @@ -1,6 +1,6 @@ #!/usr/bin/python -import math import json +import math # Numerical setup Nx = 399 diff --git a/examples/1D_impact/case.py b/examples/1D_impact/case.py index f83793ea14..7f434741f8 100755 --- a/examples/1D_impact/case.py +++ b/examples/1D_impact/case.py @@ -1,6 +1,6 @@ #!/usr/bin/python -import math import json +import math # Numerical setup Nx = 399 diff --git a/examples/1D_inert_shocktube/case.py b/examples/1D_inert_shocktube/case.py index c9a1a8f9d6..26f45f1658 100644 --- a/examples/1D_inert_shocktube/case.py +++ b/examples/1D_inert_shocktube/case.py @@ -2,8 +2,8 @@ # References: # + https://doi.org/10.1016/j.compfluid.2013.10.014: 4.3. Multi-component inert shock tube -import json import argparse +import json import cantera as ct @@ -105,8 +105,8 @@ if args.chemistry: for i in range(len(sol_L.Y)): - case[f"patch_icpp(1)%Y({i+1})"] = sol_L.Y[i] - case[f"patch_icpp(2)%Y({i+1})"] = sol_R.Y[i] + case[f"patch_icpp(1)%Y({i + 1})"] = sol_L.Y[i] + case[f"patch_icpp(2)%Y({i + 1})"] = sol_R.Y[i] if __name__ == "__main__": print(json.dumps(case)) diff --git a/examples/1D_laxshocktube/case.py b/examples/1D_laxshocktube/case.py index 6426e2fe05..15445f792c 100644 --- a/examples/1D_laxshocktube/case.py +++ b/examples/1D_laxshocktube/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 200 diff --git a/examples/1D_multispecies_diffusion/case.py b/examples/1D_multispecies_diffusion/case.py index ecacbd965c..b4f52ae438 100644 --- a/examples/1D_multispecies_diffusion/case.py +++ b/examples/1D_multispecies_diffusion/case.py @@ -2,9 +2,10 @@ # References: # + https://doi.org/10.1016/j.compfluid.2013.10.014: 4.4. Multicomponent diffusion test case -import json import argparse +import json import math + import cantera as ct ctfile = "gri30.yaml" @@ -73,6 +74,6 @@ for i in range(len(sol_L.Y)): case[f"chem_wrt_Y({i + 1})"] = "T" - case[f"patch_icpp(1)%Y({i+1})"] = 0.0 + case[f"patch_icpp(1)%Y({i + 1})"] = 0.0 if __name__ == "__main__": print(json.dumps(case)) diff --git a/examples/1D_poly_bubscreen/case.py b/examples/1D_poly_bubscreen/case.py index b5ebf8e43d..9006f72f31 100644 --- a/examples/1D_poly_bubscreen/case.py +++ b/examples/1D_poly_bubscreen/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # FLUID PROPERTIES R_uni = 8314.0 # [J/kmol/K] @@ -46,7 +46,6 @@ u0 = math.sqrt(p0 / rho0) # [m/s] t0 = x0 / u0 # [s] -# cact = 1475.0 cfl = 0.4 Nx = 20 diff --git a/examples/1D_qbmm/case.py b/examples/1D_qbmm/case.py index 2c75a6454d..426e1460b5 100644 --- a/examples/1D_qbmm/case.py +++ b/examples/1D_qbmm/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python2 -import math import json +import math # FLUID PROPERTIES R_uni = 8314.0 # [J/kmol/K] @@ -48,7 +48,6 @@ u0 = math.sqrt(p0 / rho0) # [m/s] t0 = x0 / u0 # [s] -# cact = 1475.0 cfl = 0.1 Nx = 400 diff --git a/examples/1D_reactive_shocktube/case.py b/examples/1D_reactive_shocktube/case.py index c9ce96b804..d14c20c2ea 100644 --- a/examples/1D_reactive_shocktube/case.py +++ b/examples/1D_reactive_shocktube/case.py @@ -3,8 +3,9 @@ # + https://doi.org/10.1016/j.ijhydene.2023.03.190: Verification of numerical method # + https://doi.org/10.1016/j.compfluid.2013.10.014: 4.7. Multi-species reactive shock tube -import json import argparse +import json + import cantera as ct parser = argparse.ArgumentParser(prog="1D_reactive_shocktube", formatter_class=argparse.ArgumentDefaultsHelpFormatter) @@ -111,8 +112,8 @@ if args.chemistry: for i in range(len(sol_L.Y)): case[f"chem_wrt_Y({i + 1})"] = "T" - case[f"patch_icpp(1)%Y({i+1})"] = sol_L.Y[i] - case[f"patch_icpp(2)%Y({i+1})"] = sol_R.Y[i] + case[f"patch_icpp(1)%Y({i + 1})"] = sol_L.Y[i] + case[f"patch_icpp(2)%Y({i + 1})"] = sol_R.Y[i] if __name__ == "__main__": print(json.dumps(case)) diff --git a/examples/1D_shuosher_analytical/case.py b/examples/1D_shuosher_analytical/case.py index 8126714ff4..4f9ceaff56 100644 --- a/examples/1D_shuosher_analytical/case.py +++ b/examples/1D_shuosher_analytical/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_old/case.py b/examples/1D_shuosher_old/case.py index 7ad71e733a..7169f664b2 100644 --- a/examples/1D_shuosher_old/case.py +++ b/examples/1D_shuosher_old/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_teno5/case.py b/examples/1D_shuosher_teno5/case.py index 2477ba6a87..8132ed2067 100644 --- a/examples/1D_shuosher_teno5/case.py +++ b/examples/1D_shuosher_teno5/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_teno7/case.py b/examples/1D_shuosher_teno7/case.py index 9bac5d82a0..6c7963ced1 100644 --- a/examples/1D_shuosher_teno7/case.py +++ b/examples/1D_shuosher_teno7/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_wenojs5/case.py b/examples/1D_shuosher_wenojs5/case.py index 52763938fd..94a9564552 100644 --- a/examples/1D_shuosher_wenojs5/case.py +++ b/examples/1D_shuosher_wenojs5/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_wenom5/case.py b/examples/1D_shuosher_wenom5/case.py index c3dde3a589..fd479db63c 100644 --- a/examples/1D_shuosher_wenom5/case.py +++ b/examples/1D_shuosher_wenom5/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_shuosher_wenoz5/case.py b/examples/1D_shuosher_wenoz5/case.py index f959f363ae..4bc4789a6d 100644 --- a/examples/1D_shuosher_wenoz5/case.py +++ b/examples/1D_shuosher_wenoz5/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 1000 diff --git a/examples/1D_sodHypo/case.py b/examples/1D_sodHypo/case.py index 73c7a3f88e..2339e02026 100755 --- a/examples/1D_sodHypo/case.py +++ b/examples/1D_sodHypo/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 201 diff --git a/examples/1D_sodshocktube/case.py b/examples/1D_sodshocktube/case.py index 0cb23dbb92..ea0be00820 100755 --- a/examples/1D_sodshocktube/case.py +++ b/examples/1D_sodshocktube/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 399 diff --git a/examples/1D_sodshocktube_muscl/case.py b/examples/1D_sodshocktube_muscl/case.py index ec0d2cb3f8..df63c55535 100755 --- a/examples/1D_sodshocktube_muscl/case.py +++ b/examples/1D_sodshocktube_muscl/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 399 diff --git a/examples/1D_titarevtorro/case.py b/examples/1D_titarevtorro/case.py index 63dfe39b79..2d058d1746 100644 --- a/examples/1D_titarevtorro/case.py +++ b/examples/1D_titarevtorro/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 999 diff --git a/examples/1D_titarevtorro_analytical/case.py b/examples/1D_titarevtorro_analytical/case.py index 3a51bd86b0..73f012225f 100644 --- a/examples/1D_titarevtorro_analytical/case.py +++ b/examples/1D_titarevtorro_analytical/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Numerical setup Nx = 999 diff --git a/examples/2D_5wave_quasi1D/case.py b/examples/2D_5wave_quasi1D/case.py index 3a971977ba..0f5d6ba4a7 100755 --- a/examples/2D_5wave_quasi1D/case.py +++ b/examples/2D_5wave_quasi1D/case.py @@ -1,6 +1,6 @@ #!/usr/bin/python -import math import json +import math # Numerical setup Nx = 399 diff --git a/examples/2D_GreshoVortex/case.py b/examples/2D_GreshoVortex/case.py index 96f3638333..ada31b2dd7 100644 --- a/examples/2D_GreshoVortex/case.py +++ b/examples/2D_GreshoVortex/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math gam = 1.4 Ma0 = 1e-3 diff --git a/examples/2D_IGR_2fluid/case.py b/examples/2D_IGR_2fluid/case.py index be59b206e3..3b7f520d9d 100644 --- a/examples/2D_IGR_2fluid/case.py +++ b/examples/2D_IGR_2fluid/case.py @@ -1,12 +1,11 @@ - #!/usr/bin/env python3 # This case file demonstrates the Laplace pressure jump of a water droplet in air. The laplace pressure jump # in 2D is given by delta = sigma / r where delta is the pressure jump, sigma is the surface tension coefficient, # and r is the radius of the droplet. The results of this simulation agree with theory to well within 1% # relative error. -import math import json +import math l = 1 eps = 1e-6 diff --git a/examples/2D_IGR_triple_point/case.py b/examples/2D_IGR_triple_point/case.py index 0af966e364..cf0708419c 100755 --- a/examples/2D_IGR_triple_point/case.py +++ b/examples/2D_IGR_triple_point/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math eps = 1e-8 Nx = 699 diff --git a/examples/2D_TaylorGreenVortex/case.py b/examples/2D_TaylorGreenVortex/case.py index 3157b23a3d..4a419d38e6 100644 --- a/examples/2D_TaylorGreenVortex/case.py +++ b/examples/2D_TaylorGreenVortex/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math gam_a = 1.4 Mu = 10000 # Define the fluid's dynamic viscosity diff --git a/examples/2D_acoustic_pulse/case.py b/examples/2D_acoustic_pulse/case.py index 899242ca48..be11755d9b 100644 --- a/examples/2D_acoustic_pulse/case.py +++ b/examples/2D_acoustic_pulse/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Numerical setup Nx = 99 diff --git a/examples/2D_acoustic_pulse_analytical/case.py b/examples/2D_acoustic_pulse_analytical/case.py index 73cec89a66..76192fd0e4 100644 --- a/examples/2D_acoustic_pulse_analytical/case.py +++ b/examples/2D_acoustic_pulse_analytical/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Numerical setup Nx = 99 diff --git a/examples/2D_axisym_shockbubble/case.py b/examples/2D_axisym_shockbubble/case.py index 3dcdb8ba3a..86bacd5a83 100644 --- a/examples/2D_axisym_shockbubble/case.py +++ b/examples/2D_axisym_shockbubble/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math ps = 248758.567 gam = 1.4 diff --git a/examples/2D_axisym_shockwatercavity/case.py b/examples/2D_axisym_shockwatercavity/case.py index bded43fd7a..b331322e76 100644 --- a/examples/2D_axisym_shockwatercavity/case.py +++ b/examples/2D_axisym_shockwatercavity/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # athmospheric pressure - Pa (used as reference value) patm = 101325 diff --git a/examples/2D_isentropicvortex/case.py b/examples/2D_isentropicvortex/case.py index a3b2b63e78..6bbffcd45c 100644 --- a/examples/2D_isentropicvortex/case.py +++ b/examples/2D_isentropicvortex/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Parameters epsilon = "5d0" @@ -20,14 +20,14 @@ f"{alpha_rho1_i}*(1d0 - ({alpha_rho1_i}/{pres_i})*({epsilon}/(2d0*pi))*" + f"({epsilon}/(8d0*{alpha}*({gamma} + 1d0)*pi))*" + f"exp(2d0*{alpha}*(1d0 - (x - xc)**2d0" - + f"- (y - yc)**2d0))" + + "- (y - yc)**2d0))" + f")**{gamma}" ) pres = ( f"{pres_i}*(1d0 - ({alpha_rho1_i}/{pres_i})*({epsilon}/(2d0*pi))*" + f"({epsilon}/(8d0*{alpha}*({gamma} + 1d0)*pi))*" + f"exp(2d0*{alpha}*(1d0 - (x - xc)**2d0" - + f"- (y - yc)**2d0))" + + "- (y - yc)**2d0))" + f")**({gamma} + 1d0)" ) diff --git a/examples/2D_isentropicvortex_analytical/case.py b/examples/2D_isentropicvortex_analytical/case.py index f25773e1a5..ba31fb491c 100644 --- a/examples/2D_isentropicvortex_analytical/case.py +++ b/examples/2D_isentropicvortex_analytical/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Parameters epsilon = "5d0" @@ -20,14 +20,14 @@ f"{alpha_rho1_i}*(1d0 - ({alpha_rho1_i}/{pres_i})*({epsilon}/(2d0*pi))*" + f"({epsilon}/(8d0*{alpha}*({gamma} + 1d0)*pi))*" + f"exp(2d0*{alpha}*(1d0 - (x - xc)**2d0" - + f"- (y - yc)**2d0))" + + "- (y - yc)**2d0))" + f")**{gamma}" ) pres = ( f"{pres_i}*(1d0 - ({alpha_rho1_i}/{pres_i})*({epsilon}/(2d0*pi))*" + f"({epsilon}/(8d0*{alpha}*({gamma} + 1d0)*pi))*" + f"exp(2d0*{alpha}*(1d0 - (x - xc)**2d0" - + f"- (y - yc)**2d0))" + + "- (y - yc)**2d0))" + f")**({gamma} + 1d0)" ) diff --git a/examples/2D_jet/case.py b/examples/2D_jet/case.py index c4a9374851..7da489c055 100644 --- a/examples/2D_jet/case.py +++ b/examples/2D_jet/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math pA = 101325 rhoA = 1.29 diff --git a/examples/2D_lagrange_bubblescreen/case.py b/examples/2D_lagrange_bubblescreen/case.py index 74a4540ddf..eba8edb699 100644 --- a/examples/2D_lagrange_bubblescreen/case.py +++ b/examples/2D_lagrange_bubblescreen/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Bubble screen # Description: A planar acoustic wave interacts with a bubble cloud diff --git a/examples/2D_laplace_pressure_jump/case.py b/examples/2D_laplace_pressure_jump/case.py index e66b63645d..ec1b2a32a8 100644 --- a/examples/2D_laplace_pressure_jump/case.py +++ b/examples/2D_laplace_pressure_jump/case.py @@ -4,8 +4,8 @@ # and r is the radius of the droplet. The results of this simulation agree with theory to well within 1% # relative error. -import math import json +import math l = 0.375 diff --git a/examples/2D_lungwave/case.py b/examples/2D_lungwave/case.py index 85b0387919..badea226f7 100644 --- a/examples/2D_lungwave/case.py +++ b/examples/2D_lungwave/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math pi = 3.141592653589 # material parameters diff --git a/examples/2D_lungwave_horizontal/case.py b/examples/2D_lungwave_horizontal/case.py index 8fea0a0285..0c9d2632a3 100644 --- a/examples/2D_lungwave_horizontal/case.py +++ b/examples/2D_lungwave_horizontal/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math pi = 3.141592653589 # material parameters diff --git a/examples/2D_mixing_artificial_Ma/case.py b/examples/2D_mixing_artificial_Ma/case.py index e9d926df71..c14b7b8f16 100644 --- a/examples/2D_mixing_artificial_Ma/case.py +++ b/examples/2D_mixing_artificial_Ma/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # FLUID PROPERTIES (WATER, VAPOR & AIR) # Water diff --git a/examples/2D_patch_modal_shape/case.py b/examples/2D_patch_modal_shape/case.py index 8eb3bf58ce..c1be303320 100644 --- a/examples/2D_patch_modal_shape/case.py +++ b/examples/2D_patch_modal_shape/case.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Minimal 2D acoustic case with geometry 13 (2D modal Fourier shape). Additive form.""" -import math + import json +import math Nx, Ny = 64, 64 Lx, Ly = 8.0, 8.0 diff --git a/examples/2D_patch_modal_shape_exp/case.py b/examples/2D_patch_modal_shape_exp/case.py index abf98c620b..9d1873e4d5 100644 --- a/examples/2D_patch_modal_shape_exp/case.py +++ b/examples/2D_patch_modal_shape_exp/case.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Minimal 2D acoustic case with geometry 13 in exponential form (modal_use_exp_form).""" -import math + import json +import math Nx, Ny = 64, 64 Lx = 8.0 diff --git a/examples/2D_phasechange_bubble/case.py b/examples/2D_phasechange_bubble/case.py index 012afe3ad3..c7b782daab 100644 --- a/examples/2D_phasechange_bubble/case.py +++ b/examples/2D_phasechange_bubble/case.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import math -import json import argparse +import json +import math parser = argparse.ArgumentParser(prog="phasechange", description="phase change considering both 5 and 6 equation models.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--mfc", type=json.loads, default={}, metavar="DICT", help="MFC's toolchain's internal state.") diff --git a/examples/2D_rayleigh_taylor/case.py b/examples/2D_rayleigh_taylor/case.py index 029178732a..ab7d9267db 100644 --- a/examples/2D_rayleigh_taylor/case.py +++ b/examples/2D_rayleigh_taylor/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math lam = 0.2 h = 1.2 diff --git a/examples/2D_shockbubble/case.py b/examples/2D_shockbubble/case.py index 9d66dafab8..6a195c7a2f 100644 --- a/examples/2D_shockbubble/case.py +++ b/examples/2D_shockbubble/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math ps = 248758.567 gam = 1.4 diff --git a/examples/2D_shockdroplet/case.py b/examples/2D_shockdroplet/case.py index 4167f0f6a4..910d1e42aa 100755 --- a/examples/2D_shockdroplet/case.py +++ b/examples/2D_shockdroplet/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math Ma = 1.4 ps = 238558 diff --git a/examples/2D_shockdroplet_muscl/case.py b/examples/2D_shockdroplet_muscl/case.py index 5f8f2fbce2..20915f1577 100755 --- a/examples/2D_shockdroplet_muscl/case.py +++ b/examples/2D_shockdroplet_muscl/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math Ma = 1.4 ps = 238558 diff --git a/examples/2D_shocktube_phasechange/case.py b/examples/2D_shocktube_phasechange/case.py index 48277019a8..fe4614316e 100644 --- a/examples/2D_shocktube_phasechange/case.py +++ b/examples/2D_shocktube_phasechange/case.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import math -import json import argparse +import json +import math parser = argparse.ArgumentParser(prog="phasechange", description="phase change considering both 5 and 6 equation models.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--mfc", type=json.loads, default="{}", metavar="DICT", help="MFC's toolchain's internal state.") diff --git a/examples/2D_triple_point/case.py b/examples/2D_triple_point/case.py index 89a0795834..a67ebe8cce 100755 --- a/examples/2D_triple_point/case.py +++ b/examples/2D_triple_point/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math eps = 1e-8 Nx = 699 diff --git a/examples/2D_viscous/case.py b/examples/2D_viscous/case.py index de0fa1a7cf..f525067b29 100644 --- a/examples/2D_viscous/case.py +++ b/examples/2D_viscous/case.py @@ -3,8 +3,8 @@ # Dependencies and Logistics # Command to navigate between directories -import math import json +import math myeps = 1.4 / 150.0 diff --git a/examples/2D_whale_bubble_annulus/case.py b/examples/2D_whale_bubble_annulus/case.py index ecb9215767..e6de0dca20 100755 --- a/examples/2D_whale_bubble_annulus/case.py +++ b/examples/2D_whale_bubble_annulus/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python2 -import math import json +import math # x0 = 10.E-06 x0 = 1.0 diff --git a/examples/2D_zero_circ_vortex/case.py b/examples/2D_zero_circ_vortex/case.py index 2eb8b24643..a400cb02e5 100644 --- a/examples/2D_zero_circ_vortex/case.py +++ b/examples/2D_zero_circ_vortex/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Numerical setup Nx = 250 diff --git a/examples/2D_zero_circ_vortex_analytical/case.py b/examples/2D_zero_circ_vortex_analytical/case.py index 793f265b7c..da28a8c43e 100644 --- a/examples/2D_zero_circ_vortex_analytical/case.py +++ b/examples/2D_zero_circ_vortex_analytical/case.py @@ -1,5 +1,5 @@ -import math import json +import math # Numerical setup Nx = 250 diff --git a/examples/3D_IGR_33jet/case.py b/examples/3D_IGR_33jet/case.py index 236812f8b2..d7b32157a6 100644 --- a/examples/3D_IGR_33jet/case.py +++ b/examples/3D_IGR_33jet/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Domain parameters D = 2.5 # Jet diameter diff --git a/examples/3D_IGR_TaylorGreenVortex/case.py b/examples/3D_IGR_TaylorGreenVortex/case.py index 20372828e2..f10c503746 100644 --- a/examples/3D_IGR_TaylorGreenVortex/case.py +++ b/examples/3D_IGR_TaylorGreenVortex/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math N = 99 diff --git a/examples/3D_IGR_TaylorGreenVortex_nvidia/case.py b/examples/3D_IGR_TaylorGreenVortex_nvidia/case.py index e2b22e8017..daf00de189 100644 --- a/examples/3D_IGR_TaylorGreenVortex_nvidia/case.py +++ b/examples/3D_IGR_TaylorGreenVortex_nvidia/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math N = 799 Nx = N diff --git a/examples/3D_IGR_jet/case.py b/examples/3D_IGR_jet/case.py index e26abdacd7..e0062f0991 100644 --- a/examples/3D_IGR_jet/case.py +++ b/examples/3D_IGR_jet/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Domain parameters diff --git a/examples/3D_TaylorGreenVortex/case.py b/examples/3D_TaylorGreenVortex/case.py index 8b48c2990f..e6df484570 100644 --- a/examples/3D_TaylorGreenVortex/case.py +++ b/examples/3D_TaylorGreenVortex/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math N = 256 diff --git a/examples/3D_TaylorGreenVortex_analytical/case.py b/examples/3D_TaylorGreenVortex_analytical/case.py index de440980cb..11cab03bdb 100644 --- a/examples/3D_TaylorGreenVortex_analytical/case.py +++ b/examples/3D_TaylorGreenVortex_analytical/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math N = 256 diff --git a/examples/3D_lagrange_bubblescreen/case.py b/examples/3D_lagrange_bubblescreen/case.py index b44799dcf6..ab61ffbe42 100644 --- a/examples/3D_lagrange_bubblescreen/case.py +++ b/examples/3D_lagrange_bubblescreen/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Bubble screen # Description: A planar acoustic wave interacts with a bubble cloud diff --git a/examples/3D_lagrange_shbubcollapse/case.py b/examples/3D_lagrange_shbubcollapse/case.py index 58240fea99..73e0b88f69 100644 --- a/examples/3D_lagrange_shbubcollapse/case.py +++ b/examples/3D_lagrange_shbubcollapse/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # Single bubble collapse # Description: A planar acoustic wave interacts with a single bubble diff --git a/examples/3D_patch_spherical_harmonic/case.py b/examples/3D_patch_spherical_harmonic/case.py index d2cb2423ab..dc0314a869 100644 --- a/examples/3D_patch_spherical_harmonic/case.py +++ b/examples/3D_patch_spherical_harmonic/case.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Minimal 3D acoustic case with geometry 14 (spherical harmonic surface).""" -import math + import json +import math L = 8.0 N = 50 diff --git a/examples/3D_phasechange_bubble/case.py b/examples/3D_phasechange_bubble/case.py index 4e3c5f446f..6c71aa1936 100644 --- a/examples/3D_phasechange_bubble/case.py +++ b/examples/3D_phasechange_bubble/case.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import math -import json import argparse +import json +import math parser = argparse.ArgumentParser(prog="phasechange", description="phase change considering both 5 and 6 equation models.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--mfc", type=json.loads, default="{}", metavar="DICT", help="MFC's toolchain's internal state.") diff --git a/examples/3D_rayleigh_taylor/case.py b/examples/3D_rayleigh_taylor/case.py index 3f3ec8a7e3..647127426d 100644 --- a/examples/3D_rayleigh_taylor/case.py +++ b/examples/3D_rayleigh_taylor/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math lam = 0.2 h = 1.2 diff --git a/examples/3D_rayleigh_taylor_muscl/case.py b/examples/3D_rayleigh_taylor_muscl/case.py index b72ea84236..9962b24864 100644 --- a/examples/3D_rayleigh_taylor_muscl/case.py +++ b/examples/3D_rayleigh_taylor_muscl/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math lam = 0.2 h = 1.2 diff --git a/examples/3D_recovering_sphere/case.py b/examples/3D_recovering_sphere/case.py index c16ac81b55..9a92c654a2 100644 --- a/examples/3D_recovering_sphere/case.py +++ b/examples/3D_recovering_sphere/case.py @@ -2,8 +2,8 @@ # This simulation shows the early stages of a cubic droplet recovering a spherical shape due to capillary # forces. While the relaxation is not complete, it demonstrates the expecteed symmetric behavior. -import math import json +import math l = 0.375 diff --git a/examples/3D_shockdroplet/case.py b/examples/3D_shockdroplet/case.py index e935861cc1..10741bc82e 100644 --- a/examples/3D_shockdroplet/case.py +++ b/examples/3D_shockdroplet/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # athmospheric pressure - Pa (used as reference value) patm = 101325 diff --git a/examples/3D_shockdroplet_muscl/case.py b/examples/3D_shockdroplet_muscl/case.py index 8d4337b0da..699ace9379 100644 --- a/examples/3D_shockdroplet_muscl/case.py +++ b/examples/3D_shockdroplet_muscl/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # athmospheric pressure - Pa (used as reference value) patm = 101325 diff --git a/examples/3D_turb_mixing/case.py b/examples/3D_turb_mixing/case.py index 5ff57c0e34..f4b8a0c486 100644 --- a/examples/3D_turb_mixing/case.py +++ b/examples/3D_turb_mixing/case.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import math import json +import math # SURROUNDING FLOW # Nondimensional parameters diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py index a7f212fd3f..738e9114a6 100644 --- a/examples/nD_perfect_reactor/analyze.py +++ b/examples/nD_perfect_reactor/analyze.py @@ -15,48 +15,51 @@ MFC writes species as Y_{name} (e.g. Y_OH) and density as alpha_rho1. Run `./mfc.sh viz . --list-vars` to verify variable names in your output. """ -from case import dt, Tend, SAVE_COUNT, sol -from mfc.viz import assemble_silo, discover_timesteps -from tqdm import tqdm -import cantera as ct -import matplotlib.pyplot as plt + import sys +import cantera as ct import matplotlib -matplotlib.use('Agg') +import matplotlib.pyplot as plt +from case import SAVE_COUNT, Tend, dt, sol +from tqdm import tqdm + +from mfc.viz import assemble_silo, discover_timesteps + +matplotlib.use("Agg") # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -CASE_DIR = '.' -Y_MAJORS = {'H', 'O', 'OH', 'HO2'} -Y_MINORS = {'H2O', 'H2O2'} +CASE_DIR = "." +Y_MAJORS = {"H", "O", "OH", "HO2"} +Y_MINORS = {"H2O", "H2O2"} Y_VARS = Y_MAJORS | Y_MINORS -oh_idx = sol.species_index('OH') -skinner_induction_time = 0.052e-3 # Skinner & Ringrose (1965) +oh_idx = sol.species_index("OH") +skinner_induction_time = 0.052e-3 # Skinner & Ringrose (1965) # --------------------------------------------------------------------------- # Load MFC output # --------------------------------------------------------------------------- -steps = discover_timesteps(CASE_DIR, 'silo') +steps = discover_timesteps(CASE_DIR, "silo") if not steps: - sys.exit('No silo timesteps found — did you run post_process?') + sys.exit("No silo timesteps found — did you run post_process?") mfc_times = [] mfc_rhos = [] mfc_Ys = {y: [] for y in Y_VARS} -for step in tqdm(steps, desc='Loading MFC output'): +for step in tqdm(steps, desc="Loading MFC output"): assembled = assemble_silo(CASE_DIR, step) # Perfectly stirred reactor: spatially uniform — take the midpoint cell. mid = assembled.x_cc.size // 2 mfc_times.append(step * dt) # alpha_rho1 = partial density of fluid 1; equals total density for single-fluid cases. - mfc_rhos.append(float(assembled.variables['alpha_rho1'][mid])) + mfc_rhos.append(float(assembled.variables["alpha_rho1"][mid])) for y in Y_VARS: - mfc_Ys[y].append(float(assembled.variables[f'Y_{y}'][mid])) + mfc_Ys[y].append(float(assembled.variables[f"Y_{y}"][mid])) # --------------------------------------------------------------------------- # Cantera 0-D reference @@ -96,38 +99,35 @@ def find_induction_time(ts, Ys_OH, rhos): ct_induction = find_induction_time(ct_ts, [Y[oh_idx] for Y in ct_Ys], ct_rhos) -mfc_induction = find_induction_time(mfc_times, mfc_Ys['OH'], mfc_rhos) +mfc_induction = find_induction_time(mfc_times, mfc_Ys["OH"], mfc_rhos) -print('Induction Times ([OH] >= 1e-6 mol/m^3):') -print(f' Skinner et al.: {skinner_induction_time:.3e} s') -print(f' Cantera: {ct_induction:.3e} s' - if ct_induction is not None else ' Cantera: not reached') -print(f' (Che)MFC: {mfc_induction:.3e} s' - if mfc_induction is not None else ' (Che)MFC: not reached') +print("Induction Times ([OH] >= 1e-6 mol/m^3):") +print(f" Skinner et al.: {skinner_induction_time:.3e} s") +print(f" Cantera: {ct_induction:.3e} s" if ct_induction is not None else " Cantera: not reached") +print(f" (Che)MFC: {mfc_induction:.3e} s" if mfc_induction is not None else " (Che)MFC: not reached") # --------------------------------------------------------------------------- # Plot # --------------------------------------------------------------------------- fig, axes = plt.subplots(1, 2, figsize=(12, 6)) -_colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] +_colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] _color = {y: _colors[i % len(_colors)] for i, y in enumerate(sorted(Y_VARS))} for ax, group in zip(axes, [sorted(Y_MAJORS), sorted(Y_MINORS)]): for y in group: - ax.plot(mfc_times, mfc_Ys[y], color=_color[y], label=f'${y}$') - ax.plot(ct_ts, [Y[sol.species_index(y)] for Y in ct_Ys], - linestyle=':', color=_color[y], alpha=0.6, label=f'{y} (Cantera)') - ax.set_xlabel('Time (s)') - ax.set_ylabel('$Y_k$') - ax.set_xscale('log') - ax.set_yscale('log') - ax.legend(title='Species', ncol=2) + ax.plot(mfc_times, mfc_Ys[y], color=_color[y], label=f"${y}$") + ax.plot(ct_ts, [Y[sol.species_index(y)] for Y in ct_Ys], linestyle=":", color=_color[y], alpha=0.6, label=f"{y} (Cantera)") + ax.set_xlabel("Time (s)") + ax.set_ylabel("$Y_k$") + ax.set_xscale("log") + ax.set_yscale("log") + ax.legend(title="Species", ncol=2) # Mark induction times on both panels induction_lines = [ - (skinner_induction_time, 'r', '-', 'Skinner et al.'), - (mfc_induction, 'b', '-.', '(Che)MFC'), - (ct_induction, 'g', ':', 'Cantera'), + (skinner_induction_time, "r", "-", "Skinner et al."), + (mfc_induction, "b", "-.", "(Che)MFC"), + (ct_induction, "g", ":", "Cantera"), ] for ax in axes: for t, c, ls, _ in induction_lines: @@ -135,14 +135,13 @@ def find_induction_time(ts, Ys_OH, rhos): ax.axvline(t, color=c, linestyle=ls) axes[0].legend( - handles=[plt.Line2D([0], [0], color=c, linestyle=ls) - for t, c, ls, _lbl in induction_lines if t is not None], + handles=[plt.Line2D([0], [0], color=c, linestyle=ls) for t, c, ls, _lbl in induction_lines if t is not None], labels=[lbl for t, _c, _ls, lbl in induction_lines if t is not None], - title='Induction Times', - loc='lower right', + title="Induction Times", + loc="lower right", ) plt.tight_layout() -plt.savefig('plots.png', dpi=300) +plt.savefig("plots.png", dpi=300) plt.close() -print('Saved: plots.png') +print("Saved: plots.png") diff --git a/examples/nD_perfect_reactor/case.py b/examples/nD_perfect_reactor/case.py index 680978dfbc..d0000deb89 100644 --- a/examples/nD_perfect_reactor/case.py +++ b/examples/nD_perfect_reactor/case.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 # Reference: # + https://doi.org/10.1063/1.1696266 -import json import argparse +import json + import cantera as ct from mfc.case_utils import * @@ -119,7 +120,7 @@ for i in range(len(sol.Y)): case[f"chem_wrt_Y({i + 1})"] = "T" - case[f"patch_icpp(1)%Y({i+1})"] = sol.Y[i] + case[f"patch_icpp(1)%Y({i + 1})"] = sol.Y[i] case = remove_higher_dimensional_keys(case, args.ndim) diff --git a/examples/scaling/analyze.py b/examples/scaling/analyze.py index 0f358417c3..ce9831e12e 100644 --- a/examples/scaling/analyze.py +++ b/examples/scaling/analyze.py @@ -1,8 +1,9 @@ import os import re -import pandas as pd from io import StringIO +import pandas as pd + def parse_time_avg(path): last_val = None @@ -172,7 +173,7 @@ def parse_reference_file(filename): subset["grind_time"] = times subset["rel_perf"] = subset["grind_time"] / ref["grind_time"].values - print(f"Grind Time - Single Device") + print("Grind Time - Single Device") print(subset[["memory", "grind_time", "rel_perf"]].to_string(index=False)) print() diff --git a/examples/scaling/benchmark.py b/examples/scaling/benchmark.py index 3efc5615df..a3d2620555 100644 --- a/examples/scaling/benchmark.py +++ b/examples/scaling/benchmark.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -import sys +import argparse import json import math +import sys import typing -import argparse parser = argparse.ArgumentParser( prog="scaling_and_perf", diff --git a/examples/scaling/export.py b/examples/scaling/export.py index 3208cf8541..c8b389b4a6 100644 --- a/examples/scaling/export.py +++ b/examples/scaling/export.py @@ -1,7 +1,7 @@ -import re -import os import csv import glob +import os +import re import statistics from dataclasses import dataclass, fields diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000000..1131d80b5c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,25 @@ +line-length = 200 +target-version = "py39" +exclude = ["_version.py"] + +[lint] +select = ["E", "F", "W", "I", "PL"] +ignore = [ + # Complexity thresholds (project style) + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0915", # too-many-statements + "PLR2004", # magic-value-comparison + # Import patterns (project style) + "PLC0415", # import-outside-toplevel + # Global variable reads (module-level singleton pattern) + "PLW0602", # global-variable-not-assigned +] + +[lint.per-file-ignores] +"examples/**/*.py" = ["F401", "F403", "F405", "F811", "F821", "E402", "E722", "E741"] +"benchmarks/*/case.py" = ["F401", "F403", "F405", "F811", "F821", "E402", "E741"] + +[lint.isort] +known-first-party = ["mfc"] diff --git a/toolchain/bootstrap/format.sh b/toolchain/bootstrap/format.sh index 8dc3841036..ce500ab301 100644 --- a/toolchain/bootstrap/format.sh +++ b/toolchain/bootstrap/format.sh @@ -43,45 +43,80 @@ done log "Formatting MFC:" if [[ ${#PATHS[@]} -gt 0 ]]; then - # Custom paths provided - format all file types in those paths SEARCH_PATHS="${PATHS[@]}" + FORTRAN_DIRS="$SEARCH_PATHS" + PYTHON_DIRS="$SEARCH_PATHS" +else + FORTRAN_DIRS="src" + PYTHON_DIRS="toolchain/ examples/ benchmarks/" +fi - # Format Fortran files (.f90, .fpp) - if ! find $SEARCH_PATHS -type f 2>/dev/null | grep -Ev 'autogen' | grep -E '\.(f90|fpp)$' \ - | xargs --no-run-if-empty -L 1 -P ${JOBS:-1} $SHELL toolchain/bootstrap/format_file.sh; then - error "Formatting Fortran files failed." - exit 1 - fi +# Format Fortran files (.f90, .fpp) +FORTRAN_FILES=$(find $FORTRAN_DIRS -type f 2>/dev/null | grep -Ev 'autogen' | grep -E '\.(f90|fpp)$' || true) +if [[ -n "$FORTRAN_FILES" ]]; then + FPRETTIFY_OPTS="--silent --indent 4 --c-relations --enable-replacements --enable-decl --whitespace-comma 1 --whitespace-multdiv 0 --whitespace-plusminus 1 --case 1 1 1 1 --strict-indent --line-length 1000" - # Format Python files - if ! find $SEARCH_PATHS -type f 2>/dev/null | grep -E '\.(py)$' \ - | xargs --no-run-if-empty -L 1 -P ${JOBS:-1} $SHELL toolchain/bootstrap/format_python.sh; then - error "Formatting Python files failed." - exit 1 - fi -else - # Default: format src/, examples/, and benchmarks/ + # Skip files unchanged since last format (hash cache in build/.cache/format/) + CACHE_DIR="build/.cache/format" + mkdir -p "$CACHE_DIR" + DIRTY_FILES="" + for f in $FORTRAN_FILES; do + cache_key=$(echo "$f" | tr '/' '_') + current_hash=$(md5sum "$f" | cut -d' ' -f1) + cached_hash=$(cat "$CACHE_DIR/$cache_key" 2>/dev/null || true) + if [[ "$current_hash" != "$cached_hash" ]]; then + DIRTY_FILES+="$f"$'\n' + fi + done + DIRTY_FILES=$(echo "$DIRTY_FILES" | sed '/^$/d') - # Format Fortran files (.f90, .fpp) in src/ - if ! find src -type f 2>/dev/null | grep -Ev 'autogen' | grep -E '\.(f90|fpp)$' \ - | xargs --no-run-if-empty -L 1 -P ${JOBS:-1} $SHELL toolchain/bootstrap/format_file.sh; then - error "Formatting MFC source failed." - exit 1 - fi + if [[ -n "$DIRTY_FILES" ]]; then + for niter in 1 2 3 4; do + old_hash=$(echo "$DIRTY_FILES" | xargs cat | md5sum) - # Format Python files in examples/ - if ! find examples -type f 2>/dev/null | grep -E '\.(py)$' \ - | xargs --no-run-if-empty -L 1 -P ${JOBS:-1} $SHELL toolchain/bootstrap/format_python.sh; then - error "Formatting MFC examples failed." - exit 1 - fi + # Run indenter on dirty files in one process + if ! echo "$DIRTY_FILES" | xargs python3 toolchain/indenter.py; then + error "Formatting Fortran files failed: indenter.py." + exit 1 + fi - # Format Python files in benchmarks/ - if ! find benchmarks -type f 2>/dev/null | grep -E '\.(py)$' \ - | xargs --no-run-if-empty -L 1 -P ${JOBS:-1} $SHELL toolchain/bootstrap/format_python.sh; then - error "Formatting MFC benchmarks failed." - exit 1 + # Run fprettify in parallel (one process per file) + if ! echo "$DIRTY_FILES" | xargs -P ${JOBS:-1} -L 1 fprettify $FPRETTIFY_OPTS; then + error "Formatting Fortran files failed: fprettify." + exit 1 + fi + + new_hash=$(echo "$DIRTY_FILES" | xargs cat | md5sum) + if [[ "$old_hash" == "$new_hash" ]]; then + break + fi + if [[ "$niter" -eq 4 ]]; then + error "Formatting Fortran files failed: no steady-state after $niter iterations." + exit 1 + fi + done + + # Update hash cache for formatted files + for f in $DIRTY_FILES; do + cache_key=$(echo "$f" | tr '/' '_') + md5sum "$f" | cut -d' ' -f1 > "$CACHE_DIR/$cache_key" + done + + echo "$DIRTY_FILES" | while read -r f; do echo "> $f"; done fi fi +# Apply safe auto-fixes (import sorting, etc.) before formatting. +# --fix-only exits 0 even when unfixable violations remain — those are +# caught later by `ruff check` in lint.sh. This only errors if ruff +# itself fails to run. +if ! ruff check --fix-only $PYTHON_DIRS; then + error "ruff failed to run. Check your ruff installation." + exit 1 +fi +if ! ruff format $PYTHON_DIRS; then + error "Formatting Python files failed." + exit 1 +fi + ok "Done. MFC has been formatted." diff --git a/toolchain/bootstrap/format_file.sh b/toolchain/bootstrap/format_file.sh deleted file mode 100644 index 5f086e17b7..0000000000 --- a/toolchain/bootstrap/format_file.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -. toolchain/util.sh - -echo "> $1" - -niter=0 -old_file="" - -while : -do - niter=$((niter+1)) - new_file=`cat "$1"` - if [[ "$new_file" == "$old_file" ]]; then - break - fi - old_file="$new_file" - - if [[ "$niter" -gt 4 ]]; then - error "Failed to format $1: No steady-state (after $niter iterations)." - exit 1 - fi - - if ! python3 toolchain/indenter.py "$1"; then - error "Failed to format $1: indenter.py." - exit 1 - fi - - if ! fprettify "$1" --silent --indent 4 --c-relations --enable-replacements \ - --enable-decl --whitespace-comma 1 --whitespace-multdiv 0 \ - --whitespace-plusminus 1 --case 1 1 1 1 --strict-indent \ - --line-length 1000; then - error "Failed to format $1: fprettify." - exit 1 - fi -done - diff --git a/toolchain/bootstrap/format_python.sh b/toolchain/bootstrap/format_python.sh deleted file mode 100644 index 3d95c7aadc..0000000000 --- a/toolchain/bootstrap/format_python.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -. toolchain/util.sh - -echo "> $1" - -# Use autopep8 for Python formatting -# (black has issues with Python 3.12.5 which is common on HPC systems) -if ! autopep8 --in-place --max-line-length 200 "$1" 2>&1; then - error "Failed to format $1 with autopep8" - exit 1 -fi - diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 89649cd5aa..6ff9ca88eb 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -12,24 +12,28 @@ for arg in "$@"; do esac done -log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." +log "(venv) Auto-fixing safe lint issues with$MAGENTA ruff$COLOR_RESET..." -pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 "$(pwd)/toolchain/" +ruff check --fix-only toolchain/ examples/*/case.py benchmarks/*/case.py -log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""examples$COLOR_RESET." +log "(venv) Running$MAGENTA ruff$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." -pylint -d C0103,C0114,C0301,R0801,C0410,W0611,W1514,E0401,C0115,C0116,C0200,W1309,W0401,E0602,R1720,W0614,E1101 $(pwd)/examples/*/case.py +ruff check toolchain/ -log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""benchmarks$COLOR_RESET." +log "(venv) Running$MAGENTA ruff$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""examples$COLOR_RESET." -pylint -d C0103,C0114,C0301,R0801,C0410,W0611,W1514,E0401,C0115,C0116,C0200,W1309,W0401,E0602,R1720,W0614,E1101 $(pwd)/benchmarks/*/case.py +ruff check examples/*/case.py + +log "(venv) Running$MAGENTA ruff$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""benchmarks$COLOR_RESET." + +ruff check benchmarks/*/case.py # Run toolchain unit tests unless --no-test is specified if [ "$RUN_TESTS" = true ]; then log "(venv) Running$MAGENTA unit tests$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." # Run tests as modules from the toolchain directory to resolve relative imports - cd "$(pwd)/toolchain" + cd toolchain python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v python3 -m unittest mfc.viz.test_viz -v diff --git a/toolchain/bootstrap/precheck.sh b/toolchain/bootstrap/precheck.sh index 9274f3abae..f474cf345f 100755 --- a/toolchain/bootstrap/precheck.sh +++ b/toolchain/bootstrap/precheck.sh @@ -55,81 +55,124 @@ while [[ $# -gt 0 ]]; do shift done -FAILED=0 +# Skip slow rendering tests (matplotlib/imageio) during local precheck. +# CI runs the full suite via ./mfc.sh lint without this variable. +export MFC_SKIP_RENDER_TESTS=1 log "Running$MAGENTA precheck$COLOR_RESET (same checks as CI lint-gate)..." echo "" -# 1. Check formatting -log "[$CYAN 1/5$COLOR_RESET] Checking$MAGENTA formatting$COLOR_RESET..." -# Capture state before formatting +# Temp files for collecting results from parallel jobs +TMPDIR_PC=$(mktemp -d) +trap "rm -rf $TMPDIR_PC" EXIT + +# --- Phase 1: Format (modifies files, must run alone) --- BEFORE_HASH=$(git diff -- '*.f90' '*.fpp' '*.py' 2>/dev/null | compute_hash) if ! ./mfc.sh format -j "$JOBS" > /dev/null 2>&1; then - error "Formatting check failed to run." - FAILED=1 + FORMAT_OK=1 else - # Check if formatting changed any Fortran/Python files AFTER_HASH=$(git diff -- '*.f90' '*.fpp' '*.py' 2>/dev/null | compute_hash) if [ "$BEFORE_HASH" != "$AFTER_HASH" ]; then - error "Code was not formatted. Files have been auto-formatted; review and stage the changes." - echo "" - git diff --stat -- '*.f90' '*.fpp' '*.py' 2>/dev/null || true - echo "" - FAILED=1 + FORMAT_OK=2 + else + FORMAT_OK=0 + fi +fi + +# --- Phase 2: All remaining checks in parallel (read-only) --- + +# Spell check +( + if ./mfc.sh spelling > /dev/null 2>&1; then + echo "0" > "$TMPDIR_PC/spell_exit" + else + echo "1" > "$TMPDIR_PC/spell_exit" + fi +) & +PID_SPELL=$! + +# Lint (ruff + unit tests) — safe after format since files are stable +( + if ./mfc.sh lint > /dev/null 2>&1; then + echo "0" > "$TMPDIR_PC/lint_exit" else - ok "Formatting check passed." + echo "1" > "$TMPDIR_PC/lint_exit" fi +) & +PID_LINT=$! + +# Source lint (fast grep checks — run inline) +SOURCE_FAILED=0 +SOURCE_MSGS="" +if grep -qiR '!\$acc\|!\$omp' --exclude="parallel_macros.fpp" --exclude="acc_macros.fpp" --exclude="omp_macros.fpp" --exclude="shared_parallel_macros.fpp" --exclude="syscheck.fpp" ./src/* 2>/dev/null; then + SOURCE_MSGS+="Found raw OpenACC/OpenMP directives. Use macros instead.\n" + SOURCE_FAILED=1 +fi +if grep -qiR 'double_precision\|dsqrt\|dexp\|dlog\|dble\|dabs\|double\ precision\|real(8)\|real(4)\|dprod\|dmin\|dmax\|dfloat\|dreal\|dcos\|dsin\|dtan\|dsign\|dtanh\|dsinh\|dcosh\|d0' --exclude-dir=syscheck --exclude="*nvtx*" --exclude="*precision_select*" ./src/* 2>/dev/null; then + SOURCE_MSGS+="Found double precision intrinsics. Use generic intrinsics.\n" + SOURCE_FAILED=1 +fi +if grep -qiR -e '\.\.\.' -e '\-\-\-' -e '===' ./src/* 2>/dev/null; then + SOURCE_MSGS+="Found junk code patterns (..., ---, ===) in source.\n" + SOURCE_FAILED=1 +fi + +# Doc reference check +DOC_FAILED=0 +if ! python3 toolchain/mfc/lint_docs.py > /dev/null 2>&1; then + DOC_FAILED=1 fi -# 2. Spell check +# --- Collect results --- + +FAILED=0 + +log "[$CYAN 1/5$COLOR_RESET] Checking$MAGENTA formatting$COLOR_RESET..." +if [ "$FORMAT_OK" = "1" ]; then + error "Formatting check failed to run." + FAILED=1 +elif [ "$FORMAT_OK" = "2" ]; then + error "Code was not formatted. Files have been auto-formatted; review and stage the changes." + echo "" + git diff --stat -- '*.f90' '*.fpp' '*.py' 2>/dev/null || true + echo "" + FAILED=1 +else + ok "Formatting check passed." +fi + +wait $PID_SPELL log "[$CYAN 2/5$COLOR_RESET] Running$MAGENTA spell check$COLOR_RESET..." -if ./mfc.sh spelling > /dev/null 2>&1; then +SPELL_RC=$(cat "$TMPDIR_PC/spell_exit" 2>/dev/null || echo "1") +if [ "$SPELL_RC" = "0" ]; then ok "Spell check passed." else error "Spell check failed. Run$MAGENTA ./mfc.sh spelling$COLOR_RESET for details." FAILED=1 fi -# 3. Lint toolchain (Python) +wait $PID_LINT log "[$CYAN 3/5$COLOR_RESET] Running$MAGENTA toolchain lint$COLOR_RESET..." -if ./mfc.sh lint > /dev/null 2>&1; then +LINT_RC=$(cat "$TMPDIR_PC/lint_exit" 2>/dev/null || echo "1") +if [ "$LINT_RC" = "0" ]; then ok "Toolchain lint passed." else error "Toolchain lint failed. Run$MAGENTA ./mfc.sh lint$COLOR_RESET for details." FAILED=1 fi -# 4. Source code lint checks log "[$CYAN 4/5$COLOR_RESET] Running$MAGENTA source lint$COLOR_RESET checks..." -SOURCE_FAILED=0 - -# Check for raw OpenACC/OpenMP directives -if grep -qiR '!\$acc\|!\$omp' --exclude="parallel_macros.fpp" --exclude="acc_macros.fpp" --exclude="omp_macros.fpp" --exclude="shared_parallel_macros.fpp" --exclude="syscheck.fpp" ./src/* 2>/dev/null; then - error "Found raw OpenACC/OpenMP directives. Use macros instead." - SOURCE_FAILED=1 -fi - -# Check for double precision intrinsics -if grep -qiR 'double_precision\|dsqrt\|dexp\|dlog\|dble\|dabs\|double\ precision\|real(8)\|real(4)\|dprod\|dmin\|dmax\|dfloat\|dreal\|dcos\|dsin\|dtan\|dsign\|dtanh\|dsinh\|dcosh\|d0' --exclude-dir=syscheck --exclude="*nvtx*" --exclude="*precision_select*" ./src/* 2>/dev/null; then - error "Found double precision intrinsics. Use generic intrinsics." - SOURCE_FAILED=1 -fi - -# Check for junk code patterns -if grep -qiR -e '\.\.\.' -e '\-\-\-' -e '===' ./src/* 2>/dev/null; then - error "Found junk code patterns (..., ---, ===) in source." - SOURCE_FAILED=1 -fi - if [ $SOURCE_FAILED -eq 0 ]; then ok "Source lint passed." else + echo -e "$SOURCE_MSGS" | while read -r msg; do + [ -n "$msg" ] && error "$msg" + done FAILED=1 fi -# 5. Doc reference check log "[$CYAN 5/5$COLOR_RESET] Checking$MAGENTA doc references$COLOR_RESET..." -if python3 toolchain/mfc/lint_docs.py 2>&1; then +if [ $DOC_FAILED -eq 0 ]; then ok "Doc references are valid." else error "Doc reference check failed. Run$MAGENTA python3 toolchain/mfc/lint_docs.py$COLOR_RESET for details." diff --git a/toolchain/indenter.py b/toolchain/indenter.py index bea485c181..ab0203ae4e 100644 --- a/toolchain/indenter.py +++ b/toolchain/indenter.py @@ -1,38 +1,36 @@ #!/usr/bin/env python3 -import os, argparse +import argparse +import os + def main(): - parser = argparse.ArgumentParser( - prog='indenter.py', - description='Adjust indentation of OpenACC directives in a Fortran file') - parser.add_argument('filepath', metavar='input_file', type=str, help='File to format') - args = vars(parser.parse_args()) + parser = argparse.ArgumentParser(prog="indenter.py", description="Adjust indentation of OpenACC directives in Fortran files") + parser.add_argument("filepaths", metavar="input_file", type=str, nargs="+", help="Files to format") + args = parser.parse_args() + + for filepath in args.filepaths: + temp_filepath = f"{filepath}.new" + adjust_indentation(filepath, temp_filepath) + os.replace(temp_filepath, filepath) - filepath = args['filepath'] - temp_filepath = f"{filepath}.new" - adjust_indentation(filepath, temp_filepath) - os.replace(temp_filepath, filepath) +BLOCK_STARTERS = ("if", "do", "#:if", "#:else", "#ifdef", "#else") +BLOCK_ENDERS = ("end", "contains", "else", "#:end", "#:else", "#else", "#endif") +LOOP_DIRECTIVES = ("!$acc loop", "!$acc parallel loop") +INDENTERS = ("!DIR", "!$acc") -BLOCK_STARTERS = ('if', 'do', '#:if', '#:else', "#ifdef", "#else") -BLOCK_ENDERS = ('end', 'contains', 'else', '#:end', '#:else', '#else', '#endif') -LOOP_DIRECTIVES = ('!$acc loop', '!$acc parallel loop') -INDENTERS = ('!DIR', '!$acc') -# pylint: disable=too-many-branches def adjust_indentation(input_file, output_file): - max_empty_lines=4 - indent_len=4 + max_empty_lines = 4 + indent_len = 4 - with open(input_file, 'r') as file_in, open(output_file, 'w') as file_out: + with open(input_file, "r") as file_in, open(output_file, "w") as file_out: lines = file_in.readlines() # this makes sure !$acc lines that have line continuations get indented at proper level - # pylint: disable=too-many-nested-blocks for _ in range(10): # loop through file - # pylint: disable=consider-using-enumerate for i in range(len(lines)): if lines[i].lstrip().startswith(INDENTERS) and i + 1 < len(lines): j = i + 1 @@ -43,12 +41,12 @@ def adjust_indentation(input_file, output_file): if lines[j].lstrip().startswith(BLOCK_ENDERS): empty_lines = max_empty_lines # skip empty lines - elif lines[j].strip() == '': + elif lines[j].strip() == "": empty_lines += 1 # indent acc lines elif not lines[j].lstrip().startswith(INDENTERS): indent = len(lines[j]) - len(lines[j].lstrip()) - lines[i] = ' ' * indent + lines[i].lstrip() + lines[i] = " " * indent + lines[i].lstrip() break j += 1 # if looking down just finds empty lines, start looking up for indentation level @@ -56,30 +54,30 @@ def adjust_indentation(input_file, output_file): k = i - 1 while k >= 0: # if line above is not empty - if lines[k].strip() != '': + if lines[k].strip() != "": # if line 2 above ends with line continuation, indent at that level - if lines[k-1].strip().endswith('&'): - indent = len(lines[k-1]) - len(lines[k-1].lstrip()) + if lines[k - 1].strip().endswith("&"): + indent = len(lines[k - 1]) - len(lines[k - 1].lstrip()) # if line above starts a loop or branch, indent elif lines[k].lstrip().startswith(BLOCK_STARTERS): indent = indent_len + (len(lines[k]) - len(lines[k].lstrip())) # else indent at level of line above else: indent = len(lines[k]) - len(lines[k].lstrip()) - lines[i] = ' ' * indent + lines[i].lstrip() + lines[i] = " " * indent + lines[i].lstrip() break k -= 1 # remove empty lines following an acc loop directive i = 0 while i < len(lines): - if lines[i].lstrip().startswith(LOOP_DIRECTIVES) and \ - i+1 < len(lines) and lines[i+1].strip() == '': + if lines[i].lstrip().startswith(LOOP_DIRECTIVES) and i + 1 < len(lines) and lines[i + 1].strip() == "": file_out.write(lines[i]) i += 2 else: file_out.write(lines[i]) i += 1 + if __name__ == "__main__": main() diff --git a/toolchain/main.py b/toolchain/main.py index d024fb46d9..5efd9ff5ec 100644 --- a/toolchain/main.py +++ b/toolchain/main.py @@ -1,21 +1,26 @@ #!/usr/bin/env python3 -import os, signal, getpass, platform, itertools +import getpass +import itertools +import os +import platform +import signal # Only import what's needed for startup - other modules are loaded lazily -from mfc import args, lock, state -from mfc.state import ARG -from mfc.common import MFC_LOGO, MFC_ROOT_DIR, MFCException, quit, format_list_to_string, does_command_exist, setup_debug_logging +from mfc import args, lock, state +from mfc.common import MFC_LOGO, MFC_ROOT_DIR, MFCException, does_command_exist, format_list_to_string, quit, setup_debug_logging from mfc.printer import cons +from mfc.state import ARG def __do_regenerate(toolchain: str): """Perform the actual regeneration of completion scripts and schema.""" - import json # pylint: disable=import-outside-toplevel - from pathlib import Path # pylint: disable=import-outside-toplevel - from mfc.cli.commands import MFC_CLI_SCHEMA # pylint: disable=import-outside-toplevel - from mfc.cli.completion_gen import generate_bash_completion, generate_zsh_completion # pylint: disable=import-outside-toplevel - from mfc.params.generators.json_schema_gen import generate_json_schema # pylint: disable=import-outside-toplevel + import json + from pathlib import Path + + from mfc.cli.commands import MFC_CLI_SCHEMA + from mfc.cli.completion_gen import generate_bash_completion, generate_zsh_completion + from mfc.params.generators.json_schema_gen import generate_json_schema cons.print("[dim]Auto-regenerating completion scripts...[/dim]") @@ -27,14 +32,14 @@ def __do_regenerate(toolchain: str): (completions_dir / "_mfc").write_text(generate_zsh_completion(MFC_CLI_SCHEMA)) # Generate JSON schema - with open(Path(toolchain) / "mfc-case-schema.json", 'w', encoding='utf-8') as f: + with open(Path(toolchain) / "mfc-case-schema.json", "w", encoding="utf-8") as f: json.dump(generate_json_schema(include_descriptions=True), f, indent=2) def __update_installed_completions(toolchain: str): """Update installed shell completions if they're older than generated ones.""" - import shutil # pylint: disable=import-outside-toplevel - from pathlib import Path # pylint: disable=import-outside-toplevel + import shutil + from pathlib import Path src_dir = Path(toolchain) / "completions" dst_dir = Path.home() / ".local" / "share" / "mfc" / "completions" @@ -81,10 +86,7 @@ def __ensure_generated_files(): return # No source files found, skip check # Check if any generated file is missing or older than sources - needs_regen = any( - not os.path.exists(g) or os.path.getmtime(g) < source_mtime - for g in generated - ) + needs_regen = any(not os.path.exists(g) or os.path.getmtime(g) < source_mtime for g in generated) if needs_regen: __do_regenerate(toolchain) @@ -92,34 +94,31 @@ def __ensure_generated_files(): # Always check if completions need to be installed or updated __update_installed_completions(toolchain) + def __print_greeting(): - MFC_LOGO_LINES = MFC_LOGO.splitlines() + MFC_LOGO_LINES = MFC_LOGO.splitlines() max_logo_line_length = max(len(line) for line in MFC_LOGO_LINES) - host_line = f"{getpass.getuser()}@{platform.node()} [{platform.system()}]" + host_line = f"{getpass.getuser()}@{platform.node()} [{platform.system()}]" targets_line = f"[bold]--targets {format_list_to_string(ARG('targets'), 'magenta', 'None')}[/bold]" - help_line = "$ ./mfc.sh (build, run, test, clean, new, validate, params) --help" + help_line = "$ ./mfc.sh (build, run, test, clean, new, validate, params) --help" MFC_SIDEBAR_LINES = [ f"[bold]{host_line}[/bold]", - '-' * len(host_line), - '', + "-" * len(host_line), + "", f"[bold]--jobs [magenta]{ARG('jobs')}[/magenta][/bold]", f"[bold]{' '.join(state.gCFG.make_options())}[/bold]", targets_line if ARG("command") != "test" else "", - '', - '-' * len(help_line), + "", + "-" * len(help_line), f"[yellow]{help_line}[/yellow]", ] for a, b in itertools.zip_longest(MFC_LOGO_LINES, MFC_SIDEBAR_LINES): - a = a or '' - lhs = a.ljust(max_logo_line_length) - rhs = b or '' - cons.print( - f"[bold]{lhs}[/bold] | {rhs}", - highlight=False - ) + lhs = (a or "").ljust(max_logo_line_length) + rhs = b or "" + cons.print(f"[bold]{lhs}[/bold] | {rhs}", highlight=False) cons.print() @@ -131,57 +130,73 @@ def __checks(): raise MFCException("CMake is required to build MFC but couldn't be located on your system. Please ensure it installed and discoverable (e.g in your system's $PATH).") -def __run(): # pylint: disable=too-many-branches +def __run(): # Lazy import modules only when needed for the specific command cmd = ARG("command") if cmd == "test": - from mfc.test import test # pylint: disable=import-outside-toplevel + from mfc.test import test + test.test() elif cmd == "run": - from mfc.run import run # pylint: disable=import-outside-toplevel + from mfc.run import run + run.run() elif cmd == "build": - from mfc import build # pylint: disable=import-outside-toplevel + from mfc import build + build.build() elif cmd == "bench": - from mfc import bench # pylint: disable=import-outside-toplevel + from mfc import bench + bench.bench() elif cmd == "bench_diff": - from mfc import bench # pylint: disable=import-outside-toplevel + from mfc import bench + bench.diff() elif cmd == "count": - from mfc import count # pylint: disable=import-outside-toplevel + from mfc import count + count.count() elif cmd == "count_diff": - from mfc import count # pylint: disable=import-outside-toplevel + from mfc import count + count.count_diff() elif cmd == "clean": - from mfc import clean # pylint: disable=import-outside-toplevel + from mfc import clean + clean.clean() elif cmd == "packer": - from mfc.packer import packer # pylint: disable=import-outside-toplevel + from mfc.packer import packer + packer.packer() elif cmd == "validate": - from mfc import validate # pylint: disable=import-outside-toplevel + from mfc import validate + validate.validate() elif cmd == "new": - from mfc import init # pylint: disable=import-outside-toplevel + from mfc import init + init.init() elif cmd == "interactive": - from mfc.user_guide import interactive_mode # pylint: disable=import-outside-toplevel + from mfc.user_guide import interactive_mode + interactive_mode() elif cmd == "completion": - from mfc import completion # pylint: disable=import-outside-toplevel + from mfc import completion + completion.completion() elif cmd == "generate": - from mfc import generate # pylint: disable=import-outside-toplevel + from mfc import generate + generate.generate() elif cmd == "viz": - from mfc.viz import viz # pylint: disable=import-outside-toplevel + from mfc.viz import viz + viz.viz() elif cmd == "params": - from mfc import params_cmd # pylint: disable=import-outside-toplevel + from mfc import params_cmd + params_cmd.params() @@ -196,7 +211,8 @@ def __run(): # pylint: disable=too-many-branches lock.switch(state.MFCConfig.from_dict(state.gARG)) # Ensure IDE configuration is up to date (lightweight check) - from mfc.ide import ensure_vscode_settings # pylint: disable=import-outside-toplevel + from mfc.ide import ensure_vscode_settings + ensure_vscode_settings() # Auto-regenerate completion scripts if source files changed @@ -214,7 +230,7 @@ def __run(): # pylint: disable=too-many-branches [bold red]Error[/bold red]: {str(exc)} """) quit(signal.SIGTERM) - except KeyboardInterrupt as exc: + except KeyboardInterrupt: quit(signal.SIGTERM) except Exception as exc: cons.reset() diff --git a/toolchain/mfc/args.py b/toolchain/mfc/args.py index 80e6e0888c..9a3d82f83f 100644 --- a/toolchain/mfc/args.py +++ b/toolchain/mfc/args.py @@ -5,17 +5,21 @@ from the central CLI schema in cli/commands.py. """ +import os.path import re import sys -import os.path +from .cli.argparse_gen import generate_parser +from .cli.commands import COMMAND_ALIASES, MFC_CLI_SCHEMA from .common import MFCException from .state import MFCConfig -from .cli.commands import MFC_CLI_SCHEMA, COMMAND_ALIASES -from .cli.argparse_gen import generate_parser from .user_guide import ( - print_help, is_first_time_user, print_welcome, - print_command_help, print_topic_help, print_help_topics, + is_first_time_user, + print_command_help, + print_help, + print_help_topics, + print_topic_help, + print_welcome, ) @@ -27,7 +31,7 @@ def _get_command_from_args(args_list): """ # Skip the program name and any leading options (starting with '-') for token in args_list[1:]: - if not token.startswith('-'): + if not token.startswith("-"): return COMMAND_ALIASES.get(token, token) return None @@ -51,7 +55,6 @@ def _handle_enhanced_help(args_list): return None -# pylint: disable=too-many-locals, too-many-branches, too-many-statements def parse(config: MFCConfig): """Parse command line arguments using the CLI schema.""" # Handle enhanced help before argparse @@ -66,7 +69,7 @@ def parse(config: MFCConfig): sys.exit(0) try: - extra_index = sys.argv.index('--') + extra_index = sys.argv.index("--") except ValueError: extra_index = len(sys.argv) @@ -80,13 +83,13 @@ def custom_error(message): print_command_help(attempted_command, show_argparse=False) subparser.print_help() sys.stdout.flush() # Ensure help prints before error - sys.stderr.write(f'\n{subparser.prog}: error: {message}\n') + sys.stderr.write(f"\n{subparser.prog}: error: {message}\n") sys.exit(2) subparser.error = custom_error args: dict = vars(parser.parse_args(sys.argv[1:extra_index])) - args["--"] = sys.argv[extra_index + 1:] + args["--"] = sys.argv[extra_index + 1 :] # Handle --help at top level if args.get("help") and args["command"] is None: @@ -145,7 +148,7 @@ def custom_error(message): # "Slugify" the name of the job (only for batch jobs, not for new command) if args.get("name") is not None and isinstance(args["name"], str) and args["command"] != "new": - args["name"] = re.sub(r'[\W_]+', '-', args["name"]) + args["name"] = re.sub(r"[\W_]+", "-", args["name"]) # We need to check for some invalid combinations of arguments because of # the limitations of argparse. @@ -158,7 +161,8 @@ def custom_error(message): # Resolve test case defaults (deferred to avoid slow startup for non-test commands) if args["command"] == "test": - from .test.cases import list_cases # pylint: disable=import-outside-toplevel + from .test.cases import list_cases + test_cases = list_cases() if args.get("from") is None: args["from"] = test_cases[0].get_uuid() diff --git a/toolchain/mfc/bench.py b/toolchain/mfc/bench.py index 58b90e965b..a6a04e6f8e 100644 --- a/toolchain/mfc/bench.py +++ b/toolchain/mfc/bench.py @@ -1,13 +1,19 @@ -import os, sys, uuid, subprocess, dataclasses, typing, math, traceback, time +import dataclasses +import math +import os +import subprocess +import sys +import time +import traceback +import typing +import uuid import rich.table +from .build import DEFAULT_TARGETS, SIMULATION, get_targets +from .common import MFC_BENCH_FILEPATH, MFC_BUILD_DIR, MFCException, create_directory, file_dump_yaml, file_load_yaml, format_list_to_string, system from .printer import cons -from .state import ARG, CFG -from .build import get_targets, DEFAULT_TARGETS, SIMULATION -from .common import system, MFC_BENCH_FILEPATH, MFC_BUILD_DIR, format_list_to_string -from .common import file_load_yaml, file_dump_yaml, create_directory -from .common import MFCException +from .state import ARG, CFG @dataclasses.dataclass @@ -16,8 +22,8 @@ class BenchCase: path: str args: typing.List[str] -# pylint: disable=too-many-locals, too-many-branches, too-many-statements, too-many-nested-blocks -def bench(targets = None): + +def bench(targets=None): if targets is None: targets = ARG("targets") @@ -33,7 +39,7 @@ def bench(targets = None): try: cons.print() - CASES = [ BenchCase(**case) for case in file_load_yaml(MFC_BENCH_FILEPATH) ] + CASES = [BenchCase(**case) for case in file_load_yaml(MFC_BENCH_FILEPATH)] for case in CASES: case.args = case.args + ARG("--") @@ -44,10 +50,7 @@ def bench(targets = None): raise MFCException(f"Benchmark case file not found: {case.path}") results = { - "metadata": { - "invocation": sys.argv[1:], - "lock": dataclasses.asdict(CFG()) - }, + "metadata": {"invocation": sys.argv[1:], "lock": dataclasses.asdict(CFG())}, "cases": {}, } @@ -57,9 +60,9 @@ def bench(targets = None): for i, case in enumerate(CASES): summary_filepath = os.path.join(bench_dirpath, f"{case.slug}.yaml") - log_filepath = os.path.join(bench_dirpath, f"{case.slug}.out") + log_filepath = os.path.join(bench_dirpath, f"{case.slug}.out") - cons.print(f"{str(i+1).zfill(len(CASES) // 10 + 1)}/{len(CASES)}: {case.slug} @ [bold]{os.path.relpath(case.path)}[/bold]") + cons.print(f"{str(i + 1).zfill(len(CASES) // 10 + 1)}/{len(CASES)}: {case.slug} @ [bold]{os.path.relpath(case.path)}[/bold]") cons.indent() cons.print() cons.print(f"> Log: [bold]{os.path.relpath(log_filepath)}[/bold]") @@ -70,20 +73,17 @@ def bench(targets = None): try: with open(log_filepath, "w") as log_file: result = system( - ["./mfc.sh", "run", case.path] + - ["--targets"] + [t.name for t in targets] + - ["--output-summary", summary_filepath] + - case.args + - ["--", "--gbpp", str(ARG('mem'))], + ["./mfc.sh", "run", case.path] + ["--targets"] + [t.name for t in targets] + ["--output-summary", summary_filepath] + case.args + ["--", "--gbpp", str(ARG("mem"))], stdout=log_file, - stderr=subprocess.STDOUT) + stderr=subprocess.STDOUT, + ) # Check return code (handle CompletedProcess or int defensively) rc = result.returncode if hasattr(result, "returncode") else result if rc != 0: if attempt < max_attempts: cons.print(f"[bold yellow]WARNING[/bold yellow]: Case {case.slug} failed with exit code {rc} (attempt {attempt}/{max_attempts})") - cons.print(f"Retrying in 5s...") + cons.print("Retrying in 5s...") time.sleep(5) continue cons.print(f"[bold red]ERROR[/bold red]: Case {case.slug} failed with exit code {rc}") @@ -95,7 +95,7 @@ def bench(targets = None): if not os.path.exists(summary_filepath): if attempt < max_attempts: cons.print(f"[bold yellow]WARNING[/bold yellow]: Summary file not created for {case.slug} (attempt {attempt}/{max_attempts})") - cons.print(f"Retrying in 5s...") + cons.print("Retrying in 5s...") time.sleep(5) continue cons.print(f"[bold red]ERROR[/bold red]: Summary file not created for {case.slug}") @@ -130,7 +130,7 @@ def bench(targets = None): # Add to results results["cases"][case.slug] = { - "description": dataclasses.asdict(case), + "description": dataclasses.asdict(case), "output_summary": summary, } cons.print(f"[bold green]✓[/bold green] Case {case.slug} completed successfully") @@ -139,7 +139,7 @@ def bench(targets = None): except Exception as e: if attempt < max_attempts: cons.print(f"[bold yellow]WARNING[/bold yellow]: Unexpected error running {case.slug} (attempt {attempt}/{max_attempts}): {e}") - cons.print(f"Retrying in 5s...") + cons.print("Retrying in 5s...") time.sleep(5) continue cons.print(f"[bold red]ERROR[/bold red]: Unexpected error running {case.slug}: {e}") @@ -168,36 +168,41 @@ def bench(targets = None): # TODO: This function is too long and not nicely written at all. Someone should # refactor it... -# pylint: disable=too-many-branches def diff(): lhs, rhs = file_load_yaml(ARG("lhs")), file_load_yaml(ARG("rhs")) - cons.print(f"[bold]Comparing Benchmarks: Speedups from [magenta]{os.path.relpath(ARG('lhs'))}[/magenta] to [magenta]{os.path.relpath(ARG('rhs'))}[/magenta] are displayed below. Thus, numbers > 1 represent increases in performance.[/bold]") + lhs_path = os.path.relpath(ARG("lhs")) + rhs_path = os.path.relpath(ARG("rhs")) + cons.print( + f"[bold]Comparing Benchmarks: Speedups from [magenta]{lhs_path}[/magenta] to [magenta]{rhs_path}[/magenta] are displayed below. Thus, numbers > 1 represent increases in performance.[/bold]" + ) if lhs["metadata"] != rhs["metadata"]: - _lock_to_str = lambda lock: ' '.join([f"{k}={v}" for k, v in lock.items()]) + + def _lock_to_str(lock): + return " ".join([f"{k}={v}" for k, v in lock.items()]) cons.print(f"""\ [bold yellow]Warning[/bold yellow]: Metadata in lhs and rhs are not equal. This could mean that the benchmarks are not comparable (e.g. one was run on CPUs and the other on GPUs). lhs: - * Invocation: [magenta]{' '.join(lhs['metadata']['invocation'])}[/magenta] - * Modes: {_lock_to_str(lhs['metadata']['lock'])} + * Invocation: [magenta]{" ".join(lhs["metadata"]["invocation"])}[/magenta] + * Modes: {_lock_to_str(lhs["metadata"]["lock"])} rhs: - * Invocation: {' '.join(rhs['metadata']['invocation'])} - * Modes: [magenta]{_lock_to_str(rhs['metadata']['lock'])}[/magenta] + * Invocation: {" ".join(rhs["metadata"]["invocation"])} + * Modes: [magenta]{_lock_to_str(rhs["metadata"]["lock"])}[/magenta] """) slugs = set(lhs["cases"].keys()) & set(rhs["cases"].keys()) if len(slugs) not in [len(lhs["cases"]), len(rhs["cases"])]: cons.print(f"""\ [bold yellow]Warning[/bold yellow]: Cases in lhs and rhs are not equal. - * rhs cases: {', '.join(set(rhs['cases'].keys()) - slugs)}. - * lhs cases: {', '.join(set(lhs['cases'].keys()) - slugs)}. + * rhs cases: {", ".join(set(rhs["cases"].keys()) - slugs)}. + * lhs cases: {", ".join(set(lhs["cases"].keys()) - slugs)}. Using intersection: {slugs} with {len(slugs)} elements. """) table = rich.table.Table(show_header=True, box=rich.table.box.SIMPLE) - table.add_column("[bold]Case[/bold]", justify="left") + table.add_column("[bold]Case[/bold]", justify="left") table.add_column("[bold]Pre Process[/bold]", justify="right") table.add_column("[bold]Simulation[/bold]", justify="right") table.add_column("[bold]Post Process[/bold]", justify="right") @@ -205,12 +210,13 @@ def diff(): err = 0 for slug in slugs: lhs_summary, rhs_summary = lhs["cases"][slug]["output_summary"], rhs["cases"][slug]["output_summary"] - speedups = ['N/A', 'N/A', 'N/A'] + speedups = ["N/A", "N/A", "N/A"] for i, target in enumerate(sorted(DEFAULT_TARGETS, key=lambda t: t.runOrder)): if (target.name not in lhs_summary) or (target.name not in rhs_summary): cons.print(f"{target.name} not present in lhs_summary or rhs_summary - Case: {slug}") - err = 1; continue + err = 1 + continue if not math.isfinite(lhs_summary[target.name]["exec"]) or not math.isfinite(rhs_summary[target.name]["exec"]): err = 1 @@ -230,10 +236,7 @@ def diff(): if grind_time_value < 0.95: cons.print(f"[bold yellow]Warning[/bold yellow]: Grind time speedup for {target.name} below threshold (<0.95) - Case: {slug}") except Exception as e: - cons.print( - f"[bold red]ERROR[/bold red]: Failed to compute speedup for {target.name} in {slug}: {e}\n" - f"{traceback.format_exc()}" - ) + cons.print(f"[bold red]ERROR[/bold red]: Failed to compute speedup for {target.name} in {slug}: {e}\n{traceback.format_exc()}") err = 1 table.add_row(f"[magenta]{slug}[/magenta]", *speedups) diff --git a/toolchain/mfc/build.py b/toolchain/mfc/build.py index 08ff6d7510..d6daf97bb6 100644 --- a/toolchain/mfc/build.py +++ b/toolchain/mfc/build.py @@ -1,27 +1,32 @@ -import os, typing, hashlib, dataclasses, subprocess, re, time, sys, threading, queue +import dataclasses +import hashlib +import os +import queue +import re +import subprocess +import sys +import threading +import time +import typing from rich.panel import Panel -from rich.text import Text -from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TaskProgressColumn +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn +from rich.text import Text -from .case import Case +from .case import Case +from .common import MFCException, create_directory, debug, delete_directory, format_list_to_string, system from .printer import cons -from .common import MFCException, system, delete_directory, create_directory, \ - format_list_to_string, debug -from .state import ARG, CFG -from .run import input -from .state import gpuConfigOptions +from .run import input +from .state import ARG, CFG, gpuConfigOptions from .user_guide import Tips - # Regex to parse build progress # Ninja format: [42/156] Building Fortran object ... -_NINJA_PROGRESS_RE = re.compile(r'^\[(\d+)/(\d+)\]\s+(.*)$') +_NINJA_PROGRESS_RE = re.compile(r"^\[(\d+)/(\d+)\]\s+(.*)$") # Make format: [ 16%] Building Fortran object ... or [100%] Linking ... -_MAKE_PROGRESS_RE = re.compile(r'^\[\s*(\d+)%\]\s+(.*)$') +_MAKE_PROGRESS_RE = re.compile(r"^\[\s*(\d+)%\]\s+(.*)$") -# pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks def _run_build_with_progress(command: typing.List[str], target_name: str, streaming: bool = False) -> subprocess.CompletedProcess: """ Run a build command with a progress bar that parses ninja output. @@ -49,19 +54,19 @@ def _run_build_with_progress(command: typing.List[str], target_name: str, stream if streaming: # Streaming mode (-v): merge stderr into stdout to avoid pipe deadlock - process = subprocess.Popen( # pylint: disable=consider-using-with + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered ) cons.print(f" [bold blue]Building[/bold blue] [magenta]{target_name}[/magenta] [dim](-v)[/dim]...") start_time = time.time() # Read merged stdout+stderr and print matching lines - for line in iter(process.stdout.readline, ''): + for line in iter(process.stdout.readline, ""): all_stdout.append(line) stripped = line.strip() @@ -74,7 +79,7 @@ def _run_build_with_progress(command: typing.List[str], target_name: str, stream # Extract filename from action parts = action.split() if len(parts) >= 3: - filename = os.path.basename(parts[-1]).replace('.o', '').replace('.obj', '') + filename = os.path.basename(parts[-1]).replace(".o", "").replace(".obj", "") if len(filename) > 40: filename = filename[:37] + "..." cons.print(f" [dim][{completed}/{total}][/dim] {filename}") @@ -90,7 +95,7 @@ def _run_build_with_progress(command: typing.List[str], target_name: str, stream if len(parts) >= 3: # Get the last part which is usually the file path obj_path = parts[-1] - filename = os.path.basename(obj_path).replace('.o', '').replace('.obj', '') + filename = os.path.basename(obj_path).replace(".o", "").replace(".obj", "") if len(filename) > 40: filename = filename[:37] + "..." cons.print(f" [dim][{percent:>3}%][/dim] {filename}") @@ -101,15 +106,15 @@ def _run_build_with_progress(command: typing.List[str], target_name: str, stream if elapsed > 5: cons.print(f" [dim](build took {elapsed:.1f}s)[/dim]") - return subprocess.CompletedProcess(cmd, process.returncode, ''.join(all_stdout), '') + return subprocess.CompletedProcess(cmd, process.returncode, "".join(all_stdout), "") # Start the process for non-streaming modes - process = subprocess.Popen( # pylint: disable=consider-using-with + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered ) if not is_tty: @@ -142,24 +147,19 @@ def _run_build_with_progress(command: typing.List[str], target_name: str, stream refresh_per_second=4, ) as progress: # Start with indeterminate progress (total=None shows spinner behavior) - task = progress.add_task( - "build", - total=None, - target=target_name, - current_file="" - ) + task = progress.add_task("build", total=None, target=target_name, current_file="") # Use threads to read stdout and stderr concurrently stdout_queue = queue.Queue() stderr_queue = queue.Queue() def read_stdout(): - for line in iter(process.stdout.readline, ''): + for line in iter(process.stdout.readline, ""): stdout_queue.put(line) stdout_queue.put(None) # Signal EOF def read_stderr(): - for line in iter(process.stderr.readline, ''): + for line in iter(process.stderr.readline, ""): stderr_queue.put(line) stderr_queue.put(None) # Signal EOF @@ -193,7 +193,7 @@ def read_stderr(): parts = action.split() if len(parts) >= 3: obj_path = parts[-1] - current_file = os.path.basename(obj_path).replace('.o', '').replace('.obj', '') + current_file = os.path.basename(obj_path).replace(".o", "").replace(".obj", "") if len(current_file) > 30: current_file = current_file[:27] + "..." @@ -201,11 +201,7 @@ def read_stderr(): progress_detected = True progress.update(task, total=total_files) - progress.update( - task, - completed=completed_files, - current_file=current_file - ) + progress.update(task, completed=completed_files, current_file=current_file) else: # Try make format: [ 16%] Action make_match = _MAKE_PROGRESS_RE.match(stripped) @@ -218,7 +214,7 @@ def read_stderr(): parts = action.split() if len(parts) >= 3: obj_path = parts[-1] - current_file = os.path.basename(obj_path).replace('.o', '').replace('.obj', '') + current_file = os.path.basename(obj_path).replace(".o", "").replace(".obj", "") if len(current_file) > 30: current_file = current_file[:27] + "..." @@ -227,11 +223,7 @@ def read_stderr(): # Make uses percentage, so set total to 100 progress.update(task, total=100) - progress.update( - task, - completed=percent, - current_file=current_file - ) + progress.update(task, completed=percent, current_file=current_file) except queue.Empty: pass @@ -256,12 +248,7 @@ def read_stderr(): stdout_thread.join(timeout=1) stderr_thread.join(timeout=1) - return subprocess.CompletedProcess( - cmd, - process.returncode, - ''.join(all_stdout), - ''.join(all_stderr) - ) + return subprocess.CompletedProcess(cmd, process.returncode, "".join(all_stdout), "".join(all_stderr)) def _show_build_error(result: subprocess.CompletedProcess, stage: str): @@ -271,20 +258,21 @@ def _show_build_error(result: subprocess.CompletedProcess, stage: str): # Show stdout if available (often contains the actual error for CMake) if result.stdout: - stdout_text = result.stdout if isinstance(result.stdout, str) else result.stdout.decode('utf-8', errors='replace') + stdout_text = result.stdout if isinstance(result.stdout, str) else result.stdout.decode("utf-8", errors="replace") stdout_text = stdout_text.strip() if stdout_text: cons.raw.print(Panel(Text(stdout_text), title="Output", border_style="yellow")) # Show stderr if available if result.stderr: - stderr_text = result.stderr if isinstance(result.stderr, str) else result.stderr.decode('utf-8', errors='replace') + stderr_text = result.stderr if isinstance(result.stderr, str) else result.stderr.decode("utf-8", errors="replace") stderr_text = stderr_text.strip() if stderr_text: cons.raw.print(Panel(Text(stderr_text), title="Errors", border_style="red")) cons.print() + @dataclasses.dataclass class MFCTarget: @dataclasses.dataclass @@ -294,23 +282,23 @@ class Dependencies: gpu: typing.List def compute(self) -> typing.Set: - r = self.all[:] + r = self.all[:] r += self.gpu[:] if (ARG("gpu") != gpuConfigOptions.NONE.value) else self.cpu[:] return r - name: str # Name of the target - flags: typing.List[str] # Extra flags to pass to CMakeMFCTarget - isDependency: bool # Is it a dependency of an MFC target? - isDefault: bool # Should it be built by default? (unspecified -t | --targets) - isRequired: bool # Should it always be built? (no matter what -t | --targets is) - requires: Dependencies # Build dependencies of the target - runOrder: int # For MFC Targets: Order in which targets should logically run + name: str # Name of the target + flags: typing.List[str] # Extra flags to pass to CMakeMFCTarget + isDependency: bool # Is it a dependency of an MFC target? + isDefault: bool # Should it be built by default? (unspecified -t | --targets) + isRequired: bool # Should it always be built? (no matter what -t | --targets is) + requires: Dependencies # Build dependencies of the target + runOrder: int # For MFC Targets: Order in which targets should logically run def __hash__(self) -> int: return hash(self.name) - def get_slug(self, case: Case ) -> str: + def get_slug(self, case: Case) -> str: if self.isDependency: return self.name @@ -319,44 +307,44 @@ def get_slug(self, case: Case ) -> str: m.update(CFG().make_slug().encode()) m.update(case.get_fpp(self, False).encode()) - if case.params.get('chemistry', 'F') == 'T': + if case.params.get("chemistry", "F") == "T": m.update(case.get_cantera_solution().name.encode()) return m.hexdigest()[:10] # Get path to directory that will store the build files - def get_staging_dirpath(self, case: Case ) -> str: - return os.sep.join([os.getcwd(), "build", "staging", self.get_slug(case) ]) + def get_staging_dirpath(self, case: Case) -> str: + return os.sep.join([os.getcwd(), "build", "staging", self.get_slug(case)]) # Get the directory that contains the target's CMakeLists.txt def get_cmake_dirpath(self) -> str: # The CMakeLists.txt file is located: # * Regular: /CMakelists.txt # * Dependency: /toolchain/dependencies/CMakelists.txt - return os.sep.join([ - os.getcwd(), - os.sep.join(["toolchain", "dependencies"]) if self.isDependency else "", - ]) + return os.sep.join( + [ + os.getcwd(), + os.sep.join(["toolchain", "dependencies"]) if self.isDependency else "", + ] + ) - def get_install_dirpath(self, case: Case ) -> str: + def get_install_dirpath(self, case: Case) -> str: # The install directory is located /build/install/ return os.sep.join([os.getcwd(), "build", "install", self.get_slug(case)]) def get_home_dirpath(self) -> str: return os.sep.join([os.getcwd()]) - def get_install_binpath(self, case: Case ) -> str: + def get_install_binpath(self, case: Case) -> str: # /install//bin/ return os.sep.join([self.get_install_dirpath(case), "bin", self.name]) - def is_configured(self, case: Case ) -> bool: + def is_configured(self, case: Case) -> bool: # We assume that if the CMakeCache.txt file exists, then the target is # configured. (this isn't perfect, but it's good enough for now) - return os.path.isfile( - os.sep.join([self.get_staging_dirpath(case), "CMakeCache.txt"]) - ) + return os.path.isfile(os.sep.join([self.get_staging_dirpath(case), "CMakeCache.txt"])) - def get_configuration_txt(self, case: Case ) -> typing.Optional[dict]: + def get_configuration_txt(self, case: Case) -> typing.Optional[dict]: if not self.is_configured(case): return None @@ -377,13 +365,11 @@ def is_buildable(self) -> bool: return True def configure(self, case: Case): - build_dirpath = self.get_staging_dirpath(case) - cmake_dirpath = self.get_cmake_dirpath() + build_dirpath = self.get_staging_dirpath(case) + cmake_dirpath = self.get_cmake_dirpath() install_dirpath = self.get_install_dirpath(case) - install_prefixes = ';'.join([ - t.get_install_dirpath(case) for t in self.requires.compute() - ]) + install_prefixes = ";".join([t.get_install_dirpath(case) for t in self.requires.compute()]) flags: list = self.flags.copy() + [ # Disable CMake warnings intended for developers (us). @@ -415,20 +401,20 @@ def configure(self, case: Case): # See: https://cmake.org/cmake/help/latest/command/install.html. f"-DCMAKE_INSTALL_PREFIX={install_dirpath}", f"-DMFC_SINGLE_PRECISION={'ON' if (ARG('single') or ARG('mixed')) else 'OFF'}", - f"-DMFC_MIXED_PRECISION={'ON' if ARG('mixed') else 'OFF'}" + f"-DMFC_MIXED_PRECISION={'ON' if ARG('mixed') else 'OFF'}", ] # Verbosity level 3 (-vvv): add cmake debug flags if ARG("verbose") >= 3: - flags.append('--debug-find') + flags.append("--debug-find") if not self.isDependency: - flags.append(f"-DMFC_MPI={ 'ON' if ARG('mpi') else 'OFF'}") + flags.append(f"-DMFC_MPI={'ON' if ARG('mpi') else 'OFF'}") # flags.append(f"-DMFC_OpenACC={'ON' if ARG('acc') else 'OFF'}") # flags.append(f"-DMFC_OpenMP={'ON' if ARG('mp') else 'OFF'}") flags.append(f"-DMFC_OpenACC={'ON' if (ARG('gpu') == gpuConfigOptions.ACC.value) else 'OFF'}") flags.append(f"-DMFC_OpenMP={'ON' if (ARG('gpu') == gpuConfigOptions.MP.value) else 'OFF'}") - flags.append(f"-DMFC_GCov={ 'ON' if ARG('gcov') else 'OFF'}") + flags.append(f"-DMFC_GCov={'ON' if ARG('gcov') else 'OFF'}") flags.append(f"-DMFC_Unified={'ON' if ARG('unified') else 'OFF'}") flags.append(f"-DMFC_Fastmath={'ON' if ARG('fastmath') else 'OFF'}") @@ -442,7 +428,7 @@ def configure(self, case: Case): debug(f"Configuring {self.name} in {build_dirpath}") debug(f"CMake flags: {' '.join(flags)}") - verbosity = ARG('verbose') + verbosity = ARG("verbose") if verbosity >= 2: # -vv or higher: show raw cmake output level_str = "vv" + "v" * (verbosity - 2) if verbosity > 2 else "vv" @@ -469,12 +455,9 @@ def configure(self, case: Case): def build(self, case: input.MFCInputFile): case.generate_fpp(self) - command = ["cmake", "--build", self.get_staging_dirpath(case), - "--target", self.name, - "--parallel", ARG("jobs"), - "--config", 'Debug' if ARG('debug') else 'Release'] + command = ["cmake", "--build", self.get_staging_dirpath(case), "--target", self.name, "--parallel", ARG("jobs"), "--config", "Debug" if ARG("debug") else "Release"] - verbosity = ARG('verbose') + verbosity = ARG("verbose") # -vv or higher: add cmake --verbose flag for full compiler commands if verbosity >= 2: command.append("--verbose") @@ -522,25 +505,27 @@ def install(self, case: input.MFCInputFile): cons.print(f" [bold green]✓[/bold green] Installed [magenta]{self.name}[/magenta]") cons.print(no_indent=True) + # name flags isDep isDef isReq dependencies run order -FFTW = MFCTarget('fftw', ['-DMFC_FFTW=ON'], True, False, False, MFCTarget.Dependencies([], [], []), -1) -HDF5 = MFCTarget('hdf5', ['-DMFC_HDF5=ON'], True, False, False, MFCTarget.Dependencies([], [], []), -1) -SILO = MFCTarget('silo', ['-DMFC_SILO=ON'], True, False, False, MFCTarget.Dependencies([HDF5], [], []), -1) -LAPACK = MFCTarget('lapack', ['-DMFC_LAPACK=ON'], True, False, False, MFCTarget.Dependencies([],[],[]), -1) -HIPFORT = MFCTarget('hipfort', ['-DMFC_HIPFORT=ON'], True, False, False, MFCTarget.Dependencies([], [], []), -1) -PRE_PROCESS = MFCTarget('pre_process', ['-DMFC_PRE_PROCESS=ON'], False, True, False, MFCTarget.Dependencies([], [], []), 0) -SIMULATION = MFCTarget('simulation', ['-DMFC_SIMULATION=ON'], False, True, False, MFCTarget.Dependencies([], [FFTW], [HIPFORT]), 1) -POST_PROCESS = MFCTarget('post_process', ['-DMFC_POST_PROCESS=ON'], False, True, False, MFCTarget.Dependencies([FFTW, HDF5, SILO, LAPACK], [], []), 2) -SYSCHECK = MFCTarget('syscheck', ['-DMFC_SYSCHECK=ON'], False, False, True, MFCTarget.Dependencies([], [], [HIPFORT]), -1) -DOCUMENTATION = MFCTarget('documentation', ['-DMFC_DOCUMENTATION=ON'], False, False, False, MFCTarget.Dependencies([], [], []), -1) +FFTW = MFCTarget("fftw", ["-DMFC_FFTW=ON"], True, False, False, MFCTarget.Dependencies([], [], []), -1) +HDF5 = MFCTarget("hdf5", ["-DMFC_HDF5=ON"], True, False, False, MFCTarget.Dependencies([], [], []), -1) +SILO = MFCTarget("silo", ["-DMFC_SILO=ON"], True, False, False, MFCTarget.Dependencies([HDF5], [], []), -1) +LAPACK = MFCTarget("lapack", ["-DMFC_LAPACK=ON"], True, False, False, MFCTarget.Dependencies([], [], []), -1) +HIPFORT = MFCTarget("hipfort", ["-DMFC_HIPFORT=ON"], True, False, False, MFCTarget.Dependencies([], [], []), -1) +PRE_PROCESS = MFCTarget("pre_process", ["-DMFC_PRE_PROCESS=ON"], False, True, False, MFCTarget.Dependencies([], [], []), 0) +SIMULATION = MFCTarget("simulation", ["-DMFC_SIMULATION=ON"], False, True, False, MFCTarget.Dependencies([], [FFTW], [HIPFORT]), 1) +POST_PROCESS = MFCTarget("post_process", ["-DMFC_POST_PROCESS=ON"], False, True, False, MFCTarget.Dependencies([FFTW, HDF5, SILO, LAPACK], [], []), 2) +SYSCHECK = MFCTarget("syscheck", ["-DMFC_SYSCHECK=ON"], False, False, True, MFCTarget.Dependencies([], [], [HIPFORT]), -1) +DOCUMENTATION = MFCTarget("documentation", ["-DMFC_DOCUMENTATION=ON"], False, False, False, MFCTarget.Dependencies([], [], []), -1) + +TARGETS = {FFTW, HDF5, SILO, LAPACK, HIPFORT, PRE_PROCESS, SIMULATION, POST_PROCESS, SYSCHECK, DOCUMENTATION} -TARGETS = { FFTW, HDF5, SILO, LAPACK, HIPFORT, PRE_PROCESS, SIMULATION, POST_PROCESS, SYSCHECK, DOCUMENTATION } +DEFAULT_TARGETS = {target for target in TARGETS if target.isDefault} +REQUIRED_TARGETS = {target for target in TARGETS if target.isRequired} +DEPENDENCY_TARGETS = {target for target in TARGETS if target.isDependency} -DEFAULT_TARGETS = { target for target in TARGETS if target.isDefault } -REQUIRED_TARGETS = { target for target in TARGETS if target.isRequired } -DEPENDENCY_TARGETS = { target for target in TARGETS if target.isDependency } +TARGET_MAP = {target.name: target for target in TARGETS} -TARGET_MAP = { target.name: target for target in TARGETS } def get_target(target: typing.Union[str, MFCTarget]) -> MFCTarget: if isinstance(target, MFCTarget): @@ -553,7 +538,7 @@ def get_target(target: typing.Union[str, MFCTarget]) -> MFCTarget: def get_targets(targets: typing.List[typing.Union[str, MFCTarget]]) -> typing.List[MFCTarget]: - return [ get_target(t) for t in targets ] + return [get_target(t) for t in targets] def __build_target(target: typing.Union[MFCTarget, str], case: input.MFCInputFile, history: typing.Set[str] = None): @@ -584,32 +569,29 @@ def __build_target(target: typing.Union[MFCTarget, str], case: input.MFCInputFil def get_configured_targets(case: input.MFCInputFile) -> typing.List[MFCTarget]: - return [ target for target in TARGETS if target.is_configured(case) ] + return [target for target in TARGETS if target.is_configured(case)] def __generate_header(case: input.MFCInputFile, targets: typing.List): - feature_flags = [ - 'Build', - format_list_to_string([ t.name for t in get_targets(targets) ], 'magenta') - ] + feature_flags = ["Build", format_list_to_string([t.name for t in get_targets(targets)], "magenta")] if ARG("case_optimization"): feature_flags.append(f"Case Optimized: [magenta]{ARG('input')}[/magenta]") - if case.params.get('chemistry', 'F') == 'T': + if case.params.get("chemistry", "F") == "T": feature_flags.append(f"Chemistry: [magenta]{case.get_cantera_solution().source}[/magenta]") return f"[bold]{' | '.join(feature_flags or ['Generic'])}[/bold]" -def build(targets = None, case: input.MFCInputFile = None, history: typing.Set[str] = None): +def build(targets=None, case: input.MFCInputFile = None, history: typing.Set[str] = None): if history is None: history = set() if isinstance(targets, (MFCTarget, str)): - targets = [ targets ] + targets = [targets] if targets is None: targets = ARG("targets") targets = get_targets(list(REQUIRED_TARGETS) + targets) - case = case or input.load(ARG("input"), ARG("--"), {}) + case = case or input.load(ARG("input"), ARG("--"), {}) case.validate_params() if len(history) == 0: diff --git a/toolchain/mfc/case.py b/toolchain/mfc/case.py index 445a111c57..4388ac97c7 100644 --- a/toolchain/mfc/case.py +++ b/toolchain/mfc/case.py @@ -1,28 +1,40 @@ -# pylint: disable=import-outside-toplevel -import re, json, math, copy, dataclasses, difflib, fastjsonschema +import copy +import dataclasses +import difflib +import json +import math +import re + +import fastjsonschema from . import common from .printer import cons - +from .run import case_dicts from .state import ARG -from .run import case_dicts def _suggest_similar_params(unknown_key: str, valid_keys: list, n: int = 3) -> list: """Find similar parameter names for typo suggestions.""" return difflib.get_close_matches(unknown_key, valid_keys, n=n, cutoff=0.6) + QPVF_IDX_VARS = { - 'alpha_rho': 'contxb', 'vel' : 'momxb', 'pres': 'E_idx', - 'alpha': 'advxb', 'tau_e': 'stress_idx%beg', 'Y': 'chemxb', - 'cf_val': 'c_idx', 'Bx': 'B_idx%beg', 'By': 'B_idx%end-1', 'Bz': 'B_idx%end', + "alpha_rho": "contxb", + "vel": "momxb", + "pres": "E_idx", + "alpha": "advxb", + "tau_e": "stress_idx%beg", + "Y": "chemxb", + "cf_val": "c_idx", + "Bx": "B_idx%beg", + "By": "B_idx%end-1", + "Bz": "B_idx%end", } -MIBM_ANALYTIC_VARS = [ - 'vel(1)', 'vel(2)', 'vel(3)', 'angular_vel(1)', 'angular_vel(2)', 'angular_vel(3)' -] +MIBM_ANALYTIC_VARS = ["vel(1)", "vel(2)", "vel(3)", "angular_vel(1)", "angular_vel(2)", "angular_vel(3)"] # "B_idx%end - 1" not "B_idx%beg + 1" must be used because 1D does not have Bx + @dataclasses.dataclass(init=False) class Case: params: dict @@ -36,14 +48,15 @@ def get_parameters(self) -> dict: def get_cell_count(self) -> int: return math.prod([max(1, int(self.params.get(dir, 0))) for dir in ["m", "n", "p"]]) - def has_parameter(self, key: str)-> bool: + def has_parameter(self, key: str) -> bool: return key in self.params.keys() def gen_json_dict_str(self) -> str: return json.dumps(self.params, indent=4) def get_inp(self, _target) -> str: - from . import build # pylint: disable=import-outside-toplevel + from . import build + target = build.get_target(_target) cons.print(f"Generating [magenta]{target.name}.inp[/magenta]:") @@ -74,17 +87,17 @@ def get_inp(self, _target) -> str: hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else "" raise common.MFCException(f"Unknown parameter '{key}'.{hint}") - cons.print(f"[yellow]INFO:[/yellow] Forwarded {len(self.params)-len(ignored)}/{len(self.params)} parameters.") + cons.print(f"[yellow]INFO:[/yellow] Forwarded {len(self.params) - len(ignored)}/{len(self.params)} parameters.") cons.unindent() return f"&user_inputs\n{dict_str}&end/\n" def validate_params(self, origin_txt: str = None): - '''Validates parameters read from case file: + """Validates parameters read from case file: 1. Type checking via JSON schema 2. Constraint validation (valid values, ranges) 3. Dependency checking (required/recommended params) - ''' + """ # Type checking try: case_dicts.get_validator()(self.params) @@ -115,37 +128,36 @@ def __get_ndims(self) -> int: return 1 + min(int(self.params.get("n", 0)), 1) + min(int(self.params.get("p", 0)), 1) def __is_ic_analytical(self, key: str, val: str) -> bool: - '''Is this initial condition analytical? - More precisely, is this an arbitrary expression or a string representing a number?''' + """Is this initial condition analytical? + More precisely, is this an arbitrary expression or a string representing a number?""" if common.is_number(val) or not isinstance(val, str): return False for array in QPVF_IDX_VARS: - if re.match(fr'^patch_icpp\([0-9]+\)%{array}', key): + if re.match(rf"^patch_icpp\([0-9]+\)%{array}", key): return True return False def __is_mib_analytical(self, key: str, val: str) -> bool: - '''Is this initial condition analytical? - More precisely, is this an arbitrary expression or a string representing a number?''' + """Is this initial condition analytical? + More precisely, is this an arbitrary expression or a string representing a number?""" if common.is_number(val) or not isinstance(val, str): return False for variable in MIBM_ANALYTIC_VARS: - if re.match(fr'^patch_ib\([0-9]+\)%{re.escape(variable)}', key): + if re.match(rf"^patch_ib\([0-9]+\)%{re.escape(variable)}", key): return True return False - # pylint: disable=too-many-locals def __get_analytic_ic_fpp(self, print: bool) -> str: # generates the content of an FFP file that will hold the functions for # some initial condition DATA = { - 1: {'ptypes': [1, 15, 16], 'sf_idx': 'i, 0, 0'}, - 2: {'ptypes': [2, 3, 4, 5, 6, 7, 13, 17, 18, 21], 'sf_idx': 'i, j, 0'}, - 3: {'ptypes': [8, 9, 10, 11, 12, 14, 19, 21], 'sf_idx': 'i, j, k'} + 1: {"ptypes": [1, 15, 16], "sf_idx": "i, 0, 0"}, + 2: {"ptypes": [2, 3, 4, 5, 6, 7, 13, 17, 18, 21], "sf_idx": "i, j, 0"}, + 3: {"ptypes": [8, 9, 10, 11, 12, 14, 19, 21], "sf_idx": "i, j, k"}, }[self.__get_ndims()] patches = {} @@ -156,7 +168,7 @@ def __get_analytic_ic_fpp(self, print: bool) -> str: if not self.__is_ic_analytical(key, val): continue - patch_id = re.search(r'[0-9]+', key).group(0) + patch_id = re.search(r"[0-9]+", key).group(0) if patch_id not in patches: patches[patch_id] = [] @@ -170,22 +182,28 @@ def __get_analytic_ic_fpp(self, print: bool) -> str: for pid, items in patches.items(): ptype = self.params[f"patch_icpp({pid})%geometry"] - if ptype not in DATA['ptypes']: + if ptype not in DATA["ptypes"]: raise common.MFCException(f"Patch #{pid} of type {ptype} cannot be analytically defined.") # function that defines how we will replace variable names with # values from the case file def rhs_replace(match): return { - 'x': 'x_cc(i)', 'y': 'y_cc(j)', 'z': 'z_cc(k)', - - 'xc': f'patch_icpp({pid})%x_centroid', 'yc': f'patch_icpp({pid})%y_centroid', 'zc': f'patch_icpp({pid})%z_centroid', - 'lx': f'patch_icpp({pid})%length_x', 'ly': f'patch_icpp({pid})%length_y', 'lz': f'patch_icpp({pid})%length_z', - - 'r': f'patch_icpp({pid})%radius', 'eps': f'patch_icpp({pid})%epsilon', 'beta': f'patch_icpp({pid})%beta', - 'tau_e': f'patch_icpp({pid})%tau_e', 'radii': f'patch_icpp({pid})%radii', - - 'e' : f'{math.e}', + "x": "x_cc(i)", + "y": "y_cc(j)", + "z": "z_cc(k)", + "xc": f"patch_icpp({pid})%x_centroid", + "yc": f"patch_icpp({pid})%y_centroid", + "zc": f"patch_icpp({pid})%z_centroid", + "lx": f"patch_icpp({pid})%length_x", + "ly": f"patch_icpp({pid})%length_y", + "lz": f"patch_icpp({pid})%length_z", + "r": f"patch_icpp({pid})%radius", + "eps": f"patch_icpp({pid})%epsilon", + "beta": f"patch_icpp({pid})%beta", + "tau_e": f"patch_icpp({pid})%tau_e", + "radii": f"patch_icpp({pid})%radii", + "e": f"{math.e}", }.get(match.group(), match.group()) lines = [] @@ -195,11 +213,11 @@ def rhs_replace(match): if print: cons.print(f"* Codegen: {attribute} = {expr}") - varname = re.findall(r"[a-zA-Z][a-zA-Z0-9_]*", attribute)[1] + varname = re.findall(r"[a-zA-Z][a-zA-Z0-9_]*", attribute)[1] qpvf_idx = QPVF_IDX_VARS[varname][:] if len(re.findall(r"[0-9]+", attribute)) == 2: - idx = int(re.findall(r'[0-9]+', attribute)[1]) - 1 + idx = int(re.findall(r"[0-9]+", attribute)[1]) - 1 qpvf_idx = f"{qpvf_idx} + {idx}" lhs = f"q_prim_vf({qpvf_idx})%sf({DATA['sf_idx']})" @@ -212,7 +230,7 @@ def rhs_replace(match): # new lines as a fully concatenated string with fortran syntax srcs.append(f"""\ if (patch_id == {pid}) then -{f'{chr(10)}'.join(lines)} +{f"{chr(10)}".join(lines)} end if\ """) @@ -222,7 +240,7 @@ def rhs_replace(match): ! expressions that are evaluated at runtime from the input file. #:def analytical() -{f'{chr(10)}{chr(10)}'.join(srcs)} +{f"{chr(10)}{chr(10)}".join(srcs)} #:enddef """ return content @@ -237,7 +255,7 @@ def __get_analytic_mib_fpp(self, print: bool) -> str: if not self.__is_mib_analytical(key, val): continue - patch_id = re.search(r'[0-9]+', key).group(0) + patch_id = re.search(r"[0-9]+", key).group(0) if patch_id not in ib_patches: ib_patches[patch_id] = [] @@ -249,17 +267,16 @@ def __get_analytic_mib_fpp(self, print: bool) -> str: # for each analytical patch that is required to be added, generate # the string that contains that function. for pid, items in ib_patches.items(): - # function that defines how we will replace variable names with # values from the case file def rhs_replace(match): return { - 'x': 'x_cc(i)', 'y': 'y_cc(j)', 'z': 'z_cc(k)', - 't': 'mytime', - - 'r': f'patch_ib({pid})%radius', - - 'e' : f'{math.e}', + "x": "x_cc(i)", + "y": "y_cc(j)", + "z": "z_cc(k)", + "t": "mytime", + "r": f"patch_ib({pid})%radius", + "e": f"{math.e}", }.get(match.group(), match.group()) lines = [] @@ -279,7 +296,7 @@ def rhs_replace(match): # new lines as a fully concatenated string with fortran syntax srcs.append(f"""\ if (i == {pid}) then -{f'{chr(10)}'.join(lines)} +{f"{chr(10)}".join(lines)} end if\ """) @@ -288,7 +305,7 @@ def rhs_replace(match): ! parameterize the velocity and rotation rate of a moving IB. #:def mib_analytical() -{f'{chr(10)}{chr(10)}'.join(srcs)} +{f"{chr(10)}{chr(10)}".join(srcs)} #:enddef """ return content @@ -307,11 +324,11 @@ def __get_sim_fpp(self, print: bool) -> str: elif bubble_model == 3: nterms = 7 - mapped_weno = 1 if self.params.get("mapped_weno", 'F') == 'T' else 0 - wenoz = 1 if self.params.get("wenoz", 'F') == 'T' else 0 - teno = 1 if self.params.get("teno", 'F') == 'T' else 0 + mapped_weno = 1 if self.params.get("mapped_weno", "F") == "T" else 0 + wenoz = 1 if self.params.get("wenoz", "F") == "T" else 0 + teno = 1 if self.params.get("teno", "F") == "T" else 0 wenojs = 0 if (mapped_weno or wenoz or teno) else 1 - igr = 1 if self.params.get("igr", 'F') == 'T' else 0 + igr = 1 if self.params.get("igr", "F") == "T" else 0 recon_type = self.params.get("recon_type", 1) @@ -322,7 +339,7 @@ def __get_sim_fpp(self, print: bool) -> str: else: weno_polyn = 1 - if self.params.get("igr", "F") == 'T': + if self.params.get("igr", "F") == "T": weno_order = 5 weno_polyn = 3 @@ -332,16 +349,16 @@ def __get_sim_fpp(self, print: bool) -> str: weno_num_stencils = weno_polyn num_dims = 1 + min(int(self.params.get("n", 0)), 1) + min(int(self.params.get("p", 0)), 1) - if self.params.get("mhd", 'F') == 'T': + if self.params.get("mhd", "F") == "T": num_vels = 3 else: num_vels = num_dims - mhd = 1 if self.params.get("mhd", 'F') == 'T' else 0 - relativity = 1 if self.params.get("relativity", 'F') == 'T' else 0 - viscous = 1 if self.params.get("viscous", 'F') == 'T' else 0 - igr = 1 if self.params.get("igr", 'F') == 'T' else 0 - igr_pres_lim = 1 if self.params.get("igr_pres_lim", 'F') == 'T' else 0 + mhd = 1 if self.params.get("mhd", "F") == "T" else 0 + relativity = 1 if self.params.get("relativity", "F") == "T" else 0 + viscous = 1 if self.params.get("viscous", "F") == "T" else 0 + igr = 1 if self.params.get("igr", "F") == "T" else 0 + igr_pres_lim = 1 if self.params.get("igr_pres_lim", "F") == "T" else 0 # Throw error if wenoz_q is required but not set out = f"""\ @@ -380,25 +397,24 @@ def __get_sim_fpp(self, print: bool) -> str: # We need to also include the pre_processing includes so that common subroutines have access to the @:analytical function return out + f"\n{self.__get_pre_fpp(print)}" - def __get_pre_fpp(self, print: bool) -> str: out = self.__get_analytic_ic_fpp(print) return out - def get_fpp(self, target, print = True) -> str: - from . import build # pylint: disable=import-outside-toplevel + def get_fpp(self, target, print=True) -> str: + from . import build def _prepend() -> str: return f"""\ -#:set chemistry = {self.params.get("chemistry", 'F') == 'T'} +#:set chemistry = {self.params.get("chemistry", "F") == "T"} """ def _default(_) -> str: return "! This file is purposefully empty." result = { - "pre_process" : self.__get_pre_fpp, - "simulation" : self.__get_sim_fpp, + "pre_process": self.__get_pre_fpp, + "simulation": self.__get_sim_fpp, }.get(build.get_target(target).name, _default)(print) return _prepend() + result diff --git a/toolchain/mfc/case_utils.py b/toolchain/mfc/case_utils.py index a4c2f9caab..3bcd9ef5e2 100644 --- a/toolchain/mfc/case_utils.py +++ b/toolchain/mfc/case_utils.py @@ -1,19 +1,17 @@ import re + def remove_higher_dimensional_keys(case: dict, ndims: int) -> dict: assert 1 <= ndims <= 3 - rm_dims = [set(), set(['y', 'z']), set(['z']), set()][ndims] - dim_ids = {dim: i + 1 for i, dim in enumerate(['x', 'y', 'z'])} - dim_mnp = {'x': 'm', 'y': 'n', 'z': 'p'} + rm_dims = [set(), set(["y", "z"]), set(["z"]), set()][ndims] + dim_ids = {dim: i + 1 for i, dim in enumerate(["x", "y", "z"])} + dim_mnp = {"x": "m", "y": "n", "z": "p"} rm_keys = set() for key in case.keys(): for dim in rm_dims: - if any([ - re.match(f'.+_{dim}', key), re.match(f'{dim}_.+', key), - re.match(f'%{dim}', key), f'%vel({dim_ids[dim]})' in key - ]): + if any([re.match(f".+_{dim}", key), re.match(f"{dim}_.+", key), re.match(f"%{dim}", key), f"%vel({dim_ids[dim]})" in key]): rm_keys.add(key) break diff --git a/toolchain/mfc/case_validator.py b/toolchain/mfc/case_validator.py index cc6fba0c00..db7a3b8d72 100644 --- a/toolchain/mfc/case_validator.py +++ b/toolchain/mfc/case_validator.py @@ -11,17 +11,16 @@ - src/simulation/m_checker.fpp - src/post_process/m_checker.fpp """ -# pylint: disable=too-many-lines # Justification: Comprehensive validator covering all MFC parameter constraints import re -from typing import Dict, Any, List, Set from functools import lru_cache +from typing import Any, Dict, List, Set + from .common import MFCException from .params.definitions import CONSTRAINTS from .state import CFG - # Physics documentation for check methods. # Each entry maps a check method name to metadata used by gen_physics_docs.py # to auto-generate docs/documentation/physics_constraints.md. @@ -39,22 +38,14 @@ "title": "EOS Parameter Sanity (Transformed Gamma)", "category": "Thermodynamic Constraints", "math": r"\Gamma = \frac{1}{\gamma - 1}", - "explanation": ( - "MFC uses the transformed stiffened gas parameter. " - "A common mistake is entering the physical gamma (e.g., 1.4 for air) " - "instead of the transformed value 1/(gamma-1) = 2.5." - ), + "explanation": ("MFC uses the transformed stiffened gas parameter. A common mistake is entering the physical gamma (e.g., 1.4 for air) instead of the transformed value 1/(gamma-1) = 2.5."), "references": ["Wilfong26", "Allaire02"], }, "check_patch_physics": { "title": "Patch Initial Condition Constraints", "category": "Thermodynamic Constraints", "math": r"p > 0, \quad \alpha_i \rho_i \geq 0, \quad 0 \leq \alpha_i \leq 1", - "explanation": ( - "All initial patch pressures must be strictly positive. " - "Partial densities must be non-negative. " - "Volume fractions must be in [0,1]." - ), + "explanation": ("All initial patch pressures must be strictly positive. Partial densities must be non-negative. Volume fractions must be in [0,1]."), }, # --- Mixture Constraints --- "check_volume_fraction_sum": { @@ -74,10 +65,7 @@ "title": "Alpha-Rho Consistency", "category": "Mixture Constraints", "math": r"\alpha_j = 0 \Rightarrow \alpha_j \rho_j = 0, \quad \alpha_j > 0 \Rightarrow \alpha_j \rho_j > 0", - "explanation": ( - "Warns about physically inconsistent combinations: " - "density assigned to an absent phase, or a present phase with zero density." - ), + "explanation": ("Warns about physically inconsistent combinations: density assigned to an absent phase, or a present phase with zero density."), }, # --- Domain and Geometry --- "check_domain_bounds": { @@ -90,18 +78,12 @@ "title": "Dimensionality", "category": "Domain and Geometry", "math": r"m > 0, \quad n \geq 0, \quad p \geq 0", - "explanation": ( - "The x-direction must have cells. Cannot have z without y. " - "Cylindrical coordinates require odd p." - ), + "explanation": ("The x-direction must have cells. Cannot have z without y. Cylindrical coordinates require odd p."), }, "check_patch_within_domain": { "title": "Patch Within Domain", "category": "Domain and Geometry", - "explanation": ( - "For patches with centroid + length geometry, the bounding box must not be " - "entirely outside the computational domain. Skipped when grid stretching is active." - ), + "explanation": ("For patches with centroid + length geometry, the bounding box must not be entirely outside the computational domain. Skipped when grid stretching is active."), }, # --- Velocity and Dimensional Consistency --- "check_velocity_components": { @@ -115,40 +97,26 @@ "check_model_eqns_and_num_fluids": { "title": "Model Equation Selection", "category": "Model Equations", - "explanation": ( - "Model 1: gamma-law single-fluid. " - "Model 2: five-equation (Allaire). " - "Model 3: six-equation (Saurel). " - "Model 4: four-equation (single-component with bubbles)." - ), + "explanation": ("Model 1: gamma-law single-fluid. Model 2: five-equation (Allaire). Model 3: six-equation (Saurel). Model 4: four-equation (single-component with bubbles)."), "references": ["Wilfong26", "Allaire02", "Saurel09"], }, # --- Boundary Conditions --- "check_boundary_conditions": { "title": "Boundary Condition Compatibility", "category": "Boundary Conditions", - "explanation": ( - "Periodicity must match on both ends. Valid BC values range from -1 to -17. " - "Cylindrical coordinates have specific BC requirements at the axis." - ), + "explanation": ("Periodicity must match on both ends. Valid BC values range from -1 to -17. Cylindrical coordinates have specific BC requirements at the axis."), }, # --- Bubble Physics --- "check_bubbles_euler": { "title": "Euler-Euler Bubble Model", "category": "Bubble Physics", - "explanation": ( - "Requires nb >= 1, positive reference quantities. " - "Polydisperse requires odd nb > 1 and poly_sigma > 0. QBMM requires nnode = 4." - ), + "explanation": ("Requires nb >= 1, positive reference quantities. Polydisperse requires odd nb > 1 and poly_sigma > 0. QBMM requires nnode = 4."), "references": ["Bryngelson21"], }, "check_bubbles_euler_simulation": { "title": "Bubble Simulation Constraints", "category": "Bubble Physics", - "explanation": ( - "Requires HLLC Riemann solver and arithmetic average. " - "Five-equation model does not support Gilmore bubble_model." - ), + "explanation": ("Requires HLLC Riemann solver and arithmetic average. Five-equation model does not support Gilmore bubble_model."), }, "check_bubbles_lagrange": { "title": "Euler-Lagrange Bubble Model", @@ -159,10 +127,7 @@ "check_weno": { "title": "WENO Reconstruction", "category": "Numerical Schemes", - "explanation": ( - "weno_order must be 1, 3, 5, or 7. Grid must have enough cells. " - "Only one of mapped_weno, wenoz, teno can be active." - ), + "explanation": ("weno_order must be 1, 3, 5, or 7. Grid must have enough cells. Only one of mapped_weno, wenoz, teno can be active."), }, "check_muscl": { "title": "MUSCL Reconstruction", @@ -172,10 +137,7 @@ "check_time_stepping": { "title": "Time Stepping", "category": "Numerical Schemes", - "explanation": ( - "time_stepper in {1,2,3}. Fixed dt must be positive. " - "CFL-based modes require cfl_target in (0,1]." - ), + "explanation": ("time_stepper in {1,2,3}. Fixed dt must be positive. CFL-based modes require cfl_target in (0,1]."), }, "check_viscosity": { "title": "Viscosity", @@ -187,10 +149,7 @@ "check_mhd": { "title": "Magnetohydrodynamics (MHD)", "category": "Feature Compatibility", - "explanation": ( - "Requires model_eqns = 2, num_fluids = 1, HLL or HLLD Riemann solver. " - "No relativity with HLLD." - ), + "explanation": ("Requires model_eqns = 2, num_fluids = 1, HLL or HLLD Riemann solver. No relativity with HLLD."), }, "check_surface_tension": { "title": "Surface Tension", @@ -215,19 +174,13 @@ "check_igr": { "title": "Iterative Generalized Riemann (IGR)", "category": "Feature Compatibility", - "explanation": ( - "Requires model_eqns = 2. Incompatible with characteristic BCs, " - "bubbles, MHD, and elastic models." - ), + "explanation": ("Requires model_eqns = 2. Incompatible with characteristic BCs, bubbles, MHD, and elastic models."), }, # --- Acoustic Sources --- "check_acoustic_source": { "title": "Acoustic Sources", "category": "Acoustic Sources", - "explanation": ( - "Dimension-specific support types. Pulse type in {1,2,3,4}. " - "Non-planar sources require foc_length and aperture." - ), + "explanation": ("Dimension-specific support types. Pulse type in {1,2,3,4}. Non-planar sources require foc_length and aperture."), }, # --- Post-Processing --- "check_vorticity": { @@ -238,10 +191,7 @@ "check_fft": { "title": "FFT Output", "category": "Post-Processing", - "explanation": ( - "Requires 3D with all periodic boundaries. " - "Global dimensions must be even. Incompatible with cylindrical coordinates." - ), + "explanation": ("Requires 3D with all periodic boundaries. Global dimensions must be even. Incompatible with cylindrical coordinates."), }, } @@ -257,20 +207,17 @@ def _get_logical_params_from_registry() -> Set[str]: Returns: Set of parameter names that have LOG type. """ - from .params import REGISTRY # pylint: disable=import-outside-toplevel - from .params.schema import ParamType # pylint: disable=import-outside-toplevel + from .params import REGISTRY + from .params.schema import ParamType - return { - name for name, param in REGISTRY.all_params.items() - if param.param_type == ParamType.LOG - } + return {name for name, param in REGISTRY.all_params.items() if param.param_type == ParamType.LOG} class CaseConstraintError(MFCException): """Exception raised when case parameters violate constraints""" -class CaseValidator: # pylint: disable=too-many-public-methods +class CaseValidator: """Validates MFC case parameter constraints""" def __init__(self, params: Dict[str, Any]): @@ -299,10 +246,8 @@ def warn(self, condition: bool, message: str): def _validate_logical(self, key: str): """Validate that a parameter is a valid Fortran logical ('T' or 'F').""" val = self.get(key) - if val is not None and val not in ('T', 'F'): - self.errors.append( - f"{key} must be 'T' or 'F', got '{val}'" - ) + if val is not None and val not in ("T", "F"): + self.errors.append(f"{key} must be 'T' or 'F', got '{val}'") def check_parameter_types(self): """Validate parameter types before other checks. @@ -320,12 +265,10 @@ def check_parameter_types(self): self._validate_logical(param) # Required domain parameters when m > 0 - m = self.get('m') + m = self.get("m") if m is not None and m > 0: - self.prohibit(not self.is_set('x_domain%beg'), - "x_domain%beg must be set when m > 0") - self.prohibit(not self.is_set('x_domain%end'), - "x_domain%end must be set when m > 0") + self.prohibit(not self.is_set("x_domain%beg"), "x_domain%beg must be set when m > 0") + self.prohibit(not self.is_set("x_domain%end"), "x_domain%end must be set when m > 0") # =================================================================== # Common Checks (All Stages) @@ -333,262 +276,209 @@ def check_parameter_types(self): def check_simulation_domain(self): """Checks constraints on dimensionality and number of cells""" - m = self.get('m') - n = self.get('n', 0) - p = self.get('p', 0) - cyl_coord = self.get('cyl_coord', 'F') == 'T' + m = self.get("m") + n = self.get("n", 0) + p = self.get("p", 0) + cyl_coord = self.get("cyl_coord", "F") == "T" self.prohibit(m is None, "m must be set") self.prohibit(m is not None and m <= 0, "m must be positive") self.prohibit(n is not None and n < 0, "n must be non-negative") self.prohibit(p is not None and p < 0, "p must be non-negative") - self.prohibit(cyl_coord and p is not None and p > 0 and p % 2 == 0, - "p must be odd for cylindrical coordinates") - self.prohibit(n is not None and p is not None and n == 0 and p > 0, - "p must be 0 if n = 0") + self.prohibit(cyl_coord and p is not None and p > 0 and p % 2 == 0, "p must be odd for cylindrical coordinates") + self.prohibit(n is not None and p is not None and n == 0 and p > 0, "p must be 0 if n = 0") def check_model_eqns_and_num_fluids(self): """Checks constraints on model equations and number of fluids""" - model_eqns = self.get('model_eqns') - num_fluids = self.get('num_fluids') - mpp_lim = self.get('mpp_lim', 'F') == 'T' - cyl_coord = self.get('cyl_coord', 'F') == 'T' - p = self.get('p', 0) - - self.prohibit(model_eqns is not None and model_eqns not in [1, 2, 3, 4], - "model_eqns must be 1, 2, 3, or 4") - self.prohibit(num_fluids is not None and num_fluids < 1, - "num_fluids must be positive") - self.prohibit(model_eqns == 1 and num_fluids is not None, - "num_fluids is not supported for model_eqns = 1") - self.prohibit(model_eqns == 2 and num_fluids is None, - "5-equation model (model_eqns = 2) requires num_fluids to be set") - self.prohibit(model_eqns == 3 and num_fluids is None, - "6-equation model (model_eqns = 3) requires num_fluids to be set") - self.prohibit(model_eqns == 4 and num_fluids is None, - "4-equation model (model_eqns = 4) requires num_fluids to be set") - self.prohibit(model_eqns == 1 and mpp_lim, - "model_eqns = 1 does not support mpp_lim") - self.prohibit(num_fluids == 1 and mpp_lim, - "num_fluids = 1 does not support mpp_lim") - self.prohibit(model_eqns == 3 and cyl_coord and p != 0, - "6-equation model (model_eqns = 3) does not support cylindrical coordinates (cyl_coord = T and p != 0)") + model_eqns = self.get("model_eqns") + num_fluids = self.get("num_fluids") + mpp_lim = self.get("mpp_lim", "F") == "T" + cyl_coord = self.get("cyl_coord", "F") == "T" + p = self.get("p", 0) + + self.prohibit(model_eqns is not None and model_eqns not in [1, 2, 3, 4], "model_eqns must be 1, 2, 3, or 4") + self.prohibit(num_fluids is not None and num_fluids < 1, "num_fluids must be positive") + self.prohibit(model_eqns == 1 and num_fluids is not None, "num_fluids is not supported for model_eqns = 1") + self.prohibit(model_eqns == 2 and num_fluids is None, "5-equation model (model_eqns = 2) requires num_fluids to be set") + self.prohibit(model_eqns == 3 and num_fluids is None, "6-equation model (model_eqns = 3) requires num_fluids to be set") + self.prohibit(model_eqns == 4 and num_fluids is None, "4-equation model (model_eqns = 4) requires num_fluids to be set") + self.prohibit(model_eqns == 1 and mpp_lim, "model_eqns = 1 does not support mpp_lim") + self.prohibit(num_fluids == 1 and mpp_lim, "num_fluids = 1 does not support mpp_lim") + self.prohibit(model_eqns == 3 and cyl_coord and p != 0, "6-equation model (model_eqns = 3) does not support cylindrical coordinates (cyl_coord = T and p != 0)") def check_igr(self): """Checks constraints regarding IGR order""" - igr = self.get('igr', 'F') == 'T' - igr_pres_lim = self.get('igr_pres_lim', 'F') == 'T' + igr = self.get("igr", "F") == "T" + igr_pres_lim = self.get("igr_pres_lim", "F") == "T" - self.prohibit(igr_pres_lim and not igr, - "igr_pres_lim requires igr to be enabled") + self.prohibit(igr_pres_lim and not igr, "igr_pres_lim requires igr to be enabled") if not igr: return - igr_order = self.get('igr_order') - m = self.get('m', 0) - n = self.get('n', 0) - p = self.get('p', 0) - self.prohibit(igr_order not in [None, 3, 5], - "igr_order must be 3 or 5") + igr_order = self.get("igr_order") + m = self.get("m", 0) + n = self.get("n", 0) + p = self.get("p", 0) + self.prohibit(igr_order not in [None, 3, 5], "igr_order must be 3 or 5") if igr_order: - self.prohibit(m + 1 < igr_order, - f"m must be at least igr_order - 1 (= {igr_order - 1})") - self.prohibit(n is not None and n > 0 and n + 1 < igr_order, - f"n must be at least igr_order - 1 (= {igr_order - 1})") - self.prohibit(p is not None and p > 0 and p + 1 < igr_order, - f"p must be at least igr_order - 1 (= {igr_order - 1})") + self.prohibit(m + 1 < igr_order, f"m must be at least igr_order - 1 (= {igr_order - 1})") + self.prohibit(n is not None and n > 0 and n + 1 < igr_order, f"n must be at least igr_order - 1 (= {igr_order - 1})") + self.prohibit(p is not None and p > 0 and p + 1 < igr_order, f"p must be at least igr_order - 1 (= {igr_order - 1})") def check_weno(self): """Checks constraints regarding WENO order""" - recon_type = self.get('recon_type', 1) + recon_type = self.get("recon_type", 1) # WENO_TYPE = 1 if recon_type != 1: return - weno_order = self.get('weno_order') - m = self.get('m', 0) - n = self.get('n', 0) - p = self.get('p', 0) + weno_order = self.get("weno_order") + m = self.get("m", 0) + n = self.get("n", 0) + p = self.get("p", 0) if weno_order is None: return - self.prohibit(weno_order not in [1, 3, 5, 7], - "weno_order must be 1, 3, 5, or 7") - self.prohibit(m + 1 < weno_order, - f"m must be at least weno_order - 1 (= {weno_order - 1})") - self.prohibit(n is not None and n > 0 and n + 1 < weno_order, - f"For 2D simulation, n must be at least weno_order - 1 (= {weno_order - 1})") - self.prohibit(p is not None and p > 0 and p + 1 < weno_order, - f"For 3D simulation, p must be at least weno_order - 1 (= {weno_order - 1})") + self.prohibit(weno_order not in [1, 3, 5, 7], "weno_order must be 1, 3, 5, or 7") + self.prohibit(m + 1 < weno_order, f"m must be at least weno_order - 1 (= {weno_order - 1})") + self.prohibit(n is not None and n > 0 and n + 1 < weno_order, f"For 2D simulation, n must be at least weno_order - 1 (= {weno_order - 1})") + self.prohibit(p is not None and p > 0 and p + 1 < weno_order, f"For 3D simulation, p must be at least weno_order - 1 (= {weno_order - 1})") def check_muscl(self): """Check constraints regarding MUSCL order""" - recon_type = self.get('recon_type', 1) - int_comp = self.get('int_comp', 'F') == 'T' + recon_type = self.get("recon_type", 1) + int_comp = self.get("int_comp", "F") == "T" - self.prohibit(int_comp and recon_type != 2, - "int_comp (THINC interface compression) requires recon_type = 2 (MUSCL)") + self.prohibit(int_comp and recon_type != 2, "int_comp (THINC interface compression) requires recon_type = 2 (MUSCL)") # MUSCL_TYPE = 2 if recon_type != 2: return - muscl_order = self.get('muscl_order') - m = self.get('m', 0) - n = self.get('n', 0) - p = self.get('p', 0) + muscl_order = self.get("muscl_order") + m = self.get("m", 0) + n = self.get("n", 0) + p = self.get("p", 0) if muscl_order is None: return - self.prohibit(muscl_order not in [1, 2], - "muscl_order must be 1 or 2") - self.prohibit(m + 1 < muscl_order, - f"m must be at least muscl_order - 1 (= {muscl_order - 1})") - self.prohibit(n is not None and n > 0 and n + 1 < muscl_order, - f"For 2D simulation, n must be at least muscl_order - 1 (= {muscl_order - 1})") - self.prohibit(p is not None and p > 0 and p + 1 < muscl_order, - f"For 3D simulation, p must be at least muscl_order - 1 (= {muscl_order - 1})") + self.prohibit(muscl_order not in [1, 2], "muscl_order must be 1 or 2") + self.prohibit(m + 1 < muscl_order, f"m must be at least muscl_order - 1 (= {muscl_order - 1})") + self.prohibit(n is not None and n > 0 and n + 1 < muscl_order, f"For 2D simulation, n must be at least muscl_order - 1 (= {muscl_order - 1})") + self.prohibit(p is not None and p > 0 and p + 1 < muscl_order, f"For 3D simulation, p must be at least muscl_order - 1 (= {muscl_order - 1})") - def check_boundary_conditions(self): # pylint: disable=too-many-locals + def check_boundary_conditions(self): """Checks constraints on boundary conditions""" - cyl_coord = self.get('cyl_coord', 'F') == 'T' - m = self.get('m', 0) - n = self.get('n', 0) - p = self.get('p', 0) + cyl_coord = self.get("cyl_coord", "F") == "T" + m = self.get("m", 0) + n = self.get("n", 0) + p = self.get("p", 0) - for dir, var in [('x', 'm'), ('y', 'n'), ('z', 'p')]: - var_val = {'m': m, 'n': n, 'p': p}[var] + for dir, var in [("x", "m"), ("y", "n"), ("z", "p")]: + var_val = {"m": m, "n": n, "p": p}[var] - for bound in ['beg', 'end']: - bc_key = f'bc_{dir}%{bound}' + for bound in ["beg", "end"]: + bc_key = f"bc_{dir}%{bound}" bc_val = self.get(bc_key) - self.prohibit(var_val is not None and var_val == 0 and bc_val is not None, - f"{bc_key} is not supported for {var} = 0") - self.prohibit(var_val is not None and var_val > 0 and bc_val is None, - f"{var} != 0 but {bc_key} is not set") + self.prohibit(var_val is not None and var_val == 0 and bc_val is not None, f"{bc_key} is not supported for {var} = 0") + self.prohibit(var_val is not None and var_val > 0 and bc_val is None, f"{var} != 0 but {bc_key} is not set") # Check periodicity matches - beg_bc = self.get(f'bc_{dir}%beg') - end_bc = self.get(f'bc_{dir}%end') + beg_bc = self.get(f"bc_{dir}%beg") + end_bc = self.get(f"bc_{dir}%end") if beg_bc is not None and end_bc is not None: - self.prohibit((beg_bc == -1 and end_bc != -1) or (end_bc == -1 and beg_bc != -1), - f"bc_{dir}%beg and bc_{dir}%end must be both periodic (= -1) or both non-periodic") + self.prohibit((beg_bc == -1 and end_bc != -1) or (end_bc == -1 and beg_bc != -1), f"bc_{dir}%beg and bc_{dir}%end must be both periodic (= -1) or both non-periodic") # Range check (skip for cylindrical y/z) - skip_check = cyl_coord and dir in ['y', 'z'] - for bound in ['beg', 'end']: - bc_key = f'bc_{dir}%{bound}' + skip_check = cyl_coord and dir in ["y", "z"] + for bound in ["beg", "end"]: + bc_key = f"bc_{dir}%{bound}" bc_val = self.get(bc_key) if not skip_check and bc_val is not None: - self.prohibit(bc_val > -1 or bc_val < -17, - f"{bc_key} must be between -1 and -17") - self.prohibit(bc_val == -14 and not cyl_coord, - f"{bc_key} must not be -14 (BC_AXIS) for non-cylindrical coordinates") + self.prohibit(bc_val > -1 or bc_val < -17, f"{bc_key} must be between -1 and -17") + self.prohibit(bc_val == -14 and not cyl_coord, f"{bc_key} must not be -14 (BC_AXIS) for non-cylindrical coordinates") # Check BC_NULL is not used - for dir in ['x', 'y', 'z']: - for bound in ['beg', 'end']: - bc_val = self.get(f'bc_{dir}%{bound}') - self.prohibit(bc_val == -13, - "Boundary condition -13 (BC_NULL) is not supported") + for dir in ["x", "y", "z"]: + for bound in ["beg", "end"]: + bc_val = self.get(f"bc_{dir}%{bound}") + self.prohibit(bc_val == -13, "Boundary condition -13 (BC_NULL) is not supported") # Cylindrical specific checks if cyl_coord: self.prohibit(n is not None and n == 0, "n must be positive (2D or 3D) for cylindrical coordinates") - bc_y_beg = self.get('bc_y%beg') - bc_y_end = self.get('bc_y%end') - bc_z_beg = self.get('bc_z%beg') - bc_z_end = self.get('bc_z%end') + bc_y_beg = self.get("bc_y%beg") + bc_y_end = self.get("bc_y%end") + bc_z_beg = self.get("bc_z%beg") + bc_z_end = self.get("bc_z%end") - self.prohibit(p is not None and p == 0 and bc_y_beg != -2, - "bc_y%beg must be -2 (BC_REFLECTIVE) for 2D cylindrical coordinates (p = 0)") - self.prohibit(p is not None and p > 0 and bc_y_beg != -14, - "bc_y%beg must be -14 (BC_AXIS) for 3D cylindrical coordinates (p > 0)") + self.prohibit(p is not None and p == 0 and bc_y_beg != -2, "bc_y%beg must be -2 (BC_REFLECTIVE) for 2D cylindrical coordinates (p = 0)") + self.prohibit(p is not None and p > 0 and bc_y_beg != -14, "bc_y%beg must be -14 (BC_AXIS) for 3D cylindrical coordinates (p > 0)") if bc_y_end is not None: - self.prohibit(bc_y_end > -1 or bc_y_end < -17, - "bc_y%end must be between -1 and -17") - self.prohibit(bc_y_end == -14, - "bc_y%end must not be -14 (BC_AXIS)") + self.prohibit(bc_y_end > -1 or bc_y_end < -17, "bc_y%end must be between -1 and -17") + self.prohibit(bc_y_end == -14, "bc_y%end must not be -14 (BC_AXIS)") # 3D cylindrical if p is not None and p > 0: - self.prohibit(bc_z_beg is not None and bc_z_beg not in [-1, -2], - "bc_z%beg must be -1 (periodic) or -2 (reflective) for 3D cylindrical coordinates") - self.prohibit(bc_z_end is not None and bc_z_end not in [-1, -2], - "bc_z%end must be -1 (periodic) or -2 (reflective) for 3D cylindrical coordinates") + self.prohibit(bc_z_beg is not None and bc_z_beg not in [-1, -2], "bc_z%beg must be -1 (periodic) or -2 (reflective) for 3D cylindrical coordinates") + self.prohibit(bc_z_end is not None and bc_z_end not in [-1, -2], "bc_z%end must be -1 (periodic) or -2 (reflective) for 3D cylindrical coordinates") - def check_bubbles_euler(self): # pylint: disable=too-many-locals + def check_bubbles_euler(self): """Checks constraints on bubble parameters""" - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' + bubbles_euler = self.get("bubbles_euler", "F") == "T" if not bubbles_euler: return - nb = self.get('nb') - polydisperse = self.get('polydisperse', 'F') == 'T' - thermal = self.get('thermal') - model_eqns = self.get('model_eqns') - cyl_coord = self.get('cyl_coord', 'F') == 'T' - rhoref = self.get('rhoref') - pref = self.get('pref') - num_fluids = self.get('num_fluids') - - self.prohibit(nb is None or nb < 1, - "The Ensemble-Averaged Bubble Model requires nb >= 1") - self.prohibit(polydisperse and nb == 1, - "Polydisperse bubble dynamics requires nb > 1") - self.prohibit(polydisperse and nb is not None and nb % 2 == 0, - "nb must be odd for polydisperse bubbles") - self.prohibit(thermal is not None and thermal > 3, - "thermal must be <= 3") - self.prohibit(model_eqns == 3, - "Bubble models untested with 6-equation model (model_eqns = 3)") - self.prohibit(model_eqns == 1, - "Bubble models untested with pi-gamma model (model_eqns = 1)") - self.prohibit(model_eqns == 4 and rhoref is None, - "rhoref must be set if using bubbles_euler with model_eqns = 4") - self.prohibit(rhoref is not None and rhoref <= 0, - "rhoref (reference density) must be positive") - self.prohibit(model_eqns == 4 and pref is None, - "pref must be set if using bubbles_euler with model_eqns = 4") - self.prohibit(pref is not None and pref <= 0, - "pref (reference pressure) must be positive") - self.prohibit(model_eqns == 4 and num_fluids != 1, - "4-equation model (model_eqns = 4) is single-component and requires num_fluids = 1") - self.prohibit(cyl_coord, - "Bubble models untested in cylindrical coordinates") + nb = self.get("nb") + polydisperse = self.get("polydisperse", "F") == "T" + thermal = self.get("thermal") + model_eqns = self.get("model_eqns") + cyl_coord = self.get("cyl_coord", "F") == "T" + rhoref = self.get("rhoref") + pref = self.get("pref") + num_fluids = self.get("num_fluids") + + self.prohibit(nb is None or nb < 1, "The Ensemble-Averaged Bubble Model requires nb >= 1") + self.prohibit(polydisperse and nb == 1, "Polydisperse bubble dynamics requires nb > 1") + self.prohibit(polydisperse and nb is not None and nb % 2 == 0, "nb must be odd for polydisperse bubbles") + self.prohibit(thermal is not None and thermal > 3, "thermal must be <= 3") + self.prohibit(model_eqns == 3, "Bubble models untested with 6-equation model (model_eqns = 3)") + self.prohibit(model_eqns == 1, "Bubble models untested with pi-gamma model (model_eqns = 1)") + self.prohibit(model_eqns == 4 and rhoref is None, "rhoref must be set if using bubbles_euler with model_eqns = 4") + self.prohibit(rhoref is not None and rhoref <= 0, "rhoref (reference density) must be positive") + self.prohibit(model_eqns == 4 and pref is None, "pref must be set if using bubbles_euler with model_eqns = 4") + self.prohibit(pref is not None and pref <= 0, "pref (reference pressure) must be positive") + self.prohibit(model_eqns == 4 and num_fluids != 1, "4-equation model (model_eqns = 4) is single-component and requires num_fluids = 1") + self.prohibit(cyl_coord, "Bubble models untested in cylindrical coordinates") # === BUBBLE PHYSICS PARAMETERS === # Validate bubble reference parameters (bub_pp%) - R0ref = self.get('bub_pp%R0ref') - p0ref = self.get('bub_pp%p0ref') - rho0ref = self.get('bub_pp%rho0ref') - T0ref = self.get('bub_pp%T0ref') + R0ref = self.get("bub_pp%R0ref") + p0ref = self.get("bub_pp%p0ref") + rho0ref = self.get("bub_pp%rho0ref") + T0ref = self.get("bub_pp%T0ref") if R0ref is not None: - self.prohibit(R0ref <= 0, - "bub_pp%R0ref (reference bubble radius) must be positive") + self.prohibit(R0ref <= 0, "bub_pp%R0ref (reference bubble radius) must be positive") if p0ref is not None: - self.prohibit(p0ref <= 0, - "bub_pp%p0ref (reference pressure) must be positive") + self.prohibit(p0ref <= 0, "bub_pp%p0ref (reference pressure) must be positive") if rho0ref is not None: - self.prohibit(rho0ref <= 0, - "bub_pp%rho0ref (reference density) must be positive") + self.prohibit(rho0ref <= 0, "bub_pp%rho0ref (reference density) must be positive") if T0ref is not None: - self.prohibit(T0ref <= 0, - "bub_pp%T0ref (reference temperature) must be positive") + self.prohibit(T0ref <= 0, "bub_pp%T0ref (reference temperature) must be positive") # Viscosities must be non-negative - mu_l = self.get('bub_pp%mu_l') - mu_g = self.get('bub_pp%mu_g') - mu_v = self.get('bub_pp%mu_v') + mu_l = self.get("bub_pp%mu_l") + mu_g = self.get("bub_pp%mu_g") + mu_v = self.get("bub_pp%mu_v") if mu_l is not None: self.prohibit(mu_l < 0, "bub_pp%mu_l (liquid viscosity) must be non-negative") @@ -598,103 +488,85 @@ def check_bubbles_euler(self): # pylint: disable=too-many-locals self.prohibit(mu_v < 0, "bub_pp%mu_v (vapor viscosity) must be non-negative") # Surface tension must be non-negative - ss = self.get('bub_pp%ss') + ss = self.get("bub_pp%ss") if ss is not None: self.prohibit(ss < 0, "bub_pp%ss (surface tension) must be non-negative") def check_qbmm_and_polydisperse(self): """Checks constraints on QBMM and polydisperse bubble parameters""" - polydisperse = self.get('polydisperse', 'F') == 'T' - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - poly_sigma = self.get('poly_sigma') - qbmm = self.get('qbmm', 'F') == 'T' - nnode = self.get('nnode') - - self.prohibit(polydisperse and not bubbles_euler, - "Polydisperse bubble modeling requires the bubbles_euler flag to be set") - self.prohibit(polydisperse and poly_sigma is None, - "Polydisperse bubble modeling requires poly_sigma to be set") - self.prohibit(polydisperse and poly_sigma is not None and poly_sigma <= 0, - "poly_sigma must be positive") - self.prohibit(qbmm and not bubbles_euler, - "QBMM requires the bubbles_euler flag to be set") - self.prohibit(qbmm and nnode is not None and nnode != 4, - "QBMM requires nnode = 4") + polydisperse = self.get("polydisperse", "F") == "T" + bubbles_euler = self.get("bubbles_euler", "F") == "T" + poly_sigma = self.get("poly_sigma") + qbmm = self.get("qbmm", "F") == "T" + nnode = self.get("nnode") + + self.prohibit(polydisperse and not bubbles_euler, "Polydisperse bubble modeling requires the bubbles_euler flag to be set") + self.prohibit(polydisperse and poly_sigma is None, "Polydisperse bubble modeling requires poly_sigma to be set") + self.prohibit(polydisperse and poly_sigma is not None and poly_sigma <= 0, "poly_sigma must be positive") + self.prohibit(qbmm and not bubbles_euler, "QBMM requires the bubbles_euler flag to be set") + self.prohibit(qbmm and nnode is not None and nnode != 4, "QBMM requires nnode = 4") def check_adv_n(self): """Checks constraints on adv_n flag""" - adv_n = self.get('adv_n', 'F') == 'T' - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - num_fluids = self.get('num_fluids') - qbmm = self.get('qbmm', 'F') == 'T' + adv_n = self.get("adv_n", "F") == "T" + bubbles_euler = self.get("bubbles_euler", "F") == "T" + num_fluids = self.get("num_fluids") + qbmm = self.get("qbmm", "F") == "T" if not adv_n: return - self.prohibit(not bubbles_euler, - "adv_n requires bubbles_euler to be enabled") - self.prohibit(num_fluids != 1, - "adv_n requires num_fluids = 1") - self.prohibit(qbmm, - "adv_n is not compatible with qbmm") + self.prohibit(not bubbles_euler, "adv_n requires bubbles_euler to be enabled") + self.prohibit(num_fluids != 1, "adv_n requires num_fluids = 1") + self.prohibit(qbmm, "adv_n is not compatible with qbmm") def check_hypoelasticity(self): """Checks constraints on hypoelasticity parameters""" - hypoelasticity = self.get('hypoelasticity', 'F') == 'T' - model_eqns = self.get('model_eqns') - riemann_solver = self.get('riemann_solver') + hypoelasticity = self.get("hypoelasticity", "F") == "T" + model_eqns = self.get("model_eqns") + riemann_solver = self.get("riemann_solver") if not hypoelasticity: return - self.prohibit(model_eqns is not None and model_eqns != 2, - "hypoelasticity requires model_eqns = 2") - self.prohibit(riemann_solver is not None and riemann_solver != 1, - "hypoelasticity requires HLL Riemann solver (riemann_solver = 1)") + self.prohibit(model_eqns is not None and model_eqns != 2, "hypoelasticity requires model_eqns = 2") + self.prohibit(riemann_solver is not None and riemann_solver != 1, "hypoelasticity requires HLL Riemann solver (riemann_solver = 1)") def check_phase_change(self): """Checks constraints on phase change parameters""" - relax = self.get('relax', 'F') == 'T' - relax_model = self.get('relax_model') - model_eqns = self.get('model_eqns') - palpha_eps = self.get('palpha_eps') - ptgalpha_eps = self.get('ptgalpha_eps') + relax = self.get("relax", "F") == "T" + relax_model = self.get("relax_model") + model_eqns = self.get("model_eqns") + palpha_eps = self.get("palpha_eps") + ptgalpha_eps = self.get("ptgalpha_eps") if not relax: return - self.prohibit(( - model_eqns not in (2, 3) or - (model_eqns == 2 and relax_model not in (5, 6)) or - (model_eqns == 3 and relax_model not in (1, 4, 5, 6))), - "phase change requires model_eqns==2 with relax_model in [5,6] or model_eqns==3 with relax_model in [1,4,5,6]") - self.prohibit(palpha_eps is not None and palpha_eps <= 0, - "palpha_eps must be positive") - self.prohibit(palpha_eps is not None and palpha_eps >= 1, - "palpha_eps must be less than 1") - self.prohibit(ptgalpha_eps is not None and ptgalpha_eps <= 0, - "ptgalpha_eps must be positive") - self.prohibit(ptgalpha_eps is not None and ptgalpha_eps >= 1, - "ptgalpha_eps must be less than 1") + self.prohibit( + (model_eqns not in (2, 3) or (model_eqns == 2 and relax_model not in (5, 6)) or (model_eqns == 3 and relax_model not in (1, 4, 5, 6))), + "phase change requires model_eqns==2 with relax_model in [5,6] or model_eqns==3 with relax_model in [1,4,5,6]", + ) + self.prohibit(palpha_eps is not None and palpha_eps <= 0, "palpha_eps must be positive") + self.prohibit(palpha_eps is not None and palpha_eps >= 1, "palpha_eps must be less than 1") + self.prohibit(ptgalpha_eps is not None and ptgalpha_eps <= 0, "ptgalpha_eps must be positive") + self.prohibit(ptgalpha_eps is not None and ptgalpha_eps >= 1, "ptgalpha_eps must be less than 1") def check_ibm(self): """Checks constraints on Immersed Boundaries parameters""" - ib = self.get('ib', 'F') == 'T' - n = self.get('n', 0) - num_ibs = self.get('num_ibs', 0) + ib = self.get("ib", "F") == "T" + n = self.get("n", 0) + num_ibs = self.get("num_ibs", 0) - self.prohibit(ib and n <= 0, - "Immersed Boundaries do not work in 1D (requires n > 0)") - self.prohibit(ib and (num_ibs <= 0 or num_ibs > 1000), - "num_ibs must be between 1 and num_patches_max (1000)") - self.prohibit(not ib and num_ibs > 0, - "num_ibs is set, but ib is not enabled") + self.prohibit(ib and n <= 0, "Immersed Boundaries do not work in 1D (requires n > 0)") + self.prohibit(ib and (num_ibs <= 0 or num_ibs > 1000), "num_ibs must be between 1 and num_patches_max (1000)") + self.prohibit(not ib and num_ibs > 0, "num_ibs is set, but ib is not enabled") def check_stiffened_eos(self): """Checks constraints on stiffened equation of state fluids parameters""" - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") + bubbles_euler = self.get("bubbles_euler", "F") == "T" if num_fluids is None: return @@ -703,70 +575,54 @@ def check_stiffened_eos(self): bub_fac = 1 if (bubbles_euler) else 0 for i in range(1, num_fluids + 1 + bub_fac): - gamma = self.get(f'fluid_pp({i})%gamma') - pi_inf = self.get(f'fluid_pp({i})%pi_inf') - cv = self.get(f'fluid_pp({i})%cv') + gamma = self.get(f"fluid_pp({i})%gamma") + pi_inf = self.get(f"fluid_pp({i})%pi_inf") + cv = self.get(f"fluid_pp({i})%cv") # Positivity checks if gamma is not None: - self.prohibit(gamma <= 0, - f"fluid_pp({i})%gamma must be positive") + self.prohibit(gamma <= 0, f"fluid_pp({i})%gamma must be positive") if pi_inf is not None: - self.prohibit(pi_inf < 0, - f"fluid_pp({i})%pi_inf must be non-negative") + self.prohibit(pi_inf < 0, f"fluid_pp({i})%pi_inf must be non-negative") if cv is not None: - self.prohibit(cv < 0, - f"fluid_pp({i})%cv must be positive") + self.prohibit(cv < 0, f"fluid_pp({i})%cv must be positive") # Model-specific support if model_eqns == 1: - self.prohibit(gamma is not None, - f"model_eqns = 1 does not support fluid_pp({i})%gamma") - self.prohibit(pi_inf is not None, - f"model_eqns = 1 does not support fluid_pp({i})%pi_inf") + self.prohibit(gamma is not None, f"model_eqns = 1 does not support fluid_pp({i})%gamma") + self.prohibit(pi_inf is not None, f"model_eqns = 1 does not support fluid_pp({i})%pi_inf") def check_surface_tension(self): """Checks constraints on surface tension""" - surface_tension = self.get('surface_tension', 'F') == 'T' - sigma = self.get('sigma') - model_eqns = self.get('model_eqns') - num_fluids = self.get('num_fluids') + surface_tension = self.get("surface_tension", "F") == "T" + sigma = self.get("sigma") + model_eqns = self.get("model_eqns") + num_fluids = self.get("num_fluids") if not surface_tension and sigma is None: return - self.prohibit(surface_tension and sigma is None, - "sigma must be set if surface_tension is enabled") - self.prohibit(surface_tension and sigma is not None and sigma < 0, - "sigma must be greater than or equal to zero") - self.prohibit(sigma is not None and not surface_tension, - "sigma is set but surface_tension is not enabled") - self.prohibit(surface_tension and model_eqns not in [2, 3], - "The surface tension model requires model_eqns = 2 or model_eqns = 3") - self.prohibit(surface_tension and num_fluids != 2, - "The surface tension model requires num_fluids = 2") + self.prohibit(surface_tension and sigma is None, "sigma must be set if surface_tension is enabled") + self.prohibit(surface_tension and sigma is not None and sigma < 0, "sigma must be greater than or equal to zero") + self.prohibit(sigma is not None and not surface_tension, "sigma is set but surface_tension is not enabled") + self.prohibit(surface_tension and model_eqns not in [2, 3], "The surface tension model requires model_eqns = 2 or model_eqns = 3") + self.prohibit(surface_tension and num_fluids != 2, "The surface tension model requires num_fluids = 2") def check_mhd(self): """Checks constraints on MHD parameters""" - mhd = self.get('mhd', 'F') == 'T' - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') - relativity = self.get('relativity', 'F') == 'T' - Bx0 = self.get('Bx0') - n = self.get('n', 0) - - self.prohibit(mhd and num_fluids != 1, - "MHD is only available for single-component flows (num_fluids = 1)") - self.prohibit(mhd and model_eqns != 2, - "MHD is only available for the 5-equation model (model_eqns = 2)") - self.prohibit(relativity and not mhd, - "relativity requires mhd to be enabled") - self.prohibit(Bx0 is not None and not mhd, - "Bx0 must not be set if MHD is not enabled") - self.prohibit(mhd and n is not None and n == 0 and Bx0 is None, - "Bx0 must be set in 1D MHD simulations") - self.prohibit(mhd and n is not None and n > 0 and Bx0 is not None, - "Bx0 must not be set in 2D/3D MHD simulations") + mhd = self.get("mhd", "F") == "T" + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") + relativity = self.get("relativity", "F") == "T" + Bx0 = self.get("Bx0") + n = self.get("n", 0) + + self.prohibit(mhd and num_fluids != 1, "MHD is only available for single-component flows (num_fluids = 1)") + self.prohibit(mhd and model_eqns != 2, "MHD is only available for the 5-equation model (model_eqns = 2)") + self.prohibit(relativity and not mhd, "relativity requires mhd to be enabled") + self.prohibit(Bx0 is not None and not mhd, "Bx0 must not be set if MHD is not enabled") + self.prohibit(mhd and n is not None and n == 0 and Bx0 is None, "Bx0 must be set in 1D MHD simulations") + self.prohibit(mhd and n is not None and n > 0 and Bx0 is not None, "Bx0 must not be set in 2D/3D MHD simulations") # =================================================================== # Simulation-Specific Checks @@ -774,358 +630,280 @@ def check_mhd(self): def check_riemann_solver(self): """Checks constraints on Riemann solver (simulation only)""" - riemann_solver = self.get('riemann_solver') - model_eqns = self.get('model_eqns') - wave_speeds = self.get('wave_speeds') - avg_state = self.get('avg_state') - low_Mach = self.get('low_Mach', 0) - cyl_coord = self.get('cyl_coord', 'F') == 'T' - viscous = self.get('viscous', 'F') == 'T' + riemann_solver = self.get("riemann_solver") + model_eqns = self.get("model_eqns") + wave_speeds = self.get("wave_speeds") + avg_state = self.get("avg_state") + low_Mach = self.get("low_Mach", 0) + cyl_coord = self.get("cyl_coord", "F") == "T" + viscous = self.get("viscous", "F") == "T" if riemann_solver is None: return - self.prohibit(riemann_solver < 1 or riemann_solver > 5, - "riemann_solver must be 1, 2, 3, 4 or 5") - self.prohibit(riemann_solver != 2 and model_eqns == 3, - "6-equation model (model_eqns = 3) requires riemann_solver = 2 (HLLC)") - self.prohibit(wave_speeds is not None and wave_speeds not in [1, 2], - "wave_speeds must be 1 or 2") - self.prohibit(riemann_solver == 3 and wave_speeds is not None, - "Exact Riemann (riemann_solver = 3) does not support wave_speeds") - self.prohibit(avg_state is not None and avg_state not in [1, 2], - "avg_state must be 1 or 2") - self.prohibit(riemann_solver not in [3, 5] and wave_speeds is None, - "wave_speeds must be set if riemann_solver != 3,5") - self.prohibit(riemann_solver not in [3, 5] and avg_state is None, - "avg_state must be set if riemann_solver != 3,5") - self.prohibit(low_Mach not in [0, 1, 2], - "low_Mach must be 0, 1, or 2") - self.prohibit(riemann_solver != 2 and low_Mach == 2, - "low_Mach = 2 requires riemann_solver = 2") - self.prohibit(low_Mach != 0 and model_eqns not in [2, 3], - "low_Mach = 1 or 2 requires model_eqns = 2 or 3") - self.prohibit(riemann_solver == 5 and cyl_coord and viscous, - "Lax Friedrichs with cylindrical viscous flux not supported") + self.prohibit(riemann_solver < 1 or riemann_solver > 5, "riemann_solver must be 1, 2, 3, 4 or 5") + self.prohibit(riemann_solver != 2 and model_eqns == 3, "6-equation model (model_eqns = 3) requires riemann_solver = 2 (HLLC)") + self.prohibit(wave_speeds is not None and wave_speeds not in [1, 2], "wave_speeds must be 1 or 2") + self.prohibit(riemann_solver == 3 and wave_speeds is not None, "Exact Riemann (riemann_solver = 3) does not support wave_speeds") + self.prohibit(avg_state is not None and avg_state not in [1, 2], "avg_state must be 1 or 2") + self.prohibit(riemann_solver not in [3, 5] and wave_speeds is None, "wave_speeds must be set if riemann_solver != 3,5") + self.prohibit(riemann_solver not in [3, 5] and avg_state is None, "avg_state must be set if riemann_solver != 3,5") + self.prohibit(low_Mach not in [0, 1, 2], "low_Mach must be 0, 1, or 2") + self.prohibit(riemann_solver != 2 and low_Mach == 2, "low_Mach = 2 requires riemann_solver = 2") + self.prohibit(low_Mach != 0 and model_eqns not in [2, 3], "low_Mach = 1 or 2 requires model_eqns = 2 or 3") + self.prohibit(riemann_solver == 5 and cyl_coord and viscous, "Lax Friedrichs with cylindrical viscous flux not supported") def check_time_stepping(self): """Checks time stepping parameters (simulation/post-process)""" - cfl_dt = self.get('cfl_dt', 'F') == 'T' - cfl_adap_dt = self.get('cfl_adap_dt', 'F') == 'T' - adap_dt = self.get('adap_dt', 'F') == 'T' - time_stepper = self.get('time_stepper') + cfl_dt = self.get("cfl_dt", "F") == "T" + cfl_adap_dt = self.get("cfl_adap_dt", "F") == "T" + adap_dt = self.get("adap_dt", "F") == "T" + time_stepper = self.get("time_stepper") # Check time_stepper bounds - self.prohibit(time_stepper is not None and (time_stepper < 1 or time_stepper > 3), - "time_stepper must be 1, 2, or 3") + self.prohibit(time_stepper is not None and (time_stepper < 1 or time_stepper > 3), "time_stepper must be 1, 2, or 3") # CFL-based variable dt modes (use t_stop/t_save for termination) # Note: adap_dt is NOT included here - it uses t_step_* for termination variable_dt = cfl_dt or cfl_adap_dt # dt validation (applies to all modes if dt is set) - dt = self.get('dt') - self.prohibit(dt is not None and dt <= 0, - "dt must be positive") + dt = self.get("dt") + self.prohibit(dt is not None and dt <= 0, "dt must be positive") if variable_dt: - cfl_target = self.get('cfl_target') - t_stop = self.get('t_stop') - t_save = self.get('t_save') - n_start = self.get('n_start') - - self.prohibit(cfl_target is not None and (cfl_target <= 0 or cfl_target > 1), - "cfl_target must be in (0, 1]") - self.prohibit(t_stop is not None and t_stop <= 0, - "t_stop must be positive") - self.prohibit(t_save is not None and t_save <= 0, - "t_save must be positive") - self.prohibit(t_save is not None and t_stop is not None and t_save > t_stop, - "t_save must be <= t_stop") - self.prohibit(n_start is not None and n_start < 0, - "n_start must be non-negative") + cfl_target = self.get("cfl_target") + t_stop = self.get("t_stop") + t_save = self.get("t_save") + n_start = self.get("n_start") + + self.prohibit(cfl_target is not None and (cfl_target <= 0 or cfl_target > 1), "cfl_target must be in (0, 1]") + self.prohibit(t_stop is not None and t_stop <= 0, "t_stop must be positive") + self.prohibit(t_save is not None and t_save <= 0, "t_save must be positive") + self.prohibit(t_save is not None and t_stop is not None and t_save > t_stop, "t_save must be <= t_stop") + self.prohibit(n_start is not None and n_start < 0, "n_start must be non-negative") # t_step_* validation (applies to fixed and adap_dt modes) - t_step_start = self.get('t_step_start') - t_step_stop = self.get('t_step_stop') - t_step_save = self.get('t_step_save') - - self.prohibit(t_step_start is not None and t_step_start < 0, - "t_step_start must be non-negative") - self.prohibit(t_step_stop is not None and t_step_stop < 0, - "t_step_stop must be non-negative") - self.prohibit(t_step_stop is not None and t_step_start is not None and t_step_stop <= t_step_start, - "t_step_stop must be > t_step_start") - self.prohibit(t_step_save is not None and t_step_save <= 0, - "t_step_save must be positive") - self.prohibit(t_step_save is not None and t_step_stop is not None and t_step_start is not None and - t_step_save > t_step_stop - t_step_start, - "t_step_save must be <= (t_step_stop - t_step_start)") + t_step_start = self.get("t_step_start") + t_step_stop = self.get("t_step_stop") + t_step_save = self.get("t_step_save") + + self.prohibit(t_step_start is not None and t_step_start < 0, "t_step_start must be non-negative") + self.prohibit(t_step_stop is not None and t_step_stop < 0, "t_step_stop must be non-negative") + self.prohibit(t_step_stop is not None and t_step_start is not None and t_step_stop <= t_step_start, "t_step_stop must be > t_step_start") + self.prohibit(t_step_save is not None and t_step_save <= 0, "t_step_save must be positive") + self.prohibit( + t_step_save is not None and t_step_stop is not None and t_step_start is not None and t_step_save > t_step_stop - t_step_start, "t_step_save must be <= (t_step_stop - t_step_start)" + ) if not variable_dt: # dt is required in pure fixed dt mode (not cfl_dt, not cfl_adap_dt) # adap_dt mode uses dt as initial value, so it's optional - uses_fixed_stepping = self.is_set('t_step_start') or self.is_set('t_step_stop') - self.prohibit(uses_fixed_stepping and not adap_dt and not self.is_set('dt'), - "dt must be set when using fixed time stepping (t_step_start/t_step_stop)") + uses_fixed_stepping = self.is_set("t_step_start") or self.is_set("t_step_stop") + self.prohibit(uses_fixed_stepping and not adap_dt and not self.is_set("dt"), "dt must be set when using fixed time stepping (t_step_start/t_step_stop)") def check_finite_difference(self): """Checks constraints on finite difference parameters""" - fd_order = self.get('fd_order') + fd_order = self.get("fd_order") if fd_order is None: return - self.prohibit(fd_order not in [1, 2, 4], - "fd_order must be 1, 2, or 4") + self.prohibit(fd_order not in [1, 2, 4], "fd_order must be 1, 2, or 4") def check_weno_simulation(self): """Checks WENO-specific constraints for simulation""" - weno_order = self.get('weno_order') - weno_eps = self.get('weno_eps') - wenoz = self.get('wenoz', 'F') == 'T' - wenoz_q = self.get('wenoz_q') - teno = self.get('teno', 'F') == 'T' - teno_CT = self.get('teno_CT') - mapped_weno = self.get('mapped_weno', 'F') == 'T' - mp_weno = self.get('mp_weno', 'F') == 'T' - weno_avg = self.get('weno_avg', 'F') == 'T' - model_eqns = self.get('model_eqns') + weno_order = self.get("weno_order") + weno_eps = self.get("weno_eps") + wenoz = self.get("wenoz", "F") == "T" + wenoz_q = self.get("wenoz_q") + teno = self.get("teno", "F") == "T" + teno_CT = self.get("teno_CT") + mapped_weno = self.get("mapped_weno", "F") == "T" + mp_weno = self.get("mp_weno", "F") == "T" + weno_avg = self.get("weno_avg", "F") == "T" + model_eqns = self.get("model_eqns") # Check for multiple WENO schemes (regardless of weno_order being set) num_schemes = sum([mapped_weno, wenoz, teno]) - self.prohibit(num_schemes >= 2, - "Only one of mapped_weno, wenoz, or teno can be set to true") + self.prohibit(num_schemes >= 2, "Only one of mapped_weno, wenoz, or teno can be set to true") # Early return if weno_order not set (other checks need it) if weno_order is None: return - self.prohibit(weno_order != 1 and weno_eps is None, - "weno_order != 1 requires weno_eps to be set. A typical value is 1e-6") - self.prohibit(weno_eps is not None and weno_eps <= 0, - "weno_eps must be positive. A typical value is 1e-6") - self.prohibit(wenoz and weno_order == 7 and wenoz_q is None, - "wenoz at 7th order requires wenoz_q to be set (should be 2, 3, or 4)") - self.prohibit(wenoz and weno_order == 7 and wenoz_q is not None and wenoz_q not in [2, 3, 4], - "wenoz_q must be either 2, 3, or 4)") - self.prohibit(teno and teno_CT is None, - "teno requires teno_CT to be set. A typical value is 1e-6") - self.prohibit(teno and teno_CT is not None and teno_CT <= 0, - "teno_CT must be positive. A typical value is 1e-6") - - self.prohibit(weno_order == 1 and mapped_weno, - "mapped_weno is not compatible with weno_order = 1") - self.prohibit(weno_order == 1 and wenoz, - "wenoz is not compatible with weno_order = 1") - self.prohibit(weno_order in [1, 3] and teno, - "teno requires weno_order = 5 or 7") - self.prohibit(weno_order != 5 and mp_weno, - "mp_weno requires weno_order = 5") - self.prohibit(model_eqns == 1 and weno_avg, - "weno_avg is not compatible with model_eqns = 1") + self.prohibit(weno_order != 1 and weno_eps is None, "weno_order != 1 requires weno_eps to be set. A typical value is 1e-6") + self.prohibit(weno_eps is not None and weno_eps <= 0, "weno_eps must be positive. A typical value is 1e-6") + self.prohibit(wenoz and weno_order == 7 and wenoz_q is None, "wenoz at 7th order requires wenoz_q to be set (should be 2, 3, or 4)") + self.prohibit(wenoz and weno_order == 7 and wenoz_q is not None and wenoz_q not in [2, 3, 4], "wenoz_q must be either 2, 3, or 4)") + self.prohibit(teno and teno_CT is None, "teno requires teno_CT to be set. A typical value is 1e-6") + self.prohibit(teno and teno_CT is not None and teno_CT <= 0, "teno_CT must be positive. A typical value is 1e-6") + + self.prohibit(weno_order == 1 and mapped_weno, "mapped_weno is not compatible with weno_order = 1") + self.prohibit(weno_order == 1 and wenoz, "wenoz is not compatible with weno_order = 1") + self.prohibit(weno_order in [1, 3] and teno, "teno requires weno_order = 5 or 7") + self.prohibit(weno_order != 5 and mp_weno, "mp_weno requires weno_order = 5") + self.prohibit(model_eqns == 1 and weno_avg, "weno_avg is not compatible with model_eqns = 1") def check_muscl_simulation(self): """Checks MUSCL-specific constraints for simulation""" - muscl_order = self.get('muscl_order') - muscl_lim = self.get('muscl_lim') + muscl_order = self.get("muscl_order") + muscl_lim = self.get("muscl_lim") if muscl_order is None: return - self.prohibit(muscl_order == 2 and muscl_lim is None, - "muscl_lim must be defined if using muscl_order = 2") - self.prohibit(muscl_lim is not None and (muscl_lim < 1 or muscl_lim > 5), - "muscl_lim must be 1, 2, 3, 4, or 5") + self.prohibit(muscl_order == 2 and muscl_lim is None, "muscl_lim must be defined if using muscl_order = 2") + self.prohibit(muscl_lim is not None and (muscl_lim < 1 or muscl_lim > 5), "muscl_lim must be 1, 2, 3, 4, or 5") def check_model_eqns_simulation(self): """Checks model equation constraints specific to simulation""" - model_eqns = self.get('model_eqns') - avg_state = self.get('avg_state') - wave_speeds = self.get('wave_speeds') + model_eqns = self.get("model_eqns") + avg_state = self.get("avg_state") + wave_speeds = self.get("wave_speeds") if model_eqns != 3: return - self.prohibit(avg_state is not None and avg_state != 2, - "6-equation model (model_eqns = 3) requires avg_state = 2") - self.prohibit(wave_speeds is not None and wave_speeds != 1, - "6-equation model (model_eqns = 3) requires wave_speeds = 1") + self.prohibit(avg_state is not None and avg_state != 2, "6-equation model (model_eqns = 3) requires avg_state = 2") + self.prohibit(wave_speeds is not None and wave_speeds != 1, "6-equation model (model_eqns = 3) requires wave_speeds = 1") def check_bubbles_euler_simulation(self): """Checks bubble constraints specific to simulation""" - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - bubbles_lagrange = self.get('bubbles_lagrange', 'F') == 'T' - riemann_solver = self.get('riemann_solver') - avg_state = self.get('avg_state') - model_eqns = self.get('model_eqns') - bubble_model = self.get('bubble_model') + bubbles_euler = self.get("bubbles_euler", "F") == "T" + bubbles_lagrange = self.get("bubbles_lagrange", "F") == "T" + riemann_solver = self.get("riemann_solver") + avg_state = self.get("avg_state") + model_eqns = self.get("model_eqns") + bubble_model = self.get("bubble_model") - self.prohibit(bubbles_euler and bubbles_lagrange, - "Activate only one of the bubble subgrid models (bubbles_euler or bubbles_lagrange)") + self.prohibit(bubbles_euler and bubbles_lagrange, "Activate only one of the bubble subgrid models (bubbles_euler or bubbles_lagrange)") if not bubbles_euler: return - self.prohibit(riemann_solver is not None and riemann_solver != 2, - "Bubble modeling requires HLLC Riemann solver (riemann_solver = 2)") - self.prohibit(avg_state is not None and avg_state != 2, - "Bubble modeling requires arithmetic average (avg_state = 2)") - self.prohibit(model_eqns == 2 and bubble_model == 1, - "The 5-equation bubbly flow model does not support bubble_model = 1 (Gilmore)") + self.prohibit(riemann_solver is not None and riemann_solver != 2, "Bubble modeling requires HLLC Riemann solver (riemann_solver = 2)") + self.prohibit(avg_state is not None and avg_state != 2, "Bubble modeling requires arithmetic average (avg_state = 2)") + self.prohibit(model_eqns == 2 and bubble_model == 1, "The 5-equation bubbly flow model does not support bubble_model = 1 (Gilmore)") def check_body_forces(self): """Checks constraints on body forces parameters""" - for dir in ['x', 'y', 'z']: - bf = self.get(f'bf_{dir}', 'F') == 'T' + for dir in ["x", "y", "z"]: + bf = self.get(f"bf_{dir}", "F") == "T" if not bf: continue - self.prohibit(self.get(f'k_{dir}') is None, - f"k_{dir} must be specified if bf_{dir} is true") - self.prohibit(self.get(f'w_{dir}') is None, - f"w_{dir} must be specified if bf_{dir} is true") - self.prohibit(self.get(f'p_{dir}') is None, - f"p_{dir} must be specified if bf_{dir} is true") - self.prohibit(self.get(f'g_{dir}') is None, - f"g_{dir} must be specified if bf_{dir} is true") + self.prohibit(self.get(f"k_{dir}") is None, f"k_{dir} must be specified if bf_{dir} is true") + self.prohibit(self.get(f"w_{dir}") is None, f"w_{dir} must be specified if bf_{dir} is true") + self.prohibit(self.get(f"p_{dir}") is None, f"p_{dir} must be specified if bf_{dir} is true") + self.prohibit(self.get(f"g_{dir}") is None, f"g_{dir} must be specified if bf_{dir} is true") def check_viscosity(self): """Checks constraints on viscosity parameters""" - viscous = self.get('viscous', 'F') == 'T' - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') - weno_order = self.get('weno_order') - weno_avg = self.get('weno_avg', 'F') == 'T' - igr = self.get('igr', 'F') == 'T' + viscous = self.get("viscous", "F") == "T" + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") + weno_order = self.get("weno_order") + weno_avg = self.get("weno_avg", "F") == "T" + igr = self.get("igr", "F") == "T" # If num_fluids is not set, check at least fluid 1 (for model_eqns=1) if num_fluids is None: num_fluids = 1 for i in range(1, num_fluids + 1): - Re1 = self.get(f'fluid_pp({i})%Re(1)') - Re2 = self.get(f'fluid_pp({i})%Re(2)') + Re1 = self.get(f"fluid_pp({i})%Re(1)") + Re2 = self.get(f"fluid_pp({i})%Re(2)") for j, Re_val in [(1, Re1), (2, Re2)]: if Re_val is not None: - self.prohibit(Re_val <= 0, - f"fluid_pp({i})%Re({j}) must be positive") - self.prohibit(model_eqns == 1, - f"model_eqns = 1 does not support fluid_pp({i})%Re({j})") + self.prohibit(Re_val <= 0, f"fluid_pp({i})%Re({j}) must be positive") + self.prohibit(model_eqns == 1, f"model_eqns = 1 does not support fluid_pp({i})%Re({j})") if not igr: - self.prohibit(weno_order == 1 and not weno_avg, - f"weno_order = 1 without weno_avg does not support fluid_pp({i})%Re({j})") - self.prohibit(not viscous, - f"Re({j}) is specified, but viscous is not set to true") + self.prohibit(weno_order == 1 and not weno_avg, f"weno_order = 1 without weno_avg does not support fluid_pp({i})%Re({j})") + self.prohibit(not viscous, f"Re({j}) is specified, but viscous is not set to true") # Check Re(1) requirement - self.prohibit(Re1 is None and viscous, - f"viscous is set to true, but fluid_pp({i})%Re(1) is not specified") + self.prohibit(Re1 is None and viscous, f"viscous is set to true, but fluid_pp({i})%Re(1) is not specified") def check_mhd_simulation(self): """Checks MHD constraints specific to simulation""" - mhd = self.get('mhd', 'F') == 'T' - riemann_solver = self.get('riemann_solver') - relativity = self.get('relativity', 'F') == 'T' - hyper_cleaning = self.get('hyper_cleaning', 'F') == 'T' - wave_speeds = self.get('wave_speeds') - n = self.get('n', 0) - - self.prohibit(mhd and riemann_solver is not None and riemann_solver not in [1, 4], - "MHD simulations require riemann_solver = 1 (HLL) or riemann_solver = 4 (HLLD)") - self.prohibit(mhd and wave_speeds is not None and wave_speeds == 2, - "MHD requires wave_speeds = 1") - self.prohibit(riemann_solver == 4 and not mhd, - "HLLD (riemann_solver = 4) is only available for MHD simulations") - self.prohibit(riemann_solver == 4 and relativity, - "HLLD is not available for RMHD (relativity)") - self.prohibit(hyper_cleaning and not mhd, - "Hyperbolic cleaning requires mhd to be enabled") - self.prohibit(hyper_cleaning and n is not None and n == 0, - "Hyperbolic cleaning is not supported for 1D simulations") - - - def check_igr_simulation(self): # pylint: disable=too-many-locals + mhd = self.get("mhd", "F") == "T" + riemann_solver = self.get("riemann_solver") + relativity = self.get("relativity", "F") == "T" + hyper_cleaning = self.get("hyper_cleaning", "F") == "T" + wave_speeds = self.get("wave_speeds") + n = self.get("n", 0) + + self.prohibit(mhd and riemann_solver is not None and riemann_solver not in [1, 4], "MHD simulations require riemann_solver = 1 (HLL) or riemann_solver = 4 (HLLD)") + self.prohibit(mhd and wave_speeds is not None and wave_speeds == 2, "MHD requires wave_speeds = 1") + self.prohibit(riemann_solver == 4 and not mhd, "HLLD (riemann_solver = 4) is only available for MHD simulations") + self.prohibit(riemann_solver == 4 and relativity, "HLLD is not available for RMHD (relativity)") + self.prohibit(hyper_cleaning and not mhd, "Hyperbolic cleaning requires mhd to be enabled") + self.prohibit(hyper_cleaning and n is not None and n == 0, "Hyperbolic cleaning is not supported for 1D simulations") + + def check_igr_simulation(self): """Checks IGR constraints specific to simulation""" - igr = self.get('igr', 'F') == 'T' + igr = self.get("igr", "F") == "T" if not igr: return - num_igr_iters = self.get('num_igr_iters') - num_igr_warm_start_iters = self.get('num_igr_warm_start_iters') - igr_iter_solver = self.get('igr_iter_solver') - alf_factor = self.get('alf_factor') - model_eqns = self.get('model_eqns') - ib = self.get('ib', 'F') == 'T' - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - bubbles_lagrange = self.get('bubbles_lagrange', 'F') == 'T' - alt_soundspeed = self.get('alt_soundspeed', 'F') == 'T' - surface_tension = self.get('surface_tension', 'F') == 'T' - hypoelasticity = self.get('hypoelasticity', 'F') == 'T' - acoustic_source = self.get('acoustic_source', 'F') == 'T' - relax = self.get('relax', 'F') == 'T' - mhd = self.get('mhd', 'F') == 'T' - hyperelasticity = self.get('hyperelasticity', 'F') == 'T' - cyl_coord = self.get('cyl_coord', 'F') == 'T' - probe_wrt = self.get('probe_wrt', 'F') == 'T' - - self.prohibit(num_igr_iters is not None and num_igr_iters < 0, - "num_igr_iters must be greater than or equal to 0") - self.prohibit(num_igr_warm_start_iters is not None and num_igr_warm_start_iters < 0, - "num_igr_warm_start_iters must be greater than or equal to 0") - self.prohibit(igr_iter_solver is not None and igr_iter_solver not in [1, 2], - "igr_iter_solver must be 1 or 2") - self.prohibit(alf_factor is not None and alf_factor < 0, - "alf_factor must be non-negative") - self.prohibit(model_eqns is not None and model_eqns != 2, - "IGR only supports model_eqns = 2") - self.prohibit(ib, - "IGR does not support the immersed boundary method") - self.prohibit(bubbles_euler, - "IGR does not support Euler-Euler bubble models") - self.prohibit(bubbles_lagrange, - "IGR does not support Euler-Lagrange bubble models") - self.prohibit(alt_soundspeed, - "IGR does not support alt_soundspeed = T") - self.prohibit(surface_tension, - "IGR does not support surface tension") - self.prohibit(hypoelasticity, - "IGR does not support hypoelasticity") - self.prohibit(acoustic_source, - "IGR does not support acoustic sources") - self.prohibit(relax, - "IGR does not support phase change") - self.prohibit(mhd, - "IGR does not support magnetohydrodynamics") - self.prohibit(hyperelasticity, - "IGR does not support hyperelasticity") - self.prohibit(cyl_coord, - "IGR does not support cylindrical or axisymmetric coordinates") - self.prohibit(probe_wrt, - "IGR does not support probe writes") + num_igr_iters = self.get("num_igr_iters") + num_igr_warm_start_iters = self.get("num_igr_warm_start_iters") + igr_iter_solver = self.get("igr_iter_solver") + alf_factor = self.get("alf_factor") + model_eqns = self.get("model_eqns") + ib = self.get("ib", "F") == "T" + bubbles_euler = self.get("bubbles_euler", "F") == "T" + bubbles_lagrange = self.get("bubbles_lagrange", "F") == "T" + alt_soundspeed = self.get("alt_soundspeed", "F") == "T" + surface_tension = self.get("surface_tension", "F") == "T" + hypoelasticity = self.get("hypoelasticity", "F") == "T" + acoustic_source = self.get("acoustic_source", "F") == "T" + relax = self.get("relax", "F") == "T" + mhd = self.get("mhd", "F") == "T" + hyperelasticity = self.get("hyperelasticity", "F") == "T" + cyl_coord = self.get("cyl_coord", "F") == "T" + probe_wrt = self.get("probe_wrt", "F") == "T" + + self.prohibit(num_igr_iters is not None and num_igr_iters < 0, "num_igr_iters must be greater than or equal to 0") + self.prohibit(num_igr_warm_start_iters is not None and num_igr_warm_start_iters < 0, "num_igr_warm_start_iters must be greater than or equal to 0") + self.prohibit(igr_iter_solver is not None and igr_iter_solver not in [1, 2], "igr_iter_solver must be 1 or 2") + self.prohibit(alf_factor is not None and alf_factor < 0, "alf_factor must be non-negative") + self.prohibit(model_eqns is not None and model_eqns != 2, "IGR only supports model_eqns = 2") + self.prohibit(ib, "IGR does not support the immersed boundary method") + self.prohibit(bubbles_euler, "IGR does not support Euler-Euler bubble models") + self.prohibit(bubbles_lagrange, "IGR does not support Euler-Lagrange bubble models") + self.prohibit(alt_soundspeed, "IGR does not support alt_soundspeed = T") + self.prohibit(surface_tension, "IGR does not support surface tension") + self.prohibit(hypoelasticity, "IGR does not support hypoelasticity") + self.prohibit(acoustic_source, "IGR does not support acoustic sources") + self.prohibit(relax, "IGR does not support phase change") + self.prohibit(mhd, "IGR does not support magnetohydrodynamics") + self.prohibit(hyperelasticity, "IGR does not support hyperelasticity") + self.prohibit(cyl_coord, "IGR does not support cylindrical or axisymmetric coordinates") + self.prohibit(probe_wrt, "IGR does not support probe writes") # Check BCs - IGR does not support characteristic BCs # Characteristic BCs are BC_CHAR_SLIP_WALL (-5) through BC_CHAR_SUP_OUTFLOW (-12) - for dir in ['x', 'y', 'z']: - for bound in ['beg', 'end']: - bc = self.get(f'bc_{dir}%{bound}') + for dir in ["x", "y", "z"]: + for bound in ["beg", "end"]: + bc = self.get(f"bc_{dir}%{bound}") if bc is not None: - self.prohibit(-12 <= bc <= -5, - f"Characteristic boundary condition bc_{dir}%{bound} is not compatible with IGR") + self.prohibit(-12 <= bc <= -5, f"Characteristic boundary condition bc_{dir}%{bound} is not compatible with IGR") - def check_acoustic_source(self): # pylint: disable=too-many-locals,too-many-branches,too-many-statements + def check_acoustic_source(self): """Checks acoustic source parameters (simulation)""" - acoustic_source = self.get('acoustic_source', 'F') == 'T' + acoustic_source = self.get("acoustic_source", "F") == "T" if not acoustic_source: return - num_source = self.get('num_source') - n = self.get('n', 0) - p = self.get('p', 0) - cyl_coord = self.get('cyl_coord', 'F') == 'T' + num_source = self.get("num_source") + n = self.get("n", 0) + p = self.get("p", 0) + cyl_coord = self.get("cyl_coord", "F") == "T" # Determine dimensionality if n is not None and n == 0: @@ -1135,10 +913,8 @@ def check_acoustic_source(self): # pylint: disable=too-many-locals,too-many-bra else: dim = 3 - self.prohibit(num_source is None, - "num_source must be specified for acoustic_source") - self.prohibit(num_source is not None and num_source < 0, - "num_source must be non-negative") + self.prohibit(num_source is None, "num_source must be specified for acoustic_source") + self.prohibit(num_source is not None and num_source < 0, "num_source must be non-negative") if num_source is None or num_source <= 0: return @@ -1147,317 +923,243 @@ def check_acoustic_source(self): # pylint: disable=too-many-locals,too-many-bra for j in range(1, num_source + 1): jstr = str(j) - support = self.get(f'acoustic({j})%support') - loc = [self.get(f'acoustic({j})%loc({i})') for i in range(1, 4)] - mag = self.get(f'acoustic({j})%mag') - pulse = self.get(f'acoustic({j})%pulse') - frequency = self.get(f'acoustic({j})%frequency') - wavelength = self.get(f'acoustic({j})%wavelength') - gauss_sigma_time = self.get(f'acoustic({j})%gauss_sigma_time') - gauss_sigma_dist = self.get(f'acoustic({j})%gauss_sigma_dist') - bb_num_freq = self.get(f'acoustic({j})%bb_num_freq') - bb_bandwidth = self.get(f'acoustic({j})%bb_bandwidth') - bb_lowest_freq = self.get(f'acoustic({j})%bb_lowest_freq') - npulse = self.get(f'acoustic({j})%npulse') - dipole = self.get(f'acoustic({j})%dipole', 'F') == 'T' - dir_val = self.get(f'acoustic({j})%dir') - delay = self.get(f'acoustic({j})%delay') - length = self.get(f'acoustic({j})%length') - height = self.get(f'acoustic({j})%height') - foc_length = self.get(f'acoustic({j})%foc_length') - aperture = self.get(f'acoustic({j})%aperture') - num_elements = self.get(f'acoustic({j})%num_elements') - element_on = self.get(f'acoustic({j})%element_on') - element_spacing_angle = self.get(f'acoustic({j})%element_spacing_angle') - element_polygon_ratio = self.get(f'acoustic({j})%element_polygon_ratio') - - self.prohibit(support is None, - f"acoustic({jstr})%support must be specified for acoustic_source") + support = self.get(f"acoustic({j})%support") + loc = [self.get(f"acoustic({j})%loc({i})") for i in range(1, 4)] + mag = self.get(f"acoustic({j})%mag") + pulse = self.get(f"acoustic({j})%pulse") + frequency = self.get(f"acoustic({j})%frequency") + wavelength = self.get(f"acoustic({j})%wavelength") + gauss_sigma_time = self.get(f"acoustic({j})%gauss_sigma_time") + gauss_sigma_dist = self.get(f"acoustic({j})%gauss_sigma_dist") + bb_num_freq = self.get(f"acoustic({j})%bb_num_freq") + bb_bandwidth = self.get(f"acoustic({j})%bb_bandwidth") + bb_lowest_freq = self.get(f"acoustic({j})%bb_lowest_freq") + npulse = self.get(f"acoustic({j})%npulse") + dipole = self.get(f"acoustic({j})%dipole", "F") == "T" + dir_val = self.get(f"acoustic({j})%dir") + delay = self.get(f"acoustic({j})%delay") + length = self.get(f"acoustic({j})%length") + height = self.get(f"acoustic({j})%height") + foc_length = self.get(f"acoustic({j})%foc_length") + aperture = self.get(f"acoustic({j})%aperture") + num_elements = self.get(f"acoustic({j})%num_elements") + element_on = self.get(f"acoustic({j})%element_on") + element_spacing_angle = self.get(f"acoustic({j})%element_spacing_angle") + element_polygon_ratio = self.get(f"acoustic({j})%element_polygon_ratio") + + self.prohibit(support is None, f"acoustic({jstr})%support must be specified for acoustic_source") # Dimension-specific support checks (only if support was specified) if support is not None: if dim == 1: - self.prohibit(support != 1, - f"Only acoustic({jstr})%support = 1 is allowed for 1D simulations") - self.prohibit(support == 1 and loc[0] is None, - f"acoustic({jstr})%loc(1) must be specified for support = 1") + self.prohibit(support != 1, f"Only acoustic({jstr})%support = 1 is allowed for 1D simulations") + self.prohibit(support == 1 and loc[0] is None, f"acoustic({jstr})%loc(1) must be specified for support = 1") elif dim == 2: if cyl_coord: - self.prohibit(support not in [2, 6, 10], - f"Only acoustic({jstr})%support = 2, 6, or 10 is allowed for 2D axisymmetric") + self.prohibit(support not in [2, 6, 10], f"Only acoustic({jstr})%support = 2, 6, or 10 is allowed for 2D axisymmetric") else: - self.prohibit(support not in [2, 5, 6, 9, 10], - f"Only acoustic({jstr})%support = 2, 5, 6, 9, or 10 is allowed for 2D") + self.prohibit(support not in [2, 5, 6, 9, 10], f"Only acoustic({jstr})%support = 2, 5, 6, 9, or 10 is allowed for 2D") if support in [2, 5, 6, 9, 10]: - self.prohibit(loc[0] is None or loc[1] is None, - f"acoustic({jstr})%loc(1:2) must be specified for support = {support}") + self.prohibit(loc[0] is None or loc[1] is None, f"acoustic({jstr})%loc(1:2) must be specified for support = {support}") elif dim == 3: - self.prohibit(support not in [3, 7, 11], - f"Only acoustic({jstr})%support = 3, 7, or 11 is allowed for 3D") - self.prohibit(cyl_coord, - "Acoustic source is not supported in 3D cylindrical simulations") + self.prohibit(support not in [3, 7, 11], f"Only acoustic({jstr})%support = 3, 7, or 11 is allowed for 3D") + self.prohibit(cyl_coord, "Acoustic source is not supported in 3D cylindrical simulations") if support == 3: - self.prohibit(loc[0] is None or loc[1] is None, - f"acoustic({jstr})%loc(1:2) must be specified for support = 3") + self.prohibit(loc[0] is None or loc[1] is None, f"acoustic({jstr})%loc(1:2) must be specified for support = 3") elif support in [7, 11]: - self.prohibit(loc[0] is None or loc[1] is None or loc[2] is None, - f"acoustic({jstr})%loc(1:3) must be specified for support = {support}") + self.prohibit(loc[0] is None or loc[1] is None or loc[2] is None, f"acoustic({jstr})%loc(1:3) must be specified for support = {support}") # Pulse parameters - self.prohibit(mag is None, - f"acoustic({jstr})%mag must be specified") - self.prohibit(pulse is None, - f"acoustic({jstr})%pulse must be specified") - self.prohibit(pulse is not None and pulse not in [1, 2, 3, 4], - f"Only acoustic({jstr})%pulse = 1, 2, 3, or 4 is allowed") + self.prohibit(mag is None, f"acoustic({jstr})%mag must be specified") + self.prohibit(pulse is None, f"acoustic({jstr})%pulse must be specified") + self.prohibit(pulse is not None and pulse not in [1, 2, 3, 4], f"Only acoustic({jstr})%pulse = 1, 2, 3, or 4 is allowed") # Pulse-specific requirements if pulse in [1, 3]: freq_set = frequency is not None wave_set = wavelength is not None - self.prohibit(freq_set == wave_set, - f"One and only one of acoustic({jstr})%frequency or wavelength must be specified for pulse = {pulse}") + self.prohibit(freq_set == wave_set, f"One and only one of acoustic({jstr})%frequency or wavelength must be specified for pulse = {pulse}") # Physics: frequency and wavelength must be positive - self.prohibit(frequency is not None and frequency <= 0, - f"acoustic({jstr})%frequency must be positive") - self.prohibit(wavelength is not None and wavelength <= 0, - f"acoustic({jstr})%wavelength must be positive") + self.prohibit(frequency is not None and frequency <= 0, f"acoustic({jstr})%frequency must be positive") + self.prohibit(wavelength is not None and wavelength <= 0, f"acoustic({jstr})%wavelength must be positive") if pulse == 2: time_set = gauss_sigma_time is not None dist_set = gauss_sigma_dist is not None - self.prohibit(time_set == dist_set, - f"One and only one of acoustic({jstr})%gauss_sigma_time or gauss_sigma_dist must be specified for pulse = 2") - self.prohibit(delay is None, - f"acoustic({jstr})%delay must be specified for pulse = 2 (Gaussian)") + self.prohibit(time_set == dist_set, f"One and only one of acoustic({jstr})%gauss_sigma_time or gauss_sigma_dist must be specified for pulse = 2") + self.prohibit(delay is None, f"acoustic({jstr})%delay must be specified for pulse = 2 (Gaussian)") # Physics: gaussian parameters must be positive - self.prohibit(gauss_sigma_time is not None and gauss_sigma_time <= 0, - f"acoustic({jstr})%gauss_sigma_time must be positive") - self.prohibit(gauss_sigma_dist is not None and gauss_sigma_dist <= 0, - f"acoustic({jstr})%gauss_sigma_dist must be positive") + self.prohibit(gauss_sigma_time is not None and gauss_sigma_time <= 0, f"acoustic({jstr})%gauss_sigma_time must be positive") + self.prohibit(gauss_sigma_dist is not None and gauss_sigma_dist <= 0, f"acoustic({jstr})%gauss_sigma_dist must be positive") if pulse == 4: - self.prohibit(bb_num_freq is None, - f"acoustic({jstr})%bb_num_freq must be specified for pulse = 4") - self.prohibit(bb_bandwidth is None, - f"acoustic({jstr})%bb_bandwidth must be specified for pulse = 4") - self.prohibit(bb_lowest_freq is None, - f"acoustic({jstr})%bb_lowest_freq must be specified for pulse = 4") + self.prohibit(bb_num_freq is None, f"acoustic({jstr})%bb_num_freq must be specified for pulse = 4") + self.prohibit(bb_bandwidth is None, f"acoustic({jstr})%bb_bandwidth must be specified for pulse = 4") + self.prohibit(bb_lowest_freq is None, f"acoustic({jstr})%bb_lowest_freq must be specified for pulse = 4") # npulse checks - self.prohibit(npulse is None, - f"acoustic({jstr})%npulse must be specified") - self.prohibit(support is not None and support >= 5 and npulse is not None and not isinstance(npulse, int), - f"acoustic({jstr})%npulse must be an integer for support >= 5 (non-planar)") - self.prohibit(npulse is not None and npulse >= 5 and dipole, - f"acoustic({jstr})%dipole is not supported for npulse >= 5") - self.prohibit(support is not None and support < 5 and dir_val is None, - f"acoustic({jstr})%dir must be specified for support < 5 (planar)") - self.prohibit(support == 1 and dir_val is not None and dir_val == 0, - f"acoustic({jstr})%dir must be non-zero for support = 1") - self.prohibit(pulse == 3 and support is not None and support >= 5, - f"acoustic({jstr})%support >= 5 is not allowed for pulse = 3 (square wave)") + self.prohibit(npulse is None, f"acoustic({jstr})%npulse must be specified") + self.prohibit(support is not None and support >= 5 and npulse is not None and not isinstance(npulse, int), f"acoustic({jstr})%npulse must be an integer for support >= 5 (non-planar)") + self.prohibit(npulse is not None and npulse >= 5 and dipole, f"acoustic({jstr})%dipole is not supported for npulse >= 5") + self.prohibit(support is not None and support < 5 and dir_val is None, f"acoustic({jstr})%dir must be specified for support < 5 (planar)") + self.prohibit(support == 1 and dir_val is not None and dir_val == 0, f"acoustic({jstr})%dir must be non-zero for support = 1") + self.prohibit(pulse == 3 and support is not None and support >= 5, f"acoustic({jstr})%support >= 5 is not allowed for pulse = 3 (square wave)") # Geometry checks if support in [2, 3]: - self.prohibit(length is None, - f"acoustic({jstr})%length must be specified for support = {support}") - self.prohibit(length is not None and length <= 0, - f"acoustic({jstr})%length must be positive for support = {support}") + self.prohibit(length is None, f"acoustic({jstr})%length must be specified for support = {support}") + self.prohibit(length is not None and length <= 0, f"acoustic({jstr})%length must be positive for support = {support}") if support == 3: - self.prohibit(height is None, - f"acoustic({jstr})%height must be specified for support = 3") - self.prohibit(height is not None and height <= 0, - f"acoustic({jstr})%height must be positive for support = 3") + self.prohibit(height is None, f"acoustic({jstr})%height must be specified for support = 3") + self.prohibit(height is not None and height <= 0, f"acoustic({jstr})%height must be positive for support = 3") if support is not None and support >= 5: - self.prohibit(foc_length is None, - f"acoustic({jstr})%foc_length must be specified for support >= 5 (non-planar)") - self.prohibit(foc_length is not None and foc_length <= 0, - f"acoustic({jstr})%foc_length must be positive for support >= 5") - self.prohibit(aperture is None, - f"acoustic({jstr})%aperture must be specified for support >= 5 (non-planar)") - self.prohibit(aperture is not None and aperture <= 0, - f"acoustic({jstr})%aperture must be positive for support >= 5") + self.prohibit(foc_length is None, f"acoustic({jstr})%foc_length must be specified for support >= 5 (non-planar)") + self.prohibit(foc_length is not None and foc_length <= 0, f"acoustic({jstr})%foc_length must be positive for support >= 5") + self.prohibit(aperture is None, f"acoustic({jstr})%aperture must be specified for support >= 5 (non-planar)") + self.prohibit(aperture is not None and aperture <= 0, f"acoustic({jstr})%aperture must be positive for support >= 5") # Transducer array checks if support in [9, 10, 11]: - self.prohibit(num_elements is None, - f"acoustic({jstr})%num_elements must be specified for support = {support} (transducer array)") - self.prohibit(num_elements is not None and num_elements <= 0, - f"acoustic({jstr})%num_elements must be positive for support = {support}") - self.prohibit(element_on is not None and element_on < 0, - f"acoustic({jstr})%element_on must be non-negative for support = {support}") - self.prohibit(element_on is not None and num_elements is not None and element_on > num_elements, - f"acoustic({jstr})%element_on must be <= num_elements for support = {support}") + self.prohibit(num_elements is None, f"acoustic({jstr})%num_elements must be specified for support = {support} (transducer array)") + self.prohibit(num_elements is not None and num_elements <= 0, f"acoustic({jstr})%num_elements must be positive for support = {support}") + self.prohibit(element_on is not None and element_on < 0, f"acoustic({jstr})%element_on must be non-negative for support = {support}") + self.prohibit(element_on is not None and num_elements is not None and element_on > num_elements, f"acoustic({jstr})%element_on must be <= num_elements for support = {support}") if support in [9, 10]: - self.prohibit(element_spacing_angle is None, - f"acoustic({jstr})%element_spacing_angle must be specified for support = {support} (2D transducer)") - self.prohibit(element_spacing_angle is not None and element_spacing_angle < 0, - f"acoustic({jstr})%element_spacing_angle must be non-negative for support = {support}") + self.prohibit(element_spacing_angle is None, f"acoustic({jstr})%element_spacing_angle must be specified for support = {support} (2D transducer)") + self.prohibit(element_spacing_angle is not None and element_spacing_angle < 0, f"acoustic({jstr})%element_spacing_angle must be non-negative for support = {support}") if support == 11: - self.prohibit(element_polygon_ratio is None, - f"acoustic({jstr})%element_polygon_ratio must be specified for support = 11 (3D transducer)") - self.prohibit(element_polygon_ratio is not None and element_polygon_ratio <= 0, - f"acoustic({jstr})%element_polygon_ratio must be positive for support = 11") + self.prohibit(element_polygon_ratio is None, f"acoustic({jstr})%element_polygon_ratio must be specified for support = 11 (3D transducer)") + self.prohibit(element_polygon_ratio is not None and element_polygon_ratio <= 0, f"acoustic({jstr})%element_polygon_ratio must be positive for support = 11") def check_adaptive_time_stepping(self): """Checks adaptive time stepping parameters (simulation)""" - adap_dt = self.get('adap_dt', 'F') == 'T' + adap_dt = self.get("adap_dt", "F") == "T" if not adap_dt: return - time_stepper = self.get('time_stepper') - model_eqns = self.get('model_eqns') - polytropic = self.get('polytropic', 'F') == 'T' - bubbles_lagrange = self.get('bubbles_lagrange', 'F') == 'T' - qbmm = self.get('qbmm', 'F') == 'T' - adv_n = self.get('adv_n', 'F') == 'T' - - self.prohibit(time_stepper is not None and time_stepper != 3, - "adap_dt requires Runge-Kutta 3 (time_stepper = 3)") - self.prohibit(model_eqns == 1, - "adap_dt is not supported for model_eqns = 1") - self.prohibit(qbmm, - "adap_dt is not compatible with qbmm") - self.prohibit(not polytropic and not bubbles_lagrange, - "adap_dt requires polytropic = T or bubbles_lagrange = T") - self.prohibit(not adv_n and not bubbles_lagrange, - "adap_dt requires adv_n = T or bubbles_lagrange = T") + time_stepper = self.get("time_stepper") + model_eqns = self.get("model_eqns") + polytropic = self.get("polytropic", "F") == "T" + bubbles_lagrange = self.get("bubbles_lagrange", "F") == "T" + qbmm = self.get("qbmm", "F") == "T" + adv_n = self.get("adv_n", "F") == "T" + + self.prohibit(time_stepper is not None and time_stepper != 3, "adap_dt requires Runge-Kutta 3 (time_stepper = 3)") + self.prohibit(model_eqns == 1, "adap_dt is not supported for model_eqns = 1") + self.prohibit(qbmm, "adap_dt is not compatible with qbmm") + self.prohibit(not polytropic and not bubbles_lagrange, "adap_dt requires polytropic = T or bubbles_lagrange = T") + self.prohibit(not adv_n and not bubbles_lagrange, "adap_dt requires adv_n = T or bubbles_lagrange = T") def check_alt_soundspeed(self): """Checks alternative sound speed parameters (simulation)""" - alt_soundspeed = self.get('alt_soundspeed', 'F') == 'T' + alt_soundspeed = self.get("alt_soundspeed", "F") == "T" if not alt_soundspeed: return - model_eqns = self.get('model_eqns') - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - avg_state = self.get('avg_state') - riemann_solver = self.get('riemann_solver') - num_fluids = self.get('num_fluids') - - self.prohibit(model_eqns is not None and model_eqns != 2, - "5-equation model (model_eqns = 2) is required for alt_soundspeed") - self.prohibit(bubbles_euler, - "alt_soundspeed is not compatible with bubbles_euler") - self.prohibit(avg_state is not None and avg_state != 2, - "alt_soundspeed requires avg_state = 2") - self.prohibit(riemann_solver is not None and riemann_solver != 2, - "alt_soundspeed requires HLLC Riemann solver (riemann_solver = 2)") - self.prohibit(num_fluids is not None and num_fluids not in [2, 3], - "alt_soundspeed requires num_fluids = 2 or 3") + model_eqns = self.get("model_eqns") + bubbles_euler = self.get("bubbles_euler", "F") == "T" + avg_state = self.get("avg_state") + riemann_solver = self.get("riemann_solver") + num_fluids = self.get("num_fluids") + + self.prohibit(model_eqns is not None and model_eqns != 2, "5-equation model (model_eqns = 2) is required for alt_soundspeed") + self.prohibit(bubbles_euler, "alt_soundspeed is not compatible with bubbles_euler") + self.prohibit(avg_state is not None and avg_state != 2, "alt_soundspeed requires avg_state = 2") + self.prohibit(riemann_solver is not None and riemann_solver != 2, "alt_soundspeed requires HLLC Riemann solver (riemann_solver = 2)") + self.prohibit(num_fluids is not None and num_fluids not in [2, 3], "alt_soundspeed requires num_fluids = 2 or 3") def check_bubbles_lagrange(self): """Checks Lagrangian bubble parameters (simulation)""" - bubbles_lagrange = self.get('bubbles_lagrange', 'F') == 'T' + bubbles_lagrange = self.get("bubbles_lagrange", "F") == "T" if not bubbles_lagrange: return - n = self.get('n', 0) - file_per_process = self.get('file_per_process', 'F') == 'T' - model_eqns = self.get('model_eqns') - cluster_type = self.get('lag_params%cluster_type') - smooth_type = self.get('lag_params%smooth_type') - polytropic = self.get('polytropic', 'F') == 'T' - thermal = self.get('thermal') - - self.prohibit(n is not None and n == 0, - "bubbles_lagrange accepts 2D and 3D simulations only") - self.prohibit(file_per_process, - "file_per_process must be false for bubbles_lagrange") - self.prohibit(model_eqns == 3, - "The 6-equation flow model does not support bubbles_lagrange") - self.prohibit(polytropic, - "bubbles_lagrange requires polytropic = F") - self.prohibit(thermal is not None and thermal != 3, - "bubbles_lagrange requires thermal = 3") - self.prohibit(cluster_type is not None and cluster_type >= 2 and smooth_type != 1, - "cluster_type >= 2 requires smooth_type = 1") + n = self.get("n", 0) + file_per_process = self.get("file_per_process", "F") == "T" + model_eqns = self.get("model_eqns") + cluster_type = self.get("lag_params%cluster_type") + smooth_type = self.get("lag_params%smooth_type") + polytropic = self.get("polytropic", "F") == "T" + thermal = self.get("thermal") + + self.prohibit(n is not None and n == 0, "bubbles_lagrange accepts 2D and 3D simulations only") + self.prohibit(file_per_process, "file_per_process must be false for bubbles_lagrange") + self.prohibit(model_eqns == 3, "The 6-equation flow model does not support bubbles_lagrange") + self.prohibit(polytropic, "bubbles_lagrange requires polytropic = F") + self.prohibit(thermal is not None and thermal != 3, "bubbles_lagrange requires thermal = 3") + self.prohibit(cluster_type is not None and cluster_type >= 2 and smooth_type != 1, "cluster_type >= 2 requires smooth_type = 1") def check_continuum_damage(self): """Checks continuum damage model parameters (simulation)""" - cont_damage = self.get('cont_damage', 'F') == 'T' + cont_damage = self.get("cont_damage", "F") == "T" if not cont_damage: return - tau_star = self.get('tau_star') - cont_damage_s = self.get('cont_damage_s') - alpha_bar = self.get('alpha_bar') - model_eqns = self.get('model_eqns') + tau_star = self.get("tau_star") + cont_damage_s = self.get("cont_damage_s") + alpha_bar = self.get("alpha_bar") + model_eqns = self.get("model_eqns") - self.prohibit(tau_star is None, - "tau_star must be specified for cont_damage") - self.prohibit(cont_damage_s is None, - "cont_damage_s must be specified for cont_damage") - self.prohibit(alpha_bar is None, - "alpha_bar must be specified for cont_damage") - self.prohibit(model_eqns is not None and model_eqns != 2, - "cont_damage requires model_eqns = 2") + self.prohibit(tau_star is None, "tau_star must be specified for cont_damage") + self.prohibit(cont_damage_s is None, "cont_damage_s must be specified for cont_damage") + self.prohibit(alpha_bar is None, "alpha_bar must be specified for cont_damage") + self.prohibit(model_eqns is not None and model_eqns != 2, "cont_damage requires model_eqns = 2") def check_grcbc(self): """Checks Generalized Relaxation Characteristics BC (simulation)""" - 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' - grcbc_vel_out = self.get(f'bc_{dir}%grcbc_vel_out', 'F') == 'T' - bc_beg = self.get(f'bc_{dir}%beg') - bc_end = self.get(f'bc_{dir}%end') + 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" + grcbc_vel_out = self.get(f"bc_{dir}%grcbc_vel_out", "F") == "T" + bc_beg = self.get(f"bc_{dir}%beg") + bc_end = self.get(f"bc_{dir}%end") if grcbc_in: # Check if EITHER beg OR end is set to -7 - self.prohibit(bc_beg != -7 and bc_end != -7, - f"Subsonic Inflow (grcbc_in) requires bc_{dir}%beg = -7 or bc_{dir}%end = -7") + self.prohibit(bc_beg != -7 and bc_end != -7, f"Subsonic Inflow (grcbc_in) requires bc_{dir}%beg = -7 or bc_{dir}%end = -7") 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") + self.prohibit(bc_beg != -8 and bc_end != -8, f"Subsonic Outflow (grcbc_out) requires bc_{dir}%beg = -8 or bc_{dir}%end = -8") 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") + 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") def check_probe_integral_output(self): """Checks probe and integral output requirements (simulation)""" - probe_wrt = self.get('probe_wrt', 'F') == 'T' - integral_wrt = self.get('integral_wrt', 'F') == 'T' - fd_order = self.get('fd_order') - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - - self.prohibit(probe_wrt and fd_order is None, - "fd_order must be specified for probe_wrt") - self.prohibit(integral_wrt and fd_order is None, - "fd_order must be specified for integral_wrt") - self.prohibit(integral_wrt and not bubbles_euler, - "integral_wrt requires bubbles_euler to be enabled") + probe_wrt = self.get("probe_wrt", "F") == "T" + integral_wrt = self.get("integral_wrt", "F") == "T" + fd_order = self.get("fd_order") + bubbles_euler = self.get("bubbles_euler", "F") == "T" + + self.prohibit(probe_wrt and fd_order is None, "fd_order must be specified for probe_wrt") + self.prohibit(integral_wrt and fd_order is None, "fd_order must be specified for integral_wrt") + self.prohibit(integral_wrt and not bubbles_euler, "integral_wrt requires bubbles_euler to be enabled") def check_hyperelasticity(self): """Checks hyperelasticity constraints""" - hyperelasticity = self.get('hyperelasticity', 'F') == 'T' - pre_stress = self.get('pre_stress', 'F') == 'T' + hyperelasticity = self.get("hyperelasticity", "F") == "T" + pre_stress = self.get("pre_stress", "F") == "T" - self.prohibit(pre_stress and not hyperelasticity, - "pre_stress requires hyperelasticity to be enabled") + self.prohibit(pre_stress and not hyperelasticity, "pre_stress requires hyperelasticity to be enabled") if not hyperelasticity: return - model_eqns = self.get('model_eqns') + model_eqns = self.get("model_eqns") - self.prohibit(model_eqns == 1, - "hyperelasticity is not supported for model_eqns = 1") - self.prohibit(model_eqns is not None and model_eqns > 3, - "hyperelasticity is not supported for model_eqns > 3") + self.prohibit(model_eqns == 1, "hyperelasticity is not supported for model_eqns = 1") + self.prohibit(model_eqns is not None and model_eqns > 3, "hyperelasticity is not supported for model_eqns > 3") # =================================================================== # Pre-Process Specific Checks @@ -1465,140 +1167,111 @@ def check_hyperelasticity(self): def check_restart(self): """Checks constraints on restart parameters (pre-process)""" - old_grid = self.get('old_grid', 'F') == 'T' - old_ic = self.get('old_ic', 'F') == 'T' - t_step_old = self.get('t_step_old') - num_patches = self.get('num_patches', 0) - - self.prohibit(not old_grid and old_ic, - "old_ic can only be enabled with old_grid enabled") - 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") + old_grid = self.get("old_grid", "F") == "T" + old_ic = self.get("old_ic", "F") == "T" + t_step_old = self.get("t_step_old") + num_patches = self.get("num_patches", 0) + + self.prohibit(not old_grid and old_ic, "old_ic can only be enabled with old_grid enabled") + 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") def check_qbmm_pre_process(self): """Checks QBMM constraints for pre-process""" - qbmm = self.get('qbmm', 'F') == 'T' - dist_type = self.get('dist_type') - rhoRV = self.get('rhoRV') + qbmm = self.get("qbmm", "F") == "T" + dist_type = self.get("dist_type") + rhoRV = self.get("rhoRV") if not qbmm: return - self.prohibit(dist_type is None, - "dist_type must be set if using QBMM") - self.prohibit(dist_type is not None and dist_type != 1 and rhoRV is not None and rhoRV > 0, - "rhoRV cannot be used with dist_type != 1") + self.prohibit(dist_type is None, "dist_type must be set if using QBMM") + self.prohibit(dist_type is not None and dist_type != 1 and rhoRV is not None and rhoRV > 0, "rhoRV cannot be used with dist_type != 1") def check_parallel_io_pre_process(self): """Checks parallel I/O constraints (pre-process)""" - parallel_io = self.get('parallel_io', 'F') == 'T' - down_sample = self.get('down_sample', 'F') == 'T' - igr = self.get('igr', 'F') == 'T' - p = self.get('p', 0) - file_per_process = self.get('file_per_process', 'F') == 'T' - m = self.get('m', 0) - n = self.get('n', 0) + parallel_io = self.get("parallel_io", "F") == "T" + down_sample = self.get("down_sample", "F") == "T" + igr = self.get("igr", "F") == "T" + p = self.get("p", 0) + file_per_process = self.get("file_per_process", "F") == "T" + m = self.get("m", 0) + n = self.get("n", 0) if down_sample: - self.prohibit(not parallel_io, - "down sample requires parallel_io = T") - self.prohibit(not igr, - "down sample requires igr = T") - self.prohibit(p == 0, - "down sample requires 3D (p > 0)") - self.prohibit(not file_per_process, - "down sample requires file_per_process = T") + self.prohibit(not parallel_io, "down sample requires parallel_io = T") + self.prohibit(not igr, "down sample requires igr = T") + self.prohibit(p == 0, "down sample requires 3D (p > 0)") + self.prohibit(not file_per_process, "down sample requires file_per_process = T") if m is not None and m >= 0: - self.prohibit((m + 1) % 3 != 0, - "down sample requires m divisible by 3") + self.prohibit((m + 1) % 3 != 0, "down sample requires m divisible by 3") if n is not None and n >= 0: - self.prohibit((n + 1) % 3 != 0, - "down sample requires n divisible by 3") + self.prohibit((n + 1) % 3 != 0, "down sample requires n divisible by 3") if p is not None and p >= 0: - self.prohibit((p + 1) % 3 != 0, - "down sample requires p divisible by 3") + self.prohibit((p + 1) % 3 != 0, "down sample requires p divisible by 3") - def check_grid_stretching(self): # pylint: disable=too-many-branches + def check_grid_stretching(self): """Checks grid stretching constraints (pre-process)""" - loops_x = self.get('loops_x', 1) - loops_y = self.get('loops_y', 1) - stretch_y = self.get('stretch_y', 'F') == 'T' - stretch_z = self.get('stretch_z', 'F') == 'T' - old_grid = self.get('old_grid', 'F') == 'T' - n = self.get('n', 0) - p = self.get('p', 0) - cyl_coord = self.get('cyl_coord', 'F') == 'T' - - self.prohibit(loops_x < 1, - "loops_x must be at least 1") - self.prohibit(loops_y < 1, - "loops_y must be at least 1") - self.prohibit(stretch_y and n == 0, - "stretch_y requires n > 0") - self.prohibit(stretch_z and p == 0, - "stretch_z requires p > 0") - self.prohibit(stretch_z and cyl_coord, - "stretch_z is not compatible with cylindrical coordinates") - - for direction in ['x', 'y', 'z']: - stretch = self.get(f'stretch_{direction}', 'F') == 'T' + loops_x = self.get("loops_x", 1) + loops_y = self.get("loops_y", 1) + stretch_y = self.get("stretch_y", "F") == "T" + stretch_z = self.get("stretch_z", "F") == "T" + old_grid = self.get("old_grid", "F") == "T" + n = self.get("n", 0) + p = self.get("p", 0) + cyl_coord = self.get("cyl_coord", "F") == "T" + + self.prohibit(loops_x < 1, "loops_x must be at least 1") + self.prohibit(loops_y < 1, "loops_y must be at least 1") + self.prohibit(stretch_y and n == 0, "stretch_y requires n > 0") + self.prohibit(stretch_z and p == 0, "stretch_z requires p > 0") + self.prohibit(stretch_z and cyl_coord, "stretch_z is not compatible with cylindrical coordinates") + + for direction in ["x", "y", "z"]: + stretch = self.get(f"stretch_{direction}", "F") == "T" if not stretch: continue - a = self.get(f'a_{direction}') - coord_a = self.get(f'{direction}_a') - coord_b = self.get(f'{direction}_b') - - self.prohibit(old_grid, - f"old_grid and stretch_{direction} are incompatible") - self.prohibit(a is None, - f"a_{direction} must be set with stretch_{direction} enabled") - self.prohibit(coord_a is None, - f"{direction}_a must be set with stretch_{direction} enabled") - self.prohibit(coord_b is None, - f"{direction}_b must be set with stretch_{direction} enabled") + a = self.get(f"a_{direction}") + coord_a = self.get(f"{direction}_a") + coord_b = self.get(f"{direction}_b") + + self.prohibit(old_grid, f"old_grid and stretch_{direction} are incompatible") + self.prohibit(a is None, f"a_{direction} must be set with stretch_{direction} enabled") + self.prohibit(coord_a is None, f"{direction}_a must be set with stretch_{direction} enabled") + self.prohibit(coord_b is None, f"{direction}_b must be set with stretch_{direction} enabled") if coord_a is not None and coord_b is not None: - self.prohibit(coord_a >= coord_b, - f"{direction}_a must be less than {direction}_b with stretch_{direction} enabled") + self.prohibit(coord_a >= coord_b, f"{direction}_a must be less than {direction}_b with stretch_{direction} enabled") def check_perturb_density(self): """Checks initial partial density perturbation constraints (pre-process)""" - perturb_flow = self.get('perturb_flow', 'F') == 'T' - perturb_flow_fluid = self.get('perturb_flow_fluid') - perturb_flow_mag = self.get('perturb_flow_mag') - perturb_sph = self.get('perturb_sph', 'F') == 'T' - perturb_sph_fluid = self.get('perturb_sph_fluid') - num_fluids = self.get('num_fluids') + perturb_flow = self.get("perturb_flow", "F") == "T" + perturb_flow_fluid = self.get("perturb_flow_fluid") + perturb_flow_mag = self.get("perturb_flow_mag") + perturb_sph = self.get("perturb_sph", "F") == "T" + perturb_sph_fluid = self.get("perturb_sph_fluid") + num_fluids = self.get("num_fluids") if perturb_flow: - self.prohibit(perturb_flow_fluid is None or perturb_flow_mag is None, - "perturb_flow_fluid and perturb_flow_mag must be set with perturb_flow = T") + self.prohibit(perturb_flow_fluid is None or perturb_flow_mag is None, "perturb_flow_fluid and perturb_flow_mag must be set with perturb_flow = T") else: - self.prohibit(perturb_flow_fluid is not None or perturb_flow_mag is not None, - "perturb_flow_fluid and perturb_flow_mag must not be set with perturb_flow = F") + self.prohibit(perturb_flow_fluid is not None or perturb_flow_mag is not None, "perturb_flow_fluid and perturb_flow_mag must not be set with perturb_flow = F") if num_fluids is not None and perturb_flow_fluid is not None: - self.prohibit(perturb_flow_fluid > num_fluids or perturb_flow_fluid < 0, - "perturb_flow_fluid must be between 0 and num_fluids") + self.prohibit(perturb_flow_fluid > num_fluids or perturb_flow_fluid < 0, "perturb_flow_fluid must be between 0 and num_fluids") if perturb_sph: - self.prohibit(perturb_sph_fluid is None, - "perturb_sph_fluid must be set with perturb_sph = T") + self.prohibit(perturb_sph_fluid is None, "perturb_sph_fluid must be set with perturb_sph = T") else: - self.prohibit(perturb_sph_fluid is not None, - "perturb_sph_fluid must not be set with perturb_sph = F") + self.prohibit(perturb_sph_fluid is not None, "perturb_sph_fluid must not be set with perturb_sph = F") if num_fluids is not None and perturb_sph_fluid is not None: - self.prohibit(perturb_sph_fluid > num_fluids or perturb_sph_fluid < 0, - "perturb_sph_fluid must be between 0 and num_fluids") + self.prohibit(perturb_sph_fluid > num_fluids or perturb_sph_fluid < 0, "perturb_sph_fluid must be between 0 and num_fluids") def check_chemistry(self): """Checks chemistry constraints (pre-process) - + Note: num_species is set automatically by Cantera at runtime when cantera_file is provided. No static validation is performed here - chemistry will fail at runtime if misconfigured. @@ -1606,26 +1279,23 @@ def check_chemistry(self): def check_misc_pre_process(self): """Checks miscellaneous pre-process constraints""" - mixlayer_vel_profile = self.get('mixlayer_vel_profile', 'F') == 'T' - mixlayer_perturb = self.get('mixlayer_perturb', 'F') == 'T' - elliptic_smoothing = self.get('elliptic_smoothing', 'F') == 'T' - elliptic_smoothing_iters = self.get('elliptic_smoothing_iters') - n = self.get('n', 0) - p = self.get('p', 0) - - self.prohibit(mixlayer_vel_profile and n == 0, - "mixlayer_vel_profile requires n > 0") - self.prohibit(mixlayer_perturb and p == 0, - "mixlayer_perturb requires p > 0") + mixlayer_vel_profile = self.get("mixlayer_vel_profile", "F") == "T" + mixlayer_perturb = self.get("mixlayer_perturb", "F") == "T" + elliptic_smoothing = self.get("elliptic_smoothing", "F") == "T" + elliptic_smoothing_iters = self.get("elliptic_smoothing_iters") + n = self.get("n", 0) + p = self.get("p", 0) + + self.prohibit(mixlayer_vel_profile and n == 0, "mixlayer_vel_profile requires n > 0") + self.prohibit(mixlayer_perturb and p == 0, "mixlayer_perturb requires p > 0") if elliptic_smoothing and elliptic_smoothing_iters is not None: - self.prohibit(elliptic_smoothing_iters < 1, - "elliptic_smoothing_iters must be positive") + self.prohibit(elliptic_smoothing_iters < 1, "elliptic_smoothing_iters must be positive") def _is_numeric(self, value) -> bool: """Check if value is numeric (not a string expression).""" return isinstance(value, (int, float)) and not isinstance(value, bool) - def check_patch_physics(self): # pylint: disable=too-many-locals,too-many-branches + def check_patch_physics(self): """Checks physics constraints on patch initial conditions (pre-process). Validates that initial conditions are physically meaningful: @@ -1637,17 +1307,17 @@ def check_patch_physics(self): # pylint: disable=too-many-locals,too-many-branc Note: String values (analytical expressions like "0.5*sin(x)") are evaluated at runtime by Fortran and cannot be validated here. """ - num_patches = self.get('num_patches', 0) - num_fluids = self.get('num_fluids', 1) - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - num_ibs = self.get('num_ibs', 0) or 0 # IBM (Immersed Boundary Method) + num_patches = self.get("num_patches", 0) + num_fluids = self.get("num_fluids", 1) + bubbles_euler = self.get("bubbles_euler", "F") == "T" + num_ibs = self.get("num_ibs", 0) or 0 # IBM (Immersed Boundary Method) if not self._is_numeric(num_patches) or num_patches <= 0: return for i in range(1, num_patches + 1): istr = str(i) - geometry = self.get(f'patch_icpp({i})%geometry') + geometry = self.get(f"patch_icpp({i})%geometry") # Skip if patch not defined if geometry is None: @@ -1656,19 +1326,17 @@ def check_patch_physics(self): # pylint: disable=too-many-locals,too-many-branc # Skip thermodynamic validation for special patches: # - alter_patch patches (modifications to other patches) # - hcid patches (hard-coded initial conditions computed at runtime) - hcid = self.get(f'patch_icpp({i})%hcid') - alter_patches = [self.get(f'patch_icpp({i})%alter_patch({j})') == 'T' - for j in range(1, num_patches + 1)] + hcid = self.get(f"patch_icpp({i})%hcid") + alter_patches = [self.get(f"patch_icpp({i})%alter_patch({j})") == "T" for j in range(1, num_patches + 1)] is_special = hcid is not None or any(alter_patches) # === THERMODYNAMICS === # Pressure must be positive for physical stability # (skip for special patches where values are computed differently) if not is_special: - pres = self.get(f'patch_icpp({i})%pres') + pres = self.get(f"patch_icpp({i})%pres") if pres is not None and self._is_numeric(pres): - self.prohibit(pres <= 0, - f"patch_icpp({istr})%pres must be positive (got {pres})") + self.prohibit(pres <= 0, f"patch_icpp({istr})%pres must be positive (got {pres})") # === FLUID PROPERTIES === # (skip for special patches where values are computed differently) @@ -1677,110 +1345,91 @@ def check_patch_physics(self): # pylint: disable=too-many-locals,too-many-branc jstr = str(j) # Volume fraction must be in [0, 1] (or non-negative for IBM cases) - alpha = self.get(f'patch_icpp({i})%alpha({j})') + alpha = self.get(f"patch_icpp({i})%alpha({j})") if alpha is not None and self._is_numeric(alpha): - self.prohibit(alpha < 0, - f"patch_icpp({istr})%alpha({jstr}) must be non-negative (got {alpha})") + self.prohibit(alpha < 0, f"patch_icpp({istr})%alpha({jstr}) must be non-negative (got {alpha})") # For non-IBM cases, alpha should be in [0, 1] if num_ibs == 0: - self.prohibit(alpha > 1, - f"patch_icpp({istr})%alpha({jstr}) must be <= 1 (got {alpha})") + self.prohibit(alpha > 1, f"patch_icpp({istr})%alpha({jstr}) must be <= 1 (got {alpha})") # Density (alpha_rho) must be non-negative # Note: alpha_rho = 0 is allowed for vacuum regions and numerical convenience - alpha_rho = self.get(f'patch_icpp({i})%alpha_rho({j})') + alpha_rho = self.get(f"patch_icpp({i})%alpha_rho({j})") if alpha_rho is not None and self._is_numeric(alpha_rho): - self.prohibit(alpha_rho < 0, - f"patch_icpp({istr})%alpha_rho({jstr}) must be non-negative (got {alpha_rho})") + self.prohibit(alpha_rho < 0, f"patch_icpp({istr})%alpha_rho({jstr}) must be non-negative (got {alpha_rho})") # === GEOMETRY === # Patch dimensions must be positive (except in cylindrical coords where # length_y/length_z can be sentinel values like -1000000.0) - length_x = self.get(f'patch_icpp({i})%length_x') - length_y = self.get(f'patch_icpp({i})%length_y') - length_z = self.get(f'patch_icpp({i})%length_z') - radius = self.get(f'patch_icpp({i})%radius') - cyl_coord = self.get('cyl_coord', 'F') == 'T' + length_x = self.get(f"patch_icpp({i})%length_x") + length_y = self.get(f"patch_icpp({i})%length_y") + length_z = self.get(f"patch_icpp({i})%length_z") + radius = self.get(f"patch_icpp({i})%radius") + cyl_coord = self.get("cyl_coord", "F") == "T" if length_x is not None and self._is_numeric(length_x): - self.prohibit(length_x <= 0, - f"patch_icpp({istr})%length_x must be positive (got {length_x})") + self.prohibit(length_x <= 0, f"patch_icpp({istr})%length_x must be positive (got {length_x})") # In cylindrical coordinates, length_y and length_z can be negative sentinel values if length_y is not None and self._is_numeric(length_y) and not cyl_coord: - self.prohibit(length_y <= 0, - f"patch_icpp({istr})%length_y must be positive (got {length_y})") + self.prohibit(length_y <= 0, f"patch_icpp({istr})%length_y must be positive (got {length_y})") if length_z is not None and self._is_numeric(length_z) and not cyl_coord: - self.prohibit(length_z <= 0, - f"patch_icpp({istr})%length_z must be positive (got {length_z})") + self.prohibit(length_z <= 0, f"patch_icpp({istr})%length_z must be positive (got {length_z})") if radius is not None and self._is_numeric(radius): - self.prohibit(radius <= 0, - f"patch_icpp({istr})%radius must be positive (got {radius})") + self.prohibit(radius <= 0, f"patch_icpp({istr})%radius must be positive (got {radius})") # === BUBBLES === # Bubble radius must be positive if bubbles_euler: - r0 = self.get(f'patch_icpp({i})%r0') + r0 = self.get(f"patch_icpp({i})%r0") if r0 is not None and self._is_numeric(r0): - self.prohibit(r0 <= 0, - f"patch_icpp({istr})%r0 must be positive (got {r0})") + self.prohibit(r0 <= 0, f"patch_icpp({istr})%r0 must be positive (got {r0})") - def check_bc_patches(self): # pylint: disable=too-many-branches,too-many-statements + 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 = self.get('num_bc_patches_max', 10) + num_bc_patches = self.get("num_bc_patches", 0) + num_bc_patches_max = self.get("num_bc_patches_max", 10) if num_bc_patches <= 0: return - self.prohibit(num_bc_patches > num_bc_patches_max, - f"num_bc_patches must be <= {num_bc_patches_max}") + self.prohibit(num_bc_patches > num_bc_patches_max, f"num_bc_patches must be <= {num_bc_patches_max}") for i in range(1, num_bc_patches + 1): - geometry = self.get(f'patch_bc({i})%geometry') - bc_type = self.get(f'patch_bc({i})%type') - direction = self.get(f'patch_bc({i})%dir') - radius = self.get(f'patch_bc({i})%radius') - centroid = [self.get(f'patch_bc({i})%centroid({j})') for j in range(1, 4)] - length = [self.get(f'patch_bc({i})%length({j})') for j in range(1, 4)] + geometry = self.get(f"patch_bc({i})%geometry") + bc_type = self.get(f"patch_bc({i})%type") + direction = self.get(f"patch_bc({i})%dir") + radius = self.get(f"patch_bc({i})%radius") + centroid = [self.get(f"patch_bc({i})%centroid({j})") for j in range(1, 4)] + length = [self.get(f"patch_bc({i})%length({j})") for j in range(1, 4)] if geometry is None: continue # Line Segment BC (geometry = 1) if geometry == 1: - self.prohibit(radius is not None, - f"Line Segment Patch {i} can't have radius defined") + self.prohibit(radius is not None, f"Line Segment Patch {i} can't have radius defined") if direction in [1, 2]: - self.prohibit(centroid[direction - 1] is not None or centroid[2] is not None, - f"Line Segment Patch {i} of Dir {direction} can't have centroid in Dir {direction} or 3") - self.prohibit(length[direction - 1] is not None or length[2] is not None, - f"Line Segment Patch {i} of Dir {direction} can't have length in Dir {direction} or 3") + self.prohibit(centroid[direction - 1] is not None or centroid[2] is not None, f"Line Segment Patch {i} of Dir {direction} can't have centroid in Dir {direction} or 3") + self.prohibit(length[direction - 1] is not None or length[2] is not None, f"Line Segment Patch {i} of Dir {direction} can't have length in Dir {direction} or 3") # Circle BC (geometry = 2) elif geometry == 2: - self.prohibit(radius is None, - f"Circle Patch {i} must have radius defined") - self.prohibit(any(length_val is not None for length_val in length), - f"Circle Patch {i} can't have lengths defined") + self.prohibit(radius is None, f"Circle Patch {i} must have radius defined") + self.prohibit(any(length_val is not None for length_val in length), f"Circle Patch {i} can't have lengths defined") if direction in [1, 2, 3]: - self.prohibit(centroid[direction - 1] is not None, - f"Circle Patch {i} of Dir {direction} can't have centroid in Dir {direction}") + self.prohibit(centroid[direction - 1] is not None, f"Circle Patch {i} of Dir {direction} can't have centroid in Dir {direction}") # Rectangle BC (geometry = 3) elif geometry == 3: - self.prohibit(radius is not None, - f"Rectangle Patch {i} can't have radius defined") + self.prohibit(radius is not None, f"Rectangle Patch {i} can't have radius defined") if direction in [1, 2, 3]: - self.prohibit(centroid[direction - 1] is not None, - f"Rectangle Patch {i} of Dir {direction} can't have centroid in Dir {direction}") - self.prohibit(length[direction - 1] is not None, - f"Rectangle Patch {i} of Dir {direction} can't have length in Dir {direction}") + self.prohibit(centroid[direction - 1] is not None, f"Rectangle Patch {i} of Dir {direction} can't have centroid in Dir {direction}") + self.prohibit(length[direction - 1] is not None, f"Rectangle Patch {i} of Dir {direction} can't have length in Dir {direction}") # Check for incompatible BC types if bc_type is not None: # BC types -14 to -4, -1 (periodic), or < -17 (dirichlet) are incompatible with patches - self.prohibit((-14 <= bc_type <= -4) or bc_type == -1 or bc_type < -17, - f"Incompatible BC type for boundary condition patch {i}") + self.prohibit((-14 <= bc_type <= -4) or bc_type == -1 or bc_type < -17, f"Incompatible BC type for boundary condition patch {i}") # =================================================================== # Post-Process Specific Checks @@ -1788,262 +1437,242 @@ def check_bc_patches(self): # pylint: disable=too-many-branches,too-many-statem def check_output_format(self): """Checks output format parameters (post-process)""" - format = self.get('format') - precision = self.get('precision') + format = self.get("format") + precision = self.get("precision") if format is not None: - self.prohibit(format not in [1, 2], - "format must be 1 or 2") + self.prohibit(format not in [1, 2], "format must be 1 or 2") if precision is not None: - self.prohibit(precision not in [1, 2], - "precision must be 1 or 2") - self.prohibit( - precision == 2 and CFG().single, - "precision = 2 (double output) requires MFC built without --single" - ) + self.prohibit(precision not in [1, 2], "precision must be 1 or 2") + self.prohibit(precision == 2 and CFG().single, "precision = 2 (double output) requires MFC built without --single") def check_vorticity(self): """Checks vorticity parameters (post-process)""" - omega_wrt = [self.get(f'omega_wrt({i})', 'F') == 'T' for i in range(1, 4)] - n = self.get('n', 0) - p = self.get('p', 0) - fd_order = self.get('fd_order') - - self.prohibit(n is not None and n == 0 and any(omega_wrt), - "omega_wrt requires n > 0 (at least 2D)") - self.prohibit(p is not None and p == 0 and (omega_wrt[0] or omega_wrt[1]), - "omega_wrt(1) and omega_wrt(2) require p > 0 (3D)") - self.prohibit(any(omega_wrt) and fd_order is None, - "fd_order must be set for omega_wrt") + omega_wrt = [self.get(f"omega_wrt({i})", "F") == "T" for i in range(1, 4)] + n = self.get("n", 0) + p = self.get("p", 0) + fd_order = self.get("fd_order") + + self.prohibit(n is not None and n == 0 and any(omega_wrt), "omega_wrt requires n > 0 (at least 2D)") + self.prohibit(p is not None and p == 0 and (omega_wrt[0] or omega_wrt[1]), "omega_wrt(1) and omega_wrt(2) require p > 0 (3D)") + self.prohibit(any(omega_wrt) and fd_order is None, "fd_order must be set for omega_wrt") def check_schlieren(self): """Checks schlieren parameters (post-process)""" - schlieren_wrt = self.get('schlieren_wrt', 'F') == 'T' - n = self.get('n', 0) - fd_order = self.get('fd_order') - num_fluids = self.get('num_fluids') + schlieren_wrt = self.get("schlieren_wrt", "F") == "T" + n = self.get("n", 0) + fd_order = self.get("fd_order") + num_fluids = self.get("num_fluids") - self.prohibit(n is not None and n == 0 and schlieren_wrt, - "schlieren_wrt requires n > 0 (at least 2D)") - self.prohibit(schlieren_wrt and fd_order is None, - "fd_order must be set for schlieren_wrt") + self.prohibit(n is not None and n == 0 and schlieren_wrt, "schlieren_wrt requires n > 0 (at least 2D)") + self.prohibit(schlieren_wrt and fd_order is None, "fd_order must be set for schlieren_wrt") if num_fluids is not None: for i in range(1, num_fluids + 1): - schlieren_alpha = self.get(f'schlieren_alpha({i})') + schlieren_alpha = self.get(f"schlieren_alpha({i})") if schlieren_alpha is not None: - self.prohibit(schlieren_alpha <= 0, - f"schlieren_alpha({i}) must be greater than zero") - self.prohibit(not schlieren_wrt, - f"schlieren_alpha({i}) should be set only with schlieren_wrt enabled") + self.prohibit(schlieren_alpha <= 0, f"schlieren_alpha({i}) must be greater than zero") + self.prohibit(not schlieren_wrt, f"schlieren_alpha({i}) should be set only with schlieren_wrt enabled") - def check_partial_domain(self): # pylint: disable=too-many-locals + def check_partial_domain(self): """Checks partial domain output constraints (post-process)""" - output_partial_domain = self.get('output_partial_domain', 'F') == 'T' + output_partial_domain = self.get("output_partial_domain", "F") == "T" if not output_partial_domain: return - format_val = self.get('format') - precision = self.get('precision') - flux_wrt = self.get('flux_wrt', 'F') == 'T' - heat_ratio_wrt = self.get('heat_ratio_wrt', 'F') == 'T' - pres_inf_wrt = self.get('pres_inf_wrt', 'F') == 'T' - c_wrt = self.get('c_wrt', 'F') == 'T' - schlieren_wrt = self.get('schlieren_wrt', 'F') == 'T' - qm_wrt = self.get('qm_wrt', 'F') == 'T' - liutex_wrt = self.get('liutex_wrt', 'F') == 'T' - ib = self.get('ib', 'F') == 'T' - omega_wrt = [self.get(f'omega_wrt({i})', 'F') == 'T' for i in range(1, 4)] - n = self.get('n', 0) - p = self.get('p', 0) - - self.prohibit(format_val == 1, - "output_partial_domain requires format = 2") - self.prohibit(precision == 1, - "output_partial_domain requires precision = 2") - self.prohibit(flux_wrt or heat_ratio_wrt or pres_inf_wrt or c_wrt or - schlieren_wrt or qm_wrt or liutex_wrt or ib or any(omega_wrt), - "output_partial_domain is incompatible with certain output flags") - - x_output_beg = self.get('x_output%beg') - x_output_end = self.get('x_output%end') - self.prohibit(x_output_beg is None or x_output_end is None, - "x_output%beg and x_output%end must be set for output_partial_domain") + format_val = self.get("format") + precision = self.get("precision") + flux_wrt = self.get("flux_wrt", "F") == "T" + heat_ratio_wrt = self.get("heat_ratio_wrt", "F") == "T" + pres_inf_wrt = self.get("pres_inf_wrt", "F") == "T" + c_wrt = self.get("c_wrt", "F") == "T" + schlieren_wrt = self.get("schlieren_wrt", "F") == "T" + qm_wrt = self.get("qm_wrt", "F") == "T" + liutex_wrt = self.get("liutex_wrt", "F") == "T" + ib = self.get("ib", "F") == "T" + omega_wrt = [self.get(f"omega_wrt({i})", "F") == "T" for i in range(1, 4)] + n = self.get("n", 0) + p = self.get("p", 0) + + self.prohibit(format_val == 1, "output_partial_domain requires format = 2") + self.prohibit(precision == 1, "output_partial_domain requires precision = 2") + self.prohibit( + flux_wrt or heat_ratio_wrt or pres_inf_wrt or c_wrt or schlieren_wrt or qm_wrt or liutex_wrt or ib or any(omega_wrt), "output_partial_domain is incompatible with certain output flags" + ) + + x_output_beg = self.get("x_output%beg") + x_output_end = self.get("x_output%end") + self.prohibit(x_output_beg is None or x_output_end is None, "x_output%beg and x_output%end must be set for output_partial_domain") if n is not None and n != 0: - y_output_beg = self.get('y_output%beg') - y_output_end = self.get('y_output%end') - self.prohibit(y_output_beg is None or y_output_end is None, - "y_output%beg and y_output%end must be set for output_partial_domain with n > 0") + y_output_beg = self.get("y_output%beg") + y_output_end = self.get("y_output%end") + self.prohibit(y_output_beg is None or y_output_end is None, "y_output%beg and y_output%end must be set for output_partial_domain with n > 0") if p is not None and p != 0: - z_output_beg = self.get('z_output%beg') - z_output_end = self.get('z_output%end') - self.prohibit(z_output_beg is None or z_output_end is None, - "z_output%beg and z_output%end must be set for output_partial_domain with p > 0") - - for direction in ['x', 'y', 'z']: - beg = self.get(f'{direction}_output%beg') - end = self.get(f'{direction}_output%end') + z_output_beg = self.get("z_output%beg") + z_output_end = self.get("z_output%end") + self.prohibit(z_output_beg is None or z_output_end is None, "z_output%beg and z_output%end must be set for output_partial_domain with p > 0") + + for direction in ["x", "y", "z"]: + beg = self.get(f"{direction}_output%beg") + end = self.get(f"{direction}_output%end") if beg is not None and end is not None: - self.prohibit(beg > end, - f"{direction}_output%beg must be <= {direction}_output%end") + self.prohibit(beg > end, f"{direction}_output%beg must be <= {direction}_output%end") def check_partial_density(self): """Checks partial density output constraints (post-process)""" - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") if num_fluids is None: return for i in range(1, num_fluids + 1): - alpha_rho_wrt = self.get(f'alpha_rho_wrt({i})', 'F') == 'T' + alpha_rho_wrt = self.get(f"alpha_rho_wrt({i})", "F") == "T" if alpha_rho_wrt: - self.prohibit(model_eqns == 1, - f"alpha_rho_wrt({i}) is not supported for model_eqns = 1") + self.prohibit(model_eqns == 1, f"alpha_rho_wrt({i}) is not supported for model_eqns = 1") def check_momentum_post(self): """Checks momentum output constraints (post-process)""" - mom_wrt = [self.get(f'mom_wrt({i})', 'F') == 'T' for i in range(1, 4)] - n = self.get('n', 0) - p = self.get('p', 0) + mom_wrt = [self.get(f"mom_wrt({i})", "F") == "T" for i in range(1, 4)] + n = self.get("n", 0) + p = self.get("p", 0) - self.prohibit(n == 0 and mom_wrt[1], - "mom_wrt(2) requires n > 0") - self.prohibit(p == 0 and mom_wrt[2], - "mom_wrt(3) requires p > 0") + self.prohibit(n == 0 and mom_wrt[1], "mom_wrt(2) requires n > 0") + self.prohibit(p == 0 and mom_wrt[2], "mom_wrt(3) requires p > 0") def check_velocity_post(self): """Checks velocity output constraints (post-process)""" - vel_wrt = [self.get(f'vel_wrt({i})', 'F') == 'T' for i in range(1, 4)] - n = self.get('n', 0) - p = self.get('p', 0) + vel_wrt = [self.get(f"vel_wrt({i})", "F") == "T" for i in range(1, 4)] + n = self.get("n", 0) + p = self.get("p", 0) - self.prohibit(n == 0 and vel_wrt[1], - "vel_wrt(2) requires n > 0") - self.prohibit(p == 0 and vel_wrt[2], - "vel_wrt(3) requires p > 0") + self.prohibit(n == 0 and vel_wrt[1], "vel_wrt(2) requires n > 0") + self.prohibit(p == 0 and vel_wrt[2], "vel_wrt(3) requires p > 0") def check_flux_limiter(self): """Checks flux limiter constraints (post-process)""" - flux_wrt = [self.get(f'flux_wrt({i})', 'F') == 'T' for i in range(1, 4)] - flux_lim = self.get('flux_lim') - n = self.get('n', 0) - p = self.get('p', 0) + flux_wrt = [self.get(f"flux_wrt({i})", "F") == "T" for i in range(1, 4)] + flux_lim = self.get("flux_lim") + n = self.get("n", 0) + p = self.get("p", 0) - self.prohibit(n == 0 and flux_wrt[1], - "flux_wrt(2) requires n > 0") - self.prohibit(p == 0 and flux_wrt[2], - "flux_wrt(3) requires p > 0") + self.prohibit(n == 0 and flux_wrt[1], "flux_wrt(2) requires n > 0") + self.prohibit(p == 0 and flux_wrt[2], "flux_wrt(3) requires p > 0") if flux_lim is not None: - self.prohibit(flux_lim not in [1, 2, 3, 4, 5, 6, 7], - "flux_lim must be between 1 and 7") + self.prohibit(flux_lim not in [1, 2, 3, 4, 5, 6, 7], "flux_lim must be between 1 and 7") def check_volume_fraction(self): """Checks volume fraction output constraints (post-process)""" - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") if num_fluids is None: return for i in range(1, num_fluids + 1): - alpha_wrt = self.get(f'alpha_wrt({i})', 'F') == 'T' + alpha_wrt = self.get(f"alpha_wrt({i})", "F") == "T" if alpha_wrt: - self.prohibit(model_eqns == 1, - f"alpha_wrt({i}) is not supported for model_eqns = 1") + self.prohibit(model_eqns == 1, f"alpha_wrt({i}) is not supported for model_eqns = 1") def check_fft(self): """Checks FFT output constraints (post-process)""" - fft_wrt = self.get('fft_wrt', 'F') == 'T' + fft_wrt = self.get("fft_wrt", "F") == "T" if not fft_wrt: return - n = self.get('n', 0) - p = self.get('p', 0) - cyl_coord = self.get('cyl_coord', 'F') == 'T' - m_glb = self.get('m_glb') - n_glb = self.get('n_glb') - p_glb = self.get('p_glb') + n = self.get("n", 0) + p = self.get("p", 0) + cyl_coord = self.get("cyl_coord", "F") == "T" + m_glb = self.get("m_glb") + n_glb = self.get("n_glb") + p_glb = self.get("p_glb") - self.prohibit(n == 0 or p == 0, - "FFT WRT only supported in 3D") - self.prohibit(cyl_coord, - "FFT WRT incompatible with cylindrical coordinates") + self.prohibit(n == 0 or p == 0, "FFT WRT only supported in 3D") + self.prohibit(cyl_coord, "FFT WRT incompatible with cylindrical coordinates") if m_glb is not None and n_glb is not None and p_glb is not None: - self.prohibit((m_glb + 1) % 2 != 0 or (n_glb + 1) % 2 != 0 or (p_glb + 1) % 2 != 0, - "FFT WRT requires global dimensions divisible by 2") + self.prohibit((m_glb + 1) % 2 != 0 or (n_glb + 1) % 2 != 0 or (p_glb + 1) % 2 != 0, "FFT WRT requires global dimensions divisible by 2") # BC checks: all boundaries must be periodic (-1) - for direction in ['x', 'y', 'z']: - for end in ['beg', 'end']: - bc_val = self.get(f'bc_{direction}%{end}') + for direction in ["x", "y", "z"]: + for end in ["beg", "end"]: + bc_val = self.get(f"bc_{direction}%{end}") if bc_val is not None: - self.prohibit(bc_val != -1, - "FFT WRT requires periodic BCs (all BCs should be -1)") + self.prohibit(bc_val != -1, "FFT WRT requires periodic BCs (all BCs should be -1)") def check_qm(self): """Checks Q-criterion output constraints (post-process)""" - qm_wrt = self.get('qm_wrt', 'F') == 'T' - n = self.get('n', 0) + qm_wrt = self.get("qm_wrt", "F") == "T" + n = self.get("n", 0) - self.prohibit(n == 0 and qm_wrt, - "qm_wrt requires n > 0 (at least 2D)") + self.prohibit(n == 0 and qm_wrt, "qm_wrt requires n > 0 (at least 2D)") def check_liutex_post(self): """Checks liutex output constraints (post-process)""" - liutex_wrt = self.get('liutex_wrt', 'F') == 'T' - n = self.get('n', 0) + liutex_wrt = self.get("liutex_wrt", "F") == "T" + n = self.get("n", 0) - self.prohibit(n == 0 and liutex_wrt, - "liutex_wrt requires n > 0 (at least 2D)") + self.prohibit(n == 0 and liutex_wrt, "liutex_wrt requires n > 0 (at least 2D)") def check_surface_tension_post(self): """Checks surface tension output constraints (post-process)""" - cf_wrt = self.get('cf_wrt', 'F') == 'T' - surface_tension = self.get('surface_tension', 'F') == 'T' + cf_wrt = self.get("cf_wrt", "F") == "T" + surface_tension = self.get("surface_tension", "F") == "T" - self.prohibit(cf_wrt and not surface_tension, - "cf_wrt can only be enabled if surface_tension is enabled") + self.prohibit(cf_wrt and not surface_tension, "cf_wrt can only be enabled if surface_tension is enabled") - def check_no_flow_variables(self): # pylint: disable=too-many-locals + def check_no_flow_variables(self): """Checks that at least one flow variable is selected (post-process)""" - rho_wrt = self.get('rho_wrt', 'F') == 'T' - E_wrt = self.get('E_wrt', 'F') == 'T' - pres_wrt = self.get('pres_wrt', 'F') == 'T' - gamma_wrt = self.get('gamma_wrt', 'F') == 'T' - heat_ratio_wrt = self.get('heat_ratio_wrt', 'F') == 'T' - pi_inf_wrt = self.get('pi_inf_wrt', 'F') == 'T' - pres_inf_wrt = self.get('pres_inf_wrt', 'F') == 'T' - cons_vars_wrt = self.get('cons_vars_wrt', 'F') == 'T' - prim_vars_wrt = self.get('prim_vars_wrt', 'F') == 'T' - c_wrt = self.get('c_wrt', 'F') == 'T' - schlieren_wrt = self.get('schlieren_wrt', 'F') == 'T' + rho_wrt = self.get("rho_wrt", "F") == "T" + E_wrt = self.get("E_wrt", "F") == "T" + pres_wrt = self.get("pres_wrt", "F") == "T" + gamma_wrt = self.get("gamma_wrt", "F") == "T" + heat_ratio_wrt = self.get("heat_ratio_wrt", "F") == "T" + pi_inf_wrt = self.get("pi_inf_wrt", "F") == "T" + pres_inf_wrt = self.get("pres_inf_wrt", "F") == "T" + cons_vars_wrt = self.get("cons_vars_wrt", "F") == "T" + prim_vars_wrt = self.get("prim_vars_wrt", "F") == "T" + c_wrt = self.get("c_wrt", "F") == "T" + schlieren_wrt = self.get("schlieren_wrt", "F") == "T" # Check array variables - num_fluids = self.get('num_fluids') + num_fluids = self.get("num_fluids") if num_fluids is None: num_fluids = 1 - alpha_rho_wrt_any = any(self.get(f'alpha_rho_wrt({i})', 'F') == 'T' for i in range(1, num_fluids + 1)) - mom_wrt_any = any(self.get(f'mom_wrt({i})', 'F') == 'T' for i in range(1, 4)) - vel_wrt_any = any(self.get(f'vel_wrt({i})', 'F') == 'T' for i in range(1, 4)) - flux_wrt_any = any(self.get(f'flux_wrt({i})', 'F') == 'T' for i in range(1, 4)) - alpha_wrt_any = any(self.get(f'alpha_wrt({i})', 'F') == 'T' for i in range(1, num_fluids + 1)) - omega_wrt_any = any(self.get(f'omega_wrt({i})', 'F') == 'T' for i in range(1, 4)) - - has_output = (rho_wrt or E_wrt or pres_wrt or gamma_wrt or heat_ratio_wrt or - pi_inf_wrt or pres_inf_wrt or cons_vars_wrt or prim_vars_wrt or - c_wrt or schlieren_wrt or alpha_rho_wrt_any or mom_wrt_any or - vel_wrt_any or flux_wrt_any or alpha_wrt_any or omega_wrt_any) + alpha_rho_wrt_any = any(self.get(f"alpha_rho_wrt({i})", "F") == "T" for i in range(1, num_fluids + 1)) + mom_wrt_any = any(self.get(f"mom_wrt({i})", "F") == "T" for i in range(1, 4)) + vel_wrt_any = any(self.get(f"vel_wrt({i})", "F") == "T" for i in range(1, 4)) + flux_wrt_any = any(self.get(f"flux_wrt({i})", "F") == "T" for i in range(1, 4)) + alpha_wrt_any = any(self.get(f"alpha_wrt({i})", "F") == "T" for i in range(1, num_fluids + 1)) + omega_wrt_any = any(self.get(f"omega_wrt({i})", "F") == "T" for i in range(1, 4)) + + has_output = ( + rho_wrt + or E_wrt + or pres_wrt + or gamma_wrt + or heat_ratio_wrt + or pi_inf_wrt + or pres_inf_wrt + or cons_vars_wrt + or prim_vars_wrt + or c_wrt + or schlieren_wrt + or alpha_rho_wrt_any + or mom_wrt_any + or vel_wrt_any + or flux_wrt_any + or alpha_wrt_any + or omega_wrt_any + ) - self.prohibit(not has_output, - "None of the flow variables have been selected for post-process") + self.prohibit(not has_output, "None of the flow variables have been selected for post-process") # =================================================================== # Cross-Cutting Physics Checks @@ -2051,29 +1680,26 @@ def check_no_flow_variables(self): # pylint: disable=too-many-locals def check_domain_bounds(self): """Checks that domain end > domain begin for each active dimension""" - x_beg = self.get('x_domain%beg') - x_end = self.get('x_domain%end') + x_beg = self.get("x_domain%beg") + x_end = self.get("x_domain%end") if self._is_numeric(x_beg) and self._is_numeric(x_end): - self.prohibit(x_end <= x_beg, - f"x_domain%end ({x_end}) must be greater than x_domain%beg ({x_beg})") + self.prohibit(x_end <= x_beg, f"x_domain%end ({x_end}) must be greater than x_domain%beg ({x_beg})") - n = self.get('n', 0) + n = self.get("n", 0) if self._is_numeric(n) and n > 0: - y_beg = self.get('y_domain%beg') - y_end = self.get('y_domain%end') + y_beg = self.get("y_domain%beg") + y_end = self.get("y_domain%end") if self._is_numeric(y_beg) and self._is_numeric(y_end): - self.prohibit(y_end <= y_beg, - f"y_domain%end ({y_end}) must be greater than y_domain%beg ({y_beg})") + self.prohibit(y_end <= y_beg, f"y_domain%end ({y_end}) must be greater than y_domain%beg ({y_beg})") - p = self.get('p', 0) + p = self.get("p", 0) if self._is_numeric(p) and p > 0: - z_beg = self.get('z_domain%beg') - z_end = self.get('z_domain%end') + z_beg = self.get("z_domain%beg") + z_end = self.get("z_domain%end") if self._is_numeric(z_beg) and self._is_numeric(z_end): - self.prohibit(z_end <= z_beg, - f"z_domain%end ({z_end}) must be greater than z_domain%beg ({z_beg})") + self.prohibit(z_end <= z_beg, f"z_domain%end ({z_end}) must be greater than z_domain%beg ({z_beg})") - def check_volume_fraction_sum(self): # pylint: disable=too-many-locals + def check_volume_fraction_sum(self): """Warns if volume fractions do not sum to 1 for multi-component models. For model_eqns in [2, 3, 4], the mixture constraint sum(alpha_j) = 1 @@ -2082,14 +1708,14 @@ def check_volume_fraction_sum(self): # pylint: disable=too-many-locals the void fraction, not a partition of unity), and bubbles_lagrange cases (where the Lagrangian phase is not tracked on the Euler grid). """ - model_eqns = self.get('model_eqns') + model_eqns = self.get("model_eqns") if model_eqns not in [2, 3, 4]: return - num_patches = self.get('num_patches', 0) - num_fluids = self.get('num_fluids', 1) - bubbles_euler = self.get('bubbles_euler', 'F') == 'T' - bubbles_lagrange = self.get('bubbles_lagrange', 'F') == 'T' + num_patches = self.get("num_patches", 0) + num_fluids = self.get("num_fluids", 1) + bubbles_euler = self.get("bubbles_euler", "F") == "T" + bubbles_lagrange = self.get("bubbles_lagrange", "F") == "T" # For bubbles_euler with single fluid, alpha is the void fraction # and does not need to sum to 1 @@ -2103,7 +1729,7 @@ def check_volume_fraction_sum(self): # pylint: disable=too-many-locals # IBM cases use alpha as a level-set indicator, not a physical # volume fraction, so the sum-to-1 constraint does not apply - num_ibs = self.get('num_ibs', 0) or 0 + num_ibs = self.get("num_ibs", 0) or 0 if num_ibs > 0: return @@ -2111,16 +1737,15 @@ def check_volume_fraction_sum(self): # pylint: disable=too-many-locals return for i in range(1, num_patches + 1): - geometry = self.get(f'patch_icpp({i})%geometry') + geometry = self.get(f"patch_icpp({i})%geometry") if geometry is None: continue # Skip special patches - hcid = self.get(f'patch_icpp({i})%hcid') + hcid = self.get(f"patch_icpp({i})%hcid") if hcid is not None: continue - alter_patches = [self.get(f'patch_icpp({i})%alter_patch({j})') == 'T' - for j in range(1, num_patches + 1)] + alter_patches = [self.get(f"patch_icpp({i})%alter_patch({j})") == "T" for j in range(1, num_patches + 1)] if any(alter_patches): continue @@ -2128,7 +1753,7 @@ def check_volume_fraction_sum(self): # pylint: disable=too-many-locals alphas = [] has_expression = False for j in range(1, num_fluids + 1): - alpha = self.get(f'patch_icpp({i})%alpha({j})') + alpha = self.get(f"patch_icpp({i})%alpha({j})") if alpha is None: has_expression = True break @@ -2141,8 +1766,7 @@ def check_volume_fraction_sum(self): # pylint: disable=too-many-locals continue alpha_sum = sum(alphas) - self.warn(abs(alpha_sum - 1.0) > 1e-6, - f"patch_icpp({i}): volume fractions sum to {alpha_sum:.8g}, expected 1.0") + self.warn(abs(alpha_sum - 1.0) > 1e-6, f"patch_icpp({i}): volume fractions sum to {alpha_sum:.8g}, expected 1.0") def check_alpha_rho_consistency(self): """Warns about inconsistent alpha/alpha_rho pairs. @@ -2151,43 +1775,38 @@ def check_alpha_rho_consistency(self): - alpha(j) = 0 but alpha_rho(j) != 0 (density in absent phase) - alpha(j) > 0 but alpha_rho(j) = 0 (present phase has zero density) """ - num_patches = self.get('num_patches', 0) - num_fluids = self.get('num_fluids', 1) + num_patches = self.get("num_patches", 0) + num_fluids = self.get("num_fluids", 1) if not self._is_numeric(num_patches) or num_patches <= 0 or not self._is_numeric(num_fluids): return for i in range(1, num_patches + 1): - geometry = self.get(f'patch_icpp({i})%geometry') + geometry = self.get(f"patch_icpp({i})%geometry") if geometry is None: continue # Skip special patches - hcid = self.get(f'patch_icpp({i})%hcid') + hcid = self.get(f"patch_icpp({i})%hcid") if hcid is not None: continue - alter_patches = [self.get(f'patch_icpp({i})%alter_patch({j})') == 'T' - for j in range(1, num_patches + 1)] + alter_patches = [self.get(f"patch_icpp({i})%alter_patch({j})") == "T" for j in range(1, num_patches + 1)] if any(alter_patches): continue for j in range(1, num_fluids + 1): - alpha = self.get(f'patch_icpp({i})%alpha({j})') - alpha_rho = self.get(f'patch_icpp({i})%alpha_rho({j})') + alpha = self.get(f"patch_icpp({i})%alpha({j})") + alpha_rho = self.get(f"patch_icpp({i})%alpha_rho({j})") if alpha is None or alpha_rho is None: continue if not self._is_numeric(alpha) or not self._is_numeric(alpha_rho): continue - self.warn(alpha == 0 and alpha_rho != 0, - f"patch_icpp({i}): alpha({j}) = 0 but alpha_rho({j}) = {alpha_rho} " - f"(density in absent phase)") - self.warn(alpha > 1e-10 and alpha_rho == 0, - f"patch_icpp({i}): alpha({j}) = {alpha} but alpha_rho({j}) = 0 " - f"(present phase has zero density)") + self.warn(alpha == 0 and alpha_rho != 0, f"patch_icpp({i}): alpha({j}) = 0 but alpha_rho({j}) = {alpha_rho} (density in absent phase)") + self.warn(alpha > 1e-10 and alpha_rho == 0, f"patch_icpp({i}): alpha({j}) = {alpha} but alpha_rho({j}) = 0 (present phase has zero density)") - def check_patch_within_domain(self): # pylint: disable=too-many-locals + def check_patch_within_domain(self): """Checks that centroid+length patches are not entirely outside the domain. Only applies to geometry types whose bounding box is fully determined @@ -2195,36 +1814,31 @@ def check_patch_within_domain(self): # pylint: disable=too-many-locals Skipped when grid stretching is active because the physical domain extents are transformed and the domain bounds are not directly comparable. """ - num_patches = self.get('num_patches', 0) + num_patches = self.get("num_patches", 0) if not self._is_numeric(num_patches) or num_patches <= 0: return # Skip when any grid stretching is active — domain bounds don't map # directly to physical coordinates in stretched grids - if (self.get('stretch_x', 'F') == 'T' or - self.get('stretch_y', 'F') == 'T' or - self.get('stretch_z', 'F') == 'T'): + if self.get("stretch_x", "F") == "T" or self.get("stretch_y", "F") == "T" or self.get("stretch_z", "F") == "T": return - x_beg = self.get('x_domain%beg') - x_end = self.get('x_domain%end') - n = self.get('n', 0) - p = self.get('p', 0) - y_beg = self.get('y_domain%beg') if self._is_numeric(n) and n > 0 else None - y_end = self.get('y_domain%end') if self._is_numeric(n) and n > 0 else None - z_beg = self.get('z_domain%beg') if self._is_numeric(p) and p > 0 else None - z_end = self.get('z_domain%end') if self._is_numeric(p) and p > 0 else None + x_beg = self.get("x_domain%beg") + x_end = self.get("x_domain%end") + n = self.get("n", 0) + p = self.get("p", 0) + y_beg = self.get("y_domain%beg") if self._is_numeric(n) and n > 0 else None + y_end = self.get("y_domain%end") if self._is_numeric(n) and n > 0 else None + z_beg = self.get("z_domain%beg") if self._is_numeric(p) and p > 0 else None + z_end = self.get("z_domain%end") if self._is_numeric(p) and p > 0 else None # Pre-check domain bounds are numeric (could be analytical expressions) - x_bounds_ok = (x_beg is not None and x_end is not None - and self._is_numeric(x_beg) and self._is_numeric(x_end)) - y_bounds_ok = (y_beg is not None and y_end is not None - and self._is_numeric(y_beg) and self._is_numeric(y_end)) - z_bounds_ok = (z_beg is not None and z_end is not None - and self._is_numeric(z_beg) and self._is_numeric(z_end)) + x_bounds_ok = x_beg is not None and x_end is not None and self._is_numeric(x_beg) and self._is_numeric(x_end) + y_bounds_ok = y_beg is not None and y_end is not None and self._is_numeric(y_beg) and self._is_numeric(y_end) + z_bounds_ok = z_beg is not None and z_end is not None and self._is_numeric(z_beg) and self._is_numeric(z_end) for i in range(1, num_patches + 1): - geometry = self.get(f'patch_icpp({i})%geometry') + geometry = self.get(f"patch_icpp({i})%geometry") if geometry is None: continue @@ -2233,39 +1847,30 @@ def check_patch_within_domain(self): # pylint: disable=too-many-locals if geometry not in [1, 3, 9]: continue - xc = self.get(f'patch_icpp({i})%x_centroid') - lx = self.get(f'patch_icpp({i})%length_x') + xc = self.get(f"patch_icpp({i})%x_centroid") + lx = self.get(f"patch_icpp({i})%length_x") has_x = xc is not None and lx is not None - if (has_x and x_bounds_ok - and self._is_numeric(xc) and self._is_numeric(lx)): + if has_x and x_bounds_ok and self._is_numeric(xc) and self._is_numeric(lx): patch_x_lo = xc - lx / 2.0 patch_x_hi = xc + lx / 2.0 - self.prohibit(patch_x_hi < x_beg or patch_x_lo > x_end, - f"patch_icpp({i}): x-extent [{patch_x_lo}, {patch_x_hi}] " - f"is entirely outside domain [{x_beg}, {x_end}]") + self.prohibit(patch_x_hi < x_beg or patch_x_lo > x_end, f"patch_icpp({i}): x-extent [{patch_x_lo}, {patch_x_hi}] is entirely outside domain [{x_beg}, {x_end}]") if geometry in [3, 9] and y_bounds_ok: - yc = self.get(f'patch_icpp({i})%y_centroid') - ly = self.get(f'patch_icpp({i})%length_y') - if (yc is not None and ly is not None - and self._is_numeric(yc) and self._is_numeric(ly)): + yc = self.get(f"patch_icpp({i})%y_centroid") + ly = self.get(f"patch_icpp({i})%length_y") + if yc is not None and ly is not None and self._is_numeric(yc) and self._is_numeric(ly): patch_y_lo = yc - ly / 2.0 patch_y_hi = yc + ly / 2.0 - self.prohibit(patch_y_hi < y_beg or patch_y_lo > y_end, - f"patch_icpp({i}): y-extent [{patch_y_lo}, {patch_y_hi}] " - f"is entirely outside domain [{y_beg}, {y_end}]") + self.prohibit(patch_y_hi < y_beg or patch_y_lo > y_end, f"patch_icpp({i}): y-extent [{patch_y_lo}, {patch_y_hi}] is entirely outside domain [{y_beg}, {y_end}]") if geometry == 9 and z_bounds_ok: - zc = self.get(f'patch_icpp({i})%z_centroid') - lz = self.get(f'patch_icpp({i})%length_z') - if (zc is not None and lz is not None - and self._is_numeric(zc) and self._is_numeric(lz)): + zc = self.get(f"patch_icpp({i})%z_centroid") + lz = self.get(f"patch_icpp({i})%length_z") + if zc is not None and lz is not None and self._is_numeric(zc) and self._is_numeric(lz): patch_z_lo = zc - lz / 2.0 patch_z_hi = zc + lz / 2.0 - self.prohibit(patch_z_hi < z_beg or patch_z_lo > z_end, - f"patch_icpp({i}): z-extent [{patch_z_lo}, {patch_z_hi}] " - f"is entirely outside domain [{z_beg}, {z_end}]") + self.prohibit(patch_z_hi < z_beg or patch_z_lo > z_end, f"patch_icpp({i}): z-extent [{patch_z_lo}, {patch_z_hi}] is entirely outside domain [{z_beg}, {z_end}]") def check_eos_parameter_sanity(self): """Warns if EOS gamma parameter looks like raw physical gamma. @@ -2277,26 +1882,22 @@ def check_eos_parameter_sanity(self): - gamma < 0.1 implies physical gamma > 11 (unusual) - gamma > 1000 implies physical gamma ≈ 1.001 (unusual) """ - num_fluids = self.get('num_fluids') - model_eqns = self.get('model_eqns') + num_fluids = self.get("num_fluids") + model_eqns = self.get("model_eqns") if not self._is_numeric(num_fluids) or model_eqns == 1: return for i in range(1, int(num_fluids) + 1): - gamma = self.get(f'fluid_pp({i})%gamma') + gamma = self.get(f"fluid_pp({i})%gamma") if gamma is None or not self._is_numeric(gamma) or gamma <= 0: continue # gamma = 1/(physical_gamma - 1), so physical_gamma = 1/gamma + 1 physical_gamma = 1.0 / gamma + 1.0 - self.warn(gamma < 0.1, - f"fluid_pp({i})%gamma = {gamma} implies physical gamma = {physical_gamma:.2f} " - f"(unusually high). MFC uses the transformed parameter Gamma = 1/(gamma-1)") - self.warn(gamma > 1000, - f"fluid_pp({i})%gamma = {gamma} implies physical gamma = {physical_gamma:.6f} " - f"(very close to 1). Did you enter the physical gamma instead of 1/(gamma-1)?") + self.warn(gamma < 0.1, f"fluid_pp({i})%gamma = {gamma} implies physical gamma = {physical_gamma:.2f} (unusually high). MFC uses the transformed parameter Gamma = 1/(gamma-1)") + self.warn(gamma > 1000, f"fluid_pp({i})%gamma = {gamma} implies physical gamma = {physical_gamma:.6f} (very close to 1). Did you enter the physical gamma instead of 1/(gamma-1)?") def check_velocity_components(self): """Checks that velocity components are not set in inactive dimensions. @@ -2306,10 +1907,10 @@ def check_velocity_components(self): physically meaningful even in 1D (they carry transverse momentum coupled to the magnetic field). """ - n = self.get('n', 0) - p = self.get('p', 0) - num_patches = self.get('num_patches', 0) - mhd = self.get('mhd', 'F') == 'T' + n = self.get("n", 0) + p = self.get("p", 0) + num_patches = self.get("num_patches", 0) + mhd = self.get("mhd", "F") == "T" if not self._is_numeric(num_patches) or num_patches <= 0: return @@ -2322,21 +1923,19 @@ def check_velocity_components(self): p_is_2d = self._is_numeric(p) and p == 0 for i in range(1, num_patches + 1): - geometry = self.get(f'patch_icpp({i})%geometry') + geometry = self.get(f"patch_icpp({i})%geometry") if geometry is None: continue if n_is_1d: - vel2 = self.get(f'patch_icpp({i})%vel(2)') + vel2 = self.get(f"patch_icpp({i})%vel(2)") if vel2 is not None and self._is_numeric(vel2): - self.prohibit(vel2 != 0, - f"patch_icpp({i})%vel(2) = {vel2} but n = 0 (1D simulation)") + self.prohibit(vel2 != 0, f"patch_icpp({i})%vel(2) = {vel2} but n = 0 (1D simulation)") if p_is_2d: - vel3 = self.get(f'patch_icpp({i})%vel(3)') + vel3 = self.get(f"patch_icpp({i})%vel(3)") if vel3 is not None and self._is_numeric(vel3): - self.prohibit(vel3 != 0, - f"patch_icpp({i})%vel(3) = {vel3} but p = 0 (1D/2D simulation)") + self.prohibit(vel3 != 0, f"patch_icpp({i})%vel(3) = {vel3} but p = 0 (1D/2D simulation)") # =================================================================== # Build-Flag Compatibility Checks @@ -2349,21 +1948,14 @@ def check_build_flags(self): MFC binaries were compiled (--mpi/--no-mpi, --single, etc.) before any binary is invoked. """ - parallel_io = self.get('parallel_io', 'F') == 'T' - self.prohibit( - parallel_io and not CFG().mpi, - "parallel_io = T requires MFC built with --mpi" - ) + parallel_io = self.get("parallel_io", "F") == "T" + self.prohibit(parallel_io and not CFG().mpi, "parallel_io = T requires MFC built with --mpi") def check_geometry_precision_simulation(self): """Checks that 3D cylindrical geometry is not used with --single builds.""" - cyl_coord = self.get('cyl_coord', 'F') == 'T' - p = self.get('p', 0) - self.prohibit( - CFG().single and cyl_coord and p > 0, - "Fully 3D cylindrical geometry (cyl_coord = T, p > 0) is not supported " - "in single precision (--single)" - ) + cyl_coord = self.get("cyl_coord", "F") == "T" + p = self.get("p", 0) + self.prohibit(CFG().single and cyl_coord and p > 0, "Fully 3D cylindrical geometry (cyl_coord = T, p > 0) is not supported in single precision (--single)") # =================================================================== # Main Validation Entry Points @@ -2452,7 +2044,7 @@ def validate_post_process(self): self.check_surface_tension_post() self.check_no_flow_variables() - def validate(self, stage: str = 'simulation'): + def validate(self, stage: str = "simulation"): """Main validation method Args: @@ -2465,11 +2057,11 @@ def validate(self, stage: str = 'simulation'): self.errors = [] self.warnings = [] - if stage == 'simulation': + if stage == "simulation": self.validate_simulation() - elif stage == 'pre_process': + elif stage == "pre_process": self.validate_pre_process() - elif stage == 'post_process': + elif stage == "post_process": self.validate_post_process() else: # No stage-specific constraints for auxiliary targets like 'syscheck'. @@ -2500,15 +2092,14 @@ def _format_errors(self) -> str: # Auto-generate hints from CONSTRAINTS with value_labels for param_name, constraint in CONSTRAINTS.items(): - if not re.search(r'\b' + re.escape(param_name.lower()) + r'\b', err_lower): + if not re.search(r"\b" + re.escape(param_name.lower()) + r"\b", err_lower): continue choices = constraint.get("choices") if not choices: continue labels = constraint.get("value_labels", {}) if labels: - items = [f"{v} ({labels[v]})" if v in labels else str(v) - for v in choices] + items = [f"{v} ({labels[v]})" if v in labels else str(v) for v in choices] hint = f"Valid values: {', '.join(items)}" else: hint = f"Valid values: {choices}" @@ -2521,7 +2112,7 @@ def _format_errors(self) -> str: return "\n".join(lines) -def validate_case_constraints(params: Dict[str, Any], stage: str = 'simulation') -> List[str]: +def validate_case_constraints(params: Dict[str, Any], stage: str = "simulation") -> List[str]: """Convenience function to validate case parameters Args: diff --git a/toolchain/mfc/clean.py b/toolchain/mfc/clean.py index 3d9c2ff096..9c1f7436e8 100644 --- a/toolchain/mfc/clean.py +++ b/toolchain/mfc/clean.py @@ -5,8 +5,8 @@ import os import shutil -from .printer import cons from .common import MFC_BUILD_DIR +from .printer import cons def clean(): diff --git a/toolchain/mfc/cli/__init__.py b/toolchain/mfc/cli/__init__.py index d8b5178a5b..ee63d2e815 100644 --- a/toolchain/mfc/cli/__init__.py +++ b/toolchain/mfc/cli/__init__.py @@ -11,16 +11,16 @@ """ from .schema import ( + ArgAction, + Argument, CLISchema, Command, - Argument, - Positional, - Example, CommonArgumentSet, - MutuallyExclusiveGroup, - ArgAction, - CompletionType, Completion, + CompletionType, + Example, + MutuallyExclusiveGroup, + Positional, ) __all__ = [ diff --git a/toolchain/mfc/cli/argparse_gen.py b/toolchain/mfc/cli/argparse_gen.py index b95087b83b..6859f61077 100644 --- a/toolchain/mfc/cli/argparse_gen.py +++ b/toolchain/mfc/cli/argparse_gen.py @@ -8,10 +8,7 @@ import dataclasses from typing import Dict, Tuple -from .schema import ( - CLISchema, Command, Argument, Positional, - ArgAction, CommonArgumentSet -) +from .schema import ArgAction, Argument, CLISchema, Command, CommonArgumentSet, Positional def _action_to_argparse(action: ArgAction) -> str: @@ -61,7 +58,7 @@ def _add_positional(parser: argparse.ArgumentParser, pos: Positional): "metavar": pos.name.upper(), } - if pos.type != str: + if pos.type is not str: kwargs["type"] = pos.type if pos.nargs is not None: kwargs["nargs"] = pos.nargs @@ -80,52 +77,29 @@ def _add_mfc_config_arguments(parser: argparse.ArgumentParser, config): This handles --mpi/--no-mpi, --gpu/--no-gpu, etc. from MFCConfig dataclass. """ # Import here to avoid circular dependency - from ..state import gpuConfigOptions # pylint: disable=import-outside-toplevel + from ..state import gpuConfigOptions for f in dataclasses.fields(config): - if f.name == 'gpu': + if f.name == "gpu": parser.add_argument( f"--{f.name}", action="store", - nargs='?', + nargs="?", const=gpuConfigOptions.ACC.value, default=gpuConfigOptions.NONE.value, dest=f.name, choices=[e.value for e in gpuConfigOptions], - help=f"Turn the {f.name} option to OpenACC or OpenMP." - ) - parser.add_argument( - f"--no-{f.name}", - action="store_const", - const=gpuConfigOptions.NONE.value, - dest=f.name, - help=f"Turn the {f.name} option OFF." + help=f"Turn the {f.name} option to OpenACC or OpenMP.", ) + parser.add_argument(f"--no-{f.name}", action="store_const", const=gpuConfigOptions.NONE.value, dest=f.name, help=f"Turn the {f.name} option OFF.") else: - parser.add_argument( - f"--{f.name}", - action="store_true", - help=f"Turn the {f.name} option ON." - ) - parser.add_argument( - f"--no-{f.name}", - action="store_false", - dest=f.name, - help=f"Turn the {f.name} option OFF." - ) + parser.add_argument(f"--{f.name}", action="store_true", help=f"Turn the {f.name} option ON.") + parser.add_argument(f"--no-{f.name}", action="store_false", dest=f.name, help=f"Turn the {f.name} option OFF.") - parser.set_defaults(**{ - f.name: getattr(config, f.name) - for f in dataclasses.fields(config) - }) + parser.set_defaults(**{f.name: getattr(config, f.name) for f in dataclasses.fields(config)}) -def _add_common_arguments( - parser: argparse.ArgumentParser, - command: Command, - common_sets: Dict[str, CommonArgumentSet], - config=None -): +def _add_common_arguments(parser: argparse.ArgumentParser, command: Command, common_sets: Dict[str, CommonArgumentSet], config=None): """Add common arguments to a command parser.""" for set_name in command.include_common: common_set = common_sets.get(set_name) @@ -140,12 +114,7 @@ def _add_common_arguments( _add_argument(parser, arg) -def _add_command_subparser( - subparsers, - cmd: Command, - common_sets: Dict[str, CommonArgumentSet], - config -) -> argparse.ArgumentParser: +def _add_command_subparser(subparsers, cmd: Command, common_sets: Dict[str, CommonArgumentSet], config) -> argparse.ArgumentParser: """Add a single command's subparser and return it.""" subparser = subparsers.add_parser( name=cmd.name, @@ -190,7 +159,7 @@ def _add_command_subparser( def generate_parser( schema: CLISchema, - config=None # MFCConfig instance + config=None, # MFCConfig instance ) -> Tuple[argparse.ArgumentParser, Dict[str, argparse.ArgumentParser]]: """ Generate complete argparse parser from schema. diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index d4b34df3d8..ea2c2ce241 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -10,38 +10,24 @@ When adding a new command or option, ONLY modify this file. Then run `./mfc.sh generate` to update completions. """ -# pylint: disable=too-many-lines - -from .schema import ( - CLISchema, Command, Argument, Positional, Example, - ArgAction, Completion, CompletionType, - CommonArgumentSet, MutuallyExclusiveGroup -) +from .schema import ArgAction, Argument, CLISchema, Command, CommonArgumentSet, Completion, CompletionType, Example, MutuallyExclusiveGroup, Positional # ============================================================================= # CONSTANTS (shared with other modules) # ============================================================================= -TARGET_NAMES = [ - 'fftw', 'hdf5', 'silo', 'lapack', 'hipfort', - 'pre_process', 'simulation', 'post_process', - 'syscheck', 'documentation' -] +TARGET_NAMES = ["fftw", "hdf5", "silo", "lapack", "hipfort", "pre_process", "simulation", "post_process", "syscheck", "documentation"] -DEFAULT_TARGET_NAMES = ['pre_process', 'simulation', 'post_process'] +DEFAULT_TARGET_NAMES = ["pre_process", "simulation", "post_process"] -TEMPLATE_NAMES = [ - 'bridges2', 'carpenter', 'carpenter-cray', 'default', - 'delta', 'deltaai', 'frontier', 'hipergator', 'nautilus', - 'oscar', 'phoenix', 'phoenix-bench', 'santis', 'tuo' -] +TEMPLATE_NAMES = ["bridges2", "carpenter", "carpenter-cray", "default", "delta", "deltaai", "frontier", "hipergator", "nautilus", "oscar", "phoenix", "phoenix-bench", "santis", "tuo"] -GPU_OPTIONS = ['acc', 'mp'] +GPU_OPTIONS = ["acc", "mp"] -ENGINE_OPTIONS = ['interactive', 'batch'] +ENGINE_OPTIONS = ["interactive", "batch"] -MPI_BINARIES = ['mpirun', 'jsrun', 'srun', 'mpiexec'] +MPI_BINARIES = ["mpirun", "jsrun", "srun", "mpiexec"] # ============================================================================= @@ -62,7 +48,7 @@ metavar="TARGET", completion=Completion(type=CompletionType.CHOICES, choices=TARGET_NAMES), ), - ] + ], ) COMMON_JOBS = CommonArgumentSet( @@ -76,7 +62,7 @@ default=1, metavar="JOBS", ), - ] + ], ) COMMON_VERBOSE = CommonArgumentSet( @@ -89,7 +75,7 @@ action=ArgAction.COUNT, default=0, ), - ] + ], ) COMMON_DEBUG_LOG = CommonArgumentSet( @@ -102,7 +88,7 @@ action=ArgAction.STORE_TRUE, dest="debug_log", ), - ] + ], ) COMMON_GPUS = CommonArgumentSet( @@ -116,7 +102,7 @@ type=int, default=None, ), - ] + ], ) # MFCConfig flags are handled specially in argparse_gen.py @@ -460,28 +446,30 @@ ), ], mutually_exclusive=[ - MutuallyExclusiveGroup(arguments=[ - Argument( - name="generate", - help="(Test Generation) Generate golden files.", - action=ArgAction.STORE_TRUE, - default=False, - ), - Argument( - name="add-new-variables", - help="(Test Generation) If new variables are found in D/ when running tests, add them to the golden files.", - action=ArgAction.STORE_TRUE, - default=False, - dest="add_new_variables", - ), - Argument( - name="remove-old-tests", - help="(Test Generation) Delete test directories that are no longer needed.", - action=ArgAction.STORE_TRUE, - default=False, - dest="remove_old_tests", - ), - ]), + MutuallyExclusiveGroup( + arguments=[ + Argument( + name="generate", + help="(Test Generation) Generate golden files.", + action=ArgAction.STORE_TRUE, + default=False, + ), + Argument( + name="add-new-variables", + help="(Test Generation) If new variables are found in D/ when running tests, add them to the golden files.", + action=ArgAction.STORE_TRUE, + default=False, + dest="add_new_variables", + ), + Argument( + name="remove-old-tests", + help="(Test Generation) Delete test directories that are no longer needed.", + action=ArgAction.STORE_TRUE, + default=False, + dest="remove_old_tests", + ), + ] + ), ], examples=[ Example("./mfc.sh test", "Run all tests"), @@ -718,20 +706,20 @@ LINT_COMMAND = Command( name="lint", help="Lints and tests all toolchain code.", - description="Run pylint and unit tests on MFC's toolchain Python code.", + description="Run ruff and unit tests on MFC's toolchain Python code.", arguments=[ Argument( name="no-test", - help="Skip running unit tests (only run pylint).", + help="Skip running unit tests (only run ruff).", action=ArgAction.STORE_TRUE, ), ], examples=[ - Example("./mfc.sh lint", "Run pylint and unit tests"), - Example("./mfc.sh lint --no-test", "Run only pylint (skip unit tests)"), + Example("./mfc.sh lint", "Run ruff and unit tests"), + Example("./mfc.sh lint --no-test", "Run only ruff (skip unit tests)"), ], key_options=[ - ("--no-test", "Skip unit tests, only run pylint"), + ("--no-test", "Skip unit tests, only run ruff"), ], ) @@ -918,7 +906,7 @@ "Use --list-steps to see available timesteps." ), type=str, - default='last', + default="last", metavar="STEP", ), Argument( @@ -943,33 +931,102 @@ name="cmap", help="Matplotlib colormap name (--png, --mp4 only).", type=str, - default='viridis', + default="viridis", metavar="CMAP", - completion=Completion(type=CompletionType.CHOICES, choices=[ - # Perceptually uniform sequential - "viridis", "plasma", "inferno", "magma", "cividis", - # Diverging - "RdBu", "RdYlBu", "RdYlGn", "RdGy", "coolwarm", "bwr", "seismic", - "PiYG", "PRGn", "BrBG", "PuOr", "Spectral", - # Sequential - "Blues", "Greens", "Oranges", "Reds", "Purples", "Greys", - "YlOrRd", "YlOrBr", "YlGn", "YlGnBu", "GnBu", "BuGn", - "BuPu", "PuBu", "PuBuGn", "PuRd", "RdPu", - # Sequential 2 - "hot", "afmhot", "gist_heat", "copper", - "bone", "gray", "pink", "spring", "summer", "autumn", "winter", "cool", - "binary", "gist_yarg", "gist_gray", - # Cyclic - "twilight", "twilight_shifted", "hsv", - # Qualitative - "tab10", "tab20", "tab20b", "tab20c", - "Set1", "Set2", "Set3", "Paired", "Accent", "Dark2", "Pastel1", "Pastel2", - # Miscellaneous - "turbo", "jet", "rainbow", "nipy_spectral", "gist_ncar", - "gist_rainbow", "gist_stern", "gist_earth", "ocean", "terrain", - "gnuplot", "gnuplot2", "CMRmap", "cubehelix", "brg", "flag", "prism", - "Wistia", - ]), + completion=Completion( + type=CompletionType.CHOICES, + choices=[ + # Perceptually uniform sequential + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + # Diverging + "RdBu", + "RdYlBu", + "RdYlGn", + "RdGy", + "coolwarm", + "bwr", + "seismic", + "PiYG", + "PRGn", + "BrBG", + "PuOr", + "Spectral", + # Sequential + "Blues", + "Greens", + "Oranges", + "Reds", + "Purples", + "Greys", + "YlOrRd", + "YlOrBr", + "YlGn", + "YlGnBu", + "GnBu", + "BuGn", + "BuPu", + "PuBu", + "PuBuGn", + "PuRd", + "RdPu", + # Sequential 2 + "hot", + "afmhot", + "gist_heat", + "copper", + "bone", + "gray", + "pink", + "spring", + "summer", + "autumn", + "winter", + "cool", + "binary", + "gist_yarg", + "gist_gray", + # Cyclic + "twilight", + "twilight_shifted", + "hsv", + # Qualitative + "tab10", + "tab20", + "tab20b", + "tab20c", + "Set1", + "Set2", + "Set3", + "Paired", + "Accent", + "Dark2", + "Pastel1", + "Pastel2", + # Miscellaneous + "turbo", + "jet", + "rainbow", + "nipy_spectral", + "gist_ncar", + "gist_rainbow", + "gist_stern", + "gist_earth", + "ocean", + "terrain", + "gnuplot", + "gnuplot2", + "CMRmap", + "cubehelix", + "brg", + "flag", + "prism", + "Wistia", + ], + ), ), Argument( name="vmin", @@ -996,7 +1053,7 @@ name="slice-axis", help="Axis for 3D slice (--png, --mp4 only).", type=str, - default='z', + default="z", choices=["x", "y", "z"], dest="slice_axis", completion=Completion(type=CompletionType.CHOICES, choices=["x", "y", "z"]), @@ -1054,11 +1111,7 @@ Argument( name="interactive", short="i", - help=( - "Launch an interactive Dash web UI in your browser. " - "Loads all timesteps (or the set given by --step) and lets you " - "scrub through them and switch variables live." - ), + help=("Launch an interactive Dash web UI in your browser. Loads all timesteps (or the set given by --step) and lets you scrub through them and switch variables live."), action=ArgAction.STORE_TRUE, default=False, ), @@ -1077,10 +1130,7 @@ ), Argument( name="png", - help=( - "Save PNG image(s) to the output directory instead of " - "launching the terminal UI." - ), + help=("Save PNG image(s) to the output directory instead of launching the terminal UI."), action=ArgAction.STORE_TRUE, default=False, ), @@ -1250,7 +1300,6 @@ running, and cleaning of MFC in various configurations on all supported platforms. \ The README documents this tool and its various commands in more detail. To get \ started, run `./mfc.sh build -h`.""", - arguments=[ Argument( name="help", @@ -1259,7 +1308,6 @@ action=ArgAction.STORE_TRUE, ), ], - commands=[ BUILD_COMMAND, RUN_COMMAND, @@ -1284,7 +1332,6 @@ COUNT_COMMAND, COUNT_DIFF_COMMAND, ], - common_sets=[ COMMON_TARGETS, COMMON_JOBS, @@ -1293,7 +1340,6 @@ COMMON_GPUS, COMMON_MFC_CONFIG, ], - help_topics=HELP_TOPICS, ) @@ -1308,6 +1354,7 @@ for alias in cmd.aliases: COMMAND_ALIASES[alias] = cmd.name + # Commands dict (replaces COMMANDS in user_guide.py) def get_commands_dict(): """Generate COMMANDS dict from schema for user_guide.py compatibility.""" @@ -1321,4 +1368,5 @@ def get_commands_dict(): for cmd in MFC_CLI_SCHEMA.commands } + COMMANDS = get_commands_dict() diff --git a/toolchain/mfc/cli/completion_gen.py b/toolchain/mfc/cli/completion_gen.py index 7a286bc981..de1ac0cd98 100644 --- a/toolchain/mfc/cli/completion_gen.py +++ b/toolchain/mfc/cli/completion_gen.py @@ -6,8 +6,8 @@ """ from typing import List, Set -from .schema import CLISchema, Command, CompletionType +from .schema import CLISchema, Command, CompletionType # Mapping of completion types to bash completion expressions _BASH_COMPLETION_MAP = { @@ -31,16 +31,26 @@ def _collect_all_options(cmd: Command, schema: CLISchema) -> List[str]: # MFC config flags if common_set.mfc_config_flags: - options.update([ - "--mpi", "--no-mpi", - "--gpu", "--no-gpu", - "--debug", "--no-debug", - "--gcov", "--no-gcov", - "--unified", "--no-unified", - "--single", "--no-single", - "--mixed", "--no-mixed", - "--fastmath", "--no-fastmath", - ]) + options.update( + [ + "--mpi", + "--no-mpi", + "--gpu", + "--no-gpu", + "--debug", + "--no-debug", + "--gcov", + "--no-gcov", + "--unified", + "--no-unified", + "--single", + "--no-single", + "--mixed", + "--no-mixed", + "--fastmath", + "--no-fastmath", + ] + ) else: for arg in common_set.arguments: if arg.short: @@ -86,9 +96,7 @@ def _generate_bash_prev_cases(cmd: Command, schema: CLISchema) -> List[str]: """Generate bash prev-based completion cases for a command.""" lines = [] has_prev_cases = False - completable_types = (CompletionType.CHOICES, CompletionType.FILES_PY, - CompletionType.FILES_PACK, CompletionType.FILES, - CompletionType.DIRECTORIES, CompletionType.FILES_YAML) + completable_types = (CompletionType.CHOICES, CompletionType.FILES_PY, CompletionType.FILES_PACK, CompletionType.FILES, CompletionType.DIRECTORIES, CompletionType.FILES_YAML) all_args = _collect_all_args(cmd, schema) @@ -103,29 +111,29 @@ def _generate_bash_prev_cases(cmd: Command, schema: CLISchema) -> List[str]: if multivalue_args: # Generate backward-scanning logic for multi-value args - lines.append(' # Check for multi-value arguments by scanning backwards') - lines.append(' local i') - lines.append(' for ((i=COMP_CWORD-1; i>=2; i--)); do') + lines.append(" # Check for multi-value arguments by scanning backwards") + lines.append(" local i") + lines.append(" for ((i=COMP_CWORD-1; i>=2; i--)); do") lines.append(' case "${COMP_WORDS[i]}" in') for arg in multivalue_args: - flags = [f'-{arg.short}'] if arg.short else [] - flags.append(f'--{arg.name}') - lines.append(f' {"|".join(flags)})') + flags = [f"-{arg.short}"] if arg.short else [] + flags.append(f"--{arg.name}") + lines.append(f" {'|'.join(flags)})") comp_choices = arg.completion.choices or arg.choices completion_code = _bash_completion_for_type(arg.completion.type, comp_choices) if completion_code: - lines.append(f' {completion_code}') - lines.append(' return 0') - lines.append(' ;;') + lines.append(f" {completion_code}") + lines.append(" return 0") + lines.append(" ;;") # Stop scanning if we hit any other flag - lines.append(' -*)') - lines.append(' break') - lines.append(' ;;') - lines.append(' esac') - lines.append(' done') - lines.append('') + lines.append(" -*)") + lines.append(" break") + lines.append(" ;;") + lines.append(" esac") + lines.append(" done") + lines.append("") # Then handle single-value arguments with prev-based completion for arg in all_args: @@ -139,19 +147,19 @@ def _generate_bash_prev_cases(cmd: Command, schema: CLISchema) -> List[str]: lines.append(' case "${prev}" in') has_prev_cases = True - flags = [f'-{arg.short}'] if arg.short else [] - flags.append(f'--{arg.name}') + flags = [f"-{arg.short}"] if arg.short else [] + flags.append(f"--{arg.name}") - lines.append(f' {"|".join(flags)})') + lines.append(f" {'|'.join(flags)})") comp_choices = arg.completion.choices or arg.choices completion_code = _bash_completion_for_type(arg.completion.type, comp_choices) if completion_code: - lines.append(f' {completion_code}') - lines.append(' return 0') - lines.append(' ;;') + lines.append(f" {completion_code}") + lines.append(" return 0") + lines.append(" ;;") if has_prev_cases: - lines.append(' esac') + lines.append(" esac") return lines @@ -162,18 +170,18 @@ def _generate_bash_command_case(cmd: Command, schema: CLISchema) -> List[str]: # Include aliases in case pattern patterns = [cmd.name] + cmd.aliases - lines.append(f' {"|".join(patterns)})') + lines.append(f" {'|'.join(patterns)})") options = _collect_all_options(cmd, schema) # Handle subcommands (like packer pack, packer compare) if cmd.subcommands: - lines.append(' if [[ ${COMP_CWORD} -eq 2 ]]; then') + lines.append(" if [[ ${COMP_CWORD} -eq 2 ]]; then") subcmd_names = [sc.name for sc in cmd.subcommands] lines.append(f' COMPREPLY=( $(compgen -W "{" ".join(subcmd_names)}" -- "${{cur}}") )') - lines.append(' return 0') - lines.append(' fi') - lines.append(' ;;') + lines.append(" return 0") + lines.append(" fi") + lines.append(" ;;") return lines # Generate prev-based completion @@ -186,23 +194,23 @@ def _generate_bash_command_case(cmd: Command, schema: CLISchema) -> List[str]: lines.append(' COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )') if cmd.positionals and cmd.positionals[0].completion.type != CompletionType.NONE: - lines.append(' else') + lines.append(" else") pos = cmd.positionals[0] comp_choices = pos.completion.choices or pos.choices completion_code = _bash_completion_for_type(pos.completion.type, comp_choices) if completion_code: - lines.append(f' {completion_code}') + lines.append(f" {completion_code}") - lines.append(' fi') + lines.append(" fi") elif cmd.positionals and cmd.positionals[0].completion.type != CompletionType.NONE: pos = cmd.positionals[0] comp_choices = pos.completion.choices or pos.choices completion_code = _bash_completion_for_type(pos.completion.type, comp_choices) if completion_code: - lines.append(f' {completion_code}') + lines.append(f" {completion_code}") - lines.append(' return 0') - lines.append(' ;;') + lines.append(" return 0") + lines.append(" ;;") return lines @@ -211,26 +219,26 @@ def generate_bash_completion(schema: CLISchema) -> str: commands = schema.get_all_command_names() lines = [ - '#!/usr/bin/env bash', - '# AUTO-GENERATED from cli/commands.py - Do not edit manually', - '# Regenerate with: ./mfc.sh generate', - '', - '_mfc_completions() {', - ' local cur prev command', - ' COMPREPLY=()', + "#!/usr/bin/env bash", + "# AUTO-GENERATED from cli/commands.py - Do not edit manually", + "# Regenerate with: ./mfc.sh generate", + "", + "_mfc_completions() {", + " local cur prev command", + " COMPREPLY=()", ' cur="${COMP_WORDS[COMP_CWORD]}"', ' prev="${COMP_WORDS[COMP_CWORD-1]}"', - '', + "", f' local commands="{" ".join(sorted(commands))}"', - '', - ' # First argument - complete commands', - ' if [[ ${COMP_CWORD} -eq 1 ]]; then', + "", + " # First argument - complete commands", + " if [[ ${COMP_CWORD} -eq 1 ]]; then", ' COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") )', - ' return 0', - ' fi', - '', + " return 0", + " fi", + "", ' local command="${COMP_WORDS[1]}"', - '', + "", ' case "${command}" in', ] @@ -239,20 +247,22 @@ def generate_bash_completion(schema: CLISchema) -> str: continue lines.extend(_generate_bash_command_case(cmd, schema)) - lines.extend([ - ' esac', - '', - ' return 0', - '}', - '', - '# -o filenames: handle escaping/slashes for file completions', - '# Removed -o bashdefault to prevent unwanted directory fallback', - 'complete -o filenames -F _mfc_completions ./mfc.sh', - 'complete -o filenames -F _mfc_completions mfc.sh', - 'complete -o filenames -F _mfc_completions mfc', - ]) + lines.extend( + [ + " esac", + "", + " return 0", + "}", + "", + "# -o filenames: handle escaping/slashes for file completions", + "# Removed -o bashdefault to prevent unwanted directory fallback", + "complete -o filenames -F _mfc_completions ./mfc.sh", + "complete -o filenames -F _mfc_completions mfc.sh", + "complete -o filenames -F _mfc_completions mfc", + ] + ) - return '\n'.join(lines) + return "\n".join(lines) def _zsh_completion_for_positional(pos, index: int) -> str: @@ -264,11 +274,11 @@ def _zsh_completion_for_positional(pos, index: int) -> str: completion = ':_files -g "*.pack"' elif pos.completion.type == CompletionType.CHOICES: choices = pos.completion.choices or pos.choices or [] - completion = f':({" ".join(choices)})' + completion = f":({' '.join(choices)})" elif pos.completion.type == CompletionType.DIRECTORIES: - completion = ':_files -/' + completion = ":_files -/" elif pos.completion.type == CompletionType.FILES: - completion = ':_files' + completion = ":_files" help_text = pos.help.replace("'", "").replace("[", "").replace("]", "")[:120] return f"'{index}:{help_text}{completion}'" @@ -282,15 +292,15 @@ def _zsh_completion_for_arg(arg) -> str: if arg.completion.type == CompletionType.CHOICES: choices = arg.completion.choices or arg.choices or [] - return f'{label}:({" ".join(str(c) for c in choices)})' + return f"{label}:({' '.join(str(c) for c in choices)})" if arg.completion.type == CompletionType.FILES_PY: return f'{label}:_files -g "*.py"' if arg.completion.type == CompletionType.FILES_PACK: return f'{label}:_files -g "*.pack"' if arg.completion.type == CompletionType.FILES: - return f'{label}:_files' + return f"{label}:_files" if arg.completion.type == CompletionType.DIRECTORIES: - return f'{label}:_files -/' + return f"{label}:_files -/" return "" @@ -314,24 +324,26 @@ def _generate_zsh_command_args(cmd: Command, schema: CLISchema) -> List[str]: continue if common_set.mfc_config_flags: - arg_lines.extend([ - "'--mpi[Enable MPI]'", - "'--no-mpi[Disable MPI]'", - "'--gpu[Enable GPU]:mode:(acc mp)'", - "'--no-gpu[Disable GPU]'", - "'--debug[Build with debug compiler flags (for MFC code)]'", - "'--no-debug[Build without debug flags]'", - "'--gcov[Enable gcov coverage]'", - "'--no-gcov[Disable gcov coverage]'", - "'--unified[Enable unified memory]'", - "'--no-unified[Disable unified memory]'", - "'--single[Enable single precision]'", - "'--no-single[Disable single precision]'", - "'--mixed[Enable mixed precision]'", - "'--no-mixed[Disable mixed precision]'", - "'--fastmath[Enable fast math]'", - "'--no-fastmath[Disable fast math]'", - ]) + arg_lines.extend( + [ + "'--mpi[Enable MPI]'", + "'--no-mpi[Disable MPI]'", + "'--gpu[Enable GPU]:mode:(acc mp)'", + "'--no-gpu[Disable GPU]'", + "'--debug[Build with debug compiler flags (for MFC code)]'", + "'--no-debug[Build without debug flags]'", + "'--gcov[Enable gcov coverage]'", + "'--no-gcov[Disable gcov coverage]'", + "'--unified[Enable unified memory]'", + "'--no-unified[Disable unified memory]'", + "'--single[Enable single precision]'", + "'--no-single[Disable single precision]'", + "'--mixed[Enable mixed precision]'", + "'--no-mixed[Disable mixed precision]'", + "'--fastmath[Enable fast math]'", + "'--no-fastmath[Disable fast math]'", + ] + ) else: for arg in common_set.arguments: desc = arg.help.replace("'", "").replace("[", "").replace("]", "")[:120] @@ -360,16 +372,16 @@ def _generate_zsh_command_args(cmd: Command, schema: CLISchema) -> List[str]: def generate_zsh_completion(schema: CLISchema) -> str: """Generate zsh completion script from schema.""" lines = [ - '#compdef mfc.sh ./mfc.sh mfc', - '# AUTO-GENERATED from cli/commands.py - Do not edit manually', - '# Regenerate with: ./mfc.sh generate', - '', - '_mfc() {', - ' local context state state_descr line', - ' typeset -A opt_args', - '', - ' local -a commands', - ' commands=(', + "#compdef mfc.sh ./mfc.sh mfc", + "# AUTO-GENERATED from cli/commands.py - Do not edit manually", + "# Regenerate with: ./mfc.sh generate", + "", + "_mfc() {", + " local context state state_descr line", + " typeset -A opt_args", + "", + " local -a commands", + " commands=(", ] # Commands with descriptions @@ -379,42 +391,46 @@ def generate_zsh_completion(schema: CLISchema) -> str: for alias in cmd.aliases: lines.append(f' "{alias}:Alias for {cmd.name}"') - lines.extend([ - ' )', - '', - ' _arguments -C \\', - " '1: :->command' \\", - " '*:: :->args'", - '', - ' case $state in', - ' command)', - " _describe -t commands 'mfc command' commands", - ' ;;', - ' args)', - ' case $words[1] in', - ]) + lines.extend( + [ + " )", + "", + " _arguments -C \\", + " '1: :->command' \\", + " '*:: :->args'", + "", + " case $state in", + " command)", + " _describe -t commands 'mfc command' commands", + " ;;", + " args)", + " case $words[1] in", + ] + ) # Generate case for each command for cmd in schema.commands: all_names = [cmd.name] + cmd.aliases for name in all_names: - lines.append(f' {name})') + lines.append(f" {name})") arg_lines = _generate_zsh_command_args(cmd, schema) if arg_lines: - lines.append(' _arguments \\') - lines.append(' ' + ' \\\n '.join(arg_lines)) + lines.append(" _arguments \\") + lines.append(" " + " \\\n ".join(arg_lines)) else: # Explicitly disable default completion for commands with no args - lines.append(' :') - lines.append(' ;;') - - lines.extend([ - ' esac', - ' ;;', - ' esac', - '}', - '', - '_mfc "$@"', - ]) - - return '\n'.join(lines) + lines.append(" :") + lines.append(" ;;") + + lines.extend( + [ + " esac", + " ;;", + " esac", + "}", + "", + '_mfc "$@"', + ] + ) + + return "\n".join(lines) diff --git a/toolchain/mfc/cli/docs_gen.py b/toolchain/mfc/cli/docs_gen.py index 989ea6a612..9fc1b8bfbf 100644 --- a/toolchain/mfc/cli/docs_gen.py +++ b/toolchain/mfc/cli/docs_gen.py @@ -7,7 +7,8 @@ import re from typing import List -from .schema import CLISchema, Command, Argument + +from .schema import Argument, CLISchema, Command def _escape_doxygen(text: str) -> str: @@ -194,11 +195,7 @@ def _generate_command_section(cmd: Command, schema: CLISchema) -> List[str]: return lines -def _generate_commands_by_category( - schema: CLISchema, - category_commands: List[str], - header: str -) -> List[str]: +def _generate_commands_by_category(schema: CLISchema, category_commands: List[str], header: str) -> List[str]: """Generate command sections for a category.""" lines = [] matching = [c for c in schema.commands if c.name in category_commands] @@ -260,45 +257,47 @@ def generate_cli_reference(schema: CLISchema) -> str: lines.extend(_generate_commands_by_category(schema, other_commands, "Other Commands")) # Common options section - lines.extend([ - "## Common Options", - "", - "Many commands share common option sets:", - "", - "### Target Selection (`-t, --targets`)", - "", - "Available targets:", - "- `pre_process` - Pre-processor", - "- `simulation` - Main simulation", - "- `post_process` - Post-processor", - "- `syscheck` - System check utility", - "- `documentation` - Build documentation", - "", - "### Build Configuration Flags", - "", - "| Flag | Description |", - "|------|-------------|", - "| `--mpi` / `--no-mpi` | Enable/disable MPI support |", - "| `--gpu [acc/mp]` / `--no-gpu` | Enable GPU with OpenACC or OpenMP |", - "| `--debug` / `--no-debug` | Build with debug compiler flags |", - "| `--gcov` / `--no-gcov` | Enable code coverage |", - "| `--single` / `--no-single` | Single precision |", - "| `--mixed` / `--no-mixed` | Mixed precision |", - "", - "### Verbosity (`-v, --verbose`)", - "", - "Controls output verbosity level:", - "", - "- `-v` - Basic verbose output", - "- `-vv` - Show build commands", - "- `-vvv` - Full verbose output including CMake details", - "", - "### Debug Logging (`-d, --debug-log`)", - "", - "Enables debug logging for the Python toolchain (mfc.sh internals).", - "This is for troubleshooting the build system, not the MFC simulation code.", - "", - ]) + lines.extend( + [ + "## Common Options", + "", + "Many commands share common option sets:", + "", + "### Target Selection (`-t, --targets`)", + "", + "Available targets:", + "- `pre_process` - Pre-processor", + "- `simulation` - Main simulation", + "- `post_process` - Post-processor", + "- `syscheck` - System check utility", + "- `documentation` - Build documentation", + "", + "### Build Configuration Flags", + "", + "| Flag | Description |", + "|------|-------------|", + "| `--mpi` / `--no-mpi` | Enable/disable MPI support |", + "| `--gpu [acc/mp]` / `--no-gpu` | Enable GPU with OpenACC or OpenMP |", + "| `--debug` / `--no-debug` | Build with debug compiler flags |", + "| `--gcov` / `--no-gcov` | Enable code coverage |", + "| `--single` / `--no-single` | Single precision |", + "| `--mixed` / `--no-mixed` | Mixed precision |", + "", + "### Verbosity (`-v, --verbose`)", + "", + "Controls output verbosity level:", + "", + "- `-v` - Basic verbose output", + "- `-vv` - Show build commands", + "- `-vvv` - Full verbose output including CMake details", + "", + "### Debug Logging (`-d, --debug-log`)", + "", + "Enables debug logging for the Python toolchain (mfc.sh internals).", + "This is for troubleshooting the build system, not the MFC simulation code.", + "", + ] + ) return "\n".join(lines) @@ -316,28 +315,30 @@ def generate_command_summary(schema: CLISchema) -> str: alias_str = f" ({cmd.aliases[0]})" if cmd.aliases else "" lines.append(f"- **{cmd.name}**{alias_str}: {cmd.help}") - lines.extend([ - "", - "## Common Patterns", - "", - "```bash", - "# Build MFC", - "./mfc.sh build", - "./mfc.sh build --gpu # With GPU support", - "./mfc.sh build -j 8 # Parallel build", - "", - "# Run a case", - "./mfc.sh run case.py", - "./mfc.sh run case.py -n 4 # 4 MPI ranks", - "", - "# Run tests", - "./mfc.sh test", - "./mfc.sh test -j 4 # Parallel tests", - "", - "# Validate a case", - "./mfc.sh validate case.py", - "```", - "", - ]) + lines.extend( + [ + "", + "## Common Patterns", + "", + "```bash", + "# Build MFC", + "./mfc.sh build", + "./mfc.sh build --gpu # With GPU support", + "./mfc.sh build -j 8 # Parallel build", + "", + "# Run a case", + "./mfc.sh run case.py", + "./mfc.sh run case.py -n 4 # 4 MPI ranks", + "", + "# Run tests", + "./mfc.sh test", + "./mfc.sh test -j 4 # Parallel tests", + "", + "# Validate a case", + "./mfc.sh validate case.py", + "```", + "", + ] + ) return "\n".join(lines) diff --git a/toolchain/mfc/cli/schema.py b/toolchain/mfc/cli/schema.py index 6fc673b8d0..871d98f6da 100644 --- a/toolchain/mfc/cli/schema.py +++ b/toolchain/mfc/cli/schema.py @@ -8,11 +8,12 @@ from dataclasses import dataclass, field from enum import Enum, auto -from typing import List, Optional, Any, Union +from typing import Any, List, Optional, Union class ArgAction(Enum): """Supported argparse actions.""" + STORE = "store" STORE_TRUE = "store_true" STORE_FALSE = "store_false" @@ -23,44 +24,47 @@ class ArgAction(Enum): class CompletionType(Enum): """Types of shell completion behavior.""" - NONE = auto() # No completion - FILES = auto() # All file completion - FILES_PY = auto() # Python files only (*.py) - FILES_PACK = auto() # Pack files only (*.pack) - FILES_YAML = auto() # YAML files only (*.yaml, *.yml) - DIRECTORIES = auto() # Directory completion - CHOICES = auto() # Static choices from choices list + + NONE = auto() # No completion + FILES = auto() # All file completion + FILES_PY = auto() # Python files only (*.py) + FILES_PACK = auto() # Pack files only (*.pack) + FILES_YAML = auto() # YAML files only (*.yaml, *.yml) + DIRECTORIES = auto() # Directory completion + CHOICES = auto() # Static choices from choices list @dataclass class Completion: """Completion configuration for an argument.""" + type: CompletionType = CompletionType.NONE choices: Optional[List[str]] = None @dataclass -class Argument: # pylint: disable=too-many-instance-attributes +class Argument: """ Definition of a single CLI option argument (--flag). This represents one add_argument() call for a flag-style argument. """ + # Identity - name: str # Long form without dashes (e.g., "targets") - short: Optional[str] = None # Short form without dash (e.g., "t") + name: str # Long form without dashes (e.g., "targets") + short: Optional[str] = None # Short form without dash (e.g., "t") # Argparse configuration help: str = "" action: ArgAction = ArgAction.STORE - type: Optional[type] = None # str, int, float, etc. + type: Optional[type] = None # str, int, float, etc. default: Any = None choices: Optional[List[Any]] = None nargs: Optional[Union[str, int]] = None # "+", "*", "?", int, or "..." for REMAINDER metavar: Optional[str] = None required: bool = False - dest: Optional[str] = None # Override destination name - const: Any = None # For store_const action + dest: Optional[str] = None # Override destination name + const: Any = None # For store_const action # Completion completion: Completion = field(default_factory=Completion) @@ -83,7 +87,8 @@ def get_dest(self) -> str: @dataclass class Positional: """Definition of a positional argument.""" - name: str # Metavar and destination + + name: str # Metavar and destination help: str = "" type: type = str nargs: Optional[Union[str, int]] = None @@ -97,6 +102,7 @@ class Positional: @dataclass class Example: """A usage example for documentation.""" + command: str description: str @@ -104,17 +110,19 @@ class Example: @dataclass class MutuallyExclusiveGroup: """A group where only one argument can be specified.""" + arguments: List[Argument] = field(default_factory=list) required: bool = False @dataclass -class Command: # pylint: disable=too-many-instance-attributes +class Command: """ Definition of a CLI command/subcommand. This is the main building block for the CLI structure. """ + # Identity name: str help: str @@ -132,12 +140,12 @@ class Command: # pylint: disable=too-many-instance-attributes subcommands: List["Command"] = field(default_factory=list) # Documentation - description: Optional[str] = None # Long description for docs + description: Optional[str] = None # Long description for docs examples: List[Example] = field(default_factory=list) key_options: List[tuple] = field(default_factory=list) # (option, description) pairs # Handler module path (for dispatch) - handler: Optional[str] = None # Module.function path + handler: Optional[str] = None # Module.function path @dataclass @@ -147,7 +155,8 @@ class CommonArgumentSet: Replaces the add_common_arguments() function pattern. """ - name: str # Identifier for include_common + + name: str # Identifier for include_common arguments: List[Argument] = field(default_factory=list) # For MFCConfig flags that need --X and --no-X pairs mfc_config_flags: bool = False @@ -165,6 +174,7 @@ class CLISchema: - User guide help content - CLI reference documentation """ + prog: str = "./mfc.sh" description: str = "" diff --git a/toolchain/mfc/cli/test_cli.py b/toolchain/mfc/cli/test_cli.py index 6eaee9963c..abec7829e1 100644 --- a/toolchain/mfc/cli/test_cli.py +++ b/toolchain/mfc/cli/test_cli.py @@ -3,7 +3,6 @@ Verifies that modules can be imported and basic functionality works. """ -# pylint: disable=import-outside-toplevel import unittest @@ -14,32 +13,37 @@ class TestCliImports(unittest.TestCase): def test_schema_import(self): """Schema module should import and export expected classes.""" from . import schema - self.assertTrue(hasattr(schema, 'Command')) - self.assertTrue(hasattr(schema, 'Argument')) - self.assertTrue(hasattr(schema, 'Positional')) - self.assertTrue(hasattr(schema, 'CLISchema')) + + self.assertTrue(hasattr(schema, "Command")) + self.assertTrue(hasattr(schema, "Argument")) + self.assertTrue(hasattr(schema, "Positional")) + self.assertTrue(hasattr(schema, "CLISchema")) def test_commands_import(self): """Commands module should import and have MFC_CLI_SCHEMA.""" from . import commands - self.assertTrue(hasattr(commands, 'MFC_CLI_SCHEMA')) + + self.assertTrue(hasattr(commands, "MFC_CLI_SCHEMA")) self.assertIsNotNone(commands.MFC_CLI_SCHEMA) def test_argparse_gen_import(self): """Argparse generator should import.""" from . import argparse_gen - self.assertTrue(hasattr(argparse_gen, 'generate_parser')) + + self.assertTrue(hasattr(argparse_gen, "generate_parser")) def test_completion_gen_import(self): """Completion generator should import.""" from . import completion_gen - self.assertTrue(hasattr(completion_gen, 'generate_bash_completion')) - self.assertTrue(hasattr(completion_gen, 'generate_zsh_completion')) + + self.assertTrue(hasattr(completion_gen, "generate_bash_completion")) + self.assertTrue(hasattr(completion_gen, "generate_zsh_completion")) def test_docs_gen_import(self): """Docs generator should import.""" from . import docs_gen - self.assertTrue(hasattr(docs_gen, 'generate_cli_reference')) + + self.assertTrue(hasattr(docs_gen, "generate_cli_reference")) class TestCliSchema(unittest.TestCase): @@ -48,20 +52,23 @@ class TestCliSchema(unittest.TestCase): def test_cli_schema_has_commands(self): """MFC_CLI_SCHEMA should have commands defined.""" from .commands import MFC_CLI_SCHEMA + self.assertTrue(len(MFC_CLI_SCHEMA.commands) > 0) def test_cli_schema_has_description(self): """MFC_CLI_SCHEMA should have a description.""" from .commands import MFC_CLI_SCHEMA + self.assertIsNotNone(MFC_CLI_SCHEMA.description) self.assertIsInstance(MFC_CLI_SCHEMA.description, str) def test_commands_have_names(self): """Each command should have a name.""" from .commands import MFC_CLI_SCHEMA + for cmd in MFC_CLI_SCHEMA.commands: - self.assertIsNotNone(cmd.name, f"Command missing name") - self.assertTrue(len(cmd.name) > 0, f"Command has empty name") + self.assertIsNotNone(cmd.name, "Command missing name") + self.assertTrue(len(cmd.name) > 0, "Command has empty name") class TestArgparseGenerator(unittest.TestCase): @@ -70,6 +77,7 @@ class TestArgparseGenerator(unittest.TestCase): def test_generate_parser_returns_parser(self): """generate_parser should return a tuple with ArgumentParser.""" import argparse + from .argparse_gen import generate_parser from .commands import MFC_CLI_SCHEMA @@ -101,8 +109,8 @@ class TestCompletionGenerator(unittest.TestCase): def test_bash_completion_generates_output(self): """Bash completion should generate non-empty output.""" - from .completion_gen import generate_bash_completion from .commands import MFC_CLI_SCHEMA + from .completion_gen import generate_bash_completion output = generate_bash_completion(MFC_CLI_SCHEMA) self.assertIsInstance(output, str) @@ -111,8 +119,8 @@ def test_bash_completion_generates_output(self): def test_zsh_completion_generates_output(self): """Zsh completion should generate non-empty output.""" - from .completion_gen import generate_zsh_completion from .commands import MFC_CLI_SCHEMA + from .completion_gen import generate_zsh_completion output = generate_zsh_completion(MFC_CLI_SCHEMA) self.assertIsInstance(output, str) @@ -125,8 +133,8 @@ class TestDocsGenerator(unittest.TestCase): def test_docs_generates_markdown(self): """Docs generator should produce markdown output.""" - from .docs_gen import generate_cli_reference from .commands import MFC_CLI_SCHEMA + from .docs_gen import generate_cli_reference output = generate_cli_reference(MFC_CLI_SCHEMA) self.assertIsInstance(output, str) @@ -134,5 +142,42 @@ def test_docs_generates_markdown(self): self.assertIn("#", output) # Should contain markdown headers +class TestMFCConfigHash(unittest.TestCase): + """Test MFCConfig __hash__ / __eq__ contract.""" + + def test_equal_configs_same_hash(self): + """Equal MFCConfig objects must have the same hash.""" + from ..state import MFCConfig + + a = MFCConfig() + b = MFCConfig() + self.assertEqual(a, b) + self.assertEqual(hash(a), hash(b)) + + def test_different_configs_different_hash(self): + """Different MFCConfig objects should (likely) have different hashes.""" + from ..state import MFCConfig + + a = MFCConfig(debug=False) + b = MFCConfig(debug=True) + self.assertNotEqual(a, b) + self.assertNotEqual(hash(a), hash(b)) + + def test_usable_in_set(self): + """MFCConfig should be usable in a set.""" + from ..state import MFCConfig + + s = {MFCConfig(), MFCConfig(debug=True)} + self.assertEqual(len(s), 2) + self.assertIn(MFCConfig(), s) + + def test_usable_as_dict_key(self): + """MFCConfig should be usable as a dict key.""" + from ..state import MFCConfig + + d = {MFCConfig(): "default", MFCConfig(debug=True): "debug"} + self.assertEqual(d[MFCConfig()], "default") + + if __name__ == "__main__": unittest.main() diff --git a/toolchain/mfc/common.py b/toolchain/mfc/common.py index ce02e8251c..ff066c8672 100644 --- a/toolchain/mfc/common.py +++ b/toolchain/mfc/common.py @@ -1,28 +1,30 @@ -import os, yaml, typing, shutil, subprocess, logging +import logging +import os +import shutil +import subprocess +import typing +from os.path import abspath, dirname, join, normpath, realpath -from os.path import join, abspath, normpath, dirname, realpath +import yaml from .printer import cons - # Debug logging infrastructure _debug_logger = None + def setup_debug_logging(enabled: bool = False): """Setup debug logging for troubleshooting.""" - global _debug_logger # pylint: disable=global-statement + global _debug_logger # noqa: PLW0603 if enabled: - logging.basicConfig( - level=logging.DEBUG, - format='[DEBUG %(asctime)s] %(message)s', - datefmt='%H:%M:%S' - ) - _debug_logger = logging.getLogger('mfc') + logging.basicConfig(level=logging.DEBUG, format="[DEBUG %(asctime)s] %(message)s", datefmt="%H:%M:%S") + _debug_logger = logging.getLogger("mfc") _debug_logger.setLevel(logging.DEBUG) cons.print("[dim]Debug logging enabled[/dim]") else: _debug_logger = None + def debug(msg: str): """Log a debug message if debug logging is enabled.""" if _debug_logger: @@ -30,13 +32,13 @@ def debug(msg: str): cons.print(f"[dim][DEBUG][/dim] {msg}") -MFC_ROOT_DIR = abspath(normpath(f"{dirname(realpath(__file__))}/../..")) -MFC_TEST_DIR = abspath(join(MFC_ROOT_DIR, "tests")) -MFC_BUILD_DIR = abspath(join(MFC_ROOT_DIR, "build")) -MFC_TOOLCHAIN_DIR = abspath(join(MFC_ROOT_DIR, "toolchain")) +MFC_ROOT_DIR = abspath(normpath(f"{dirname(realpath(__file__))}/../..")) +MFC_TEST_DIR = abspath(join(MFC_ROOT_DIR, "tests")) +MFC_BUILD_DIR = abspath(join(MFC_ROOT_DIR, "build")) +MFC_TOOLCHAIN_DIR = abspath(join(MFC_ROOT_DIR, "toolchain")) MFC_EXAMPLE_DIRPATH = abspath(join(MFC_ROOT_DIR, "examples")) -MFC_LOCK_FILEPATH = abspath(join(MFC_BUILD_DIR, "lock.yaml")) -MFC_TEMPLATE_DIR = abspath(join(MFC_TOOLCHAIN_DIR, "templates")) +MFC_LOCK_FILEPATH = abspath(join(MFC_BUILD_DIR, "lock.yaml")) +MFC_TEMPLATE_DIR = abspath(join(MFC_TOOLCHAIN_DIR, "templates")) MFC_BENCH_FILEPATH = abspath(join(MFC_TOOLCHAIN_DIR, "bench.yaml")) MFC_MECHANISMS_DIR = abspath(join(MFC_TOOLCHAIN_DIR, "mechanisms")) @@ -56,8 +58,9 @@ def debug(msg: str): class MFCException(Exception): pass -def system(command: typing.List[str], print_cmd = None, **kwargs) -> subprocess.CompletedProcess: - cmd = [ str(x) for x in command if not isspace(str(x)) ] + +def system(command: typing.List[str], print_cmd=None, **kwargs) -> subprocess.CompletedProcess: + cmd = [str(x) for x in command if not isspace(str(x))] if print_cmd in [True, None]: cons.print(f"$ {' '.join(cmd)}") @@ -127,12 +130,12 @@ def delete_directory(dirpath: str) -> None: def get_program_output(arguments: typing.List[str] = None, cwd=None): - with subprocess.Popen([ str(_) for _ in arguments ] or [], cwd=cwd, stdout=subprocess.PIPE) as proc: + with subprocess.Popen([str(_) for _ in arguments] or [], cwd=cwd, stdout=subprocess.PIPE) as proc: return (proc.communicate()[0].decode(), proc.returncode) def get_py_program_output(filepath: str, arguments: typing.List[str] = None): - dirpath = os.path.abspath (os.path.dirname(filepath)) + dirpath = os.path.abspath(os.path.dirname(filepath)) filename = os.path.basename(filepath) return get_program_output(["python3", filename] + arguments, cwd=dirpath) @@ -169,7 +172,7 @@ def format_list_to_string(arr: list, item_style=None, empty=None): pre, post = "", "" if item_style is not None: - pre = f"[{item_style}]" + pre = f"[{item_style}]" post = f"[/{item_style}]" if len(arr) == 0: @@ -181,7 +184,7 @@ def format_list_to_string(arr: list, item_style=None, empty=None): if len(arr) == 2: return f"{pre}{arr[0]}{post} and {pre}{arr[1]}{post}" - lhs = ', '.join([ f"{pre}{e}{post}" for e in arr[:-1]]) + lhs = ", ".join([f"{pre}{e}{post}" for e in arr[:-1]]) rhs = f", and {pre}{arr[-1]}{post}" return lhs + rhs @@ -199,7 +202,6 @@ def find(predicate, arr: list): return None, None -# pylint: disable=redefined-builtin def quit(sig): os.kill(os.getpid(), sig) @@ -229,27 +231,26 @@ def is_number(x: str) -> bool: def get_cpuinfo(): if does_command_exist("lscpu"): # Linux - with subprocess.Popen(['lscpu'], stdout=subprocess.PIPE, universal_newlines=True) as proc: + with subprocess.Popen(["lscpu"], stdout=subprocess.PIPE, universal_newlines=True) as proc: output = f"From lscpu\n{proc.communicate()[0]}" elif does_command_exist("sysctl"): # MacOS - with subprocess.Popen(['sysctl', '-a'], stdout=subprocess.PIPE) as proc1: - with subprocess.Popen(['grep', 'machdep.cpu'], stdin=proc1.stdout, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True) as proc2: - proc1.stdout.close() # Allow proc1 to receive a SIGPIPE if proc2 exits. + with subprocess.Popen(["sysctl", "-a"], stdout=subprocess.PIPE) as proc1: + with subprocess.Popen(["grep", "machdep.cpu"], stdin=proc1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) as proc2: + proc1.stdout.close() # Allow proc1 to receive a SIGPIPE if proc2 exits. output = f"From sysctl -a \n{proc2.communicate()[0]}" else: output = "No CPU info found" return f"CPU Info:\n{output}" + def generate_git_tagline() -> str: if not does_command_exist("git"): return "Could not find git" - rev = system(["git", "rev-parse", "HEAD"], print_cmd=False, stdout=subprocess.PIPE).stdout.decode().strip() + rev = system(["git", "rev-parse", "HEAD"], print_cmd=False, stdout=subprocess.PIPE).stdout.decode().strip() branch = system(["git", "rev-parse", "--abbrev-ref", "HEAD"], print_cmd=False, stdout=subprocess.PIPE).stdout.decode().strip() - dirty = "dirty" if system(["git", "diff", "--quiet"], print_cmd=False).returncode != 0 else "clean" + dirty = "dirty" if system(["git", "diff", "--quiet"], print_cmd=False).returncode != 0 else "clean" return f"{rev} on {branch} ({dirty})" diff --git a/toolchain/mfc/completion.py b/toolchain/mfc/completion.py index 908abbde14..43f209971b 100644 --- a/toolchain/mfc/completion.py +++ b/toolchain/mfc/completion.py @@ -9,9 +9,8 @@ import shutil from pathlib import Path -from .printer import cons from .common import MFC_ROOT_DIR - +from .printer import cons # Installation directory (user-local, independent of MFC clone location) COMPLETION_INSTALL_DIR = Path.home() / ".local" / "share" / "mfc" / "completions" @@ -173,7 +172,7 @@ def show_status(): else: cons.print(" [dim]✗ Zsh completion not installed[/dim]") else: - cons.print(f" [dim]✗ Not installed[/dim]") + cons.print(" [dim]✗ Not installed[/dim]") cons.print() @@ -191,7 +190,6 @@ def show_status(): def completion(): """Main entry point for completion command.""" - # pylint: disable=import-outside-toplevel from .state import ARG action = ARG("completion_action") diff --git a/toolchain/mfc/count.py b/toolchain/mfc/count.py index 5bd0314c17..3e809c1eb3 100644 --- a/toolchain/mfc/count.py +++ b/toolchain/mfc/count.py @@ -1,23 +1,27 @@ -import os, glob, typing, typing +import glob +import os +import typing + import rich.table -from .state import ARG -from .common import MFC_ROOT_DIR, format_list_to_string, MFCException +from .common import MFC_ROOT_DIR, MFCException, format_list_to_string from .printer import cons +from .state import ARG + def handle_dir(mfc_dir: str, srcdirname: str) -> typing.Tuple[typing.Dict[str, int], int]: files = {} total = 0 - for filepath in glob.glob(os.path.join(mfc_dir, 'src', srcdirname, '*.*f*')): + for filepath in glob.glob(os.path.join(mfc_dir, "src", srcdirname, "*.*f*")): with open(filepath) as f: counter = 0 - for l in f.read().split('\n'): + for line in f.read().split("\n"): # Skip whitespace - if l.isspace() or len(l) == 0: + if line.isspace() or len(line) == 0: continue # Skip comments but not !$acc ones! - if l.lstrip().startswith("!") and not l.lstrip().startswith("!$acc"): + if line.lstrip().startswith("!") and not line.lstrip().startswith("!$acc"): continue counter += 1 @@ -26,14 +30,15 @@ def handle_dir(mfc_dir: str, srcdirname: str) -> typing.Tuple[typing.Dict[str, i return (files, total) + def count(): - target_str_list = format_list_to_string(ARG('targets'), 'magenta') + target_str_list = format_list_to_string(ARG("targets"), "magenta") cons.print(f"[bold]Counting lines of code in {target_str_list}[/bold] (excluding whitespace lines)") cons.indent() total = 0 - for codedir in ['common'] + ARG("targets"): + for codedir in ["common"] + ARG("targets"): dirfiles, dircount = handle_dir(MFC_ROOT_DIR, codedir) table = rich.table.Table(show_header=True, box=rich.table.box.SIMPLE) table.add_column(f"File (in [magenta]{codedir}[/magenta])", justify="left") @@ -50,21 +55,21 @@ def count(): cons.print() cons.unindent() -# pylint: disable=too-many-locals + def count_diff(): - target_str_list = format_list_to_string(ARG('targets'), 'magenta') + target_str_list = format_list_to_string(ARG("targets"), "magenta") cons.print(f"[bold]Counting lines of code in {target_str_list}[/bold] (excluding whitespace lines)") cons.indent() total = 0 - MFC_COMPARE_DIR=os.getenv('MFC_PR') + MFC_COMPARE_DIR = os.getenv("MFC_PR") if MFC_COMPARE_DIR is None: raise MFCException("MFC_PR is not in your environment.") - print('compare dir', MFC_COMPARE_DIR) + print("compare dir", MFC_COMPARE_DIR) # MFC_COMPARE_DIR="/Users/spencer/Downloads/MFC-shbfork" - for codedir in ['common'] + ARG("targets"): + for codedir in ["common"] + ARG("targets"): dirfiles_root, dircount_root = handle_dir(MFC_ROOT_DIR, codedir) dirfiles_pr, dircount_pr = handle_dir(MFC_COMPARE_DIR, codedir) table = rich.table.Table(show_header=True, box=rich.table.box.SIMPLE) @@ -78,17 +83,19 @@ def count_diff(): dirfiles_root[filepath] = dirfiles_root.get(filepath, 0) dirfiles_pr[filepath] = dirfiles_pr.get(filepath, 0) - PLUS = "++ " + PLUS = "++ " MINUS = "-- " diff_count = dirfiles_pr[filepath] - dirfiles_root[filepath] mycolor = "red" if diff_count > 0 else "green" mysymbol = PLUS if diff_count > 0 else MINUS - table.add_row(os.path.basename(filepath), - f"[bold cyan]{dirfiles_root[filepath]}[/bold cyan]", - f"[bold cyan]{dirfiles_pr[filepath]}[/bold cyan]", - mysymbol, - f"[bold {mycolor}]{diff_count}[/bold {mycolor}]") + table.add_row( + os.path.basename(filepath), + f"[bold cyan]{dirfiles_root[filepath]}[/bold cyan]", + f"[bold cyan]{dirfiles_pr[filepath]}[/bold cyan]", + mysymbol, + f"[bold {mycolor}]{diff_count}[/bold {mycolor}]", + ) total += dircount_root diff --git a/toolchain/mfc/gen_case_constraints_docs.py b/toolchain/mfc/gen_case_constraints_docs.py index ffb70357e0..96f569cbd7 100644 --- a/toolchain/mfc/gen_case_constraints_docs.py +++ b/toolchain/mfc/gen_case_constraints_docs.py @@ -6,17 +6,17 @@ maps them to parameters and stages, and emits Markdown to stdout. Also generates case design playbook from curated working examples. -""" # pylint: disable=too-many-lines +""" from __future__ import annotations import json -import sys import subprocess +import sys +from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Iterable, Any -from collections import defaultdict +from typing import Any, Dict, Iterable, List HERE = Path(__file__).resolve().parent CASE_VALIDATOR_PATH = HERE / "case_validator.py" @@ -28,20 +28,23 @@ if _toolchain_dir not in sys.path: sys.path.insert(0, _toolchain_dir) -from mfc.params import CONSTRAINTS, DEPENDENCIES, get_value_label # noqa: E402 pylint: disable=wrong-import-position -from mfc.params.ast_analyzer import ( # noqa: E402 pylint: disable=wrong-import-position - Rule, classify_message, feature_title, +from mfc.params import CONSTRAINTS, DEPENDENCIES, get_value_label # noqa: E402 +from mfc.params.ast_analyzer import ( # noqa: E402 + Rule, analyze_case_validator, + classify_message, + feature_title, ) - # --------------------------------------------------------------------------- # Case Playbook Generation (from working examples) # --------------------------------------------------------------------------- + @dataclass class PlaybookEntry: """A curated example case for the playbook""" + case_dir: str title: str description: str @@ -52,68 +55,16 @@ class PlaybookEntry: # Curated list of hero examples PLAYBOOK_EXAMPLES = [ PlaybookEntry( - "2D_shockbubble", - "2D Shock-Bubble Interaction", - "Two-fluid shock-interface benchmark. Classic validation case for compressible multiphase flows.", - "Beginner", - ["2D", "Multiphase", "Shock"] - ), - PlaybookEntry( - "1D_bubblescreen", - "1D Bubble Screen", - "Euler-Euler ensemble-averaged bubble dynamics through shock wave.", - "Intermediate", - ["1D", "Bubbles", "Euler-Euler"] - ), - PlaybookEntry( - "2D_lagrange_bubblescreen", - "2D Lagrangian Bubble Screen", - "Individual bubble tracking with Euler-Lagrange method.", - "Intermediate", - ["2D", "Bubbles", "Euler-Lagrange"] - ), - PlaybookEntry( - "2D_phasechange_bubble", - "2D Phase Change Bubble", - "Phase change and cavitation modeling with 6-equation model.", - "Advanced", - ["2D", "Phase-change", "Cavitation"] - ), - PlaybookEntry( - "2D_orszag_tang", - "2D Orszag-Tang MHD Vortex", - "Magnetohydrodynamics test problem with complex vortex structures.", - "Intermediate", - ["2D", "MHD"] - ), - PlaybookEntry( - "2D_ibm_airfoil", - "2D IBM Airfoil", - "Immersed boundary method around a NACA airfoil geometry.", - "Intermediate", - ["2D", "IBM", "Geometry"] - ), - PlaybookEntry( - "2D_viscous_shock_tube", - "2D Viscous Shock Tube", - "Shock tube with viscous effects and heat transfer.", - "Intermediate", - ["2D", "Viscous", "Shock"] - ), - PlaybookEntry( - "3D_TaylorGreenVortex", - "3D Taylor-Green Vortex", - "Classic 3D turbulence benchmark with viscous dissipation.", - "Advanced", - ["3D", "Viscous", "Turbulence"] - ), - PlaybookEntry( - "2D_IGR_triple_point", - "2D IGR Triple Point", - "Triple point problem using Iterative Generalized Riemann solver.", - "Advanced", - ["2D", "IGR", "Multiphase"] + "2D_shockbubble", "2D Shock-Bubble Interaction", "Two-fluid shock-interface benchmark. Classic validation case for compressible multiphase flows.", "Beginner", ["2D", "Multiphase", "Shock"] ), + PlaybookEntry("1D_bubblescreen", "1D Bubble Screen", "Euler-Euler ensemble-averaged bubble dynamics through shock wave.", "Intermediate", ["1D", "Bubbles", "Euler-Euler"]), + PlaybookEntry("2D_lagrange_bubblescreen", "2D Lagrangian Bubble Screen", "Individual bubble tracking with Euler-Lagrange method.", "Intermediate", ["2D", "Bubbles", "Euler-Lagrange"]), + PlaybookEntry("2D_phasechange_bubble", "2D Phase Change Bubble", "Phase change and cavitation modeling with 6-equation model.", "Advanced", ["2D", "Phase-change", "Cavitation"]), + PlaybookEntry("2D_orszag_tang", "2D Orszag-Tang MHD Vortex", "Magnetohydrodynamics test problem with complex vortex structures.", "Intermediate", ["2D", "MHD"]), + PlaybookEntry("2D_ibm_airfoil", "2D IBM Airfoil", "Immersed boundary method around a NACA airfoil geometry.", "Intermediate", ["2D", "IBM", "Geometry"]), + PlaybookEntry("2D_viscous_shock_tube", "2D Viscous Shock Tube", "Shock tube with viscous effects and heat transfer.", "Intermediate", ["2D", "Viscous", "Shock"]), + PlaybookEntry("3D_TaylorGreenVortex", "3D Taylor-Green Vortex", "Classic 3D turbulence benchmark with viscous dissipation.", "Advanced", ["3D", "Viscous", "Turbulence"]), + PlaybookEntry("2D_IGR_triple_point", "2D IGR Triple Point", "Triple point problem using Iterative Generalized Riemann solver.", "Advanced", ["2D", "IGR", "Multiphase"]), ] @@ -144,13 +95,7 @@ def load_case_params(case_dir: str) -> Dict[str, Any]: return {} try: - result = subprocess.run( - ["python3", str(case_path)], - capture_output=True, - text=True, - timeout=10, - check=True - ) + result = subprocess.run(["python3", str(case_path)], capture_output=True, text=True, timeout=10, check=True) params = json.loads(result.stdout) return params except (subprocess.CalledProcessError, json.JSONDecodeError, subprocess.TimeoutExpired) as e: @@ -208,7 +153,7 @@ def get_time_stepper_name(stepper: int | None) -> str: return get_value_label("time_stepper", stepper) or "Not specified" -def render_playbook_card(entry: PlaybookEntry, summary: Dict[str, Any]) -> str: # pylint: disable=too-many-branches,too-many-statements +def render_playbook_card(entry: PlaybookEntry, summary: Dict[str, Any]) -> str: """Render a single playbook entry as Markdown""" lines = [] @@ -216,49 +161,49 @@ def render_playbook_card(entry: PlaybookEntry, summary: Dict[str, Any]) -> str: level_emoji = {"Beginner": "🟢", "Intermediate": "🟡", "Advanced": "🔴"}.get(entry.level, "") lines.append("
") - lines.append(f'{entry.title} {level_emoji} {entry.level} · {entry.case_dir}\n') + lines.append(f"{entry.title} {level_emoji} {entry.level} · {entry.case_dir}\n") lines.append(f"**{entry.description}**\n") lines.append(f"**Tags:** {tags_str}\n") lines.append("**Physics Configuration:**\n") lines.append(f"- **Model:** {get_model_name(summary['model_eqns'])} (`model_eqns = {summary['model_eqns']}`)") - if summary['num_fluids'] is not None: + if summary["num_fluids"] is not None: lines.append(f"- **Number of fluids:** {summary['num_fluids']}") # Dimensionality - n, p = summary['n'], summary['p'] + n, p = summary["n"], summary["p"] dim_str = "3D" if p > 0 else ("2D" if n > 0 else "1D") lines.append(f"- **Dimensionality:** {dim_str}") - if summary['cyl_coord']: + if summary["cyl_coord"]: lines.append("- **Coordinates:** Cylindrical/Axisymmetric") # Active features active_features = [] - if summary['bubbles_euler']: + if summary["bubbles_euler"]: active_features.append("Euler-Euler bubbles") - if summary['bubbles_lagrange']: + if summary["bubbles_lagrange"]: active_features.append("Euler-Lagrange bubbles") - if summary['qbmm']: + if summary["qbmm"]: active_features.append("QBMM") - if summary['polydisperse']: + if summary["polydisperse"]: active_features.append("Polydisperse") - if summary['surface_tension']: + if summary["surface_tension"]: active_features.append("Surface tension") - if summary['mhd']: + if summary["mhd"]: active_features.append("MHD") - if summary['relax']: + if summary["relax"]: active_features.append("Phase change") - if summary['hypoelasticity']: + if summary["hypoelasticity"]: active_features.append("Hypoelasticity") - if summary['viscous']: + if summary["viscous"]: active_features.append("Viscous") - if summary['ib']: + if summary["ib"]: active_features.append("Immersed boundaries") - if summary['igr']: + if summary["igr"]: active_features.append("IGR solver") - if summary['acoustic_source']: + if summary["acoustic_source"]: active_features.append("Acoustic sources") if active_features: @@ -267,36 +212,36 @@ def render_playbook_card(entry: PlaybookEntry, summary: Dict[str, Any]) -> str: # Numerics lines.append("\n**Numerical Methods:**\n") - if summary['recon_type'] == 1 and summary['weno_order']: + if summary["recon_type"] == 1 and summary["weno_order"]: lines.append(f"- **Reconstruction:** WENO-{summary['weno_order']}") - elif summary['recon_type'] == 2 and summary['muscl_order']: + elif summary["recon_type"] == 2 and summary["muscl_order"]: lines.append(f"- **Reconstruction:** MUSCL (order {summary['muscl_order']})") - if summary['riemann_solver']: - solver_name = get_riemann_solver_name(summary['riemann_solver']) + if summary["riemann_solver"]: + solver_name = get_riemann_solver_name(summary["riemann_solver"]) lines.append(f"- **Riemann solver:** {solver_name} (`riemann_solver = {summary['riemann_solver']}`)") - if summary['time_stepper']: - stepper_name = get_time_stepper_name(summary['time_stepper']) + if summary["time_stepper"]: + stepper_name = get_time_stepper_name(summary["time_stepper"]) lines.append(f"- **Time stepping:** {stepper_name}") # Links lines.append("\n**Related Documentation:**") lines.append(f"- [Model Equations (model_eqns = {summary['model_eqns']})](#model-equations)") - if summary['riemann_solver']: + if summary["riemann_solver"]: lines.append("- [Riemann Solvers](#riemann-solvers)") - if summary['bubbles_euler'] or summary['bubbles_lagrange']: + if summary["bubbles_euler"] or summary["bubbles_lagrange"]: lines.append("- [Bubble Models](#bubble-models)") - if summary['mhd']: + if summary["mhd"]: lines.append("- [MHD](#compat-physics-models)") - if summary['ib']: + if summary["ib"]: lines.append("- [Immersed Boundaries](#compat-geometry)") - if summary['viscous']: + if summary["viscous"]: lines.append("- [Viscosity](#compat-physics-models)") lines.append("\n
\n") @@ -312,9 +257,7 @@ def generate_playbook() -> str: lines.append("## 🧩 Case Design Playbook {#case-design-playbook}\n") lines.append( - "> **Learn by example:** The cases below are curated from MFC's `examples/` " - "directory and are validated, working configurations. " - "Use them as blueprints for building your own simulations.\n" + "> **Learn by example:** The cases below are curated from MFC's `examples/` directory and are validated, working configurations. Use them as blueprints for building your own simulations.\n" ) # Group by level @@ -334,7 +277,7 @@ def generate_playbook() -> str: summary = summarize_case_params(params) card = render_playbook_card(entry, summary) lines.append(card) - except Exception as e: # pylint: disable=broad-except + except Exception as e: print(f"WARNING: Failed to process playbook entry '{entry.case_dir}': {e}", file=sys.stderr) continue @@ -345,7 +288,8 @@ def generate_playbook() -> str: # Markdown rendering # --------------------------------------------------------------------------- -def render_markdown(rules: Iterable[Rule]) -> str: # pylint: disable=too-many-locals,too-many-branches,too-many-statements + +def render_markdown(rules: Iterable[Rule]) -> str: """ Render user-friendly compatibility tables and summaries. """ @@ -361,13 +305,8 @@ def render_markdown(rules: Iterable[Rule]) -> str: # pylint: disable=too-many-l lines.append("@page case_constraints Case Creator Guide\n") lines.append("# Case Creator Guide\n") - lines.append( - "> **Quick reference** for building MFC cases: working examples, compatibility rules, " - "and configuration requirements.\n" - ) - lines.append( - "> Auto-generated from `case_validator.py` and `examples/`.\n" - ) + lines.append("> **Quick reference** for building MFC cases: working examples, compatibility rules, and configuration requirements.\n") + lines.append("> Auto-generated from `case_validator.py` and `examples/`.\n") # Add playbook at the top playbook = generate_playbook() @@ -429,7 +368,7 @@ def render_markdown(rules: Iterable[Rule]) -> str: # pylint: disable=too-many-l lines.append("## 📊 Feature Compatibility {#feature-compatibility}\n") lines.append("What works together:\n") - for category, features in major_features.items(): # pylint: disable=too-many-nested-blocks + for category, features in major_features.items(): cat_id = "compat-" + category.lower().replace(" ", "-") lines.append(f"\n### {category} {{#{cat_id}}}\n") @@ -698,7 +637,7 @@ def _render_cond_parts(trigger_str, cond_dict): if not (schema_parts or dep_parts or requirements or incompatibilities or ranges or warnings): continue - lines.append(f"\n
") + lines.append("\n
") lines.append(f"{title} (`{param}`)\n") if schema_parts: @@ -743,10 +682,7 @@ def _render_cond_parts(trigger_str, cond_dict): all_warnings = [r for r in rules if r.severity == "warning"] if all_warnings: lines.append("## ⚠️ Physics Warnings {#physics-warnings}\n") - lines.append( - "These checks are **non-fatal** — they print a yellow warning but do not abort the run. " - "They catch common mistakes in initial conditions and EOS parameters.\n" - ) + lines.append("These checks are **non-fatal** — they print a yellow warning but do not abort the run. They catch common mistakes in initial conditions and EOS parameters.\n") # Group by method warnings_by_method: Dict[str, List[Rule]] = defaultdict(list) @@ -774,7 +710,7 @@ def _render_cond_parts(trigger_str, cond_dict): descs.append(r.message) desc_str = "; ".join(descs[:2]) if len(descs) > 2: - desc_str += f" (+{len(descs)-2} more)" + desc_str += f" (+{len(descs) - 2} more)" lines.append(f"| **{title}** | {stages_str} | {desc_str} |") lines.append("") @@ -791,6 +727,7 @@ def _render_cond_parts(trigger_str, cond_dict): # Main # --------------------------------------------------------------------------- + def main(as_string: bool = False) -> str: """Generate case constraints documentation. Returns markdown string.""" analysis = analyze_case_validator(CASE_VALIDATOR_PATH) diff --git a/toolchain/mfc/gen_physics_docs.py b/toolchain/mfc/gen_physics_docs.py index 027349d595..f81c689117 100644 --- a/toolchain/mfc/gen_physics_docs.py +++ b/toolchain/mfc/gen_physics_docs.py @@ -20,8 +20,8 @@ if _toolchain_dir not in sys.path: sys.path.insert(0, _toolchain_dir) -from mfc.case_validator import PHYSICS_DOCS # noqa: E402 pylint: disable=wrong-import-position -from mfc.params.ast_analyzer import ( # noqa: E402 pylint: disable=wrong-import-position +from mfc.case_validator import PHYSICS_DOCS # noqa: E402 +from mfc.params.ast_analyzer import ( # noqa: E402 Rule, analyze_case_validator, ) @@ -166,7 +166,6 @@ def _render_method(doc: dict, method_rules: List[Rule], lines: List[str]) -> Non cites = ", ".join(f"\\cite {r}" for r in doc["references"]) lines.append(f"**References:** {cites}\n") - # Undocumented checks are omitted — they are discoverable via # @ref case_constraints "Case Creator Guide". @@ -184,19 +183,13 @@ def render(rules: List[Rule]) -> str: lines: List[str] = [] lines.append("@page physics_constraints Physics Constraints\n") lines.append("# Physics Constraints Reference\n") + lines.append("> Auto-generated from `PHYSICS_DOCS` in `case_validator.py` and AST-extracted validation rules. Do not edit by hand.\n") + lines.append("This document catalogs the physics constraints enforced by MFC's case parameter validator. Constraints are organized by physical category with mathematical justifications.\n") lines.append( - "> Auto-generated from `PHYSICS_DOCS` in `case_validator.py` and " - "AST-extracted validation rules. Do not edit by hand.\n" - ) - lines.append( - "This document catalogs the physics constraints enforced by MFC's case parameter validator. " - "Constraints are organized by physical category with mathematical justifications.\n" - ) - lines.append( - "For parameter syntax and allowed values, see @ref case \"Case Files\" and " - "the @ref parameters \"Case Parameters\" reference. " + 'For parameter syntax and allowed values, see @ref case "Case Files" and ' + 'the @ref parameters "Case Parameters" reference. ' "For feature compatibility and working examples, see " - "@ref case_constraints \"Case Creator Guide\".\n" + '@ref case_constraints "Case Creator Guide".\n' ) extra_categories = [c for c in by_category if c not in CATEGORY_ORDER] diff --git a/toolchain/mfc/generate.py b/toolchain/mfc/generate.py index 78f2273890..a6a6273c38 100644 --- a/toolchain/mfc/generate.py +++ b/toolchain/mfc/generate.py @@ -4,17 +4,17 @@ This module regenerates all derived files from the single source of truth in cli/commands.py. Run `./mfc.sh generate` after modifying commands. """ -# pylint: disable=import-outside-toplevel import json +import sys from pathlib import Path -from .printer import cons -from .common import MFC_ROOT_DIR -from .state import ARG from .cli.commands import MFC_CLI_SCHEMA from .cli.completion_gen import generate_bash_completion, generate_zsh_completion from .cli.docs_gen import generate_cli_reference +from .common import MFC_ROOT_DIR +from .printer import cons +from .state import ARG def _check_or_write(path: Path, content: str, check_mode: bool) -> bool: @@ -51,8 +51,8 @@ def _constraint_docs(docs_dir: Path) -> list: def generate(): """Regenerate completion scripts and optionally JSON schema.""" - from .params.generators.json_schema_gen import generate_json_schema from .params.generators.docs_gen import generate_parameter_docs + from .params.generators.json_schema_gen import generate_json_schema check_mode = ARG("check") json_schema_mode = ARG("json_schema") @@ -72,8 +72,7 @@ def generate(): (completions_dir / "mfc.bash", generate_bash_completion(MFC_CLI_SCHEMA)), (completions_dir / "_mfc", generate_zsh_completion(MFC_CLI_SCHEMA)), (docs_dir / "cli-reference.md", generate_cli_reference(MFC_CLI_SCHEMA)), - (Path(MFC_ROOT_DIR) / "toolchain" / "mfc-case-schema.json", - json.dumps(generate_json_schema(include_descriptions=True), indent=2)), + (Path(MFC_ROOT_DIR) / "toolchain" / "mfc-case-schema.json", json.dumps(generate_json_schema(include_descriptions=True), indent=2)), (docs_dir / "parameters.md", generate_parameter_docs()), ] + _constraint_docs(docs_dir) @@ -83,7 +82,7 @@ def generate(): all_ok = False if not all_ok: - exit(1) + sys.exit(1) if not check_mode: cons.print() @@ -92,14 +91,14 @@ def generate(): def _generate_json_schema(): """Generate JSON Schema and parameter documentation (standalone mode).""" - from .params.generators.json_schema_gen import generate_json_schema, get_schema_stats - from .params.generators.docs_gen import generate_parameter_docs from .ide import update_vscode_settings + from .params.generators.docs_gen import generate_parameter_docs + from .params.generators.json_schema_gen import generate_json_schema, get_schema_stats # Generate JSON Schema schema = generate_json_schema(include_descriptions=True) schema_path = Path(MFC_ROOT_DIR) / "toolchain" / "mfc-case-schema.json" - with open(schema_path, 'w') as f: + with open(schema_path, "w") as f: json.dump(schema, f, indent=2) # Generate parameter documentation @@ -114,7 +113,7 @@ def _generate_json_schema(): cons.print(f"[green]Generated[/green] {schema_path}") cons.print(f"[green]Generated[/green] {docs_path}") cons.print() - cons.print(f"[bold]Parameter Statistics:[/bold]") + cons.print("[bold]Parameter Statistics:[/bold]") cons.print(f" Total parameters: {stats['total_params']}") cons.print(f" With constraints: {stats['with_constraints']}") cons.print(f" With descriptions: {stats['with_descriptions']}") diff --git a/toolchain/mfc/ide.py b/toolchain/mfc/ide.py index 767b67e8ae..59279f103b 100644 --- a/toolchain/mfc/ide.py +++ b/toolchain/mfc/ide.py @@ -3,7 +3,6 @@ Automatically configures IDE settings (VS Code, etc.) for MFC development. """ -# pylint: disable=import-outside-toplevel import re from pathlib import Path @@ -16,7 +15,7 @@ # The MFC schema configuration to insert # Matches common case file names - users get auto-completion for JSON/YAML case files -_VSCODE_MFC_CONFIG = '''\ +_VSCODE_MFC_CONFIG = """\ "json.schemas": [ { "fileMatch": ["**/case.json", "**/input.json", "**/mfc-case.json", "**/mfc.json"], @@ -25,7 +24,7 @@ ], "yaml.schemas": { "./toolchain/mfc-case-schema.json": ["**/case.yaml", "**/case.yml", "**/input.yaml", "**/input.yml", "**/mfc-case.yaml", "**/mfc.yaml"] - }''' + }""" def ensure_vscode_settings() -> bool: @@ -59,26 +58,21 @@ def ensure_vscode_settings() -> bool: return False # Insert before the final closing brace - last_brace = content.rfind('}') + last_brace = content.rfind("}") if last_brace != -1: # Check if we need a comma before_brace = content[:last_brace].rstrip() - needs_comma = before_brace and not before_brace.endswith('{') and not before_brace.endswith(',') - comma = ',' if needs_comma else '' - new_content = ( - content[:last_brace].rstrip() + - comma + '\n\n ' + - marked_config + '\n' + - content[last_brace:] - ) + needs_comma = before_brace and not before_brace.endswith("{") and not before_brace.endswith(",") + comma = "," if needs_comma else "" + new_content = content[:last_brace].rstrip() + comma + "\n\n " + marked_config + "\n" + content[last_brace:] else: # Malformed JSON, just append - new_content = content + '\n' + marked_config + new_content = content + "\n" + marked_config else: # Ensure .vscode directory exists vscode_dir.mkdir(exist_ok=True) # Create new settings file with just our config - new_content = f'{{\n {marked_config}\n}}\n' + new_content = f"{{\n {marked_config}\n}}\n" settings_path.write_text(new_content) return True @@ -106,31 +100,23 @@ def update_vscode_settings() -> None: content = settings_path.read_text() # Check if our markers already exist - marker_pattern = re.compile( - rf'{re.escape(_VSCODE_MARKER_BEGIN)}.*?{re.escape(_VSCODE_MARKER_END)}', - re.DOTALL - ) + marker_pattern = re.compile(rf"{re.escape(_VSCODE_MARKER_BEGIN)}.*?{re.escape(_VSCODE_MARKER_END)}", re.DOTALL) if marker_pattern.search(content): # Replace existing marked section new_content = marker_pattern.sub(marked_config, content) else: # Insert before the final closing brace - last_brace = content.rfind('}') + last_brace = content.rfind("}") if last_brace != -1: before_brace = content[:last_brace].rstrip() - needs_comma = before_brace and not before_brace.endswith('{') and not before_brace.endswith(',') - comma = ',' if needs_comma else '' - new_content = ( - content[:last_brace].rstrip() + - comma + '\n\n ' + - marked_config + '\n' + - content[last_brace:] - ) + needs_comma = before_brace and not before_brace.endswith("{") and not before_brace.endswith(",") + comma = "," if needs_comma else "" + new_content = content[:last_brace].rstrip() + comma + "\n\n " + marked_config + "\n" + content[last_brace:] else: - new_content = content + '\n' + marked_config + new_content = content + "\n" + marked_config else: - new_content = f'{{\n {marked_config}\n}}\n' + new_content = f"{{\n {marked_config}\n}}\n" settings_path.write_text(new_content) cons.print(f"[green]Updated[/green] {settings_path}") diff --git a/toolchain/mfc/init.py b/toolchain/mfc/init.py index d339d7c9df..3f811e30f7 100644 --- a/toolchain/mfc/init.py +++ b/toolchain/mfc/init.py @@ -3,14 +3,13 @@ import os import shutil -from .printer import cons from .common import MFC_EXAMPLE_DIRPATH, MFCException +from .printer import cons from .state import ARG - # Built-in minimal templates BUILTIN_TEMPLATES = { - '1D_minimal': '''\ + "1D_minimal": '''\ #!/usr/bin/env python3 """ 1D Minimal Case Template @@ -120,8 +119,7 @@ "fluid_pp(1)%pi_inf": 0.0, })) ''', - - '2D_minimal': '''\ + "2D_minimal": '''\ #!/usr/bin/env python3 """ 2D Minimal Case Template @@ -239,8 +237,7 @@ "fluid_pp(1)%pi_inf": 0.0, })) ''', - - '3D_minimal': '''\ + "3D_minimal": '''\ #!/usr/bin/env python3 """ 3D Minimal Case Template @@ -379,7 +376,7 @@ def get_available_templates(): if os.path.isdir(MFC_EXAMPLE_DIRPATH): for name in sorted(os.listdir(MFC_EXAMPLE_DIRPATH)): example_path = os.path.join(MFC_EXAMPLE_DIRPATH, name) - if os.path.isdir(example_path) and os.path.isfile(os.path.join(example_path, 'case.py')): + if os.path.isdir(example_path) and os.path.isfile(os.path.join(example_path, "case.py")): templates.append(f"example:{name}") return templates @@ -392,10 +389,10 @@ def list_templates(): cons.print(" [bold cyan]Built-in Templates:[/bold cyan]") for name in sorted(BUILTIN_TEMPLATES.keys()): desc = { - '1D_minimal': 'Minimal 1D shock tube case', - '2D_minimal': 'Minimal 2D case with circular perturbation', - '3D_minimal': 'Minimal 3D case with spherical perturbation', - }.get(name, '') + "1D_minimal": "Minimal 1D shock tube case", + "2D_minimal": "Minimal 2D case with circular perturbation", + "3D_minimal": "Minimal 3D case with spherical perturbation", + }.get(name, "") cons.print(f" [green]{name:20s}[/green] {desc}") cons.print() @@ -405,14 +402,14 @@ def list_templates(): examples = [] for name in sorted(os.listdir(MFC_EXAMPLE_DIRPATH)): example_path = os.path.join(MFC_EXAMPLE_DIRPATH, name) - if os.path.isdir(example_path) and os.path.isfile(os.path.join(example_path, 'case.py')): + if os.path.isdir(example_path) and os.path.isfile(os.path.join(example_path, "case.py")): examples.append(name) # Group by dimension - for dim in ['0D', '1D', '2D', '3D']: + for dim in ["0D", "1D", "2D", "3D"]: dim_examples = [e for e in examples if e.startswith(dim)] if dim_examples: - cons.print(f" [dim]{dim}:[/dim] {', '.join(dim_examples[:5])}", end='') + cons.print(f" [dim]{dim}:[/dim] {', '.join(dim_examples[:5])}", end="") if len(dim_examples) > 5: cons.print(f" [dim]... (+{len(dim_examples) - 5} more)[/dim]") else: @@ -437,9 +434,9 @@ def create_case(name: str, template: str): # Check if it's a built-in template if template in BUILTIN_TEMPLATES: os.makedirs(output_dir, exist_ok=True) - case_path = os.path.join(output_dir, 'case.py') + case_path = os.path.join(output_dir, "case.py") - with open(case_path, 'w') as f: + with open(case_path, "w") as f: f.write(BUILTIN_TEMPLATES[template]) os.chmod(case_path, 0o755) # Make executable @@ -453,7 +450,7 @@ def create_case(name: str, template: str): cons.print() # Check if it's an example template - elif template.startswith('example:'): + elif template.startswith("example:"): example_name = template[8:] # Remove 'example:' prefix example_path = os.path.join(MFC_EXAMPLE_DIRPATH, example_name) @@ -472,12 +469,9 @@ def create_case(name: str, template: str): cons.print() else: - available = ', '.join(list(BUILTIN_TEMPLATES.keys())[:3]) + available = ", ".join(list(BUILTIN_TEMPLATES.keys())[:3]) raise MFCException( - f"Unknown template: {template}\n" - f"Available built-in templates: {available}\n" - f"Or use 'example:' to copy from examples.\n" - f"Run './mfc.sh new --list' to see all available templates." + f"Unknown template: {template}\nAvailable built-in templates: {available}\nOr use 'example:' to copy from examples.\nRun './mfc.sh new --list' to see all available templates." ) @@ -492,12 +486,12 @@ def init(): if not name: # Show full help like ./mfc.sh new -h - # pylint: disable=import-outside-toplevel import sys - from .user_guide import print_command_help - from .cli.commands import MFC_CLI_SCHEMA + from .cli.argparse_gen import generate_parser + from .cli.commands import MFC_CLI_SCHEMA from .state import MFCConfig + from .user_guide import print_command_help print_command_help("new", show_argparse=False) _, subparser_map = generate_parser(MFC_CLI_SCHEMA, MFCConfig()) diff --git a/toolchain/mfc/lint_docs.py b/toolchain/mfc/lint_docs.py index b6e195ecc3..a2444e4a9c 100644 --- a/toolchain/mfc/lint_docs.py +++ b/toolchain/mfc/lint_docs.py @@ -36,22 +36,37 @@ # Parameter-like names to skip (not actual MFC parameters) PARAM_SKIP = re.compile( r"^(src/|toolchain/|\.github|docs/|examples/|tests/)" # file paths - r"|^\.(?:true|false)\.$" # Fortran logicals - r"|^\d" # numeric values - r"|^[A-Z]" # constants/types (uppercase start) + r"|^\.(?:true|false)\.$" # Fortran logicals + r"|^\d" # numeric values + r"|^[A-Z]" # constants/types (uppercase start) ) # Backtick tokens in case.md that are not real parameters (analytical shorthands, # stress tensor component names, prose identifiers, hardcoded constants) CASE_MD_SKIP = { # Analytical shorthand variables (stretching formulas, "Analytical Definition" table) - "eps", "lx", "ly", "lz", "xc", "yc", "zc", "x_cb", + "eps", + "lx", + "ly", + "lz", + "xc", + "yc", + "zc", + "x_cb", # Stress tensor component names (descriptive, not params) - "tau_xx", "tau_xy", "tau_xz", "tau_yy", "tau_yz", "tau_zz", + "tau_xx", + "tau_xy", + "tau_xz", + "tau_yy", + "tau_yz", + "tau_zz", # Prose identifiers (example names, math symbols) - "scaling", "c_h", "thickness", + "scaling", + "c_h", + "thickness", # Hardcoded Fortran constants (not case-file params) - "init_dir", "zeros_default", + "init_dir", + "zeros_default", } # Docs to check for parameter references, with per-file skip sets @@ -79,10 +94,7 @@ def check_docs(repo_root: Path) -> list[str]: # Strip trailing punctuation that may have leaked in path_str = path_str.rstrip(".,;:!?") if not (repo_root / path_str).exists(): - errors.append( - f" {doc} references '{path_str}' but it does not exist." - " Fix: update the path or remove the reference" - ) + errors.append(f" {doc} references '{path_str}' but it does not exist. Fix: update the path or remove the reference") return errors @@ -111,10 +123,7 @@ def check_cite_keys(repo_root: Path) -> list[str]: for match in CITE_RE.finditer(text): key = match.group(1) if key.lower() not in valid_keys: - errors.append( - f" {rel} uses \\cite {key} but no bib entry found." - " Fix: add entry to docs/references.bib or fix the key" - ) + errors.append(f" {rel} uses \\cite {key} but no bib entry found. Fix: add entry to docs/references.bib or fix the key") return errors @@ -138,14 +147,14 @@ def _is_valid_param(param: str, valid_params: set, sub_params: set) -> bool: if "(" in param or ")" in param: return True # Skip indexed refs like patch_icpp(i)%vel(j) - base = param.split("%")[0] if "%" in param else param + base = param.split("%", maxsplit=1)[0] if "%" in param else param if base in valid_params or base in sub_params: return True # Compound params (with %): validate both family prefix and attribute if "%" in param: - sub = param.split("%")[-1] + sub = param.rsplit("%", maxsplit=1)[-1] family_ok = any(p.startswith(base + "%") for p in valid_params) return family_ok and sub in sub_params @@ -156,14 +165,14 @@ def _is_valid_param(param: str, valid_params: set, sub_params: set) -> bool: return False -def check_param_refs(repo_root: Path) -> list[str]: # pylint: disable=too-many-locals +def check_param_refs(repo_root: Path) -> list[str]: """Check that parameter names in documentation exist in the MFC registry.""" # Import REGISTRY from the toolchain toolchain_dir = str(repo_root / "toolchain") if toolchain_dir not in sys.path: sys.path.insert(0, toolchain_dir) try: - from mfc.params import REGISTRY # pylint: disable=import-outside-toplevel + from mfc.params import REGISTRY except ImportError: print(" Warning: could not import REGISTRY, skipping parameter check") return [] @@ -209,10 +218,7 @@ def check_param_refs(repo_root: Path) -> list[str]: # pylint: disable=too-many- # Normalize %% to % for lookup normalized = param.replace("%%", "%") if not _is_valid_param(normalized, valid_params, sub_params): - errors.append( - f" {doc_rel} references parameter '{param}' not in REGISTRY." - " Fix: check spelling or add to definitions.py" - ) + errors.append(f" {doc_rel} references parameter '{param}' not in REGISTRY. Fix: check spelling or add to definitions.py") return errors @@ -242,18 +248,12 @@ def check_math_syntax(repo_root: Path) -> list[str]: cleaned = re.sub(r"\\f\[.*?\\f\]", "", cleaned) if "$$" in cleaned: - errors.append( - f" {rel}:{i} uses $$...$$ display math." - " Fix: replace $$ with \\f[ and \\f]" - ) + errors.append(f" {rel}:{i} uses $$...$$ display math. Fix: replace $$ with \\f[ and \\f]") continue for m in re.finditer(r"\$([^$\n]+?)\$", cleaned): if re.search(r"\\[a-zA-Z]", m.group(1)): - errors.append( - f" {rel}:{i} uses $...$ with LaTeX commands." - " Fix: replace $ delimiters with \\f$ and \\f$" - ) + errors.append(f" {rel}:{i} uses $...$ with LaTeX commands. Fix: replace $ delimiters with \\f$ and \\f$") break # one error per line return errors @@ -261,18 +261,18 @@ def check_math_syntax(repo_root: Path) -> list[str]: def _gitignored_docs(repo_root: Path) -> set[str]: """Return set of gitignored doc file basenames.""" - import subprocess # pylint: disable=import-outside-toplevel + import subprocess doc_dir = repo_root / "docs" / "documentation" try: result = subprocess.run( ["git", "ls-files", "--ignored", "--exclude-standard", "-o"], - capture_output=True, text=True, cwd=repo_root, check=False, + capture_output=True, + text=True, + cwd=repo_root, + check=False, ) - return { - Path(f).name for f in result.stdout.splitlines() - if f.startswith(str(doc_dir.relative_to(repo_root))) - } + return {Path(f).name for f in result.stdout.splitlines() if f.startswith(str(doc_dir.relative_to(repo_root)))} except FileNotFoundError: return set() @@ -312,12 +312,7 @@ def check_section_anchors(repo_root: Path) -> list[str]: continue for m in re.finditer(r"\]\(#([\w-]+)\)", line): if m.group(1) not in anchors: - errors.append( - f" {rel}:{i} links to #{m.group(1)}" - f" but no {{#{m.group(1)}}} anchor exists." - f" Fix: add {{#{m.group(1)}}} to the target" - " section header" - ) + errors.append(f" {rel}:{i} links to #{m.group(1)} but no {{#{m.group(1)}}} anchor exists. Fix: add {{#{m.group(1)}}} to the target section header") return errors @@ -355,10 +350,7 @@ def check_doxygen_percent(repo_root: Path) -> list[str]: span = m.group(1) or m.group(2) if bad_pct_re.search(span): fixed = bad_pct_re.sub("%%", span) - errors.append( - f" {rel}:{i} Doxygen will eat the % in `{span}`." - f" Fix: `{fixed}`" - ) + errors.append(f" {rel}:{i} Doxygen will eat the % in `{span}`. Fix: `{fixed}`") return errors @@ -396,10 +388,7 @@ def check_page_refs(repo_root: Path) -> list[str]: for match in REF_RE.finditer(text): ref_target = match.group(1) if ref_target not in page_ids: - errors.append( - f" {rel} uses @ref {ref_target} but no @page with that ID exists." - " Fix: check the page ID or add @page declaration" - ) + errors.append(f" {rel} uses @ref {ref_target} but no @page with that ID exists. Fix: check the page ID or add @page declaration") return errors @@ -410,8 +399,8 @@ def check_physics_docs_coverage(repo_root: Path) -> list[str]: if toolchain_dir not in sys.path: sys.path.insert(0, toolchain_dir) try: - from mfc.case_validator import PHYSICS_DOCS # pylint: disable=import-outside-toplevel - from mfc.params.ast_analyzer import analyze_case_validator # pylint: disable=import-outside-toplevel + from mfc.case_validator import PHYSICS_DOCS + from mfc.params.ast_analyzer import analyze_case_validator except ImportError: return [] @@ -419,28 +408,28 @@ def check_physics_docs_coverage(repo_root: Path) -> list[str]: # references, and explanation) to case_validator.py to remove from this set. skip = { # Structural/mechanical checks (no physics meaning) - "check_parameter_types", # type validation - "check_output_format", # output format selection - "check_restart", # restart file logistics + "check_parameter_types", # type validation + "check_output_format", # output format selection + "check_restart", # restart file logistics "check_parallel_io_pre_process", # parallel I/O settings - "check_build_flags", # build-flag compatibility (no physics meaning) + "check_build_flags", # build-flag compatibility (no physics meaning) "check_geometry_precision_simulation", # build-flag compatibility (no physics meaning) - "check_misc_pre_process", # miscellaneous pre-process flags - "check_bc_patches", # boundary patch geometry - "check_grid_stretching", # grid stretching parameters - "check_qbmm_pre_process", # QBMM pre-process settings + "check_misc_pre_process", # miscellaneous pre-process flags + "check_bc_patches", # boundary patch geometry + "check_grid_stretching", # grid stretching parameters + "check_qbmm_pre_process", # QBMM pre-process settings "check_probe_integral_output", # probe/integral output settings - "check_finite_difference", # fd_order value validation - "check_flux_limiter", # output dimension requirements - "check_liutex_post", # output dimension requirements - "check_momentum_post", # output dimension requirements - "check_velocity_post", # output dimension requirements + "check_finite_difference", # fd_order value validation + "check_flux_limiter", # output dimension requirements + "check_liutex_post", # output dimension requirements + "check_momentum_post", # output dimension requirements + "check_velocity_post", # output dimension requirements "check_surface_tension_post", # output feature dependency - "check_no_flow_variables", # output variable selection - "check_partial_domain", # output format settings - "check_perturb_density", # parameter pairing validation - "check_qm", # output dimension requirements - "check_chemistry", # runtime Cantera validation only + "check_no_flow_variables", # output variable selection + "check_partial_domain", # output format settings + "check_perturb_density", # parameter pairing validation + "check_qm", # output dimension requirements + "check_chemistry", # runtime Cantera validation only # Awaiting proper physics documentation (math, references, explanation) "check_adaptive_time_stepping", "check_adv_n", @@ -474,11 +463,7 @@ def check_physics_docs_coverage(repo_root: Path) -> list[str]: continue if method in skip: continue - errors.append( - f" {method} has validation rules but no PHYSICS_DOCS entry." - " Fix: add entry to PHYSICS_DOCS in case_validator.py" - " or add to skip set in lint_docs.py" - ) + errors.append(f" {method} has validation rules but no PHYSICS_DOCS entry. Fix: add entry to PHYSICS_DOCS in case_validator.py or add to skip set in lint_docs.py") return errors @@ -508,17 +493,11 @@ def check_identifier_refs(repo_root: Path) -> list[str]: continue source_path = repo_root / source_file if not source_path.exists(): - errors.append( - f" contributing.md references `{identifier}` in {source_file}" - f" but {source_file} does not exist" - ) + errors.append(f" contributing.md references `{identifier}` in {source_file} but {source_file} does not exist") continue source_text = source_path.read_text(encoding="utf-8") if identifier not in source_text: - errors.append( - f" contributing.md references `{identifier}` but it was not" - f" found in {source_file}. Fix: update the docs or the identifier" - ) + errors.append(f" contributing.md references `{identifier}` but it was not found in {source_file}. Fix: update the docs or the identifier") return errors @@ -533,7 +512,7 @@ def check_cli_refs(repo_root: Path) -> list[str]: if toolchain_dir not in sys.path: sys.path.insert(0, toolchain_dir) try: - from mfc.cli.commands import MFC_CLI_SCHEMA # pylint: disable=import-outside-toplevel + from mfc.cli.commands import MFC_CLI_SCHEMA except ImportError: return [] @@ -554,11 +533,7 @@ def check_cli_refs(repo_root: Path) -> list[str]: seen.add(cmd) continue seen.add(cmd) - errors.append( - f" running.md references './mfc.sh {cmd}' but '{cmd}'" - " is not a known CLI command." - " Fix: update the command name or remove the reference" - ) + errors.append(f" running.md references './mfc.sh {cmd}' but '{cmd}' is not a known CLI command. Fix: update the command name or remove the reference") return errors @@ -590,36 +565,23 @@ def check_unpaired_math(repo_root: Path) -> list[str]: # Count \f$ occurrences (should be even per line for inline math) inline_count = len(re.findall(r"\\f\$", line)) if inline_count % 2 != 0: - errors.append( - f" {rel}:{i} has {inline_count} \\f$ delimiter(s) (odd)." - " Fix: ensure every \\f$ has a matching closing \\f$" - ) + errors.append(f" {rel}:{i} has {inline_count} \\f$ delimiter(s) (odd). Fix: ensure every \\f$ has a matching closing \\f$") # Track \f[ / \f] balance opens = len(re.findall(r"\\f\[", line)) closes = len(re.findall(r"\\f\]", line)) for _ in range(opens): if display_math_open: - errors.append( - f" {rel}:{i} opens \\f[ but previous \\f[" - f" from line {display_math_open} is still open." - " Fix: add missing \\f]" - ) + errors.append(f" {rel}:{i} opens \\f[ but previous \\f[ from line {display_math_open} is still open. Fix: add missing \\f]") display_math_open = i for _ in range(closes): if not display_math_open: - errors.append( - f" {rel}:{i} has \\f] without a preceding \\f[." - " Fix: add missing \\f[ or remove extra \\f]" - ) + errors.append(f" {rel}:{i} has \\f] without a preceding \\f[. Fix: add missing \\f[ or remove extra \\f]") else: display_math_open = 0 if display_math_open: - errors.append( - f" {rel}:{display_math_open} opens \\f[ that is never closed." - " Fix: add \\f] to close the display math block" - ) + errors.append(f" {rel}:{display_math_open} opens \\f[ that is never closed. Fix: add \\f] to close the display math block") return errors @@ -627,19 +589,45 @@ def check_unpaired_math(repo_root: Path) -> list[str]: # Doxygen block commands that are incorrectly processed inside backtick # code spans (known Doxygen bug, see github.com/doxygen/doxygen/issues/6054). _DOXYGEN_BLOCK_CMDS = { - "code", "endcode", "verbatim", "endverbatim", - "dot", "enddot", "msc", "endmsc", - "startuml", "enduml", - "latexonly", "endlatexonly", "htmlonly", "endhtmlonly", - "xmlonly", "endxmlonly", "rtfonly", "endrtfonly", - "manonly", "endmanonly", "docbookonly", "enddocbookonly", - "todo", "deprecated", "bug", "test", - "note", "warning", "attention", "remark", - "brief", "details", "param", "return", "returns", + "code", + "endcode", + "verbatim", + "endverbatim", + "dot", + "enddot", + "msc", + "endmsc", + "startuml", + "enduml", + "latexonly", + "endlatexonly", + "htmlonly", + "endhtmlonly", + "xmlonly", + "endxmlonly", + "rtfonly", + "endrtfonly", + "manonly", + "endmanonly", + "docbookonly", + "enddocbookonly", + "todo", + "deprecated", + "bug", + "test", + "note", + "warning", + "attention", + "remark", + "brief", + "details", + "param", + "return", + "returns", } -def check_doxygen_commands_in_backticks(repo_root: Path) -> list[str]: # pylint: disable=too-many-locals +def check_doxygen_commands_in_backticks(repo_root: Path) -> list[str]: """Check for Doxygen @/\\ commands inside backtick code spans. Doxygen processes certain block commands even inside backtick code @@ -674,11 +662,7 @@ def check_doxygen_commands_in_backticks(repo_root: Path) -> list[str]: # pylint cmd_match = doxy_cmd_re.search(span) if cmd_match: cmd = cmd_match.group(0) - errors.append( - f" {rel}:{i} backtick span contains Doxygen" - f" command '{cmd}' which may be processed." - " Fix: use a fenced code block or rephrase" - ) + errors.append(f" {rel}:{i} backtick span contains Doxygen command '{cmd}' which may be processed. Fix: use a fenced code block or rephrase") return errors @@ -713,11 +697,7 @@ def check_single_quote_in_backtick(repo_root: Path) -> list[str]: for m in single_bt_re.finditer(line): span = m.group(1) if "'" in span: - errors.append( - f" {rel}:{i} single-backtick span `{span}` contains" - " a single quote, which Doxygen treats as ending the" - f" span. Fix: use double backticks ``{span}``" - ) + errors.append(f" {rel}:{i} single-backtick span `{span}` contains a single quote, which Doxygen treats as ending the span. Fix: use double backticks ``{span}``") return errors @@ -734,7 +714,7 @@ def check_single_quote_in_backtick(repo_root: Path) -> list[str]: } -def check_amsmath_in_doxygen_math(repo_root: Path) -> list[str]: # pylint: disable=too-many-locals +def check_amsmath_in_doxygen_math(repo_root: Path) -> list[str]: """Flag AMSmath-only commands in Doxygen math that may not render.""" doc_dir = repo_root / "docs" / "documentation" if not doc_dir.exists(): @@ -766,10 +746,7 @@ def check_amsmath_in_doxygen_math(repo_root: Path) -> list[str]: # pylint: disa for m in inline_re.finditer(line): for cm in ams_re.finditer(m.group(1)): alt = _AMSMATH_ONLY_CMDS[cm.group(1)] - errors.append( - f" {rel}:{i} uses \\{cm.group(1)} (AMSmath) in" - f" math. Fix: use \\{alt} instead" - ) + errors.append(f" {rel}:{i} uses \\{cm.group(1)} (AMSmath) in math. Fix: use \\{alt} instead") # Check display math lines between \f[ and \f] if "\\f[" in line: @@ -777,10 +754,7 @@ def check_amsmath_in_doxygen_math(repo_root: Path) -> list[str]: # pylint: disa if in_display: for cm in ams_re.finditer(line): alt = _AMSMATH_ONLY_CMDS[cm.group(1)] - errors.append( - f" {rel}:{i} uses \\{cm.group(1)} (AMSmath) in" - f" math. Fix: use \\{alt} instead" - ) + errors.append(f" {rel}:{i} uses \\{cm.group(1)} (AMSmath) in math. Fix: use \\{alt} instead") if "\\f]" in line: in_display = False @@ -823,10 +797,7 @@ def check_module_briefs(repo_root: Path) -> list[str]: if not found_brief: rel = fpp.relative_to(repo_root) - errors.append( - f" {rel} has no module-level !> @brief before the module declaration." - " Fix: add '!> @brief ' on the line before 'module ...'" - ) + errors.append(f" {rel} has no module-level !> @brief before the module declaration. Fix: add '!> @brief ' on the line before 'module ...'") return errors @@ -856,7 +827,7 @@ def check_module_categories(repo_root: Path) -> list[str]: cats = ", ".join(e["category"] for e in categories) errors.append( f" {rel}: module {name} is not in docs/module_categories.json.\n" - f" Fix: open docs/module_categories.json and add \"{name}\" to one of: {cats}.\n" + f' Fix: open docs/module_categories.json and add "{name}" to one of: {cats}.\n' f" This ensures it appears on the Code Architecture page." ) diff --git a/toolchain/mfc/lock.py b/toolchain/mfc/lock.py index a4e0514dcd..02a8732f9b 100644 --- a/toolchain/mfc/lock.py +++ b/toolchain/mfc/lock.py @@ -1,16 +1,16 @@ -import os, dataclasses +import dataclasses +import os -from . import state, common -from .state import MFCConfig +from . import common, state from .printer import cons - +from .state import MFCConfig MFC_LOCK_CURRENT_VERSION: int = 8 @dataclasses.dataclass class MFCLockData: - config: MFCConfig + config: MFCConfig version: int @@ -18,12 +18,11 @@ class MFCLockData: def init(): - # pylint: disable=global-statement - global data + global data # noqa: PLW0603 if not os.path.exists(common.MFC_LOCK_FILEPATH): config = MFCConfig() - data = MFCLockData(config, MFC_LOCK_CURRENT_VERSION) + data = MFCLockData(config, MFC_LOCK_CURRENT_VERSION) state.gCFG = config common.create_file(common.MFC_LOCK_FILEPATH) @@ -33,8 +32,7 @@ def init(): def load(): - # pylint: disable=global-statement - global data + global data # noqa: PLW0603 d = common.file_load_yaml(common.MFC_LOCK_FILEPATH) @@ -48,20 +46,18 @@ def load(): """) config = MFCConfig.from_dict(d["config"]) - data = MFCLockData(config, d["version"]) + data = MFCLockData(config, d["version"]) state.gCFG = config def write(): - # pylint: disable=global-statement, global-variable-not-assigned - global data + global data # noqa: PLW0603 common.file_dump_yaml(common.MFC_LOCK_FILEPATH, dataclasses.asdict(data)) def switch(to: MFCConfig): - # pylint: disable=global-statement, global-variable-not-assigned - global data + global data # noqa: PLW0603 if to == data.config: return @@ -70,5 +66,5 @@ def switch(to: MFCConfig): cons.print("") data.config = to - state.gCFG = to + state.gCFG = to write() diff --git a/toolchain/mfc/packer/errors.py b/toolchain/mfc/packer/errors.py index 66ab8d4463..93cd76acf5 100644 --- a/toolchain/mfc/packer/errors.py +++ b/toolchain/mfc/packer/errors.py @@ -1,4 +1,6 @@ -import dataclasses, math +import dataclasses +import math + @dataclasses.dataclass(repr=False) class Error: @@ -24,18 +26,17 @@ def compute_error(measured: float, expected: float) -> Error: class AverageError: accumulated: Error - count: int + count: int def __init__(self) -> None: self.accumulated = Error(0, 0) - self.count = 0 + self.count = 0 def get(self) -> Error: if self.count == 0: return Error(0, 0) - return Error(self.accumulated.absolute / self.count, - self.accumulated.relative / self.count) + return Error(self.accumulated.absolute / self.count, self.accumulated.relative / self.count) def push(self, error: Error) -> None: # Do not include nans in the result diff --git a/toolchain/mfc/packer/pack.py b/toolchain/mfc/packer/pack.py index 1ad1a76834..12dc139885 100644 --- a/toolchain/mfc/packer/pack.py +++ b/toolchain/mfc/packer/pack.py @@ -1,23 +1,26 @@ -import dataclasses, typing, sys, os, re, math +import dataclasses +import math +import os +import re +import sys +import typing from datetime import datetime from pathlib import Path -from .. import common -from ..run import input -from ..build import get_configured_targets -from ..state import CFG - - +from .. import common +from ..build import get_configured_targets +from ..run import input +from ..state import CFG # This class maps to the data contained in one file in D/ @dataclasses.dataclass(repr=False) class PackEntry: filepath: str - doubles: typing.List[float] + doubles: typing.List[float] def __repr__(self) -> str: - return f"{self.filepath} {' '.join([ str(d) for d in self.doubles ])}" + return f"{self.filepath} {' '.join([str(d) for d in self.doubles])}" # This class maps to the data contained in the entirety of D/: a dictionary @@ -53,14 +56,14 @@ def save(self, filepath: str): if not filepath.endswith(".txt"): filepath += ".txt" - common.file_write(filepath, '\n'.join([ str(e) for e in sorted(self.entries.values(), key=lambda x: x.filepath) ])) + common.file_write(filepath, "\n".join([str(e) for e in sorted(self.entries.values(), key=lambda x: x.filepath)])) metadata = f"""\ This file was created on {str(datetime.now())}. mfc.sh: - Invocation: {' '.join(sys.argv[1:])} + Invocation: {" ".join(sys.argv[1:])} Lock: {CFG()} Git: {common.generate_git_tagline()} @@ -76,13 +79,13 @@ def save(self, filepath: str): metadata += f"""\ {target.name}: - {' '.join(cfg.splitlines(keepends=True))} + {" ".join(cfg.splitlines(keepends=True))} """ metadata += f"""\ CPU: - {' '.join(common.get_cpuinfo().splitlines(keepends=True))} + {" ".join(common.get_cpuinfo().splitlines(keepends=True))} """ common.file_write(f"{filepath.rstrip('.txt')}-metadata.txt", metadata) @@ -106,12 +109,9 @@ def load(filepath: str) -> Pack: if common.isspace(line): continue - arr = line.split(' ') + arr = line.split(" ") - entries.append(PackEntry( - filepath=arr[0], - doubles=[ float(d) for d in arr[1:] ] - )) + entries.append(PackEntry(filepath=arr[0], doubles=[float(d) for d in arr[1:]])) return Pack(entries) @@ -120,16 +120,15 @@ def compile(casepath: str) -> typing.Tuple[Pack, str]: entries = [] case_dir = os.path.dirname(casepath) if os.path.isfile(casepath) else casepath - D_dir = os.path.join(case_dir, "D") + D_dir = os.path.join(case_dir, "D") for filepath in list(Path(D_dir).rglob("*.dat")): - short_filepath = str(filepath).replace(f'{case_dir}', '')[1:].replace("\\", "/") - content = common.file_read(filepath) + short_filepath = str(filepath).replace(f"{case_dir}", "")[1:].replace("\\", "/") + content = common.file_read(filepath) # Takes a string of numbers and returns them as a list of floats. def _extract_doubles(s: str) -> list: - return [ float(e) for e in re.sub(r"[\n\t\s]+", " ", s).strip().split(' ') ] - + return [float(e) for e in re.sub(r"[\n\t\s]+", " ", s).strip().split(" ")] try: if "lag_bubble" in short_filepath: @@ -139,13 +138,13 @@ def _extract_doubles(s: str) -> list: else: # Every line is ( and are optional). So the # number of dimensions is the number of doubles in the first line minus 1. - ndims = len(_extract_doubles(content.split('\n', 1)[0])) - 1 + ndims = len(_extract_doubles(content.split("\n", 1)[0])) - 1 # We discard all values and only keep the ones. # This is in an effort to save on storage. - doubles = _extract_doubles(content)[ndims::ndims+1] + doubles = _extract_doubles(content)[ndims :: ndims + 1] except ValueError: return None, f"Failed to interpret the content of [magenta]{filepath}[/magenta] as a list of floating point numbers." - entries.append(PackEntry(short_filepath,doubles)) + entries.append(PackEntry(short_filepath, doubles)) return Pack(entries), None diff --git a/toolchain/mfc/packer/packer.py b/toolchain/mfc/packer/packer.py index 0ae6671e45..1b35361724 100644 --- a/toolchain/mfc/packer/packer.py +++ b/toolchain/mfc/packer/packer.py @@ -1,15 +1,18 @@ -import typing, os.path +import os.path +import typing +from ..common import MFCException from ..printer import cons from ..state import ARG, ARGS -from . import pack as _pack from . import errors +from . import pack as _pack from . import tol as packtol -from ..common import MFCException + def load(packpath: str) -> _pack.Pack: return _pack.load(packpath) + def pack(casepath: str, packpath: str = None) -> typing.Tuple[_pack.Pack, str]: if packpath is None: packpath = casepath @@ -23,6 +26,7 @@ def pack(casepath: str, packpath: str = None) -> typing.Tuple[_pack.Pack, str]: return p, None + def compare(lhs: str = None, rhs: str = None, tol: packtol.Tolerance = None) -> typing.Tuple[errors.Error, str]: if isinstance(lhs, str): lhs = load(lhs) @@ -31,6 +35,7 @@ def compare(lhs: str = None, rhs: str = None, tol: packtol.Tolerance = None) -> return packtol.compare(lhs, rhs, tol) + def packer(): if ARG("packer") == "pack": if not os.path.isdir(ARG("input")): @@ -41,10 +46,7 @@ def packer(): if err is not None: raise MFCException(err) elif ARG("packer") == "compare": - cons.print( - f"Comparing [magenta]{os.path.relpath(ARG('input1'))}[/magenta] to " - f"[magenta]{os.path.relpath(ARG('input2'))}[/magenta]:" - ) + cons.print(f"Comparing [magenta]{os.path.relpath(ARG('input1'))}[/magenta] to [magenta]{os.path.relpath(ARG('input2'))}[/magenta]:") cons.indent() cons.print() diff --git a/toolchain/mfc/packer/tol.py b/toolchain/mfc/packer/tol.py index 2ec80f61e7..21a8906b66 100644 --- a/toolchain/mfc/packer/tol.py +++ b/toolchain/mfc/packer/tol.py @@ -1,10 +1,12 @@ -import math, typing +import math +import typing -from .pack import Pack -from .errors import compute_error, AverageError, Error +from .errors import AverageError, Error, compute_error +from .pack import Pack Tolerance = Error + def is_close(error: Error, tolerance: Tolerance) -> bool: if error.absolute <= tolerance.absolute: return True @@ -25,28 +27,31 @@ def _format_error_diagnostics(max_abs_info, max_rel_info) -> str: if max_abs_info: filepath, val_idx, g_val, c_val, abs_err, rel_err = max_abs_info rel_error_str = f"{rel_err:.2E}" if not math.isnan(rel_err) else "NaN" - diagnostic_msg += f"\n\nDiagnostics - Maximum absolute error among FAILING variables:\n" \ - f" - File: {filepath}\n" \ - f" - Variable n°{val_idx+1}\n" \ - f" - Candidate: {c_val}\n" \ - f" - Golden: {g_val}\n" \ - f" - Absolute Error: {abs_err:.2E}\n" \ - f" - Relative Error: {rel_error_str}" + diagnostic_msg += ( + f"\n\nDiagnostics - Maximum absolute error among FAILING variables:\n" + f" - File: {filepath}\n" + f" - Variable n°{val_idx + 1}\n" + f" - Candidate: {c_val}\n" + f" - Golden: {g_val}\n" + f" - Absolute Error: {abs_err:.2E}\n" + f" - Relative Error: {rel_error_str}" + ) if max_rel_info: filepath, val_idx, g_val, c_val, rel_err, abs_err = max_rel_info - diagnostic_msg += f"\n\nDiagnostics - Maximum relative error among FAILING variables:\n" \ - f" - File: {filepath}\n" \ - f" - Variable n°{val_idx+1}\n" \ - f" - Candidate: {c_val}\n" \ - f" - Golden: {g_val}\n" \ - f" - Relative Error: {rel_err:.2E}\n" \ - f" - Absolute Error: {abs_err:.2E}" + diagnostic_msg += ( + f"\n\nDiagnostics - Maximum relative error among FAILING variables:\n" + f" - File: {filepath}\n" + f" - Variable n°{val_idx + 1}\n" + f" - Candidate: {c_val}\n" + f" - Golden: {g_val}\n" + f" - Relative Error: {rel_err:.2E}\n" + f" - Absolute Error: {abs_err:.2E}" + ) return diagnostic_msg -# pylint: disable=too-many-return-statements def compare(candidate: Pack, golden: Pack, tol: Tolerance) -> typing.Tuple[Error, str]: # Keep track of the average error avg_err = AverageError() @@ -78,13 +83,16 @@ def raise_err_with_failing_diagnostics(msg: str): max_abs_info, max_rel_info = find_maximum_errors_among_failing(candidate, golden, tol) diagnostic_msg = _format_error_diagnostics(max_abs_info, max_rel_info) - return None, f"""\ -Variable n°{valIndex+1} (1-indexed) in {gFilepath} {msg}: + return ( + None, + f"""\ +Variable n°{valIndex + 1} (1-indexed) in {gFilepath} {msg}: - Candidate: {cVal} - Golden: {gVal} - Error: {error} - Tolerance: {tol}{diagnostic_msg} -""" +""", + ) if math.isnan(gVal): return raise_err_with_failing_diagnostics("is NaN in the golden file") @@ -97,7 +105,11 @@ def raise_err_with_failing_diagnostics(msg: str): return avg_err.get(), None -def find_maximum_errors_among_failing(candidate: Pack, golden: Pack, tol: Tolerance) -> typing.Tuple[typing.Optional[typing.Tuple[str, int, float, float, float, float]], typing.Optional[typing.Tuple[str, int, float, float, float, float]]]: +def find_maximum_errors_among_failing( + candidate: Pack, + golden: Pack, + tol: Tolerance, +) -> typing.Tuple[typing.Optional[typing.Tuple[str, int, float, float, float, float]], typing.Optional[typing.Tuple[str, int, float, float, float, float]]]: """ Scan all files to find the maximum absolute and relative errors among FAILING variables only. A variable fails if is_close(error, tol) returns False. diff --git a/toolchain/mfc/params/__init__.py b/toolchain/mfc/params/__init__.py index 57e1912276..65267b34fe 100644 --- a/toolchain/mfc/params/__init__.py +++ b/toolchain/mfc/params/__init__.py @@ -19,15 +19,19 @@ register new parameters will raise RegistryFrozenError. """ -from .registry import REGISTRY, RegistryFrozenError -from .schema import ParamDef, ParamType - # IMPORTANT: This import populates REGISTRY with all parameter definitions # and freezes it. It must come after REGISTRY is imported and must not be removed. -from . import definitions # noqa: F401 pylint: disable=unused-import +from . import definitions # noqa: F401 from .definitions import CONSTRAINTS, DEPENDENCIES, get_value_label +from .registry import REGISTRY, RegistryFrozenError +from .schema import ParamDef, ParamType __all__ = [ - 'REGISTRY', 'RegistryFrozenError', 'ParamDef', 'ParamType', - 'CONSTRAINTS', 'DEPENDENCIES', 'get_value_label', + "REGISTRY", + "RegistryFrozenError", + "ParamDef", + "ParamType", + "CONSTRAINTS", + "DEPENDENCIES", + "get_value_label", ] diff --git a/toolchain/mfc/params/ast_analyzer.py b/toolchain/mfc/params/ast_analyzer.py index 1bd841f1a7..de7ba7e9d4 100644 --- a/toolchain/mfc/params/ast_analyzer.py +++ b/toolchain/mfc/params/ast_analyzer.py @@ -11,31 +11,32 @@ import ast import re +from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Set -from collections import defaultdict - # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- + @dataclass class Rule: - method: str # e.g. "check_igr_simulation" - lineno: int # line number of the prohibit/warn call - params: List[str] # case parameter names used in condition - message: str # user-friendly error/warning message + method: str # e.g. "check_igr_simulation" + lineno: int # line number of the prohibit/warn call + params: List[str] # case parameter names used in condition + message: str # user-friendly error/warning message stages: Set[str] = field(default_factory=set) # e.g. {"simulation", "pre_process"} - trigger: Optional[str] = None # param that "owns" this rule - severity: str = "error" # "error" (prohibit) or "warning" (warn) + trigger: Optional[str] = None # param that "owns" this rule + severity: str = "error" # "error" (prohibit) or "warning" (warn) # --------------------------------------------------------------------------- # F-string message extraction # --------------------------------------------------------------------------- + def _extract_message(node: ast.AST) -> Optional[str]: """ Extract the message string from a prohibit() call's second argument. @@ -56,7 +57,7 @@ def _extract_message(node: ast.AST) -> Optional[str]: # Unparse the expression to get a readable approximation try: parts.append(ast.unparse(value.value)) - except Exception: # pylint: disable=broad-except + except Exception: parts.append("?") else: parts.append("?") @@ -77,7 +78,7 @@ def _resolve_fstring(node: ast.JoinedStr, subs: Dict[str, str]) -> Optional[str] else: try: parts.append(ast.unparse(v.value)) - except Exception: # pylint: disable=broad-except + except Exception: parts.append("?") else: parts.append("?") @@ -95,18 +96,15 @@ def _resolve_message(msg_node: ast.AST, subs: Dict[str, str]) -> Optional[str]: def _is_self_get(call: ast.Call) -> bool: """Check if a Call node is self.get(...).""" - return (isinstance(call.func, ast.Attribute) - and isinstance(call.func.value, ast.Name) - and call.func.value.id == "self" - and call.func.attr == "get" - and bool(call.args)) + return isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name) and call.func.value.id == "self" and call.func.attr == "get" and bool(call.args) # --------------------------------------------------------------------------- # AST analysis: methods, call graph, rules # --------------------------------------------------------------------------- -class CaseValidatorAnalyzer(ast.NodeVisitor): # pylint: disable=too-many-instance-attributes + +class CaseValidatorAnalyzer(ast.NodeVisitor): """ Analyzes the CaseValidator class: @@ -179,15 +177,13 @@ def _analyze_method(self, func: ast.FunctionDef): self.local_param_stack.pop() self.current_method = None - def _enrich_rules_with_if_guards(self, func: ast.FunctionDef, - local_param_map: Dict[str, str], - alias_map: Dict[str, List[str]]): + def _enrich_rules_with_if_guards(self, func: ast.FunctionDef, local_param_map: Dict[str, str], alias_map: Dict[str, List[str]]): """ After rules are extracted, walk the function body for ast.If nodes. For each if-block, extract guard params from the test condition and add them to every rule whose lineno falls within the block's line range. """ - for node in ast.walk(func): # pylint: disable=too-many-nested-blocks + for node in ast.walk(func): if not isinstance(node, ast.If): continue # Extract params from the if-test condition @@ -215,7 +211,7 @@ def _enrich_rules_with_if_guards(self, func: ast.FunctionDef, rule.params.append(gp) break - def _build_local_param_map(self, func: ast.FunctionDef) -> Dict[str, str]: # pylint: disable=too-many-nested-blocks + def _build_local_param_map(self, func: ast.FunctionDef) -> Dict[str, str]: """ Look for assignments like: igr = self.get('igr', 'F') == 'T' @@ -225,7 +221,7 @@ def _build_local_param_map(self, func: ast.FunctionDef) -> Dict[str, str]: # py Uses ast.walk to find assignments at any nesting depth (inside if/for/with blocks). """ m: Dict[str, str] = {} - for node in ast.walk(func): # pylint: disable=too-many-nested-blocks + for node in ast.walk(func): if isinstance(node, ast.Assign): # Handle both direct calls and comparisons value = node.value @@ -235,7 +231,7 @@ def _build_local_param_map(self, func: ast.FunctionDef) -> Dict[str, str]: # py if isinstance(value, ast.Call): call = value - if ( # pylint: disable=too-many-boolean-expressions + if ( isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name) and call.func.value.id == "self" @@ -251,8 +247,7 @@ def _build_local_param_map(self, func: ast.FunctionDef) -> Dict[str, str]: # py return m @staticmethod - def _build_alias_map(func: ast.FunctionDef, - local_param_map: Dict[str, str]) -> Dict[str, List[str]]: + def _build_alias_map(func: ast.FunctionDef, local_param_map: Dict[str, str]) -> Dict[str, List[str]]: """ Detect boolean alias assignments like: variable_dt = cfl_dt or cfl_adap_dt # BoolOp(Or) @@ -285,14 +280,10 @@ def _expand_literal_loops(self, func: ast.FunctionDef, local_param_map: Dict[str """Expand `for var in [x, y, z]:` loops into concrete Rules.""" self._expand_loop_stmts(func.body, func.name, local_param_map, {}) - def _expand_loop_stmts(self, stmts: list, method_name: str, - parent_map: Dict[str, str], subs: Dict[str, str]): + def _expand_loop_stmts(self, stmts: list, method_name: str, parent_map: Dict[str, str], subs: Dict[str, str]): """Recursively find literal-list for-loops and create expanded Rules.""" for stmt in stmts: - if (isinstance(stmt, ast.For) - and isinstance(stmt.target, ast.Name) - and isinstance(stmt.iter, ast.List) - and all(isinstance(e, ast.Constant) for e in stmt.iter.elts)): + if isinstance(stmt, ast.For) and isinstance(stmt.target, ast.Name) and isinstance(stmt.iter, ast.List) and all(isinstance(e, ast.Constant) for e in stmt.iter.elts): var = stmt.target.id for elt in stmt.iter.elts: new_subs = {**subs, var: str(elt.value)} @@ -318,9 +309,8 @@ def _detect_loop_guard(stmts: list, local_map: Dict[str, str]) -> Optional[str]: if not isinstance(stmt, ast.If): continue test = stmt.test - if (isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not) - and isinstance(test.operand, ast.Name)): - if (len(stmt.body) == 1 and isinstance(stmt.body[0], ast.Continue)): + if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not) and isinstance(test.operand, ast.Name): + if len(stmt.body) == 1 and isinstance(stmt.body[0], ast.Continue): var_name = test.operand.id if var_name in local_map: return local_map[var_name] @@ -354,25 +344,18 @@ def _resolve_loop_gets(stmts: list, subs: Dict[str, str]) -> Dict[str, str]: m[target.id] = param_name return m - def _create_loop_rules(self, stmts: list, method_name: str, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals - local_map: Dict[str, str], subs: Dict[str, str], - loop_guard: Optional[str] = None): + def _create_loop_rules(self, stmts: list, method_name: str, local_map: Dict[str, str], subs: Dict[str, str], loop_guard: Optional[str] = None): """Create Rules for self.prohibit()/self.warn() calls found in loop body statements.""" for stmt in stmts: # Skip nested literal-list for-loops (handled by recursion) - if (isinstance(stmt, ast.For) - and isinstance(stmt.target, ast.Name) - and isinstance(stmt.iter, ast.List) - and all(isinstance(e, ast.Constant) for e in stmt.iter.elts)): + if isinstance(stmt, ast.For) and isinstance(stmt.target, ast.Name) and isinstance(stmt.iter, ast.List) and all(isinstance(e, ast.Constant) for e in stmt.iter.elts): continue for node in ast.walk(stmt): if not isinstance(node, ast.Call): continue - if not (isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "self" - and node.func.attr in ("prohibit", "warn") - and len(node.args) >= 2): + if not ( + isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" and node.func.attr in ("prohibit", "warn") and len(node.args) >= 2 + ): continue severity = "warning" if node.func.attr == "warn" else "error" condition, msg_node = node.args[0], node.args[1] @@ -385,8 +368,7 @@ def _create_loop_rules(self, stmts: list, method_name: str, # pylint: disable=t trigger = loop_guard param_set.add(loop_guard) else: - trigger = self._determine_trigger( - sorted(param_set), condition, local_map) + trigger = self._determine_trigger(sorted(param_set), condition, local_map) params = sorted(param_set) rule = Rule( method=method_name, @@ -399,9 +381,7 @@ def _create_loop_rules(self, stmts: list, method_name: str, # pylint: disable=t self.rules.append(rule) self._expanded_prohibit_lines.add(node.lineno) - def _extract_params_with_subs(self, condition: ast.AST, - local_map: Dict[str, str], - subs: Dict[str, str]) -> Set[str]: + def _extract_params_with_subs(self, condition: ast.AST, local_map: Dict[str, str], subs: Dict[str, str]) -> Set[str]: """Like _extract_params but also resolves JoinedStr self.get() args.""" params: Set[str] = set() for node in ast.walk(condition): @@ -421,12 +401,7 @@ def _extract_params_with_subs(self, condition: ast.AST, def visit_Call(self, node: ast.Call): # record method call edges: self.some_method(...) - if ( - isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "self" - and isinstance(node.func.attr, str) - ): + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" and isinstance(node.func.attr, str): callee = node.func.attr if self.current_method is not None: # method call on self @@ -434,7 +409,7 @@ def visit_Call(self, node: ast.Call): # detect self.prohibit(, "") and self.warn(, "") # Skip calls already handled by loop expansion - if ( # pylint: disable=too-many-boolean-expressions + if ( isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" @@ -461,8 +436,7 @@ def visit_Call(self, node: ast.Call): self.generic_visit(node) - def _determine_trigger(self, _params: List[str], condition: ast.AST, - local_map: Dict[str, str]) -> Optional[str]: + def _determine_trigger(self, _params: List[str], condition: ast.AST, local_map: Dict[str, str]) -> Optional[str]: """Determine trigger param: method guard first, then condition fallback.""" # 1. Method guard (high confidence) if self.current_method and self.current_method in self._method_guards: @@ -493,7 +467,7 @@ def _extract_params(self, condition: ast.AST) -> Set[str]: # direct self.get('param_name') if isinstance(node, ast.Call): - if ( # pylint: disable=too-many-boolean-expressions + if ( isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" @@ -511,6 +485,7 @@ def _extract_params(self, condition: ast.AST) -> Set[str]: # Trigger detection helpers # --------------------------------------------------------------------------- + def _extract_method_guard(func: ast.FunctionDef, local_param_map: Dict[str, str]) -> Optional[str]: """ Detect early-return guard patterns like: @@ -518,7 +493,7 @@ def _extract_method_guard(func: ast.FunctionDef, local_param_map: Dict[str, str] return The guarded variable's param is the trigger for all rules in that method. """ - for stmt in func.body: # pylint: disable=too-many-nested-blocks + for stmt in func.body: if not isinstance(stmt, ast.If): continue @@ -528,9 +503,7 @@ def _extract_method_guard(func: ast.FunctionDef, local_param_map: Dict[str, str] if isinstance(test.operand, ast.Name): var_name = test.operand.id # Check body is just "return" - if (len(stmt.body) == 1 - and isinstance(stmt.body[0], ast.Return) - and stmt.body[0].value is None): + if len(stmt.body) == 1 and isinstance(stmt.body[0], ast.Return) and stmt.body[0].value is None: if var_name in local_param_map: return local_param_map[var_name] @@ -540,17 +513,14 @@ def _extract_method_guard(func: ast.FunctionDef, local_param_map: Dict[str, str] if isinstance(test.ops[0], ast.NotEq): if isinstance(test.left, ast.Name): var_name = test.left.id - if (len(stmt.body) == 1 - and isinstance(stmt.body[0], ast.Return) - and stmt.body[0].value is None): + if len(stmt.body) == 1 and isinstance(stmt.body[0], ast.Return) and stmt.body[0].value is None: if var_name in local_param_map: return local_param_map[var_name] return None -def _extract_test_params(test: ast.AST, local_param_map: Dict[str, str], - alias_map: Dict[str, List[str]]) -> Set[str]: +def _extract_test_params(test: ast.AST, local_param_map: Dict[str, str], alias_map: Dict[str, List[str]]) -> Set[str]: """Extract parameter names from an if-test condition, resolving aliases.""" params: Set[str] = set() for node in ast.walk(test): @@ -566,8 +536,7 @@ def _extract_test_params(test: ast.AST, local_param_map: Dict[str, str], return params -def _extract_trigger_from_condition(condition: ast.AST, local_param_map: Dict[str, str], - alias_map: Optional[Dict[str, List[str]]] = None) -> Optional[str]: +def _extract_trigger_from_condition(condition: ast.AST, local_param_map: Dict[str, str], alias_map: Optional[Dict[str, List[str]]] = None) -> Optional[str]: """ Fallback trigger detection: walk the condition AST left-to-right, return the first parameter name found. Resolves aliases to their first source param. @@ -582,7 +551,7 @@ def _extract_trigger_from_condition(condition: ast.AST, local_param_map: Dict[st if node.id in alias_map and alias_map[node.id]: return alias_map[node.id][0] if isinstance(node, ast.Call): - if ( # pylint: disable=too-many-boolean-expressions + if ( isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" @@ -638,6 +607,7 @@ def dfs(start: str, stage: str): # Classification of messages for nicer grouping # --------------------------------------------------------------------------- + def classify_message(msg: str) -> str: """ Roughly classify rule messages for nicer grouping. @@ -646,7 +616,7 @@ def classify_message(msg: str) -> str: """ text = msg.lower() - if ( # pylint: disable=too-many-boolean-expressions + if ( "not compatible" in text or "does not support" in text or "cannot be used" in text @@ -660,7 +630,7 @@ def classify_message(msg: str) -> str: ): return "incompatibility" - if ( # pylint: disable=too-many-boolean-expressions + if ( "requires" in text or "must be set if" in text or "must be specified" in text @@ -671,7 +641,7 @@ def classify_message(msg: str) -> str: ): return "requirement" - if ( # pylint: disable=too-many-boolean-expressions + if ( "must be between" in text or "must be positive" in text or "must be non-negative" in text diff --git a/toolchain/mfc/params/definitions.py b/toolchain/mfc/params/definitions.py index 4b9c98cd7f..8760c9bca2 100644 --- a/toolchain/mfc/params/definitions.py +++ b/toolchain/mfc/params/definitions.py @@ -3,12 +3,13 @@ Single file containing all ~3,300 parameter definitions using loops. This replaces the definitions/ directory. -""" # pylint: disable=too-many-lines +""" import re -from typing import Dict, Any -from .schema import ParamDef, ParamType +from typing import Any, Dict + from .registry import REGISTRY +from .schema import ParamDef, ParamType # Index limits NP, NF, NI, NA, NPR, NB = 10, 10, 1000, 4, 10, 10 # patches, fluids, ibs, acoustic, probes, bc_patches @@ -337,12 +338,26 @@ def _auto_describe(name: str) -> str: # Last resort: clean up the name return name.replace("_", " ").replace("%", " ") + # Parameters that can be hard-coded for GPU case optimization CASE_OPT_PARAMS = { - "mapped_weno", "wenoz", "teno", "wenoz_q", "nb", "weno_order", - "num_fluids", "mhd", "relativity", "igr_order", "viscous", - "igr_iter_solver", "igr", "igr_pres_lim", "recon_type", - "muscl_order", "muscl_lim" + "mapped_weno", + "wenoz", + "teno", + "wenoz_q", + "nb", + "weno_order", + "num_fluids", + "mhd", + "relativity", + "igr_order", + "viscous", + "igr_iter_solver", + "igr", + "igr_pres_lim", + "recon_type", + "muscl_order", + "muscl_lim", } @@ -428,24 +443,24 @@ def _auto_describe(name: str) -> str: def _lookup_hint(name): """Auto-derive constraint hint from HINTS dict using family+attribute matching.""" - if '%' not in name: + if "%" not in name: # Check PREFIX_HINTS for simple params for prefix, label in PREFIX_HINTS.items(): if name.startswith(prefix): return label return "" # Compound name: extract family and attribute - prefix, attr_full = name.split('%', 1) + prefix, attr_full = name.split("%", 1) # Normalize family: "bc_x" → "bc", "patch_bc(1)" → "patch" - family = re.sub(r'[_(].*', '', prefix) + family = re.sub(r"[_(].*", "", prefix) if family not in HINTS: # Fallback: keep underscores — "patch_bc" → "patch_bc", "simplex_params" → "simplex_params" - m = re.match(r'^[a-zA-Z_]+', prefix) + m = re.match(r"^[a-zA-Z_]+", prefix) family = m.group(0) if m else "" if family not in HINTS: return "" # Strip index from attr: "vel_in(1)" → "vel_in" - m = re.match(r'^[a-zA-Z_0-9]+', attr_full) + m = re.match(r"^[a-zA-Z_0-9]+", attr_full) if not m: return "" attr = m.group(0) @@ -465,19 +480,13 @@ def _lookup_hint(name): def _validate_constraint(param_name: str, constraint: Dict[str, Any]) -> None: """Validate a constraint dict has valid keys with 'did you mean?' suggestions.""" # Import here to avoid circular import at module load time - from .suggest import invalid_key_error # pylint: disable=import-outside-toplevel + from .suggest import invalid_key_error invalid_keys = set(constraint.keys()) - _VALID_CONSTRAINT_KEYS if invalid_keys: # Get suggestion for the first invalid key first_invalid = next(iter(invalid_keys)) - raise ValueError( - invalid_key_error( - f"constraint for '{param_name}'", - first_invalid, - _VALID_CONSTRAINT_KEYS - ) - ) + raise ValueError(invalid_key_error(f"constraint for '{param_name}'", first_invalid, _VALID_CONSTRAINT_KEYS)) # Validate types if "choices" in constraint and not isinstance(constraint["choices"], list): @@ -492,63 +501,37 @@ def _validate_constraint(param_name: str, constraint: Dict[str, Any]) -> None: if "choices" in constraint: for key in constraint["value_labels"]: if key not in constraint["choices"]: - raise ValueError( - f"value_labels key {key!r} for '{param_name}' " - f"not in choices {constraint['choices']}" - ) + raise ValueError(f"value_labels key {key!r} for '{param_name}' not in choices {constraint['choices']}") def _validate_dependency(param_name: str, dependency: Dict[str, Any]) -> None: """Validate a dependency dict has valid structure with 'did you mean?' suggestions.""" # Import here to avoid circular import at module load time - from .suggest import invalid_key_error # pylint: disable=import-outside-toplevel + from .suggest import invalid_key_error invalid_keys = set(dependency.keys()) - _VALID_DEPENDENCY_KEYS if invalid_keys: first_invalid = next(iter(invalid_keys)) - raise ValueError( - invalid_key_error( - f"dependency for '{param_name}'", - first_invalid, - _VALID_DEPENDENCY_KEYS - ) - ) + raise ValueError(invalid_key_error(f"dependency for '{param_name}'", first_invalid, _VALID_DEPENDENCY_KEYS)) def _validate_condition(cond_label: str, condition: Any) -> None: """Validate a condition dict (shared by when_true, when_set, when_value entries).""" if not isinstance(condition, dict): - raise ValueError( - f"Dependency '{cond_label}' for '{param_name}' must be a dict" - ) + raise ValueError(f"Dependency '{cond_label}' for '{param_name}' must be a dict") invalid_cond_keys = set(condition.keys()) - _VALID_CONDITION_KEYS if invalid_cond_keys: first_invalid = next(iter(invalid_cond_keys)) - raise ValueError( - invalid_key_error( - f"condition in '{cond_label}' for '{param_name}'", - first_invalid, - _VALID_CONDITION_KEYS - ) - ) + raise ValueError(invalid_key_error(f"condition in '{cond_label}' for '{param_name}'", first_invalid, _VALID_CONDITION_KEYS)) for req_key in ["requires", "recommends"]: if req_key in condition and not isinstance(condition[req_key], list): - raise ValueError( - f"Dependency '{cond_label}/{req_key}' for '{param_name}' " - "must be a list" - ) + raise ValueError(f"Dependency '{cond_label}/{req_key}' for '{param_name}' must be a list") if "requires_value" in condition: rv = condition["requires_value"] if not isinstance(rv, dict): - raise ValueError( - f"Dependency '{cond_label}/requires_value' for '{param_name}' " - "must be a dict" - ) + raise ValueError(f"Dependency '{cond_label}/requires_value' for '{param_name}' must be a dict") for rv_param, rv_vals in rv.items(): if not isinstance(rv_vals, list): - raise ValueError( - f"Dependency '{cond_label}/requires_value/{rv_param}' " - f"for '{param_name}' must be a list" - ) + raise ValueError(f"Dependency '{cond_label}/requires_value/{rv_param}' for '{param_name}' must be a list") for condition_key in ["when_true", "when_set"]: if condition_key in dependency: @@ -557,9 +540,7 @@ def _validate_condition(cond_label: str, condition: Any) -> None: if "when_value" in dependency: wv = dependency["when_value"] if not isinstance(wv, dict): - raise ValueError( - f"Dependency 'when_value' for '{param_name}' must be a dict" - ) + raise ValueError(f"Dependency 'when_value' for '{param_name}' must be a dict") for val, condition in wv.items(): _validate_condition(f"when_value/{val}", condition) @@ -610,13 +591,11 @@ def get_value_label(param_name: str, value: int) -> str: "choices": [1, 2, 3, 4, 5], "value_labels": {1: "minmod", 2: "MC", 3: "Van Albada", 4: "Van Leer", 5: "SUPERBEE"}, }, - # Time stepping "time_stepper": { "choices": [1, 2, 3], "value_labels": {1: "RK1 (Forward Euler)", 2: "RK2", 3: "RK3 (SSP)"}, }, - # Riemann solver "riemann_solver": { "choices": [1, 2, 3, 4, 5], @@ -630,19 +609,16 @@ def get_value_label(param_name: str, value: int) -> str: "choices": [1, 2], "value_labels": {1: "Roe", 2: "arithmetic"}, }, - # Model equations "model_eqns": { "choices": [1, 2, 3, 4], "value_labels": {1: "Gamma-law", 2: "5-Equation", 3: "6-Equation", 4: "4-Equation"}, }, - # Bubbles "bubble_model": { "choices": [1, 2, 3], "value_labels": {1: "Gilmore", 2: "Keller-Miksis", 3: "Rayleigh-Plesset"}, }, - # Output "format": { "choices": [1, 2], @@ -652,7 +628,6 @@ def get_value_label(param_name: str, value: int) -> str: "choices": [1, 2], "value_labels": {1: "single", 2: "double"}, }, - # Time stepping (must be positive) "dt": {"min": 0}, "t_stop": {"min": 0}, @@ -661,14 +636,11 @@ def get_value_label(param_name: str, value: int) -> str: "t_step_print": {"min": 1}, "cfl_target": {"min": 0}, "cfl_max": {"min": 0}, - # WENO "weno_eps": {"min": 0}, - # Physics (must be non-negative) "R0ref": {"min": 0}, "sigma": {"min": 0}, - # Counts (must be positive) "num_fluids": {"min": 1, "max": 10}, "num_patches": {"min": 0, "max": 10}, @@ -808,7 +780,8 @@ def get_value_label(param_name: str, value: int) -> str: }, } -def _r(name, ptype, tags=None, desc=None, hint=None, math=None): # pylint: disable=too-many-arguments,too-many-positional-arguments + +def _r(name, ptype, tags=None, desc=None, hint=None, math=None): """Register a parameter with optional feature tags and description.""" if hint is None: hint = _lookup_hint(name) @@ -818,20 +791,22 @@ def _r(name, ptype, tags=None, desc=None, hint=None, math=None): # pylint: disa labels = constraint["value_labels"] suffix = ", ".join(f"{v}={labels[v]}" for v in sorted(labels)) description = f"{description} ({suffix})" - REGISTRY.register(ParamDef( - name=name, - param_type=ptype, - description=description, - case_optimization=(name in CASE_OPT_PARAMS), - constraints=constraint, - dependencies=DEPENDENCIES.get(name), - tags=tags if tags else set(), - hint=hint, - math_symbol=math or "", - )) - - -def _load(): # pylint: disable=too-many-locals,too-many-statements + REGISTRY.register( + ParamDef( + name=name, + param_type=ptype, + description=description, + case_optimization=(name in CASE_OPT_PARAMS), + constraints=constraint, + dependencies=DEPENDENCIES.get(name), + tags=tags if tags else set(), + hint=hint, + math_symbol=math or "", + ) + ) + + +def _load(): """Load all parameter definitions.""" INT, REAL, LOG, STR = ParamType.INT, ParamType.REAL, ParamType.LOG, ParamType.STR A_REAL = ParamType.ANALYTIC_REAL @@ -855,8 +830,7 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r(f"{d}_domain%end", REAL, {"grid"}) # --- Time stepping --- - for n in ["time_stepper", "t_step_old", "t_step_start", "t_step_stop", - "t_step_save", "t_step_print", "adap_dt_max_iters"]: + for n in ["time_stepper", "t_step_old", "t_step_start", "t_step_stop", "t_step_save", "t_step_print", "adap_dt_max_iters"]: _r(n, INT, {"time"}) _r("dt", REAL, {"time"}, math=r"\f$\Delta t\f$") _r("cfl_target", REAL, {"time"}, math=r"\f$\mathrm{CFL}\f$") @@ -931,22 +905,56 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r("precision", INT, {"output"}) _r("format", INT, {"output"}) _r("schlieren_alpha", REAL, {"output"}) - for n in ["parallel_io", "file_per_process", "run_time_info", "prim_vars_wrt", - "cons_vars_wrt", "fft_wrt"]: + for n in ["parallel_io", "file_per_process", "run_time_info", "prim_vars_wrt", "cons_vars_wrt", "fft_wrt"]: _r(n, LOG, {"output"}) - for n in ["schlieren_wrt", "alpha_rho_wrt", "rho_wrt", "mom_wrt", "vel_wrt", - "flux_wrt", "E_wrt", "pres_wrt", "alpha_wrt", "kappa_wrt", "gamma_wrt", - "heat_ratio_wrt", "pi_inf_wrt", "pres_inf_wrt", "c_wrt", - "omega_wrt", "qm_wrt", "liutex_wrt", "cf_wrt", "sim_data", "output_partial_domain"]: + for n in [ + "schlieren_wrt", + "alpha_rho_wrt", + "rho_wrt", + "mom_wrt", + "vel_wrt", + "flux_wrt", + "E_wrt", + "pres_wrt", + "alpha_wrt", + "kappa_wrt", + "gamma_wrt", + "heat_ratio_wrt", + "pi_inf_wrt", + "pres_inf_wrt", + "c_wrt", + "omega_wrt", + "qm_wrt", + "liutex_wrt", + "cf_wrt", + "sim_data", + "output_partial_domain", + ]: _r(n, LOG, {"output"}) for d in ["x", "y", "z"]: _r(f"{d}_output%beg", REAL, {"output"}) _r(f"{d}_output%end", REAL, {"output"}) # Lagrangian output - for v in ["lag_header", "lag_txt_wrt", "lag_db_wrt", "lag_id_wrt", "lag_pos_wrt", - "lag_pos_prev_wrt", "lag_vel_wrt", "lag_rad_wrt", "lag_rvel_wrt", - "lag_r0_wrt", "lag_rmax_wrt", "lag_rmin_wrt", "lag_dphidt_wrt", - "lag_pres_wrt", "lag_mv_wrt", "lag_mg_wrt", "lag_betaT_wrt", "lag_betaC_wrt"]: + for v in [ + "lag_header", + "lag_txt_wrt", + "lag_db_wrt", + "lag_id_wrt", + "lag_pos_wrt", + "lag_pos_prev_wrt", + "lag_vel_wrt", + "lag_rad_wrt", + "lag_rvel_wrt", + "lag_r0_wrt", + "lag_rmax_wrt", + "lag_rmin_wrt", + "lag_dphidt_wrt", + "lag_pres_wrt", + "lag_mv_wrt", + "lag_mg_wrt", + "lag_betaT_wrt", + "lag_betaC_wrt", + ]: _r(v, LOG, {"bubbles", "output"}) # --- Boundary conditions --- @@ -958,12 +966,28 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r("relativity", LOG, {"relativity"}) # --- Other (no specific feature tag) --- - for n in ["model_eqns", "num_fluids", "thermal", "relax_model", "igr_order", - "num_bc_patches", "num_patches", "perturb_flow_fluid", "perturb_sph_fluid", - "dist_type", "mixlayer_perturb_nk", "elliptic_smoothing_iters", - "n_start_old", "n_start", "fd_order", "num_igr_iters", - "num_igr_warm_start_iters", "igr_iter_solver", "nv_uvm_igr_temps_on_gpu", - "flux_lim"]: + for n in [ + "model_eqns", + "num_fluids", + "thermal", + "relax_model", + "igr_order", + "num_bc_patches", + "num_patches", + "perturb_flow_fluid", + "perturb_sph_fluid", + "dist_type", + "mixlayer_perturb_nk", + "elliptic_smoothing_iters", + "n_start_old", + "n_start", + "fd_order", + "num_igr_iters", + "num_igr_warm_start_iters", + "igr_iter_solver", + "nv_uvm_igr_temps_on_gpu", + "flux_lim", + ]: _r(n, INT) _r("pref", REAL, math=r"\f$p_\text{ref}\f$") _r("poly_sigma", REAL, math=r"\f$\sigma_\text{poly}\f$") @@ -971,16 +995,47 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r("palpha_eps", REAL, math=r"\f$\varepsilon_\alpha\f$") _r("ptgalpha_eps", REAL, math=r"\f$\varepsilon_\alpha\f$") _r("pi_fac", REAL, math=r"\f$\pi\text{-factor}\f$") - for n in ["mixlayer_vel_coef", "mixlayer_domain", "mixlayer_perturb_k0", - "perturb_flow_mag", "fluid_rho", "sigR", "sigV", "rhoRV", - "tau_star", "cont_damage_s", "alpha_bar", "alf_factor", - "ic_eps", "ic_beta"]: + for n in [ + "mixlayer_vel_coef", + "mixlayer_domain", + "mixlayer_perturb_k0", + "perturb_flow_mag", + "fluid_rho", + "sigR", + "sigV", + "rhoRV", + "tau_star", + "cont_damage_s", + "alpha_bar", + "alf_factor", + "ic_eps", + "ic_beta", + ]: _r(n, REAL) - for n in ["mpp_lim", "relax", "adv_n", "cont_damage", "igr", "down_sample", - "old_grid", "old_ic", "mixlayer_vel_profile", "mixlayer_perturb", - "perturb_flow", "perturb_sph", "pre_stress", "elliptic_smoothing", - "simplex_perturb", "alt_soundspeed", "mixture_err", "rdma_mpi", - "igr_pres_lim", "int_comp", "nv_uvm_out_of_core", "nv_uvm_pref_gpu"]: + for n in [ + "mpp_lim", + "relax", + "adv_n", + "cont_damage", + "igr", + "down_sample", + "old_grid", + "old_ic", + "mixlayer_vel_profile", + "mixlayer_perturb", + "perturb_flow", + "perturb_sph", + "pre_stress", + "elliptic_smoothing", + "simplex_perturb", + "alt_soundspeed", + "mixture_err", + "rdma_mpi", + "igr_pres_lim", + "int_comp", + "nv_uvm_out_of_core", + "nv_uvm_pref_gpu", + ]: _r(n, LOG) _r("case_dir", STR) @@ -1003,12 +1058,9 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r(f"{px}{a}", INT) for a in ["smoothen", "alter_patch"] if i >= 2 else ["smoothen"]: _r(f"{px}{a}", LOG) - for a, sym in [("rho", r"\f$\rho\f$"), ("gamma", r"\f$\gamma\f$"), - ("pi_inf", r"\f$\pi_\infty\f$"), ("cv", r"\f$c_v\f$"), - ("qv", r"\f$q_v\f$"), ("qvp", r"\f$q'_v\f$")]: + for a, sym in [("rho", r"\f$\rho\f$"), ("gamma", r"\f$\gamma\f$"), ("pi_inf", r"\f$\pi_\infty\f$"), ("cv", r"\f$c_v\f$"), ("qv", r"\f$q_v\f$"), ("qvp", r"\f$q'_v\f$")]: _r(f"{px}{a}", REAL, math=sym) - for a in ["radius", "radii", "epsilon", "beta", "normal", "alpha_rho", - "non_axis_sym", "smooth_coeff", "vel", "alpha", "model_threshold"]: + for a in ["radius", "radii", "epsilon", "beta", "normal", "alpha_rho", "non_axis_sym", "smooth_coeff", "vel", "alpha", "model_threshold"]: _r(f"{px}{a}", REAL) # Bubble fields for a in ["r0", "v0", "p0", "m0"]: @@ -1058,9 +1110,7 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements # --- fluid_pp (10 fluids) --- for f in range(1, NF + 1): px = f"fluid_pp({f})%" - for a, sym in [("gamma", r"\f$\gamma_k\f$"), ("pi_inf", r"\f$\pi_{\infty,k}\f$"), - ("cv", r"\f$c_{v,k}\f$"), ("qv", r"\f$q_{v,k}\f$"), - ("qvp", r"\f$q'_{v,k}\f$")]: + for a, sym in [("gamma", r"\f$\gamma_k\f$"), ("pi_inf", r"\f$\pi_{\infty,k}\f$"), ("cv", r"\f$c_{v,k}\f$"), ("qv", r"\f$q_{v,k}\f$"), ("qvp", r"\f$q'_{v,k}\f$")]: _r(f"{px}{a}", REAL, math=sym) _r(f"{px}mul0", REAL, {"viscosity"}, math=r"\f$\mu_{l,k}\f$") _r(f"{px}ss", REAL, {"surface_tension"}, math=r"\f$\sigma_k\f$") @@ -1071,16 +1121,28 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r(f"{px}Re(2)", REAL, {"viscosity"}, math=r"\f$\mathrm{Re}_k\f$ (bulk)") # --- bub_pp (bubble properties) --- - for a, sym in [("R0ref", r"\f$R_0\f$"), ("p0ref", r"\f$p_0\f$"), - ("rho0ref", r"\f$\rho_l\f$"), ("T0ref", r"\f$T_0\f$"), - ("ss", r"\f$\sigma\f$"), ("pv", r"\f$p_v\f$"), - ("vd", r"\f$D\f$"), ("mu_l", r"\f$\mu_l\f$"), - ("mu_v", r"\f$\mu_v\f$"), ("mu_g", r"\f$\mu_g\f$"), - ("gam_v", r"\f$\gamma_v\f$"), ("gam_g", r"\f$\gamma_g\f$"), - ("M_v", r"\f$M_v\f$"), ("M_g", r"\f$M_g\f$"), - ("k_v", r"\f$k_v\f$"), ("k_g", r"\f$k_g\f$"), - ("cp_v", r"\f$c_{p,v}\f$"), ("cp_g", r"\f$c_{p,g}\f$"), - ("R_v", r"\f$R_v\f$"), ("R_g", r"\f$R_g\f$")]: + for a, sym in [ + ("R0ref", r"\f$R_0\f$"), + ("p0ref", r"\f$p_0\f$"), + ("rho0ref", r"\f$\rho_l\f$"), + ("T0ref", r"\f$T_0\f$"), + ("ss", r"\f$\sigma\f$"), + ("pv", r"\f$p_v\f$"), + ("vd", r"\f$D\f$"), + ("mu_l", r"\f$\mu_l\f$"), + ("mu_v", r"\f$\mu_v\f$"), + ("mu_g", r"\f$\mu_g\f$"), + ("gam_v", r"\f$\gamma_v\f$"), + ("gam_g", r"\f$\gamma_g\f$"), + ("M_v", r"\f$M_v\f$"), + ("M_g", r"\f$M_g\f$"), + ("k_v", r"\f$k_v\f$"), + ("k_g", r"\f$k_g\f$"), + ("cp_v", r"\f$c_{p,v}\f$"), + ("cp_g", r"\f$c_{p,g}\f$"), + ("R_v", r"\f$R_v\f$"), + ("R_g", r"\f$R_g\f$"), + ]: _r(f"bub_pp%{a}", REAL, {"bubbles"}, math=sym) # --- patch_ib (10 immersed boundaries) --- @@ -1088,8 +1150,7 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements px = f"patch_ib({i})%" for a in ["geometry", "moving_ibm"]: _r(f"{px}{a}", INT, {"ib"}) - for a, pt in [("radius", REAL), ("theta", REAL), ("slip", LOG), ("c", REAL), - ("p", REAL), ("t", REAL), ("m", REAL), ("mass", REAL)]: + for a, pt in [("radius", REAL), ("theta", REAL), ("slip", LOG), ("c", REAL), ("p", REAL), ("t", REAL), ("m", REAL), ("mass", REAL)]: _r(f"{px}{a}", pt, {"ib"}) for j in range(1, 4): _r(f"{px}angles({j})", REAL, {"ib"}) @@ -1111,11 +1172,25 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements for a in ["pulse", "support", "num_elements", "element_on", "bb_num_freq"]: _r(f"{px}{a}", INT, {"acoustic"}) _r(f"{px}dipole", LOG, {"acoustic"}) - for a in ["mag", "length", "height", "wavelength", "frequency", - "gauss_sigma_dist", "gauss_sigma_time", "npulse", - "dir", "delay", "foc_length", "aperture", - "element_spacing_angle", "element_polygon_ratio", - "rotate_angle", "bb_bandwidth", "bb_lowest_freq"]: + for a in [ + "mag", + "length", + "height", + "wavelength", + "frequency", + "gauss_sigma_dist", + "gauss_sigma_time", + "npulse", + "dir", + "delay", + "foc_length", + "aperture", + "element_spacing_angle", + "element_polygon_ratio", + "rotate_angle", + "bb_bandwidth", + "bb_lowest_freq", + ]: _r(f"{px}{a}", REAL, {"acoustic"}) for j in range(1, 4): _r(f"{px}loc({j})", REAL, {"acoustic"}) @@ -1170,8 +1245,7 @@ def _load(): # pylint: disable=too-many-locals,too-many-statements _r(f"simplex_params%perturb_vel_offset({d},{j})", REAL) # --- lag_params (Lagrangian bubbles) --- - for a in ["heatTransfer_model", "massTransfer_model", "pressure_corrector", - "write_bubbles", "write_bubbles_stats"]: + for a in ["heatTransfer_model", "massTransfer_model", "pressure_corrector", "write_bubbles", "write_bubbles_stats"]: _r(f"lag_params%{a}", LOG, {"bubbles"}) for a in ["solver_approach", "cluster_type", "smooth_type", "nBubs_glb"]: _r(f"lag_params%{a}", INT, {"bubbles"}) @@ -1219,9 +1293,7 @@ def _init_registry(): REGISTRY.freeze() except Exception as e: # Re-raise with context to help debugging initialization failures - raise RuntimeError( - f"Failed to initialize parameter registry: {e}\n" - "This is likely a bug in the parameter definitions." - ) from e + raise RuntimeError(f"Failed to initialize parameter registry: {e}\nThis is likely a bug in the parameter definitions.") from e + _init_registry() diff --git a/toolchain/mfc/params/descriptions.py b/toolchain/mfc/params/descriptions.py index 801e094732..acb27943e2 100644 --- a/toolchain/mfc/params/descriptions.py +++ b/toolchain/mfc/params/descriptions.py @@ -35,7 +35,6 @@ "loops_x": "Number of times to apply grid stretching in x", "loops_y": "Number of times to apply grid stretching in y", "loops_z": "Number of times to apply grid stretching in z", - # Time stepping "dt": "Time step size", "t_step_start": "Starting time step index", @@ -52,7 +51,6 @@ "adap_dt": "Enable adaptive time stepping", "adap_dt_tol": "Tolerance for adaptive time stepping", "adap_dt_max_iters": "Maximum iterations for adaptive time stepping", - # Model equations "model_eqns": "Model equations", "num_fluids": "Number of fluid components", @@ -60,7 +58,6 @@ "mpp_lim": "Enable mixture pressure positivity limiter", "mixture_err": "Enable mixture error checking", "alt_soundspeed": "Use alternative sound speed formulation", - # WENO reconstruction "weno_order": "Order of WENO reconstruction", "weno_eps": "WENO epsilon parameter for smoothness", @@ -73,18 +70,15 @@ "weno_Re_flux": "Enable WENO for viscous fluxes", "weno_avg": "Enable WENO averaging", "null_weights": "Allow null WENO weights", - # MUSCL reconstruction "recon_type": "Reconstruction type", "muscl_order": "Order of MUSCL reconstruction", "muscl_lim": "MUSCL limiter type", - # Riemann solver "riemann_solver": "Riemann solver", "wave_speeds": "Wave speed estimates", "avg_state": "Average state for Riemann solver", "low_Mach": "Low Mach number correction", - # Boundary conditions "bc_x%beg": "Boundary condition at x-begin (-1=periodic, -2=reflective, -3=symmetric, etc.)", "bc_x%end": "Boundary condition at x-end", @@ -93,7 +87,6 @@ "bc_z%beg": "Boundary condition at z-begin", "bc_z%end": "Boundary condition at z-end", "num_bc_patches": "Number of boundary condition patches", - # Physics models "bubbles_euler": "Enable Euler-Euler bubble model", "bubbles_lagrange": "Enable Lagrangian bubble tracking", @@ -116,7 +109,6 @@ "hyper_cleaning_speed": "Wave speed for hyperbolic divergence cleaning", "hyper_cleaning_tau": "Damping time constant for hyperbolic divergence cleaning", "relativity": "Enable special relativity", - # Output "run_time_info": "Output run-time information", "prim_vars_wrt": "Write primitive variables", @@ -138,22 +130,17 @@ "c_wrt": "Write sound speed field", "omega_wrt": "Write vorticity field", "cf_wrt": "Write color function field", - # Immersed boundaries "ib": "Enable immersed boundary method", "num_ibs": "Number of immersed boundary patches", - # Acoustic sources "acoustic_source": "Enable acoustic source terms", "num_source": "Number of acoustic sources", - # Probes and integrals "num_probes": "Number of probe points", "num_integrals": "Number of integral regions", - # MPI/GPU "rdma_mpi": "Enable RDMA for MPI communication (GPUs)", - # Misc "case_dir": "Case directory path", "cantera_file": "Cantera mechanism file for chemistry", @@ -161,7 +148,6 @@ "old_ic": "Use initial conditions from previous simulation", "t_step_old": "Time step to restart from", "fd_order": "Finite difference order for gradients", - # Additional simple params "thermal": "Thermal model selection", "relax_model": "Relaxation model type", @@ -293,7 +279,6 @@ (r"patch_icpp\((\d+)\)%modal_r_min", "Min r when clipping for 2D modal patch {0}"), (r"patch_icpp\((\d+)\)%modal_use_exp_form", "Use exp form for 2D modal patch {0}"), (r"patch_icpp\((\d+)\)%sph_har_coeff\((\d+),(-?\d+)\)", "Spherical harmonic coeff (l={1}, m={2}) for patch {0}"), - # fluid_pp patterns (r"fluid_pp\((\d+)\)%gamma", "Specific heat ratio for fluid {0}"), (r"fluid_pp\((\d+)\)%pi_inf", "Stiffness pressure for fluid {0}"), @@ -311,7 +296,6 @@ (r"fluid_pp\((\d+)\)%k_v", "Thermal conductivity of vapor phase for fluid {0}"), (r"fluid_pp\((\d+)\)%cp_v", "Specific heat capacity (const. pressure) of vapor for fluid {0}"), (r"fluid_pp\((\d+)\)%D_v", "Vapor mass diffusivity for fluid {0}"), - # patch_ib patterns (r"patch_ib\((\d+)\)%geometry", "Geometry type for immersed boundary {0}"), (r"patch_ib\((\d+)\)%x_centroid", "X-coordinate of centroid for IB patch {0}"), @@ -338,7 +322,6 @@ (r"patch_ib\((\d+)\)%model_translate\((\d+)\)", "Model translation component {1} for IB patch {0}"), (r"patch_ib\((\d+)\)%model_scale\((\d+)\)", "Model scale component {1} for IB patch {0}"), (r"patch_ib\((\d+)\)%model_rotate\((\d+)\)", "Model rotation component {1} for IB patch {0}"), - # bc patterns (r"bc_([xyz])%vel_in\((\d+)\)", "Inlet velocity component {1} at {0}-boundary"), (r"bc_([xyz])%vel_out\((\d+)\)", "Outlet velocity component {1} at {0}-boundary"), @@ -351,7 +334,6 @@ (r"bc_([xyz])%grcbc_in", "Enable GRCBC at {0}-inlet"), (r"bc_([xyz])%grcbc_out", "Enable GRCBC at {0}-outlet"), (r"bc_([xyz])%grcbc_vel_out", "Enable GRCBC velocity at {0}-outlet"), - # patch_bc patterns (r"patch_bc\((\d+)\)%geometry", "Geometry type for BC patch {0}"), (r"patch_bc\((\d+)\)%type", "BC type for patch {0}"), @@ -360,7 +342,6 @@ (r"patch_bc\((\d+)\)%centroid\((\d+)\)", "Centroid component {1} for BC patch {0}"), (r"patch_bc\((\d+)\)%length\((\d+)\)", "Length component {1} for BC patch {0}"), (r"patch_bc\((\d+)\)%radius", "Radius for BC patch {0}"), - # acoustic patterns (r"acoustic\((\d+)\)%loc\((\d+)\)", "Location component {1} for acoustic source {0}"), (r"acoustic\((\d+)\)%mag", "Magnitude for acoustic source {0}"), @@ -386,12 +367,10 @@ (r"acoustic\((\d+)\)%bb_num_freq", "Number of broadband frequencies for source {0}"), (r"acoustic\((\d+)\)%bb_bandwidth", "Broadband bandwidth for acoustic source {0}"), (r"acoustic\((\d+)\)%bb_lowest_freq", "Lowest broadband frequency for source {0}"), - # probe patterns (r"probe\((\d+)\)%x", "X-coordinate of probe {0}"), (r"probe\((\d+)\)%y", "Y-coordinate of probe {0}"), (r"probe\((\d+)\)%z", "Z-coordinate of probe {0}"), - # integral patterns (r"integral\((\d+)\)%xmin", "X-min of integral region {0}"), (r"integral\((\d+)\)%xmax", "X-max of integral region {0}"), @@ -399,7 +378,6 @@ (r"integral\((\d+)\)%ymax", "Y-max of integral region {0}"), (r"integral\((\d+)\)%zmin", "Z-min of integral region {0}"), (r"integral\((\d+)\)%zmax", "Z-max of integral region {0}"), - # bub_pp patterns (r"bub_pp%R0ref", "Reference bubble radius"), (r"bub_pp%p0ref", "Reference pressure for bubbles"), @@ -422,7 +400,6 @@ (r"bub_pp%R_v", "Gas constant of host in vapor state"), (r"bub_pp%R_g", "Gas constant of gas (bubble)"), (r"bub_pp%(\w+)", "Bubble parameter: {0}"), - # Output array patterns (r"schlieren_alpha\((\d+)\)", "Schlieren coefficient for fluid {0}"), (r"alpha_rho_wrt\((\d+)\)", "Write partial density for fluid {0}"), @@ -434,7 +411,6 @@ (r"flux_wrt\((\d+)\)", "Write flux component {0}"), (r"omega_wrt\((\d+)\)", "Write vorticity component {0}"), (r"chem_wrt_Y\((\d+)\)", "Write mass fraction of species {0}"), - # Lagrangian output patterns - specific fields first (r"lag_pos_wrt", "Write Lagrangian bubble position"), (r"lag_pos_prev_wrt", "Write Lagrangian bubble previous position"), @@ -454,11 +430,9 @@ (r"lag_id_wrt", "Write Lagrangian bubble ID"), (r"lag_txt_wrt", "Write Lagrangian data to text files"), (r"lag_(\w+)_wrt", "Write Lagrangian {0} field"), - # Body force patterns (r"([kgwp])_([xyz])", "Body force parameter {0} in {1}-direction"), (r"bf_([xyz])", "Enable body force in {0}-direction"), - # simplex patterns (r"simplex_params%perturb_dens\((\d+)\)", "Enable density perturbation for fluid {0}"), (r"simplex_params%perturb_dens_freq\((\d+)\)", "Density perturbation frequency for fluid {0}"), @@ -468,7 +442,6 @@ (r"simplex_params%perturb_vel_freq\((\d+)\)", "Velocity perturbation frequency for direction {0}"), (r"simplex_params%perturb_vel_scale\((\d+)\)", "Velocity perturbation scale for direction {0}"), (r"simplex_params%perturb_vel_offset\((\d+),(\d+)\)", "Velocity perturbation offset ({1}) for direction {0}"), - # lag_params patterns - specific fields first (r"lag_params%solver_approach", "Lagrangian solver approach (1=one-way, 2=two-way coupling)"), (r"lag_params%cluster_type", "Cluster model for pressure at infinity"), @@ -488,14 +461,12 @@ (r"lag_params%rho0", "Initial density"), (r"lag_params%x0", "Initial bubble position"), (r"lag_params%(\w+)", "Lagrangian tracking parameter: {0}"), - # chem_params patterns - specific fields first (r"chem_params%diffusion", "Enable species diffusion for chemistry"), (r"chem_params%reactions", "Enable chemical reactions"), (r"chem_params%gamma_method", "Gamma calculation method (1=formulation, 2=cp/cv ratio)"), (r"chem_params%transport_model", "Transport model selection for chemistry"), (r"chem_params%(\w+)", "Chemistry parameter: {0}"), - # fluid_rho patterns (r"fluid_rho\((\d+)\)", "Reference density for fluid {0}"), ] @@ -517,7 +488,8 @@ def get_description(param_name: str) -> str: return template.format(*match.groups()) # 3. Auto-generated description from registry (set by _auto_describe at registration) - from . import REGISTRY # pylint: disable=import-outside-toplevel + from . import REGISTRY + param = REGISTRY.all_params.get(param_name) if param and param.description: return param.description @@ -526,7 +498,7 @@ def get_description(param_name: str) -> str: return _infer_from_naming(param_name) -def _infer_from_naming(param_name: str) -> str: # pylint: disable=too-many-return-statements,too-many-branches +def _infer_from_naming(param_name: str) -> str: """Infer description from naming conventions.""" name = param_name @@ -650,7 +622,8 @@ def get_math_symbol(param_name: str) -> str: Looks up the math_symbol field from the parameter registry (single source of truth). Symbols are defined via math= in the _r() calls in definitions.py. """ - from . import REGISTRY # pylint: disable=import-outside-toplevel + from . import REGISTRY + param = REGISTRY.all_params.get(param_name) return param.math_symbol if param else "" diff --git a/toolchain/mfc/params/errors.py b/toolchain/mfc/params/errors.py index 88a2aecf9c..212809cbf8 100644 --- a/toolchain/mfc/params/errors.py +++ b/toolchain/mfc/params/errors.py @@ -139,14 +139,8 @@ def dependency_value_error( Formatted error message. """ if condition: - return ( - f"{format_param(param)}{condition} requires {format_param(required_param)} " - f"to be one of {expected_values}, got {format_value(got)}" - ) - return ( - f"{format_param(param)} requires {format_param(required_param)} " - f"to be one of {expected_values}, got {format_value(got)}" - ) + return f"{format_param(param)}{condition} requires {format_param(required_param)} to be one of {expected_values}, got {format_value(got)}" + return f"{format_param(param)} requires {format_param(required_param)} to be one of {expected_values}, got {format_value(got)}" def required_error(param: str, context: Optional[str] = None) -> str: @@ -178,10 +172,7 @@ def mutual_exclusion_error(params: List[str], active: List[str]) -> str: """ formatted = [format_param(p) for p in params] active_formatted = [format_param(p) for p in active] - return ( - f"Only one of {', '.join(formatted)} can be enabled, " - f"but {', '.join(active_formatted)} are all enabled" - ) + return f"Only one of {', '.join(formatted)} can be enabled, but {', '.join(active_formatted)} are all enabled" def dimension_error(param: str, requirement: str) -> str: diff --git a/toolchain/mfc/params/generators/__init__.py b/toolchain/mfc/params/generators/__init__.py index b06572cf5c..4983ee10f0 100644 --- a/toolchain/mfc/params/generators/__init__.py +++ b/toolchain/mfc/params/generators/__init__.py @@ -1,6 +1,6 @@ """Code Generators for Parameter Schema.""" -from .json_schema_gen import generate_json_schema from .docs_gen import generate_parameter_docs +from .json_schema_gen import generate_json_schema -__all__ = ['generate_json_schema', 'generate_parameter_docs'] +__all__ = ["generate_json_schema", "generate_parameter_docs"] diff --git a/toolchain/mfc/params/generators/docs_gen.py b/toolchain/mfc/params/generators/docs_gen.py index 2e2f23e3e9..2e9f877b3b 100644 --- a/toolchain/mfc/params/generators/docs_gen.py +++ b/toolchain/mfc/params/generators/docs_gen.py @@ -7,21 +7,21 @@ from __future__ import annotations -from typing import Any, Dict, List, Tuple -from collections import defaultdict import re +from collections import defaultdict +from typing import Any, Dict, List, Tuple -from ..schema import ParamType -from ..registry import REGISTRY -from ..descriptions import get_description, get_math_symbol +from .. import definitions # noqa: F401 from ..ast_analyzer import analyze_case_validator, classify_message -from .. import definitions # noqa: F401 pylint: disable=unused-import +from ..descriptions import get_description, get_math_symbol +from ..registry import REGISTRY +from ..schema import ParamType def _get_family(name: str) -> str: """Extract family name from parameter (e.g., 'patch_icpp' from 'patch_icpp(1)%vel(1)').""" # Handle indexed parameters - match = re.match(r'^([a-zA-Z_]+)', name) + match = re.match(r"^([a-zA-Z_]+)", name) if match: base = match.group(1) if name.startswith(f"{base}(") or name.startswith(f"{base}%"): @@ -31,19 +31,19 @@ def _get_family(name: str) -> str: def _escape_percent(s: str) -> str: """Escape % for Doxygen (% is a special character, use %% to get literal %).""" - return s.replace('%', '%%') + return s.replace("%", "%%") def _parse_paren_content(name: str, start: int) -> Tuple[str, int]: """Parse content within parentheses, return (content, end_index) or ('', -1) if invalid.""" j = start + 1 paren_content = [] - while j < len(name) and name[j] != ')': + while j < len(name) and name[j] != ")": paren_content.append(name[j]) j += 1 if j < len(name): - return ''.join(paren_content), j - return '', -1 + return "".join(paren_content), j + return "", -1 def _collapse_indices(name: str) -> str: @@ -55,13 +55,13 @@ def _collapse_indices(name: str) -> str: simplex_params%perturb_dens_offset(1, 2) -> simplex_params%perturb_dens_offset(N, M) bc_x%vel_in(1) -> bc_x%vel_in(N) """ - placeholders = ['N', 'M', 'K', 'L', 'P', 'Q'] + placeholders = ["N", "M", "K", "L", "P", "Q"] placeholder_idx = 0 result = [] i = 0 while i < len(name): - if name[i] != '(': + if name[i] != "(": result.append(name[i]) i += 1 continue @@ -74,7 +74,7 @@ def _collapse_indices(name: str) -> str: continue # Check if content is numeric indices (possibly comma-separated) - parts = [p.strip() for p in content.split(',')] + parts = [p.strip() for p in content.split(",")] if not all(p.isdigit() for p in parts): result.append(name[i]) i += 1 @@ -83,13 +83,13 @@ def _collapse_indices(name: str) -> str: # Replace each index with a placeholder new_parts = [] for _ in parts: - ph = placeholders[placeholder_idx] if placeholder_idx < len(placeholders) else '?' + ph = placeholders[placeholder_idx] if placeholder_idx < len(placeholders) else "?" new_parts.append(ph) placeholder_idx += 1 - result.append('(' + ', '.join(new_parts) + ')') + result.append("(" + ", ".join(new_parts) + ")") i = end_idx + 1 - return ''.join(result) + return "".join(result) def _type_to_str(param_type: ParamType) -> str: @@ -114,8 +114,7 @@ def _format_constraints(param) -> str: if "choices" in c: labels = c.get("value_labels", {}) if labels: - items = [f"{v}={labels[v]}" if v in labels else str(v) - for v in c["choices"]] + items = [f"{v}={labels[v]}" if v in labels else str(v) for v in c["choices"]] parts.append(", ".join(items)) else: parts.append(f"Values: {c['choices']}") @@ -136,15 +135,15 @@ def _build_param_name_pattern(): all_names = sorted(REGISTRY.all_params.keys(), key=len, reverse=True) # Only include names >= 2 chars to avoid false positives with single-letter params # and names that are simple identifiers (no % or parens, which need escaping) - safe_names = [n for n in all_names if len(n) >= 2 and re.match(r'^[a-zA-Z_]\w*$', n)] + safe_names = [n for n in all_names if len(n) >= 2 and re.match(r"^[a-zA-Z_]\w*$", n)] if not safe_names: return None - pattern = r'\b(' + '|'.join(re.escape(n) for n in safe_names) + r')\b' + pattern = r"\b(" + "|".join(re.escape(n) for n in safe_names) + r")\b" return re.compile(pattern) # Matches compound param names like bub_pp%mu_g, fluid_pp(1)%Re(1), x_output%beg -_COMPOUND_NAME_RE = re.compile(r'\b\w+(?:\([^)]*\))?(?:%\w+(?:\([^)]*\))?)+') +_COMPOUND_NAME_RE = re.compile(r"\b\w+(?:\([^)]*\))?(?:%\w+(?:\([^)]*\))?)+") def _backtick_params(msg: str, pattern) -> str: @@ -156,30 +155,30 @@ def _backtick_params(msg: str, pattern) -> str: 3. Snake_case identifiers not in registry (e.g. cluster_type, smooth_type) """ # 1. Wrap compound names (word%word patterns) — must come first - msg = _COMPOUND_NAME_RE.sub(lambda m: f'`{m.group(0)}`', msg) + msg = _COMPOUND_NAME_RE.sub(lambda m: f"`{m.group(0)}`", msg) # 2. Wrap known simple param names, only outside existing backtick spans if pattern is not None: - parts = msg.split('`') + parts = msg.split("`") for i in range(0, len(parts), 2): - parts[i] = pattern.sub(r'`\1`', parts[i]) - msg = '`'.join(parts) + parts[i] = pattern.sub(r"`\1`", parts[i]) + msg = "`".join(parts) # 3. Wrap remaining snake_case identifiers (at least one underscore) - parts = msg.split('`') + parts = msg.split("`") for i in range(0, len(parts), 2): - parts[i] = re.sub(r'\b([a-z]\w*_\w+)\b', r'`\1`', parts[i]) - msg = '`'.join(parts) + parts[i] = re.sub(r"\b([a-z]\w*_\w+)\b", r"`\1`", parts[i]) + msg = "`".join(parts) return msg def _escape_pct_outside_backticks(text: str) -> str: """Escape % as %% for Doxygen, but not inside backtick code spans.""" - parts = text.split('`') + parts = text.split("`") for i in range(0, len(parts), 2): - parts[i] = parts[i].replace('%', '%%') - return '`'.join(parts) + parts[i] = parts[i].replace("%", "%%") + return "`".join(parts) # Lazily initialized at module level on first use @@ -187,7 +186,7 @@ def _escape_pct_outside_backticks(text: str) -> str: def _get_param_pattern(): - global _PARAM_PATTERN # noqa: PLW0603 pylint: disable=global-statement + global _PARAM_PATTERN # noqa: PLW0603 if _PARAM_PATTERN is None: _PARAM_PATTERN = _build_param_name_pattern() return _PARAM_PATTERN @@ -195,7 +194,8 @@ def _get_param_pattern(): def _build_reverse_dep_map() -> Dict[str, List[Tuple[str, str]]]: """Build map from target param -> [(relation, source_param), ...] from DEPENDENCIES.""" - from ..definitions import DEPENDENCIES # pylint: disable=import-outside-toplevel + from ..definitions import DEPENDENCIES + reverse: Dict[str, List[Tuple[str, str]]] = {} for param, dep in DEPENDENCIES.items(): if "when_true" in dep: @@ -217,13 +217,13 @@ def _build_reverse_dep_map() -> Dict[str, List[Tuple[str, str]]]: def _get_reverse_deps(): - global _REVERSE_DEPS # noqa: PLW0603 pylint: disable=global-statement + global _REVERSE_DEPS # noqa: PLW0603 if _REVERSE_DEPS is None: _REVERSE_DEPS = _build_reverse_dep_map() return _REVERSE_DEPS -def _format_tag_annotation(param_name: str, param) -> str: # pylint: disable=too-many-locals +def _format_tag_annotation(param_name: str, param) -> str: """ Return a short annotation for params with no schema constraints and no AST rules. @@ -261,8 +261,7 @@ def _format_tag_annotation(param_name: str, param) -> str: # pylint: disable=to entries = reverse[param_name] parts = [] for relation, source in entries[:2]: - parts.append(f"Required by `{source}`" if relation == "required by" - else f"Recommended for `{source}`") + parts.append(f"Required by `{source}`" if relation == "required by" else f"Recommended for `{source}`") return "; ".join(parts) # 4. ParamDef hint (data-driven from definitions.py) @@ -270,7 +269,8 @@ def _format_tag_annotation(param_name: str, param) -> str: # pylint: disable=to return param.hint # 5. Tag-based label (from TAG_DISPLAY_NAMES in definitions.py) - from ..definitions import TAG_DISPLAY_NAMES # pylint: disable=import-outside-toplevel + from ..definitions import TAG_DISPLAY_NAMES + for tag, display_name in TAG_DISPLAY_NAMES.items(): if tag in param.tags: return f"{display_name} parameter" @@ -278,8 +278,7 @@ def _format_tag_annotation(param_name: str, param) -> str: # pylint: disable=to return "" -def _format_validator_rules(param_name: str, by_trigger: Dict[str, list], # pylint: disable=too-many-locals - by_param: Dict[str, list] | None = None) -> str: +def _format_validator_rules(param_name: str, by_trigger: Dict[str, list], by_param: Dict[str, list] | None = None) -> str: """Format AST-extracted validator rules for a parameter's Constraints column. Gets rules where this param is the trigger. Falls back to by_param @@ -343,7 +342,7 @@ def _format_validator_rules(param_name: str, by_trigger: Dict[str, list], # pyl return "; ".join(parts) -def generate_parameter_docs() -> str: # pylint: disable=too-many-locals,too-many-statements +def generate_parameter_docs() -> str: """Generate markdown documentation for all parameters.""" # AST-extract rules from case_validator.py analysis = analyze_case_validator() @@ -376,10 +375,7 @@ def generate_parameter_docs() -> str: # pylint: disable=too-many-locals,too-man families[family].append((name, param)) # Sort families by size (largest first), but put "general" last - sorted_families = sorted( - families.items(), - key=lambda x: (x[0] == "general", -len(x[1]), x[0]) - ) + sorted_families = sorted(families.items(), key=lambda x: (x[0] == "general", -len(x[1]), x[0])) # Table of contents lines.append("| Family | Count | Description |") @@ -544,26 +540,28 @@ def generate_parameter_docs() -> str: # pylint: disable=too-many-locals,too-man lines.append("") # Add footer - lines.extend([ - "## CLI Reference", - "", - "Search parameters using the CLI:", - "", - "```bash", - "# Search for parameters", - "./mfc.sh params weno", - "", - "# Show parameter descriptions", - "./mfc.sh params weno -d", - "", - "# List all families", - "./mfc.sh params -f", - "", - "# Filter by type", - "./mfc.sh params -t real weno", - "```", - "", - ]) + lines.extend( + [ + "## CLI Reference", + "", + "Search parameters using the CLI:", + "", + "```bash", + "# Search for parameters", + "./mfc.sh params weno", + "", + "# Show parameter descriptions", + "./mfc.sh params weno -d", + "", + "# List all families", + "./mfc.sh params -f", + "", + "# Filter by type", + "./mfc.sh params -t real weno", + "```", + "", + ] + ) return "\n".join(lines) @@ -575,6 +573,6 @@ def write_parameter_docs(output_path: str) -> int: Number of parameters documented """ content = generate_parameter_docs() - with open(output_path, 'w') as f: + with open(output_path, "w") as f: f.write(content) return len(REGISTRY.all_params) diff --git a/toolchain/mfc/params/generators/json_schema_gen.py b/toolchain/mfc/params/generators/json_schema_gen.py index 21a8feb8b1..dd39994733 100644 --- a/toolchain/mfc/params/generators/json_schema_gen.py +++ b/toolchain/mfc/params/generators/json_schema_gen.py @@ -3,13 +3,13 @@ Generates VS Code / PyCharm compatible JSON Schema for case file auto-completion. """ -# pylint: disable=import-outside-toplevel import json -from typing import Dict, Any -from ..schema import ParamType +from typing import Any, Dict + +from .. import definitions # noqa: F401 from ..registry import REGISTRY -from .. import definitions # noqa: F401 pylint: disable=unused-import +from ..schema import ParamType def _param_type_to_json_schema(param_type: ParamType, constraints: Dict = None) -> Dict[str, Any]: @@ -85,17 +85,7 @@ def generate_json_schema(include_descriptions: bool = True) -> Dict[str, Any]: def generate_vscode_settings() -> Dict[str, Any]: """Generate VS Code settings snippet for JSON Schema association.""" - return { - "json.schemas": [ - { - "fileMatch": ["case.py", "**/case.py"], - "url": "./mfc-case-schema.json" - } - ], - "yaml.schemas": { - "./mfc-case-schema.json": ["case.yaml", "**/case.yaml"] - } - } + return {"json.schemas": [{"fileMatch": ["case.py", "**/case.py"], "url": "./mfc-case-schema.json"}], "yaml.schemas": {"./mfc-case-schema.json": ["case.yaml", "**/case.yaml"]}} def write_json_schema(output_path: str, include_descriptions: bool = True) -> None: @@ -108,7 +98,7 @@ def write_json_schema(output_path: str, include_descriptions: bool = True) -> No """ schema = generate_json_schema(include_descriptions) - with open(output_path, 'w') as f: + with open(output_path, "w") as f: json.dump(schema, f, indent=2) diff --git a/toolchain/mfc/params/namelist_parser.py b/toolchain/mfc/params/namelist_parser.py index ae4876ca02..93f43e2622 100644 --- a/toolchain/mfc/params/namelist_parser.py +++ b/toolchain/mfc/params/namelist_parser.py @@ -13,92 +13,383 @@ from pathlib import Path from typing import Dict, Set - # Fallback parameters for when Fortran source files are not available. # Generated from the namelist definitions in src/*/m_start_up.fpp. # To regenerate: python3 toolchain/mfc/params/namelist_parser.py _FALLBACK_PARAMS = { - 'pre_process': { - 'Bx0', 'Ca', 'R0ref', 'Re_inv', 'Web', - 'a_x', 'a_y', 'a_z', 'adv_n', 'bc_x', - 'bc_y', 'bc_z', 'bub_pp', 'bubbles_euler', 'bubbles_lagrange', - 'case_dir', 'cfl_adap_dt', 'cfl_const_dt', 'cont_damage', 'cyl_coord', - 'dist_type', 'down_sample', 'elliptic_smoothing', 'elliptic_smoothing_iters', 'fft_wrt', - 'file_per_process', 'fluid_pp', 'fluid_rho', 'hyper_cleaning', 'hyperelasticity', - 'hypoelasticity', 'ib', 'igr', 'igr_order', 'loops_x', - 'loops_y', 'loops_z', 'm', 'mhd', 'mixlayer_perturb', - 'mixlayer_perturb_k0', 'mixlayer_perturb_nk', 'mixlayer_vel_coef', 'mixlayer_vel_profile', 'model_eqns', - 'mpp_lim', 'muscl_order', 'n', 'n_start', 'n_start_old', - 'nb', 'num_bc_patches', 'num_fluids', 'num_ibs', 'num_patches', - 'old_grid', 'old_ic', 'p', 'palpha_eps', 'parallel_io', - 'patch_bc', 'patch_ib', 'patch_icpp', 'perturb_flow', 'perturb_flow_fluid', - 'perturb_flow_mag', 'perturb_sph', 'perturb_sph_fluid', 'pi_fac', 'poly_sigma', - 'polydisperse', 'polytropic', 'pre_stress', 'precision', 'pref', - 'ptgalpha_eps', 'qbmm', 'recon_type', 'relativity', 'relax', - 'relax_model', 'rhoRV', 'rhoref', 'sigR', 'sigV', - 'sigma', 'simplex_params', 'simplex_perturb', 'stretch_x', 'stretch_y', - 'stretch_z', 'surface_tension', 't_step_old', 't_step_start', 'thermal', - 'viscous', 'weno_order', 'x_a', 'x_b', 'x_domain', - 'y_a', 'y_b', 'y_domain', 'z_a', 'z_b', - 'z_domain', + "pre_process": { + "Bx0", + "Ca", + "R0ref", + "Re_inv", + "Web", + "a_x", + "a_y", + "a_z", + "adv_n", + "bc_x", + "bc_y", + "bc_z", + "bub_pp", + "bubbles_euler", + "bubbles_lagrange", + "case_dir", + "cfl_adap_dt", + "cfl_const_dt", + "cont_damage", + "cyl_coord", + "dist_type", + "down_sample", + "elliptic_smoothing", + "elliptic_smoothing_iters", + "fft_wrt", + "file_per_process", + "fluid_pp", + "fluid_rho", + "hyper_cleaning", + "hyperelasticity", + "hypoelasticity", + "ib", + "igr", + "igr_order", + "loops_x", + "loops_y", + "loops_z", + "m", + "mhd", + "mixlayer_perturb", + "mixlayer_perturb_k0", + "mixlayer_perturb_nk", + "mixlayer_vel_coef", + "mixlayer_vel_profile", + "model_eqns", + "mpp_lim", + "muscl_order", + "n", + "n_start", + "n_start_old", + "nb", + "num_bc_patches", + "num_fluids", + "num_ibs", + "num_patches", + "old_grid", + "old_ic", + "p", + "palpha_eps", + "parallel_io", + "patch_bc", + "patch_ib", + "patch_icpp", + "perturb_flow", + "perturb_flow_fluid", + "perturb_flow_mag", + "perturb_sph", + "perturb_sph_fluid", + "pi_fac", + "poly_sigma", + "polydisperse", + "polytropic", + "pre_stress", + "precision", + "pref", + "ptgalpha_eps", + "qbmm", + "recon_type", + "relativity", + "relax", + "relax_model", + "rhoRV", + "rhoref", + "sigR", + "sigV", + "sigma", + "simplex_params", + "simplex_perturb", + "stretch_x", + "stretch_y", + "stretch_z", + "surface_tension", + "t_step_old", + "t_step_start", + "thermal", + "viscous", + "weno_order", + "x_a", + "x_b", + "x_domain", + "y_a", + "y_b", + "y_domain", + "z_a", + "z_b", + "z_domain", }, - 'simulation': { - 'Bx0', 'Ca', 'R0ref', 'Re_inv', 'Web', - 'acoustic', 'acoustic_source', 'adap_dt', 'adap_dt_max_iters', 'adap_dt_tol', - 'adv_n', 'alf_factor', 'alpha_bar', 'alt_soundspeed', 'avg_state', - 'bc_x', 'bc_y', 'bc_z', 'bf_x', 'bf_y', - 'bf_z', 'bub_pp', 'bubble_model', 'bubbles_euler', 'bubbles_lagrange', - 'case_dir', 'cfl_adap_dt', 'cfl_const_dt', 'cfl_target', 'chem_params', - 'cont_damage', 'cont_damage_s', 'cyl_coord', 'down_sample', 'dt', - 'fd_order', 'fft_wrt', 'file_per_process', 'fluid_pp', 'g_x', - 'g_y', 'g_z', 'hyper_cleaning', 'hyper_cleaning_speed', 'hyper_cleaning_tau', - 'hyperelasticity', 'hypoelasticity', 'ib', 'ic_beta', 'ic_eps', - 'igr', 'igr_iter_solver', 'igr_order', 'igr_pres_lim', 'int_comp', - 'integral', 'integral_wrt', 'k_x', 'k_y', 'k_z', - 'lag_params', 'low_Mach', 'm', 'mapped_weno', 'mhd', - 'mixture_err', 'model_eqns', 'mp_weno', 'mpp_lim', 'muscl_lim', - 'muscl_order', 'n', 'n_start', 'nb', 'null_weights', - 'num_bc_patches', 'num_fluids', 'num_ibs', 'num_igr_iters', 'num_igr_warm_start_iters', - 'num_integrals', 'num_probes', 'num_source', 'nv_uvm_igr_temps_on_gpu', 'nv_uvm_out_of_core', - 'nv_uvm_pref_gpu', 'p', 'p_x', 'p_y', 'p_z', - 'palpha_eps', 'parallel_io', 'patch_ib', 'pi_fac', 'poly_sigma', - 'polydisperse', 'polytropic', 'precision', 'pref', 'prim_vars_wrt', - 'probe', 'probe_wrt', 'ptgalpha_eps', 'qbmm', 'rdma_mpi', - 'recon_type', 'relativity', 'relax', 'relax_model', 'rhoref', - 'riemann_solver', 'run_time_info', 'sigma', 'surface_tension', 't_save', - 't_step_old', 't_step_print', 't_step_save', 't_step_start', 't_step_stop', - 't_stop', 'tau_star', 'teno', 'teno_CT', 'thermal', - 'time_stepper', 'viscous', 'w_x', 'w_y', 'w_z', - 'wave_speeds', 'weno_Re_flux', 'weno_avg', 'weno_eps', 'weno_order', - 'wenoz', 'wenoz_q', 'x_a', 'x_b', 'x_domain', - 'y_a', 'y_b', 'y_domain', 'z_a', 'z_b', - 'z_domain', + "simulation": { + "Bx0", + "Ca", + "R0ref", + "Re_inv", + "Web", + "acoustic", + "acoustic_source", + "adap_dt", + "adap_dt_max_iters", + "adap_dt_tol", + "adv_n", + "alf_factor", + "alpha_bar", + "alt_soundspeed", + "avg_state", + "bc_x", + "bc_y", + "bc_z", + "bf_x", + "bf_y", + "bf_z", + "bub_pp", + "bubble_model", + "bubbles_euler", + "bubbles_lagrange", + "case_dir", + "cfl_adap_dt", + "cfl_const_dt", + "cfl_target", + "chem_params", + "cont_damage", + "cont_damage_s", + "cyl_coord", + "down_sample", + "dt", + "fd_order", + "fft_wrt", + "file_per_process", + "fluid_pp", + "g_x", + "g_y", + "g_z", + "hyper_cleaning", + "hyper_cleaning_speed", + "hyper_cleaning_tau", + "hyperelasticity", + "hypoelasticity", + "ib", + "ic_beta", + "ic_eps", + "igr", + "igr_iter_solver", + "igr_order", + "igr_pres_lim", + "int_comp", + "integral", + "integral_wrt", + "k_x", + "k_y", + "k_z", + "lag_params", + "low_Mach", + "m", + "mapped_weno", + "mhd", + "mixture_err", + "model_eqns", + "mp_weno", + "mpp_lim", + "muscl_lim", + "muscl_order", + "n", + "n_start", + "nb", + "null_weights", + "num_bc_patches", + "num_fluids", + "num_ibs", + "num_igr_iters", + "num_igr_warm_start_iters", + "num_integrals", + "num_probes", + "num_source", + "nv_uvm_igr_temps_on_gpu", + "nv_uvm_out_of_core", + "nv_uvm_pref_gpu", + "p", + "p_x", + "p_y", + "p_z", + "palpha_eps", + "parallel_io", + "patch_ib", + "pi_fac", + "poly_sigma", + "polydisperse", + "polytropic", + "precision", + "pref", + "prim_vars_wrt", + "probe", + "probe_wrt", + "ptgalpha_eps", + "qbmm", + "rdma_mpi", + "recon_type", + "relativity", + "relax", + "relax_model", + "rhoref", + "riemann_solver", + "run_time_info", + "sigma", + "surface_tension", + "t_save", + "t_step_old", + "t_step_print", + "t_step_save", + "t_step_start", + "t_step_stop", + "t_stop", + "tau_star", + "teno", + "teno_CT", + "thermal", + "time_stepper", + "viscous", + "w_x", + "w_y", + "w_z", + "wave_speeds", + "weno_Re_flux", + "weno_avg", + "weno_eps", + "weno_order", + "wenoz", + "wenoz_q", + "x_a", + "x_b", + "x_domain", + "y_a", + "y_b", + "y_domain", + "z_a", + "z_b", + "z_domain", }, - 'post_process': { - 'Bx0', 'Ca', 'E_wrt', 'G', 'R0ref', - 'Re_inv', 'Web', 'adv_n', 'alpha_rho_e_wrt', 'alpha_rho_wrt', - 'alpha_wrt', 'alt_soundspeed', 'avg_state', 'bc_x', 'bc_y', - 'bc_z', 'bub_pp', 'bubbles_euler', 'bubbles_lagrange', 'c_wrt', - 'case_dir', 'cf_wrt', 'cfl_adap_dt', 'cfl_const_dt', 'cfl_target', - 'chem_wrt_T', 'chem_wrt_Y', 'cons_vars_wrt', 'cont_damage', 'cyl_coord', - 'down_sample', 'fd_order', 'fft_wrt', 'file_per_process', 'fluid_pp', - 'flux_lim', 'flux_wrt', 'format', 'gamma_wrt', 'heat_ratio_wrt', - 'hyper_cleaning', 'hyperelasticity', 'hypoelasticity', 'ib', 'igr', - 'igr_order', 'lag_betaC_wrt', 'lag_betaT_wrt', 'lag_db_wrt', 'lag_dphidt_wrt', - 'lag_header', 'lag_id_wrt', 'lag_mg_wrt', 'lag_mv_wrt', 'lag_pos_prev_wrt', - 'lag_pos_wrt', 'lag_pres_wrt', 'lag_r0_wrt', 'lag_rad_wrt', 'lag_rmax_wrt', - 'lag_rmin_wrt', 'lag_rvel_wrt', 'lag_txt_wrt', 'lag_vel_wrt', 'liutex_wrt', - 'm', 'mhd', 'mixture_err', 'model_eqns', 'mom_wrt', - 'mpp_lim', 'muscl_order', 'n', 'n_start', 'nb', - 'num_bc_patches', 'num_fluids', 'num_ibs', 'omega_wrt', 'output_partial_domain', - 'p', 'parallel_io', 'pi_inf_wrt', 'poly_sigma', 'polydisperse', - 'polytropic', 'precision', 'pref', 'pres_inf_wrt', 'pres_wrt', - 'prim_vars_wrt', 'qbmm', 'qm_wrt', 'recon_type', 'relativity', - 'relax', 'relax_model', 'rho_wrt', 'rhoref', 'schlieren_alpha', - 'schlieren_wrt', 'sigR', 'sigma', 'sim_data', 'surface_tension', - 't_save', 't_step_save', 't_step_start', 't_step_stop', 't_stop', - 'thermal', 'vel_wrt', 'weno_order', 'x_output', 'y_output', - 'z_output', + "post_process": { + "Bx0", + "Ca", + "E_wrt", + "G", + "R0ref", + "Re_inv", + "Web", + "adv_n", + "alpha_rho_e_wrt", + "alpha_rho_wrt", + "alpha_wrt", + "alt_soundspeed", + "avg_state", + "bc_x", + "bc_y", + "bc_z", + "bub_pp", + "bubbles_euler", + "bubbles_lagrange", + "c_wrt", + "case_dir", + "cf_wrt", + "cfl_adap_dt", + "cfl_const_dt", + "cfl_target", + "chem_wrt_T", + "chem_wrt_Y", + "cons_vars_wrt", + "cont_damage", + "cyl_coord", + "down_sample", + "fd_order", + "fft_wrt", + "file_per_process", + "fluid_pp", + "flux_lim", + "flux_wrt", + "format", + "gamma_wrt", + "heat_ratio_wrt", + "hyper_cleaning", + "hyperelasticity", + "hypoelasticity", + "ib", + "igr", + "igr_order", + "lag_betaC_wrt", + "lag_betaT_wrt", + "lag_db_wrt", + "lag_dphidt_wrt", + "lag_header", + "lag_id_wrt", + "lag_mg_wrt", + "lag_mv_wrt", + "lag_pos_prev_wrt", + "lag_pos_wrt", + "lag_pres_wrt", + "lag_r0_wrt", + "lag_rad_wrt", + "lag_rmax_wrt", + "lag_rmin_wrt", + "lag_rvel_wrt", + "lag_txt_wrt", + "lag_vel_wrt", + "liutex_wrt", + "m", + "mhd", + "mixture_err", + "model_eqns", + "mom_wrt", + "mpp_lim", + "muscl_order", + "n", + "n_start", + "nb", + "num_bc_patches", + "num_fluids", + "num_ibs", + "omega_wrt", + "output_partial_domain", + "p", + "parallel_io", + "pi_inf_wrt", + "poly_sigma", + "polydisperse", + "polytropic", + "precision", + "pref", + "pres_inf_wrt", + "pres_wrt", + "prim_vars_wrt", + "qbmm", + "qm_wrt", + "recon_type", + "relativity", + "relax", + "relax_model", + "rho_wrt", + "rhoref", + "schlieren_alpha", + "schlieren_wrt", + "sigR", + "sigma", + "sim_data", + "surface_tension", + "t_save", + "t_step_save", + "t_step_start", + "t_step_stop", + "t_stop", + "thermal", + "vel_wrt", + "weno_order", + "x_output", + "y_output", + "z_output", }, } @@ -117,11 +408,7 @@ def parse_namelist_from_file(filepath: Path) -> Set[str]: # Find the namelist block - starts with "namelist /user_inputs/" # and continues until a line without continuation (&) or a blank line - namelist_match = re.search( - r'namelist\s+/user_inputs/\s*(.+?)(?=\n\s*\n|\n\s*!(?!\s*&)|\n\s*[a-zA-Z_]+\s*=)', - content, - re.DOTALL | re.IGNORECASE - ) + namelist_match = re.search(r"namelist\s+/user_inputs/\s*(.+?)(?=\n\s*\n|\n\s*!(?!\s*&)|\n\s*[a-zA-Z_]+\s*=)", content, re.DOTALL | re.IGNORECASE) if not namelist_match: raise ValueError(f"Could not find namelist /user_inputs/ in {filepath}") @@ -129,21 +416,21 @@ def parse_namelist_from_file(filepath: Path) -> Set[str]: namelist_text = namelist_match.group(1) # Remove Fortran line continuations (&) and join lines - namelist_text = re.sub(r'&\s*\n\s*', ' ', namelist_text) + namelist_text = re.sub(r"&\s*\n\s*", " ", namelist_text) # Remove preprocessor directives (#:if, #:endif, etc.) - namelist_text = re.sub(r'#:.*', '', namelist_text) + namelist_text = re.sub(r"#:.*", "", namelist_text) # Remove comments (! to end of line, but not inside strings) - namelist_text = re.sub(r'!.*', '', namelist_text) + namelist_text = re.sub(r"!.*", "", namelist_text) # Extract parameter names - they're comma-separated identifiers # Parameter names are alphanumeric with underscores found_params = set() - for match in re.finditer(r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b', namelist_text): + for match in re.finditer(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b", namelist_text): name = match.group(1) # Skip Fortran keywords that might appear - if name.lower() not in {'namelist', 'user_inputs', 'if', 'endif', 'not'}: + if name.lower() not in {"namelist", "user_inputs", "if", "endif", "not"}: found_params.add(name) return found_params @@ -161,9 +448,9 @@ def parse_all_namelists(mfc_root: Path) -> Dict[str, Set[str]]: Falls back to built-in parameter sets when sources are unavailable. """ targets = { - 'pre_process': mfc_root / 'src' / 'pre_process' / 'm_start_up.fpp', - 'simulation': mfc_root / 'src' / 'simulation' / 'm_start_up.fpp', - 'post_process': mfc_root / 'src' / 'post_process' / 'm_start_up.fpp', + "pre_process": mfc_root / "src" / "pre_process" / "m_start_up.fpp", + "simulation": mfc_root / "src" / "simulation" / "m_start_up.fpp", + "post_process": mfc_root / "src" / "post_process" / "m_start_up.fpp", } result = {} @@ -219,14 +506,14 @@ def is_param_valid_for_target(param_name: str, target_name: str) -> bool: # Extract base parameter name (before any index or attribute) # e.g., "patch_icpp(1)%geometry" -> "patch_icpp" # e.g., "fluid_pp(2)%gamma" -> "fluid_pp" - base_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)', param_name) + base_match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)", param_name) if base_match: return base_match.group(1) in valid_params return param_name in valid_params -if __name__ == '__main__': +if __name__ == "__main__": # Test the parser import sys diff --git a/toolchain/mfc/params/registry.py b/toolchain/mfc/params/registry.py index f6f66b3176..08aa11eac8 100644 --- a/toolchain/mfc/params/registry.py +++ b/toolchain/mfc/params/registry.py @@ -25,10 +25,11 @@ register new parameters after freezing will raise RuntimeError. """ -from typing import Dict, Set, Mapping, Any -from types import MappingProxyType +import re from collections import defaultdict from functools import lru_cache +from types import MappingProxyType +from typing import Any, Dict, Mapping, Set from .schema import ParamDef @@ -96,10 +97,7 @@ def register(self, param: ParamDef) -> None: a different type (type mismatch is not allowed). """ if self._frozen: - raise RegistryFrozenError( - f"Cannot register '{param.name}': registry is frozen. " - "All parameters must be registered during module initialization." - ) + raise RegistryFrozenError(f"Cannot register '{param.name}': registry is frozen. All parameters must be registered during module initialization.") if param.name in self._params: existing = self._params[param.name] @@ -153,18 +151,34 @@ def get_json_schema(self) -> Dict[str, Any]: """ Generate JSON schema for case file validation. + Indexed parameter families (e.g., patch_ib(1)%radius through + patch_ib(1000)%radius) are collapsed into patternProperties + regexes to keep the schema small (~500 entries vs ~40,000). + Returns: JSON schema dict compatible with fastjsonschema. """ - properties = { - name: param.param_type.json_schema - for name, param in self.all_params.items() - } + properties = {} + pattern_props = {} + + for name, param in self.all_params.items(): + if "(" not in name: + # Scalar param — explicit property + properties[name] = param.param_type.json_schema + else: + # Indexed param — collapse into pattern + # Replace digit sequences inside parens: (1) -> (\d+) + pattern = re.sub(r"\(\d+\)", "__IDX__", name) + pattern = re.escape(pattern).replace("__IDX__", r"\(\d+\)") + pattern = f"^{pattern}$" + if pattern not in pattern_props: + pattern_props[pattern] = param.param_type.json_schema return { "type": "object", "properties": properties, - "additionalProperties": False + "patternProperties": pattern_props, + "additionalProperties": False, } def get_validator(self): @@ -179,12 +193,13 @@ def get_validator(self): @lru_cache(maxsize=1) -def _get_cached_validator(registry_id: int): # pylint: disable=unused-argument +def _get_cached_validator(registry_id: int): """Cache the validator at module level (registry is immutable after freeze). Note: registry_id is used as cache key to invalidate when registry changes. """ - import fastjsonschema # pylint: disable=import-outside-toplevel + import fastjsonschema + return fastjsonschema.compile(REGISTRY.get_json_schema()) diff --git a/toolchain/mfc/params/schema.py b/toolchain/mfc/params/schema.py index 08ef309254..e0132e19fa 100644 --- a/toolchain/mfc/params/schema.py +++ b/toolchain/mfc/params/schema.py @@ -6,13 +6,14 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Set, Any, Optional, Dict, List +from typing import Any, Dict, List, Optional, Set from .errors import constraint_error class ParamType(Enum): """Parameter types matching MFC's Fortran types with JSON schema support.""" + INT = "int" REAL = "real" LOG = "log" @@ -40,7 +41,7 @@ def json_schema(self) -> Dict[str, Any]: @dataclass -class ParamDef: # pylint: disable=too-many-instance-attributes +class ParamDef: """ Definition of a single MFC parameter. @@ -53,6 +54,7 @@ class ParamDef: # pylint: disable=too-many-instance-attributes dependencies: Related params (requires, recommends) tags: Feature tags for grouping (e.g., "mhd", "bubbles", "weno") """ + name: str param_type: ParamType description: str = "" @@ -92,12 +94,8 @@ def validate_value(self, value: Any) -> List[str]: # Check numeric range constraints (only for numeric values, not analytic strings) if isinstance(value, (int, float)): if "min" in self.constraints and value < self.constraints["min"]: - errors.append( - constraint_error(self.name, "min", self.constraints["min"], value) - ) + errors.append(constraint_error(self.name, "min", self.constraints["min"], value)) if "max" in self.constraints and value > self.constraints["max"]: - errors.append( - constraint_error(self.name, "max", self.constraints["max"], value) - ) + errors.append(constraint_error(self.name, "max", self.constraints["max"], value)) return errors diff --git a/toolchain/mfc/params/suggest.py b/toolchain/mfc/params/suggest.py index 6f46107b33..11eb4bd60b 100644 --- a/toolchain/mfc/params/suggest.py +++ b/toolchain/mfc/params/suggest.py @@ -11,12 +11,13 @@ module initialization, catching developer typos in CONSTRAINTS/DEPENDENCIES dicts. """ -from typing import List, Iterable from functools import lru_cache +from typing import Iterable, List # Import rapidfuzz - falls back gracefully if not installed try: - from rapidfuzz import process, fuzz + from rapidfuzz import fuzz, process + RAPIDFUZZ_AVAILABLE = True except ImportError: RAPIDFUZZ_AVAILABLE = False @@ -104,7 +105,7 @@ def suggest_parameter(unknown_param: str) -> List[str]: List of similar valid parameter names. """ # Import here to avoid circular import (registry imports definitions which may use suggest) - from .registry import REGISTRY # pylint: disable=import-outside-toplevel + from .registry import REGISTRY return suggest_similar(unknown_param, REGISTRY.all_params.keys()) diff --git a/toolchain/mfc/params/validate.py b/toolchain/mfc/params/validate.py index 04548b4066..efbc798e38 100644 --- a/toolchain/mfc/params/validate.py +++ b/toolchain/mfc/params/validate.py @@ -27,8 +27,12 @@ 3. Physics validation (via case_validator.py) """ -from typing import Dict, Any, List, Optional, Tuple -from .registry import REGISTRY +from typing import Any, Dict, List, Optional, Tuple + +# Note: definitions is imported by params/__init__.py to populate REGISTRY. +# This redundant import ensures REGISTRY is populated even if this module +# is imported directly (e.g., during testing). +from . import definitions # noqa: F401 from .errors import ( dependency_error, dependency_recommendation, @@ -36,11 +40,8 @@ format_error_list, unknown_param_error, ) +from .registry import REGISTRY from .suggest import suggest_parameter -# Note: definitions is imported by params/__init__.py to populate REGISTRY. -# This redundant import ensures REGISTRY is populated even if this module -# is imported directly (e.g., during testing). -from . import definitions # noqa: F401 pylint: disable=unused-import def check_unknown_params(params: Dict[str, Any]) -> List[str]: @@ -96,7 +97,7 @@ def validate_constraints(params: Dict[str, Any]) -> List[str]: return errors -def _check_condition( # pylint: disable=too-many-arguments,too-many-positional-arguments +def _check_condition( name: str, condition: Dict[str, Any], condition_label: Optional[str], @@ -122,12 +123,18 @@ def _check_condition( # pylint: disable=too-many-arguments,too-many-positional- else: got = params[req_param] if got not in expected_vals: - errors.append(dependency_value_error( - name, condition_label, req_param, expected_vals, got, - )) + errors.append( + dependency_value_error( + name, + condition_label, + req_param, + expected_vals, + got, + ) + ) -def check_dependencies(params: Dict[str, Any]) -> Tuple[List[str], List[str]]: # pylint: disable=too-many-branches +def check_dependencies(params: Dict[str, Any]) -> Tuple[List[str], List[str]]: """ Check parameter dependencies. @@ -162,7 +169,12 @@ def check_dependencies(params: Dict[str, Any]) -> Tuple[List[str], List[str]]: for trigger_val, condition in deps["when_value"].items(): if value == trigger_val: _check_condition( - name, condition, f"={trigger_val}", params, errors, warnings, + name, + condition, + f"={trigger_val}", + params, + errors, + warnings, ) return errors, warnings diff --git a/toolchain/mfc/params_cmd.py b/toolchain/mfc/params_cmd.py index a25b104c26..577dfeeb55 100644 --- a/toolchain/mfc/params_cmd.py +++ b/toolchain/mfc/params_cmd.py @@ -3,17 +3,19 @@ Provides CLI access to search and explore MFC's ~3,300 case parameters. """ -# pylint: disable=import-outside-toplevel import re -from .state import ARG + from .printer import cons +from .state import ARG def params(): """Execute the params command based on CLI arguments.""" - from .params import REGISTRY - from .params import definitions # noqa: F401 pylint: disable=unused-import + from .params import ( + REGISTRY, + definitions, # noqa: F401 + ) query = ARG("query") type_filter = ARG("param_type") @@ -46,7 +48,7 @@ def params(): cons.print(" Use './mfc.sh params -F' to see all feature groups") -def _collapse_indexed_params(matches): # pylint: disable=too-many-locals,too-many-branches,too-many-statements +def _collapse_indexed_params(matches): """ Collapse indexed parameters into patterns. @@ -57,9 +59,9 @@ def _collapse_indexed_params(matches): # pylint: disable=too-many-locals,too-ma """ # Patterns for different index positions # Pattern 1: prefix(N)%suffix or prefix(N)%suffix(M) - prefix_pattern = re.compile(r'^([^(]+)\((\d+)\)%(.+)$') + prefix_pattern = re.compile(r"^([^(]+)\((\d+)\)%(.+)$") # Pattern 2: name(N) or name(N, M) at end - suffix_pattern = re.compile(r'^(.+)\((\d+)(?:,\s*(\d+))?\)$') + suffix_pattern = re.compile(r"^(.+)\((\d+)(?:,\s*(\d+))?\)$") # Two-level grouping: first by base pattern (with indices replaced), then collect indices groups = {} # normalized_pattern -> {indices: [...], param_type, stages, pattern_type} @@ -92,10 +94,10 @@ def _collapse_indexed_params(matches): # pylint: disable=too-many-locals,too-ma if base_pattern not in groups: groups[base_pattern] = { - 'indices': [], - 'param_type': param.param_type, - } - groups[base_pattern]['indices'].append((indices_key, param)) + "indices": [], + "param_type": param.param_type, + } + groups[base_pattern]["indices"].append((indices_key, param)) continue # Try suffix-only pattern: name(N) or name(N, M) @@ -114,26 +116,26 @@ def _collapse_indexed_params(matches): # pylint: disable=too-many-locals,too-ma if base_pattern not in groups: groups[base_pattern] = { - 'indices': [], - 'param_type': param.param_type, - } - groups[base_pattern]['indices'].append((indices_key, param)) + "indices": [], + "param_type": param.param_type, + } + groups[base_pattern]["indices"].append((indices_key, param)) continue # No index pattern - add as-is if name not in groups: groups[name] = { - 'indices': [(None, param)], - 'param_type': param.param_type, - } + "indices": [(None, param)], + "param_type": param.param_type, + } else: - groups[name]['indices'].append((None, param)) + groups[name]["indices"].append((None, param)) # Build collapsed results collapsed = [] for pattern, data in sorted(groups.items()): - indices = data['indices'] + indices = data["indices"] param = indices[0][1] # Get param from first entry count = len(indices) @@ -146,11 +148,11 @@ def _collapse_indexed_params(matches): # pylint: disable=too-many-locals,too-ma # Reconstruct the actual name actual_name = pattern if idx_tuple[0] is not None: - actual_name = actual_name.replace('(N)', f'({idx_tuple[0]})', 1) + actual_name = actual_name.replace("(N)", f"({idx_tuple[0]})", 1) if idx_tuple[1] is not None: - actual_name = actual_name.replace('(M)', f'({idx_tuple[1]})', 1) + actual_name = actual_name.replace("(M)", f"({idx_tuple[1]})", 1) if idx_tuple[2] is not None: - actual_name = actual_name.replace('(K)', f'({idx_tuple[2]})', 1) + actual_name = actual_name.replace("(K)", f"({idx_tuple[2]})", 1) collapsed.append((actual_name, param, 1)) else: # Multiple indices - build range string @@ -211,7 +213,7 @@ def _show_feature_groups(registry): cons.print(" Use './mfc.sh params --feature ' to see parameters for a feature.") cons.print() cons.print(f" {'Feature':<20} {'Description'}") - cons.print(f" {'-'*20} {'-'*50}") + cons.print(f" {'-' * 20} {'-' * 50}") # Get all tags from registry and show with descriptions all_tags = registry.get_all_tags() @@ -288,7 +290,7 @@ def _show_families(registry, limit): cons.print("[bold]Parameter Families[/bold]") cons.print() cons.print(f" {'Family':<40} {'Count':>6}") - cons.print(f" {'-'*40} {'-'*6}") + cons.print(f" {'-' * 40} {'-' * 6}") for prefix, count in sorted_families[:limit]: cons.print(f" {prefix:<40} {count:>6}") @@ -300,7 +302,7 @@ def _show_families(registry, limit): cons.print("[yellow]Tip:[/yellow] Use './mfc.sh params ' to see parameters in a family") -def _search_params(registry, query, type_filter, limit, describe=False, search_descriptions=True): # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def _search_params(registry, query, type_filter, limit, describe=False, search_descriptions=True): """Search for parameters matching a query.""" from .params.descriptions import get_description @@ -348,7 +350,7 @@ def _search_params(registry, query, type_filter, limit, describe=False, search_d cons.print(f" [dim]... {len(collapsed) - limit} more patterns (use -n {len(collapsed)} to show all)[/dim]") -def _show_collapsed_results(collapsed, describe=False): # pylint: disable=too-many-branches +def _show_collapsed_results(collapsed, describe=False): """Show collapsed search results.""" from .params.descriptions import get_description, get_pattern_description @@ -380,21 +382,20 @@ def _show_collapsed_results(collapsed, describe=False): # pylint: disable=too-m # Compact table mode if has_ranges: cons.print(f" {'Parameter':<40} {'Type':12} {'#':>4} {'Index Range'}") - cons.print(f" {'-'*40} {'-'*12} {'-'*4} {'-'*15}") + cons.print(f" {'-' * 40} {'-' * 12} {'-' * 4} {'-' * 15}") else: cons.print(f" {'Parameter':<40} {'Type':12}") - cons.print(f" {'-'*40} {'-'*12}") + cons.print(f" {'-' * 40} {'-' * 12}") for item in collapsed: if len(item) == 4: name, param, count, range_str = item if count > 1: cons.print(f" {name:<40} {param.param_type.name:12} {count:>4} {range_str}") + elif has_ranges: + cons.print(f" {name:<40} {param.param_type.name:12} {count:>4}") else: - if has_ranges: - cons.print(f" {name:<40} {param.param_type.name:12} {count:>4}") - else: - cons.print(f" {name:<40} {param.param_type.name:12}") + cons.print(f" {name:<40} {param.param_type.name:12}") else: name, param, count = item if has_ranges: @@ -406,6 +407,7 @@ def _show_collapsed_results(collapsed, describe=False): # pylint: disable=too-m def _suggest_alternatives(registry, query): """Suggest similar parameter names.""" import difflib + all_names = list(registry.all_params.keys()) suggestions = difflib.get_close_matches(query, all_names, n=5, cutoff=0.5) diff --git a/toolchain/mfc/params_tests/coverage.py b/toolchain/mfc/params_tests/coverage.py index 5d80fbafc6..47b8ab733d 100644 --- a/toolchain/mfc/params_tests/coverage.py +++ b/toolchain/mfc/params_tests/coverage.py @@ -7,9 +7,9 @@ import ast import json -from pathlib import Path -from typing import Dict, List, Any from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List @dataclass @@ -24,6 +24,7 @@ class ConstraintInfo: message: Error message shown when constraint is violated. condition_code: Unparsed source code of the condition expression. """ + method: str line_number: int message: str @@ -36,10 +37,7 @@ def _extract_message(msg_node: ast.expr) -> str: return msg_node.value if isinstance(msg_node, ast.JoinedStr): # f-string - extract the static parts - return "".join( - p.value if isinstance(p, ast.Constant) else "{...}" - for p in msg_node.values - ) + return "".join(p.value if isinstance(p, ast.Constant) else "{...}" for p in msg_node.values) return "" @@ -49,7 +47,7 @@ def _is_prohibit_call(node: ast.AST) -> bool: return False if not isinstance(node.func, ast.Attribute): return False - return node.func.attr == 'prohibit' and len(node.args) >= 2 + return node.func.attr == "prohibit" and len(node.args) >= 2 def _find_case_validator_class(tree: ast.Module) -> ast.ClassDef: @@ -64,7 +62,7 @@ def extract_constraints_from_validator() -> List[ConstraintInfo]: """Parse case_validator.py and extract all prohibit() calls.""" validator_path = Path(__file__).parent.parent / "case_validator.py" - with open(validator_path, 'r', encoding='utf-8') as f: + with open(validator_path, "r", encoding="utf-8") as f: source = f.read() tree = ast.parse(source) @@ -94,12 +92,7 @@ def extract_constraints_from_validator() -> List[ConstraintInfo]: # Note: node.lineno points to the start of the prohibit() call. # For multi-line calls, this is the first line, not where the # condition or message appears. - constraints.append(ConstraintInfo( - method=item.name, - line_number=node.lineno, - message=message, - condition_code=condition_code - )) + constraints.append(ConstraintInfo(method=item.name, line_number=node.lineno, message=message, condition_code=condition_code)) return constraints @@ -117,7 +110,7 @@ def extract_check_methods() -> Dict[str, Dict[str, Any]]: """Extract all check_* methods from validator with their stage.""" validator_path = Path(__file__).parent.parent / "case_validator.py" - with open(validator_path, 'r', encoding='utf-8') as f: + with open(validator_path, "r", encoding="utf-8") as f: source = f.read() methods = {} @@ -136,7 +129,7 @@ def extract_check_methods() -> Dict[str, Dict[str, Any]]: docstring = ast.get_docstring(item) or "" methods[item.name] = { "line_number": item.lineno, - "docstring": docstring.split('\n')[0] if docstring else "", + "docstring": docstring.split("\n")[0] if docstring else "", "prohibit_count": _count_prohibit_calls(item), } @@ -168,7 +161,7 @@ def extract_validate_dispatch() -> Dict[str, List[str]]: """Extract which check methods are called for each stage.""" validator_path = Path(__file__).parent.parent / "case_validator.py" - with open(validator_path, 'r', encoding='utf-8') as f: + with open(validator_path, "r", encoding="utf-8") as f: source = f.read() dispatch = {stage: [] for stage in _VALIDATE_METHOD_TO_STAGE.values()} @@ -200,11 +193,7 @@ def generate_coverage_report() -> Dict[str, Any]: for c in constraints: if c.method not in by_method: by_method[c.method] = [] - by_method[c.method].append({ - "line": c.line_number, - "message": c.message, - "condition": c.condition_code[:80] + "..." if len(c.condition_code) > 80 else c.condition_code - }) + by_method[c.method].append({"line": c.line_number, "message": c.message, "condition": c.condition_code[:80] + "..." if len(c.condition_code) > 80 else c.condition_code}) # Calculate coverage per stage stage_coverage = {} @@ -223,18 +212,13 @@ def generate_coverage_report() -> Dict[str, Any]: common_constraints = stage_coverage.get("common", {}).get("constraint_count", 0) for stage in ["pre_process", "simulation", "post_process"]: if stage in stage_coverage: - stage_coverage[stage]["total_with_common"] = ( - stage_coverage[stage]["constraint_count"] + common_constraints - ) + stage_coverage[stage]["total_with_common"] = stage_coverage[stage]["constraint_count"] + common_constraints return { "summary": { "total_constraints": len(constraints), "total_check_methods": len(methods), - "methods_with_most_constraints": sorted( - [(name, info["prohibit_count"]) for name, info in methods.items()], - key=lambda x: -x[1] - )[:10], + "methods_with_most_constraints": sorted([(name, info["prohibit_count"]) for name, info in methods.items()], key=lambda x: -x[1])[:10], }, "stage_coverage": stage_coverage, "methods": methods, @@ -254,12 +238,12 @@ def print_coverage_report(): print(f"Total check methods: {report['summary']['total_check_methods']}") print("\nMethods with most constraints:") - for method, count in report['summary']['methods_with_most_constraints']: + for method, count in report["summary"]["methods_with_most_constraints"]: print(f" {method}: {count} constraints") print("\nConstraints by stage:") - for stage, info in report['stage_coverage'].items(): - total = info.get('total_with_common', info['constraint_count']) + for stage, info in report["stage_coverage"].items(): + total = info.get("total_with_common", info["constraint_count"]) print(f" {stage}:") print(f" Methods: {info['method_count']}") print(f" Constraints: {info['constraint_count']} (+ common = {total})") @@ -268,12 +252,12 @@ def print_coverage_report(): print("Detailed constraint listing (top methods):") print("=" * 70) - for method, count in report['summary']['methods_with_most_constraints'][:5]: + for method, count in report["summary"]["methods_with_most_constraints"][:5]: print(f"\n{method} ({count} constraints):") - if method in report['constraints_by_method']: - for c in report['constraints_by_method'][method][:5]: + if method in report["constraints_by_method"]: + for c in report["constraints_by_method"][method][:5]: print(f" L{c['line']}: {c['message'][:60]}") - if len(report['constraints_by_method'][method]) > 5: + if len(report["constraints_by_method"][method]) > 5: print(f" ... and {len(report['constraints_by_method'][method]) - 5} more") @@ -284,7 +268,7 @@ def save_coverage_report(output_path: Path = None): report = generate_coverage_report() - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: json.dump(report, f, indent=2) return output_path diff --git a/toolchain/mfc/params_tests/inventory.py b/toolchain/mfc/params_tests/inventory.py index 96a88d124d..6f139571c7 100644 --- a/toolchain/mfc/params_tests/inventory.py +++ b/toolchain/mfc/params_tests/inventory.py @@ -4,14 +4,14 @@ Exports all MFC parameters with their types and tags to JSON for analysis. """ -import re import json +import re from pathlib import Path -from typing import Dict, Any +from typing import Any, Dict -from ..run.case_dicts import ALL from ..params import REGISTRY from ..params.schema import ParamType +from ..run.case_dicts import ALL def get_param_type_name(param_type) -> str: @@ -59,8 +59,8 @@ def export_parameter_inventory() -> Dict[str, Any]: # Detect pattern-based parameters if "(" in param_name: # Extract pattern (e.g., "patch_icpp(1)%x_centroid" -> "patch_icpp({id})%x_centroid") - param_pattern = re.sub(r'\((\d+)\)', r'({id})', param_name) - param_pattern = re.sub(r'\((\d+),\s*(\d+)\)', r'({id1}, {id2})', param_pattern) + param_pattern = re.sub(r"\((\d+)\)", r"({id})", param_name) + param_pattern = re.sub(r"\((\d+),\s*(\d+)\)", r"({id1}, {id2})", param_pattern) param_info["pattern"] = param_pattern inventory["parameters"][param_name] = param_info @@ -86,15 +86,11 @@ def export_parameter_patterns() -> Dict[str, Any]: continue # Normalize the pattern - normalized = re.sub(r'\((\d+)\)', r'({N})', param_name) - normalized = re.sub(r'\((\d+),\s*(\d+)\)', r'({N}, {M})', normalized) + normalized = re.sub(r"\((\d+)\)", r"({N})", param_name) + normalized = re.sub(r"\((\d+),\s*(\d+)\)", r"({N}, {M})", normalized) if normalized not in patterns: - patterns[normalized] = { - "examples": [], - "type": get_param_type_name(param_type), - "count": 0 - } + patterns[normalized] = {"examples": [], "type": get_param_type_name(param_type), "count": 0} patterns[normalized]["examples"].append(param_name) patterns[normalized]["count"] += 1 @@ -113,7 +109,7 @@ def save_inventory(output_path: Path = None): inventory = export_parameter_inventory() inventory["patterns"] = export_parameter_patterns() - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: json.dump(inventory, f, indent=2) return output_path diff --git a/toolchain/mfc/params_tests/mutation_tests.py b/toolchain/mfc/params_tests/mutation_tests.py index 15bbfa0885..2d12bc22cd 100644 --- a/toolchain/mfc/params_tests/mutation_tests.py +++ b/toolchain/mfc/params_tests/mutation_tests.py @@ -7,9 +7,9 @@ import json import subprocess -from pathlib import Path -from typing import Dict, Any, List, Tuple from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Tuple from ..case_validator import CaseValidator @@ -17,6 +17,7 @@ @dataclass class MutationResult: """Result of a mutation test.""" + case_name: str param_name: str original_value: Any @@ -41,47 +42,37 @@ class MutationResult: "weno_order": [0, 2, 4, 6, 8], "time_stepper": [0, 6, -1], "riemann_solver": [0, 10, -1], - # === BOOLEAN PARAMETERS (Fortran logicals) === "bubbles_euler": ["X", "yes", "1"], "mpp_lim": ["X", "yes"], "cyl_coord": ["X", "maybe"], - # === BOUNDARY CONDITIONS === "bc_x%beg": [None, 100, -100], "bc_x%end": [None, 100, -100], "bc_y%beg": [100, -100], "bc_y%end": [100, -100], - # === DOMAIN PARAMETERS === "x_domain%beg": [None], "x_domain%end": [None], - # === PHYSICS: THERMODYNAMICS === # gamma must be > 1 for physical gases (gamma = Cp/Cv) # In MFC, fluid_pp(i)%gamma stores 1/(gamma-1), so it must be > 0 "fluid_pp(1)%gamma": [0, -1, -0.5], - # pi_inf (stiffness) must be >= 0 for stiffened gas EOS "fluid_pp(1)%pi_inf": [-1, -1e6], - # === PHYSICS: PATCH INITIAL CONDITIONS === # Pressure must be positive "patch_icpp(1)%pres": [0, -1, -1e5], - # Density (alpha_rho) must be non-negative (0 allowed for vacuum) "patch_icpp(1)%alpha_rho(1)": [-1, -1000], - # Volume fraction must be in [0, 1] "patch_icpp(1)%alpha(1)": [-0.1, 1.5, 2.0], - # === PHYSICS: GEOMETRY === # Patch dimensions must be positive "patch_icpp(1)%length_x": [0, -1, -10], "patch_icpp(1)%length_y": [0, -1], "patch_icpp(1)%length_z": [0, -1], "patch_icpp(1)%radius": [0, -1], - # === PHYSICS: BUBBLES === # Bubble radius must be positive "patch_icpp(1)%r0": [0, -1], @@ -100,18 +91,15 @@ class MutationResult: # Global bubble reference values "rhoref": [0, -1, -1000], "pref": [0, -1, -1e5], - # === PHYSICS: ACOUSTICS === # Frequency/wavelength must be positive "acoustic(1)%frequency": [0, -1], "acoustic(1)%wavelength": [0, -1], "acoustic(1)%gauss_sigma_time": [0, -1], "acoustic(1)%gauss_sigma_dist": [0, -1], - # === NUMERICS === # CFL target should be in (0, 1] "cfl_target": [-0.1, 0, 1.5, 2.0], - # WENO epsilon must be positive (small regularization) "weno_eps": [0, -1e-6], } @@ -119,21 +107,13 @@ class MutationResult: def load_example_case(case_path: Path) -> Dict[str, Any]: """Load parameters from an example case file.""" - result = subprocess.run( - ["python3", str(case_path)], - capture_output=True, - text=True, - cwd=case_path.parent, - timeout=30, - check=False - ) + result = subprocess.run(["python3", str(case_path)], capture_output=True, text=True, cwd=case_path.parent, timeout=30, check=False) if result.returncode != 0: return None return json.loads(result.stdout.strip()) -def run_mutation(params: Dict[str, Any], param_name: str, - mutated_value: Any) -> Tuple[bool, List[str]]: +def run_mutation(params: Dict[str, Any], param_name: str, mutated_value: Any) -> Tuple[bool, List[str]]: """Apply mutation and check if validator catches it.""" mutated_params = params.copy() @@ -180,14 +160,16 @@ def run_mutations_on_case(case_name: str, params: Dict[str, Any]) -> List[Mutati caught, errors = run_mutation(params, param_name, mutated_value) - results.append(MutationResult( - case_name=case_name, - param_name=param_name, - original_value=original, - mutated_value=mutated_value, - validator_caught=caught, - errors=errors[:3] # Limit for memory - )) + results.append( + MutationResult( + case_name=case_name, + param_name=param_name, + original_value=original, + mutated_value=mutated_value, + validator_caught=caught, + errors=errors[:3], # Limit for memory + ) + ) return results @@ -257,8 +239,7 @@ def print_mutation_report(): print("\n" + "-" * 70) print("BY PARAMETER:") print("-" * 70) - for param, data in sorted(results["by_param"].items(), - key=lambda x: -x[1]["missed"]): + for param, data in sorted(results["by_param"].items(), key=lambda x: -x[1]["missed"]): total = data["caught"] + data["missed"] rate = data["caught"] / total * 100 if total > 0 else 0 status = "OK" if data["missed"] == 0 else "GAPS" @@ -271,7 +252,7 @@ def print_mutation_report(): for r in results["missed_details"][:10]: print(f" {r.case_name}") print(f" {r.param_name}: {r.original_value} -> {r.mutated_value}") - print(f" No validation error raised!") + print(" No validation error raised!") print() diff --git a/toolchain/mfc/params_tests/negative_tests.py b/toolchain/mfc/params_tests/negative_tests.py index 683c3f41c3..a4202348af 100644 --- a/toolchain/mfc/params_tests/negative_tests.py +++ b/toolchain/mfc/params_tests/negative_tests.py @@ -5,8 +5,8 @@ to ensure each constraint is properly enforced. """ -from typing import Dict, Any, List from dataclasses import dataclass +from typing import Any, Dict, List from ..case_validator import CaseValidator @@ -14,6 +14,7 @@ @dataclass class ConstraintTest: """A test case for a specific constraint.""" + method: str line_number: int message: str @@ -58,185 +59,199 @@ def generate_constraint_tests() -> List[ConstraintTest]: # =================================================================== # check_simulation_domain constraints # =================================================================== - tests.extend([ - ConstraintTest( - method="check_simulation_domain", - line_number=56, - message="m must be set", - condition="m is None", - test_params={**BASE_CASE, "m": None}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=57, - message="m must be positive", - condition="m <= 0", - test_params={**BASE_CASE, "m": 0}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=57, - message="m must be positive", - condition="m <= 0", - test_params={**BASE_CASE, "m": -5}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=58, - message="n must be non-negative", - condition="n < 0", - test_params={**BASE_CASE, "n": -1}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=59, - message="p must be non-negative", - condition="p < 0", - test_params={**BASE_CASE, "p": -1}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=60, - message="p must be odd for cylindrical coordinates", - condition="cyl_coord and p > 0 and p % 2 == 0", - test_params={**BASE_CASE, "cyl_coord": "T", "n": 10, "p": 2}, - ), - ConstraintTest( - method="check_simulation_domain", - line_number=62, - message="p must be 0 if n = 0", - condition="n == 0 and p > 0", - test_params={**BASE_CASE, "n": 0, "p": 5}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_simulation_domain", + line_number=56, + message="m must be set", + condition="m is None", + test_params={**BASE_CASE, "m": None}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=57, + message="m must be positive", + condition="m <= 0", + test_params={**BASE_CASE, "m": 0}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=57, + message="m must be positive", + condition="m <= 0", + test_params={**BASE_CASE, "m": -5}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=58, + message="n must be non-negative", + condition="n < 0", + test_params={**BASE_CASE, "n": -1}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=59, + message="p must be non-negative", + condition="p < 0", + test_params={**BASE_CASE, "p": -1}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=60, + message="p must be odd for cylindrical coordinates", + condition="cyl_coord and p > 0 and p % 2 == 0", + test_params={**BASE_CASE, "cyl_coord": "T", "n": 10, "p": 2}, + ), + ConstraintTest( + method="check_simulation_domain", + line_number=62, + message="p must be 0 if n = 0", + condition="n == 0 and p > 0", + test_params={**BASE_CASE, "n": 0, "p": 5}, + ), + ] + ) # =================================================================== # check_model_eqns_and_num_fluids constraints # =================================================================== - tests.extend([ - ConstraintTest( - method="check_model_eqns_and_num_fluids", - line_number=73, - message="model_eqns must be 1, 2, 3, or 4", - condition="model_eqns not in [1, 2, 3, 4]", - test_params={**BASE_CASE, "model_eqns": 5}, - ), - ConstraintTest( - method="check_model_eqns_and_num_fluids", - line_number=75, - message="num_fluids must be positive", - condition="num_fluids < 1", - test_params={**BASE_CASE, "num_fluids": 0}, - ), - ConstraintTest( - method="check_model_eqns_and_num_fluids", - line_number=85, - message="model_eqns = 1 does not support mpp_lim", - condition="model_eqns == 1 and mpp_lim", - test_params={**BASE_CASE, "model_eqns": 1, "num_fluids": None, "mpp_lim": "T"}, - ), - ConstraintTest( - method="check_model_eqns_and_num_fluids", - line_number=87, - message="num_fluids = 1 does not support mpp_lim", - condition="num_fluids == 1 and mpp_lim", - test_params={**BASE_CASE, "num_fluids": 1, "mpp_lim": "T"}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_model_eqns_and_num_fluids", + line_number=73, + message="model_eqns must be 1, 2, 3, or 4", + condition="model_eqns not in [1, 2, 3, 4]", + test_params={**BASE_CASE, "model_eqns": 5}, + ), + ConstraintTest( + method="check_model_eqns_and_num_fluids", + line_number=75, + message="num_fluids must be positive", + condition="num_fluids < 1", + test_params={**BASE_CASE, "num_fluids": 0}, + ), + ConstraintTest( + method="check_model_eqns_and_num_fluids", + line_number=85, + message="model_eqns = 1 does not support mpp_lim", + condition="model_eqns == 1 and mpp_lim", + test_params={**BASE_CASE, "model_eqns": 1, "num_fluids": None, "mpp_lim": "T"}, + ), + ConstraintTest( + method="check_model_eqns_and_num_fluids", + line_number=87, + message="num_fluids = 1 does not support mpp_lim", + condition="num_fluids == 1 and mpp_lim", + test_params={**BASE_CASE, "num_fluids": 1, "mpp_lim": "T"}, + ), + ] + ) # =================================================================== # check_time_stepping constraints # =================================================================== - tests.extend([ - ConstraintTest( - method="check_time_stepping", - line_number=0, # Will be determined - message="dt must be positive", - condition="dt <= 0", - test_params={**BASE_CASE, "dt": 0}, - ), - ConstraintTest( - method="check_time_stepping", - line_number=0, - message="dt must be positive", - condition="dt <= 0", - test_params={**BASE_CASE, "dt": -1e-6}, - ), - ConstraintTest( - method="check_time_stepping", - line_number=0, - message="t_step_stop must be >= t_step_start", - condition="t_step_stop < t_step_start", - test_params={**BASE_CASE, "t_step_start": 100, "t_step_stop": 50}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_time_stepping", + line_number=0, # Will be determined + message="dt must be positive", + condition="dt <= 0", + test_params={**BASE_CASE, "dt": 0}, + ), + ConstraintTest( + method="check_time_stepping", + line_number=0, + message="dt must be positive", + condition="dt <= 0", + test_params={**BASE_CASE, "dt": -1e-6}, + ), + ConstraintTest( + method="check_time_stepping", + line_number=0, + message="t_step_stop must be >= t_step_start", + condition="t_step_stop < t_step_start", + test_params={**BASE_CASE, "t_step_start": 100, "t_step_stop": 50}, + ), + ] + ) # =================================================================== # check_weno constraints # =================================================================== - tests.extend([ - ConstraintTest( - method="check_weno_simulation", - line_number=0, - message="weno_order must be 1, 3, 5, or 7", - condition="weno_order not in [1, 3, 5, 7]", - test_params={**BASE_CASE, "weno_order": 4}, - ), - ConstraintTest( - method="check_weno_simulation", - line_number=0, - message="weno_order must be 1, 3, 5, or 7", - condition="weno_order not in [1, 3, 5, 7]", - test_params={**BASE_CASE, "weno_order": 9}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_weno_simulation", + line_number=0, + message="weno_order must be 1, 3, 5, or 7", + condition="weno_order not in [1, 3, 5, 7]", + test_params={**BASE_CASE, "weno_order": 4}, + ), + ConstraintTest( + method="check_weno_simulation", + line_number=0, + message="weno_order must be 1, 3, 5, or 7", + condition="weno_order not in [1, 3, 5, 7]", + test_params={**BASE_CASE, "weno_order": 9}, + ), + ] + ) # =================================================================== # check_boundary_conditions constraints # =================================================================== - tests.extend([ - ConstraintTest( - method="check_boundary_conditions", - line_number=0, - message="bc_x%beg must be set", - condition="bc_x%beg is None", - test_params={**BASE_CASE, "bc_x%beg": None}, - ), - ConstraintTest( - method="check_boundary_conditions", - line_number=0, - message="bc_x%end must be set", - condition="bc_x%end is None", - test_params={**BASE_CASE, "bc_x%end": None}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_boundary_conditions", + line_number=0, + message="bc_x%beg must be set", + condition="bc_x%beg is None", + test_params={**BASE_CASE, "bc_x%beg": None}, + ), + ConstraintTest( + method="check_boundary_conditions", + line_number=0, + message="bc_x%end must be set", + condition="bc_x%end is None", + test_params={**BASE_CASE, "bc_x%end": None}, + ), + ] + ) # =================================================================== # check_bubbles constraints # =================================================================== bubble_case = {**BASE_CASE, "bubbles_euler": "T", "bubble_model": 2, "nb": 1} - tests.extend([ - ConstraintTest( - method="check_bubbles_euler", - line_number=0, - message="nb must be >= 1", - condition="bubbles_euler and nb < 1", - test_params={**bubble_case, "nb": 0}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_bubbles_euler", + line_number=0, + message="nb must be >= 1", + condition="bubbles_euler and nb < 1", + test_params={**bubble_case, "nb": 0}, + ), + ] + ) # =================================================================== # check_acoustic_source constraints (the biggest method) # =================================================================== - tests.extend([ - ConstraintTest( - method="check_acoustic_source", - line_number=0, - message="num_source must be positive when acoustic_source is enabled", - condition="acoustic_source and num_source < 1", - test_params={**BASE_CASE, "acoustic_source": "T", "num_source": 0}, - ), - ]) + tests.extend( + [ + ConstraintTest( + method="check_acoustic_source", + line_number=0, + message="num_source must be positive when acoustic_source is enabled", + condition="acoustic_source and num_source < 1", + test_params={**BASE_CASE, "acoustic_source": "T", "num_source": 0}, + ), + ] + ) return tests @@ -295,15 +310,17 @@ def run_constraint_tests() -> Dict[str, Any]: results["failed"] += 1 status = "FAIL" - results["details"].append({ - "method": test.method, - "message": test.message, - "status": status, - "expected_trigger": test.should_trigger, - "any_error": any_error, - "message_matched": message_matched, - "all_errors": validator.errors[:3], # Limit for display - }) + results["details"].append( + { + "method": test.method, + "message": test.message, + "status": status, + "expected_trigger": test.should_trigger, + "any_error": any_error, + "message_matched": message_matched, + "all_errors": validator.errors[:3], # Limit for display + } + ) return results @@ -335,7 +352,7 @@ def print_test_report(): print("\nResults by method:") for method, data in sorted(by_method.items()): status = "OK" if data["failed"] == 0 else "ISSUES" - print(f" {method}: {data['passed']}/{data['passed']+data['failed']} [{status}]") + print(f" {method}: {data['passed']}/{data['passed'] + data['failed']} [{status}]") if results["failed"] > 0: print("\nFailed tests (constraint not triggering as expected):") @@ -344,8 +361,8 @@ def print_test_report(): print(f"\n {detail['method']}") print(f" Expected: {detail['message']}") print(f" Got errors: {detail['any_error']}") - if detail['all_errors']: - for err in detail['all_errors'][:2]: + if detail["all_errors"]: + for err in detail["all_errors"][:2]: print(f" - {err[:60]}...") print("\n" + "=" * 70) diff --git a/toolchain/mfc/params_tests/runner.py b/toolchain/mfc/params_tests/runner.py index cc980cfbcc..aa89c87bc4 100644 --- a/toolchain/mfc/params_tests/runner.py +++ b/toolchain/mfc/params_tests/runner.py @@ -3,30 +3,15 @@ Main entry point for building and verifying the parameter validation test suite. """ -# pylint: disable=import-outside-toplevel -import sys -import json import argparse +import json +import sys from pathlib import Path -from .inventory import ( - export_parameter_inventory, - save_inventory, - print_inventory_summary -) -from .snapshot import ( - capture_all_examples, - save_snapshots, - load_snapshots, - compare_snapshots, - print_comparison_report -) -from .coverage import ( - generate_coverage_report, - print_coverage_report, - save_coverage_report -) +from .coverage import generate_coverage_report, print_coverage_report, save_coverage_report +from .inventory import export_parameter_inventory, print_inventory_summary, save_inventory +from .snapshot import capture_all_examples, compare_snapshots, load_snapshots, print_comparison_report, save_snapshots def get_data_dir() -> Path: @@ -115,9 +100,9 @@ def _print_changes_report(differences: dict, verbose: bool): print("\n" + "=" * 70) print("VALIDATION CHANGED!") print("=" * 70) - if differences['changed_validation']: + if differences["changed_validation"]: print(f" {len(differences['changed_validation'])} cases have different validation results") - if differences['removed_cases']: + if differences["removed_cases"]: print(f" {len(differences['removed_cases'])} cases were removed") print("\nIf this is expected, run 'build' to update the safety net.") @@ -153,7 +138,7 @@ def verify_safety_net(verbose: bool = True) -> bool: if verbose: print_comparison_report(differences) - has_changes = bool(differences['changed_validation'] or differences['removed_cases']) + has_changes = bool(differences["changed_validation"] or differences["removed_cases"]) if has_changes: _print_changes_report(differences, verbose) return False @@ -180,7 +165,7 @@ def show_summary(): inventory = json.load(f) print("\nParameter Inventory:") print(f" Total parameters: {inventory['metadata']['total_parameters']}") - print(f" By stage:") + print(" By stage:") print(f" Common: {inventory['metadata']['common_count']}") print(f" Pre-process: {inventory['metadata']['pre_process_count']}") print(f" Simulation: {inventory['metadata']['simulation_count']}") @@ -209,7 +194,7 @@ def show_summary(): print(f" Total constraints: {coverage['summary']['total_constraints']}") print(f" Check methods: {coverage['summary']['total_check_methods']}") print(" Top methods by constraint count:") - for method, count in coverage['summary']['methods_with_most_constraints'][:5]: + for method, count in coverage["summary"]["methods_with_most_constraints"][:5]: print(f" {method}: {count}") else: print("\nConstraint Coverage: NOT FOUND") @@ -217,20 +202,9 @@ def show_summary(): def main(): """Main entry point for command-line usage.""" - parser = argparse.ArgumentParser( - description="Parameter Validation Test Safety Net" - ) - parser.add_argument( - "command", - choices=["build", "verify", "summary", "inventory", "coverage", - "negative", "mutation"], - help="Command to run" - ) - parser.add_argument( - "-q", "--quiet", - action="store_true", - help="Reduce output verbosity" - ) + parser = argparse.ArgumentParser(description="Parameter Validation Test Safety Net") + parser.add_argument("command", choices=["build", "verify", "summary", "inventory", "coverage", "negative", "mutation"], help="Command to run") + parser.add_argument("-q", "--quiet", action="store_true", help="Reduce output verbosity") args = parser.parse_args() verbose = not args.quiet @@ -248,9 +222,11 @@ def main(): print_coverage_report() elif args.command == "negative": from .negative_tests import print_test_report + print_test_report() elif args.command == "mutation": from .mutation_tests import print_mutation_report + print_mutation_report() diff --git a/toolchain/mfc/params_tests/snapshot.py b/toolchain/mfc/params_tests/snapshot.py index c5c54f3112..3a36f38007 100644 --- a/toolchain/mfc/params_tests/snapshot.py +++ b/toolchain/mfc/params_tests/snapshot.py @@ -5,12 +5,12 @@ This allows us to verify that refactoring doesn't change validation behavior. """ -import json import hashlib +import json import subprocess +from dataclasses import asdict, dataclass from pathlib import Path -from typing import Dict, Any, List, Optional -from dataclasses import dataclass, asdict +from typing import Any, Dict, List, Optional from ..case_validator import CaseValidator @@ -18,6 +18,7 @@ @dataclass class ValidationResult: """Result of validating a single case file for a single stage.""" + case_path: str stage: str success: bool @@ -29,6 +30,7 @@ class ValidationResult: @dataclass class CaseSnapshot: """Complete validation snapshot for a case file.""" + case_path: str param_count: int param_hash: str @@ -64,31 +66,17 @@ def validate_case_for_stage(params: Dict[str, Any], stage: str) -> ValidationRes success=len(validator.errors) == 0, errors=validator.errors.copy(), param_hash=hash_params(params), - error_count=len(validator.errors) + error_count=len(validator.errors), ) except (ValueError, KeyError, TypeError, AttributeError) as e: # Catch expected validation errors, not programming bugs like SystemExit - return ValidationResult( - case_path="", - stage=stage, - success=False, - errors=[f"Exception during validation: {type(e).__name__}: {str(e)}"], - param_hash=hash_params(params), - error_count=1 - ) + return ValidationResult(case_path="", stage=stage, success=False, errors=[f"Exception during validation: {type(e).__name__}: {str(e)}"], param_hash=hash_params(params), error_count=1) def load_case_params(case_path: Path) -> Dict[str, Any]: """Load parameters from a case file by running it and capturing JSON output.""" # MFC case files print JSON to stdout when run - result = subprocess.run( - ["python3", str(case_path)], - capture_output=True, - text=True, - cwd=case_path.parent, - timeout=30, - check=False - ) + result = subprocess.run(["python3", str(case_path)], capture_output=True, text=True, cwd=case_path.parent, timeout=30, check=False) if result.returncode != 0: raise ValueError(f"Case file failed: {result.stderr[:200]}") @@ -107,16 +95,9 @@ def capture_case_snapshot(case_path: Path) -> CaseSnapshot: try: params = load_case_params(case_path) - except (ValueError, json.JSONDecodeError, subprocess.TimeoutExpired, - subprocess.SubprocessError, OSError, FileNotFoundError) as e: + except (ValueError, json.JSONDecodeError, subprocess.TimeoutExpired, subprocess.SubprocessError, OSError, FileNotFoundError) as e: # Catch expected case loading errors, not programming bugs - return CaseSnapshot( - case_path=str(case_path), - param_count=0, - param_hash="", - stages={}, - load_error=f"{type(e).__name__}: {str(e)}" - ) + return CaseSnapshot(case_path=str(case_path), param_count=0, param_hash="", stages={}, load_error=f"{type(e).__name__}: {str(e)}") stages = {} for stage in ["pre_process", "simulation", "post_process"]: @@ -124,12 +105,7 @@ def capture_case_snapshot(case_path: Path) -> CaseSnapshot: result.case_path = str(case_path) stages[stage] = result - return CaseSnapshot( - case_path=str(case_path), - param_count=len(params), - param_hash=hash_params(params), - stages=stages - ) + return CaseSnapshot(case_path=str(case_path), param_count=len(params), param_hash=hash_params(params), stages=stages) def capture_all_examples(examples_dir: Path = None) -> Dict[str, CaseSnapshot]: @@ -162,13 +138,7 @@ def capture_all_examples(examples_dir: Path = None) -> Dict[str, CaseSnapshot]: except (ValueError, KeyError, TypeError, OSError, json.JSONDecodeError) as e: # Catch expected errors during capture, not programming bugs print(f"EXCEPTION: {e}") - snapshots[case_name] = CaseSnapshot( - case_path=str(case_file), - param_count=0, - param_hash="", - stages={}, - load_error=f"Capture exception: {type(e).__name__}: {str(e)}" - ) + snapshots[case_name] = CaseSnapshot(case_path=str(case_file), param_count=0, param_hash="", stages={}, load_error=f"Capture exception: {type(e).__name__}: {str(e)}") return snapshots @@ -177,9 +147,7 @@ def snapshot_to_dict(snapshot: CaseSnapshot) -> Dict[str, Any]: """Convert snapshot to JSON-serializable dict.""" result = asdict(snapshot) # Convert ValidationResult objects in stages - result["stages"] = { - stage: asdict(vr) for stage, vr in snapshot.stages.items() - } + result["stages"] = {stage: asdict(vr) for stage, vr in snapshot.stages.items()} return result @@ -192,18 +160,12 @@ def save_snapshots(snapshots: Dict[str, CaseSnapshot], output_path: Path = None) "metadata": { "total_cases": len(snapshots), "load_errors": sum(1 for s in snapshots.values() if s.load_error), - "validation_errors": sum( - sum(stage.error_count for stage in s.stages.values()) - for s in snapshots.values() if not s.load_error - ), + "validation_errors": sum(sum(stage.error_count for stage in s.stages.values()) for s in snapshots.values() if not s.load_error), }, - "snapshots": { - name: snapshot_to_dict(snapshot) - for name, snapshot in snapshots.items() - } + "snapshots": {name: snapshot_to_dict(snapshot) for name, snapshot in snapshots.items()}, } - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) return output_path @@ -211,14 +173,11 @@ def save_snapshots(snapshots: Dict[str, CaseSnapshot], output_path: Path = None) def load_snapshots(input_path: Path) -> Dict[str, Any]: """Load snapshots from JSON file.""" - with open(input_path, 'r', encoding='utf-8') as f: + with open(input_path, "r", encoding="utf-8") as f: return json.load(f) -def compare_snapshots( - old_snapshots: Dict[str, Any], - new_snapshots: Dict[str, CaseSnapshot] -) -> Dict[str, Any]: +def compare_snapshots(old_snapshots: Dict[str, Any], new_snapshots: Dict[str, CaseSnapshot]) -> Dict[str, Any]: """Compare old and new snapshots, report differences.""" differences = { "new_cases": [], @@ -250,19 +209,18 @@ def compare_snapshots( if old_errors != new_errors: changed = True - changes.append({ - "stage": stage, - "old_error_count": len(old_errors), - "new_error_count": len(new_errors), - "added_errors": sorted(new_errors - old_errors), - "removed_errors": sorted(old_errors - new_errors), - }) + changes.append( + { + "stage": stage, + "old_error_count": len(old_errors), + "new_error_count": len(new_errors), + "added_errors": sorted(new_errors - old_errors), + "removed_errors": sorted(old_errors - new_errors), + } + ) if changed: - differences["changed_validation"].append({ - "case": case_name, - "changes": changes - }) + differences["changed_validation"].append({"case": case_name, "changes": changes}) else: differences["unchanged"].append(case_name) @@ -276,23 +234,23 @@ def print_comparison_report(differences: Dict[str, Any]): print("=" * 60) print(f"\nNew cases: {len(differences['new_cases'])}") - for case in differences['new_cases'][:5]: + for case in differences["new_cases"][:5]: print(f" + {case}") - if len(differences['new_cases']) > 5: + if len(differences["new_cases"]) > 5: print(f" ... and {len(differences['new_cases']) - 5} more") print(f"\nRemoved cases: {len(differences['removed_cases'])}") - for case in differences['removed_cases'][:5]: + for case in differences["removed_cases"][:5]: print(f" - {case}") print(f"\nChanged validation: {len(differences['changed_validation'])}") - for item in differences['changed_validation'][:10]: + for item in differences["changed_validation"][:10]: print(f"\n {item['case']}:") - for change in item['changes']: + for change in item["changes"]: print(f" [{change['stage']}] {change['old_error_count']} -> {change['new_error_count']} errors") - for err in change['added_errors'][:2]: + for err in change["added_errors"][:2]: print(f" + {err[:60]}...") - for err in change['removed_errors'][:2]: + for err in change["removed_errors"][:2]: print(f" - {err[:60]}...") print(f"\nUnchanged: {len(differences['unchanged'])}") diff --git a/toolchain/mfc/params_tests/test_definitions.py b/toolchain/mfc/params_tests/test_definitions.py index 20e0774526..4008543991 100644 --- a/toolchain/mfc/params_tests/test_definitions.py +++ b/toolchain/mfc/params_tests/test_definitions.py @@ -5,15 +5,16 @@ """ import unittest + from ..params import REGISTRY -from ..params.schema import ParamType from ..params.definitions import ( + CASE_OPT_PARAMS, CONSTRAINTS, DEPENDENCIES, - CASE_OPT_PARAMS, _validate_constraint, _validate_dependency, ) +from ..params.schema import ParamType class TestParameterDefinitions(unittest.TestCase): @@ -28,19 +29,13 @@ def test_all_params_have_names(self): def test_all_params_have_valid_type(self): """Every parameter should have a valid ParamType.""" for name, param in REGISTRY.all_params.items(): - self.assertIsInstance( - param.param_type, ParamType, - f"Parameter '{name}' has invalid type" - ) + self.assertIsInstance(param.param_type, ParamType, f"Parameter '{name}' has invalid type") def test_core_params_exist(self): """Core parameters m, n, p, model_eqns should exist.""" core_params = ["m", "n", "p", "model_eqns", "num_fluids"] for param_name in core_params: - self.assertIn( - param_name, REGISTRY.all_params, - f"Core parameter '{param_name}' not found" - ) + self.assertIn(param_name, REGISTRY.all_params, f"Core parameter '{param_name}' not found") def test_domain_params_exist(self): """Domain parameters should exist for all directions.""" @@ -95,12 +90,15 @@ class TestDependencyValidation(unittest.TestCase): def test_valid_when_true_dependency(self): """Valid when_true dependency should pass.""" - _validate_dependency("test", { - "when_true": { - "requires": ["other_param"], - "recommends": ["another_param"], - } - }) + _validate_dependency( + "test", + { + "when_true": { + "requires": ["other_param"], + "recommends": ["another_param"], + } + }, + ) def test_invalid_top_level_key_raises(self): """Invalid top-level dependency key should raise.""" @@ -112,9 +110,12 @@ def test_invalid_top_level_key_raises(self): def test_invalid_condition_key_raises(self): """Invalid condition key should raise.""" with self.assertRaises(ValueError) as ctx: - _validate_dependency("test", { - "when_true": {"reqires": ["foo"]} # Typo for requires - }) + _validate_dependency( + "test", + { + "when_true": {"reqires": ["foo"]} # Typo for requires + }, + ) self.assertIn("reqires", str(ctx.exception)) # Verify "did you mean?" suggestion is provided @@ -123,9 +124,7 @@ def test_invalid_condition_key_raises(self): def test_requires_must_be_list(self): """requires value must be a list.""" with self.assertRaises(ValueError): - _validate_dependency("test", { - "when_true": {"requires": "not a list"} - }) + _validate_dependency("test", {"when_true": {"requires": "not a list"}}) def test_all_defined_dependencies_are_valid(self): """All dependencies in DEPENDENCIES dict should be valid.""" @@ -142,19 +141,13 @@ class TestCaseOptimizationParams(unittest.TestCase): def test_case_opt_params_exist_in_registry(self): """All CASE_OPT_PARAMS should exist in registry.""" for param_name in CASE_OPT_PARAMS: - self.assertIn( - param_name, REGISTRY.all_params, - f"Case opt param '{param_name}' not in registry" - ) + self.assertIn(param_name, REGISTRY.all_params, f"Case opt param '{param_name}' not in registry") def test_case_opt_params_have_flag_set(self): """Params in CASE_OPT_PARAMS should have case_optimization=True.""" for param_name in CASE_OPT_PARAMS: param = REGISTRY.all_params[param_name] - self.assertTrue( - param.case_optimization, - f"Parameter '{param_name}' should have case_optimization=True" - ) + self.assertTrue(param.case_optimization, f"Parameter '{param_name}' should have case_optimization=True") class TestParameterCounts(unittest.TestCase): @@ -168,10 +161,7 @@ def test_total_param_count(self): def test_log_params_count(self): """Should have many LOG type parameters.""" - log_count = sum( - 1 for p in REGISTRY.all_params.values() - if p.param_type == ParamType.LOG - ) + log_count = sum(1 for p in REGISTRY.all_params.values() if p.param_type == ParamType.LOG) self.assertGreater(log_count, 300, "Too few LOG parameters") def test_tagged_params_exist(self): diff --git a/toolchain/mfc/params_tests/test_integration.py b/toolchain/mfc/params_tests/test_integration.py index 8a921310ed..96e7603d88 100644 --- a/toolchain/mfc/params_tests/test_integration.py +++ b/toolchain/mfc/params_tests/test_integration.py @@ -4,9 +4,9 @@ Tests that the parameter registry integrates correctly with case_dicts.py and provides correct JSON schema generation. """ -# pylint: disable=import-outside-toplevel import unittest + from ..params import REGISTRY from ..params.schema import ParamType @@ -20,10 +20,7 @@ def test_all_types_have_json_schema(self): schema = param_type.json_schema self.assertIsInstance(schema, dict) # Schema must have either "type" or "enum" key - self.assertTrue( - "type" in schema or "enum" in schema, - f"{param_type.name} schema has neither 'type' nor 'enum'" - ) + self.assertTrue("type" in schema or "enum" in schema, f"{param_type.name} schema has neither 'type' nor 'enum'") def test_int_schema(self): """INT should map to integer JSON schema.""" @@ -70,11 +67,15 @@ def test_get_json_schema_returns_valid_schema(self): self.assertFalse(schema["additionalProperties"]) def test_get_json_schema_has_all_params(self): - """Schema properties should include all registry params.""" + """Schema properties + patternProperties should cover all registry params.""" schema = REGISTRY.get_json_schema() properties = schema["properties"] + pattern_props = schema.get("patternProperties", {}) - self.assertEqual(len(properties), len(REGISTRY.all_params)) + # Scalar params are in properties, indexed params in patternProperties + scalar_count = sum(1 for n in REGISTRY.all_params if "(" not in n) + self.assertEqual(len(properties), scalar_count) + self.assertGreater(len(pattern_props), 0) def test_core_params_in_schema(self): """Core params should be in JSON schema.""" @@ -150,7 +151,9 @@ def test_json_schema_valid(self): self.assertIn("type", schema) self.assertEqual(schema["type"], "object") self.assertIn("properties", schema) - self.assertEqual(len(schema["properties"]), len(REGISTRY.all_params)) + self.assertIn("patternProperties", schema) + total = len(schema["properties"]) + len(schema["patternProperties"]) + self.assertGreater(total, 0) def test_json_schema_matches_registry(self): """case_dicts.SCHEMA should match REGISTRY.get_json_schema().""" @@ -222,10 +225,7 @@ def test_validator_log_params_match_registry(self): validator_log_params = _get_logical_params_from_registry() - registry_log_params = { - name for name, p in REGISTRY.all_params.items() - if p.param_type == ParamType.LOG - } + registry_log_params = {name for name, p in REGISTRY.all_params.items() if p.param_type == ParamType.LOG} self.assertEqual(validator_log_params, registry_log_params) diff --git a/toolchain/mfc/params_tests/test_registry.py b/toolchain/mfc/params_tests/test_registry.py index f6dd34bada..ee16844915 100644 --- a/toolchain/mfc/params_tests/test_registry.py +++ b/toolchain/mfc/params_tests/test_registry.py @@ -3,9 +3,9 @@ Tests registry functionality, freezing, and tag queries. """ -# pylint: disable=import-outside-toplevel import unittest + from ..params.registry import ParamRegistry, RegistryFrozenError from ..params.schema import ParamDef, ParamType @@ -28,10 +28,7 @@ def test_register_merge_tags(self): reg.register(ParamDef(name="test", param_type=ParamType.INT, tags={"mhd"})) reg.register(ParamDef(name="test", param_type=ParamType.INT, tags={"physics"})) - self.assertEqual( - reg.all_params["test"].tags, - {"mhd", "physics"} - ) + self.assertEqual(reg.all_params["test"].tags, {"mhd", "physics"}) def test_register_type_mismatch_raises(self): """Registering same param with different type should raise.""" @@ -111,11 +108,13 @@ class TestGlobalRegistry(unittest.TestCase): def test_global_registry_is_frozen(self): """Global REGISTRY should be frozen after import.""" from ..params import REGISTRY + self.assertTrue(REGISTRY.is_frozen) def test_global_registry_has_params(self): """Global REGISTRY should have parameters loaded.""" from ..params import REGISTRY + self.assertGreater(len(REGISTRY.all_params), 3500) def test_global_registry_cannot_be_modified(self): @@ -123,9 +122,7 @@ def test_global_registry_cannot_be_modified(self): from ..params import REGISTRY with self.assertRaises(RegistryFrozenError): - REGISTRY.register( - ParamDef(name="injected", param_type=ParamType.INT) - ) + REGISTRY.register(ParamDef(name="injected", param_type=ParamType.INT)) if __name__ == "__main__": diff --git a/toolchain/mfc/params_tests/test_validate.py b/toolchain/mfc/params_tests/test_validate.py index 0f2cb69d19..061470bf24 100644 --- a/toolchain/mfc/params_tests/test_validate.py +++ b/toolchain/mfc/params_tests/test_validate.py @@ -5,14 +5,15 @@ """ import unittest + +from ..params.suggest import RAPIDFUZZ_AVAILABLE from ..params.validate import ( - validate_constraints, check_dependencies, check_unknown_params, - validate_case, format_validation_results, + validate_case, + validate_constraints, ) -from ..params.suggest import RAPIDFUZZ_AVAILABLE class TestValidateConstraints(unittest.TestCase): diff --git a/toolchain/mfc/printer.py b/toolchain/mfc/printer.py index e74d8ec01c..7453858344 100644 --- a/toolchain/mfc/printer.py +++ b/toolchain/mfc/printer.py @@ -1,12 +1,13 @@ import typing -import rich, rich.console +import rich +import rich.console class MFCPrinter: def __init__(self): self.stack = [] - self.raw = rich.console.Console() + self.raw = rich.console.Console() def reset(self): self.stack = [] @@ -30,9 +31,9 @@ def print(self, *args, msg: typing.Any = None, no_indent: bool = False, **kwargs if no_indent: self.raw.print(str(msg), soft_wrap=True, *args, **kwargs) else: - print_s, lines = "", str(msg).split('\n', maxsplit=-1) + print_s, lines = "", str(msg).split("\n", maxsplit=-1) for i, s in enumerate(lines): - newline = '\n' if (i != len(lines)-1) else '' + newline = "\n" if (i != len(lines) - 1) else "" print_s += f"{''.join(self.stack)}{s}{newline}" self.raw.print(print_s, soft_wrap=True, *args, **kwargs) diff --git a/toolchain/mfc/run/case_dicts.py b/toolchain/mfc/run/case_dicts.py index 31157ea8b4..5e7006128f 100644 --- a/toolchain/mfc/run/case_dicts.py +++ b/toolchain/mfc/run/case_dicts.py @@ -12,38 +12,44 @@ get_validator(): Returns compiled JSON schema validator get_input_dict_keys(): Get parameter keys for a target """ -# pylint: disable=import-outside-toplevel import re + from ..state import ARG + def _load_all_params(): """Load all parameters as {name: ParamType} dict.""" from ..params import REGISTRY + return {name: param.param_type for name, param in REGISTRY.all_params.items()} def _load_case_optimization_params(): """Get params that can be hard-coded for GPU optimization.""" from ..params import REGISTRY + return [name for name, param in REGISTRY.all_params.items() if param.case_optimization] def _build_schema(): """Build JSON schema from registry.""" from ..params import REGISTRY + return REGISTRY.get_json_schema() def _get_validator_func(): """Get the cached validator from registry.""" from ..params import REGISTRY + return REGISTRY.get_validator() def _get_target_params(): """Get valid params for each target by parsing Fortran namelists.""" from ..params.namelist_parser import get_target_params + return get_target_params() @@ -79,7 +85,7 @@ def _is_param_valid_for_target(param_name: str, target_name: str) -> bool: # e.g., "patch_icpp(1)%geometry" -> "patch_icpp" # e.g., "fluid_pp(2)%gamma" -> "fluid_pp" # e.g., "acoustic(1)%loc(1)" -> "acoustic" - match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)', param_name) + match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)", param_name) if match: base_name = match.group(1) return base_name in target_params diff --git a/toolchain/mfc/run/input.py b/toolchain/mfc/run/input.py index c594ad17da..ee1208bf6a 100644 --- a/toolchain/mfc/run/input.py +++ b/toolchain/mfc/run/input.py @@ -1,27 +1,32 @@ -import os, json, glob, typing, dataclasses +import dataclasses +import glob +import json +import os +import typing + +from .. import case_validator, common +from ..case import Case # Note: pyrometheus and cantera are imported lazily in the methods that need them # to avoid slow startup times for commands that don't use chemistry features # Note: build is imported lazily to avoid circular import with build.py - from ..printer import cons -from .. import common -from ..state import ARGS, ARG, gpuConfigOptions -from ..case import Case -from .. import case_validator +from ..state import ARG, ARGS, gpuConfigOptions + @dataclasses.dataclass(init=False) class MFCInputFile(Case): filename: str - dirpath: str + dirpath: str def __init__(self, filename: str, dirpath: str, params: dict) -> None: super().__init__(params) self.filename = filename - self.dirpath = dirpath + self.dirpath = dirpath def generate_inp(self, target) -> None: - from .. import build # pylint: disable=import-outside-toplevel + from .. import build + target = build.get_target(target) # Save .inp input file @@ -38,9 +43,9 @@ def __save_fpp(self, target, contents: str) -> None: def get_cantera_solution(self): # Lazy import to avoid slow startup for commands that don't need chemistry - import cantera as ct # pylint: disable=import-outside-toplevel + import cantera as ct - if self.params.get("chemistry", 'F') == 'T': + if self.params.get("chemistry", "F") == "T": cantera_file = self.params["cantera_file"] candidates = [ @@ -64,12 +69,12 @@ def get_cantera_solution(self): def generate_fpp(self, target) -> None: # Lazy import to avoid slow startup for commands that don't need chemistry - import pyrometheus as pyro # pylint: disable=import-outside-toplevel + import pyrometheus as pyro if target.isDependency: return - cons.print(f"Generating [magenta]case.fpp[/magenta].") + cons.print("Generating [magenta]case.fpp[/magenta].") cons.indent() # Case FPP file @@ -80,34 +85,27 @@ def generate_fpp(self, target) -> None: common.create_directory(modules_dir) # Determine the real type based on the single precision flag - real_type = 'real(sp)' if (ARG('single') or ARG('mixed')) else 'real(dp)' + real_type = "real(sp)" if (ARG("single") or ARG("mixed")) else "real(dp)" if ARG("gpu") == gpuConfigOptions.MP.value: - directive_str = 'mp' + directive_str = "mp" elif ARG("gpu") == gpuConfigOptions.ACC.value: - directive_str = 'acc' + directive_str = "acc" else: directive_str = None # Write the generated Fortran code to the m_thermochem.f90 file with the chosen precision sol = self.get_cantera_solution() - thermochem_code = pyro.FortranCodeGenerator().generate( - "m_thermochem", - sol, - pyro.CodeGenerationOptions(scalar_type = real_type, directive_offload = directive_str) - ) + thermochem_code = pyro.FortranCodeGenerator().generate("m_thermochem", sol, pyro.CodeGenerationOptions(scalar_type=real_type, directive_offload=directive_str)) # CCE 19.0.0 workaround: pyrometheus generates !DIR$ INLINEALWAYS for Cray+ACC # but omits !$acc routine seq, so thermochem routines are not registered as # OpenACC device routines. Replace with plain !$acc routine seq (no INLINEALWAYS). # This patch can be removed once pyrometheus upstream correctly emits !$acc routine seq # for Cray+OpenACC (the broken macro originates in pyrometheus's code generator). - if directive_str == 'acc': - old_macro = ( - "#ifdef _CRAYFTN\n#define GPU_ROUTINE(name) !DIR$ INLINEALWAYS name\n" - "#else\n#define GPU_ROUTINE(name) !$acc routine seq\n#endif" - ) + if directive_str == "acc": + old_macro = "#ifdef _CRAYFTN\n#define GPU_ROUTINE(name) !DIR$ INLINEALWAYS name\n#else\n#define GPU_ROUTINE(name) !$acc routine seq\n#endif" new_macro = "#define GPU_ROUTINE(name) !$acc routine seq" patched = thermochem_code.replace(old_macro, new_macro) if patched == thermochem_code: @@ -115,28 +113,24 @@ def generate_fpp(self, target) -> None: pass # pyrometheus already emits the correct form; no patch needed else: raise common.MFCException( - "CCE 19.0.0 workaround: pyrometheus output format changed — " - "Cray+ACC GPU_ROUTINE macro patch did not apply. " - "Update the pattern in toolchain/mfc/run/input.py." + "CCE 19.0.0 workaround: pyrometheus output format changed — Cray+ACC GPU_ROUTINE macro patch did not apply. Update the pattern in toolchain/mfc/run/input.py." ) else: - cons.print("[yellow]Warning: Applied CCE 19.0.0 workaround patch to pyrometheus-generated " - "m_thermochem.f90 (replaced _CRAYFTN GPU_ROUTINE macro with !$acc routine seq). " - "Remove this patch once pyrometheus emits correct Cray+ACC directives upstream.[/yellow]") + cons.print( + "[yellow]Warning: Applied CCE 19.0.0 workaround patch to pyrometheus-generated " + "m_thermochem.f90 (replaced _CRAYFTN GPU_ROUTINE macro with !$acc routine seq). " + "Remove this patch once pyrometheus emits correct Cray+ACC directives upstream.[/yellow]" + ) thermochem_code = patched - common.file_write( - os.path.join(modules_dir, "m_thermochem.f90"), - thermochem_code, - True - ) + common.file_write(os.path.join(modules_dir, "m_thermochem.f90"), thermochem_code, True) cons.unindent() - def validate_constraints(self, target) -> None: """Validate case parameter constraints for a given target stage""" - from .. import build # pylint: disable=import-outside-toplevel + from .. import build + target_obj = build.get_target(target) stage = target_obj.name @@ -161,20 +155,18 @@ def generate(self, target) -> None: self.generate_fpp(target) def clean(self, _targets) -> None: - from .. import build # pylint: disable=import-outside-toplevel + from .. import build + targets = [build.get_target(target) for target in _targets] files = set() - dirs = set() + dirs = set() - files = set([ - "equations.dat", "run_time.inf", "time_data.dat", - "io_time_data.dat", "fort.1", "pre_time_data.dat" - ] + [f"{target.name}.inp" for target in targets]) + files = set(["equations.dat", "run_time.inf", "time_data.dat", "io_time_data.dat", "fort.1", "pre_time_data.dat"] + [f"{target.name}.inp" for target in targets]) if build.PRE_PROCESS in targets: files = files | set(glob.glob(os.path.join(self.dirpath, "D", "*.000000.dat"))) - dirs = dirs | set(glob.glob(os.path.join(self.dirpath, "p_all", "p*", "0"))) + dirs = dirs | set(glob.glob(os.path.join(self.dirpath, "p_all", "p*", "0"))) if build.SIMULATION in targets: restarts = set(glob.glob(os.path.join(self.dirpath, "restart_data", "*.dat"))) @@ -191,14 +183,12 @@ def clean(self, _targets) -> None: dirs.add("silo_hdf5") for relfile in files: - if not os.path.isfile(relfile): - relfile = os.path.join(self.dirpath, relfile) - common.delete_file(relfile) + filepath = relfile if os.path.isfile(relfile) else os.path.join(self.dirpath, relfile) + common.delete_file(filepath) for reldir in dirs: - if not os.path.isdir(reldir): - reldir = os.path.join(self.dirpath, reldir) - common.delete_directory(reldir) + dirpath = reldir if os.path.isdir(reldir) else os.path.join(self.dirpath, reldir) + common.delete_directory(dirpath) # Load the input file @@ -216,7 +206,7 @@ def load(filepath: str = None, args: typing.List[str] = None, empty_data: dict = if do_print: cons.print(f"Acquiring [bold magenta]{filename}[/bold magenta]...") - dirpath: str = os.path.abspath(os.path.dirname(filename)) + dirpath: str = os.path.abspath(os.path.dirname(filename)) dictionary: dict = {} if not os.path.exists(filename): @@ -230,8 +220,9 @@ def load(filepath: str = None, args: typing.List[str] = None, empty_data: dict = elif filename.endswith(".json"): json_str = common.file_read(filename) elif filename.endswith((".yaml", ".yml")): - import yaml # pylint: disable=import-outside-toplevel - with open(filename, 'r') as f: + import yaml + + with open(filename, "r") as f: dictionary = yaml.safe_load(f) json_str = json.dumps(dictionary) else: diff --git a/toolchain/mfc/run/queues.py b/toolchain/mfc/run/queues.py index 920c47f8d0..ef40f8a89e 100644 --- a/toolchain/mfc/run/queues.py +++ b/toolchain/mfc/run/queues.py @@ -1,6 +1,9 @@ -import os, typing, dataclasses +import dataclasses +import os +import typing + +from mfc import common -from mfc import common from ..state import ARG @@ -26,7 +29,7 @@ def is_active(self) -> bool: return True def gen_submit_cmd(self, filepath: str) -> typing.List[str]: - if os.name == 'nt': + if os.name == "nt": return [filepath] return ["/bin/bash", filepath] @@ -78,7 +81,8 @@ def gen_submit_cmd(self, filepath: str) -> None: return cmd + [filepath] -BATCH_SYSTEMS = [ LSFSystem(), SLURMSystem(), PBSSystem() ] +BATCH_SYSTEMS = [LSFSystem(), SLURMSystem(), PBSSystem()] + def get_system() -> QueueSystem: if ARG("engine") == "interactive": diff --git a/toolchain/mfc/run/run.py b/toolchain/mfc/run/run.py index 95747c3500..8157f0090d 100644 --- a/toolchain/mfc/run/run.py +++ b/toolchain/mfc/run/run.py @@ -1,18 +1,19 @@ -import re, os, sys, typing, dataclasses, shlex - +import dataclasses +import os +import re +import shlex +import sys +import typing from glob import glob -from mako.lookup import TemplateLookup +from mako.lookup import TemplateLookup from mako.template import Template -from ..build import get_targets, build, REQUIRED_TARGETS, SIMULATION +from ..build import REQUIRED_TARGETS, SIMULATION, build, get_targets +from ..common import MFC_ROOT_DIR, MFC_TEMPLATE_DIR, MFCException, does_command_exist, file_dump_yaml, file_read, file_write, format_list_to_string, isspace, system from ..printer import cons -from ..state import ARG, ARGS, CFG, gpuConfigOptions -from ..common import MFCException, isspace, file_read, does_command_exist -from ..common import MFC_TEMPLATE_DIR, file_write, system, MFC_ROOT_DIR -from ..common import format_list_to_string, file_dump_yaml - -from . import queues, input +from ..state import ARG, ARGS, CFG, gpuConfigOptions +from . import input, queues def __validate_job_options() -> None: @@ -28,7 +29,7 @@ def __validate_job_options() -> None: if not isspace(ARG("email")): # https://stackoverflow.com/questions/8022530/how-to-check-for-valid-email-address if not re.match(r"\"?([-a-zA-Z0-9.`?{}]+@\w+\.\w+)\"?", ARG("email")): - raise MFCException(f'RUN: {ARG("email")} is not a valid e-mail address.') + raise MFCException(f"RUN: {ARG('email')} is not a valid e-mail address.") def __profiler_prepend() -> typing.List[str]: @@ -36,8 +37,7 @@ def __profiler_prepend() -> typing.List[str]: if not does_command_exist("ncu"): raise MFCException("Failed to locate [bold green]NVIDIA Nsight Compute[/bold green] (ncu).") - return ["ncu", "--nvtx", "--mode=launch-and-attach", - "--cache-control=none", "--clock-control=none"] + ARG("ncu") + return ["ncu", "--nvtx", "--mode=launch-and-attach", "--cache-control=none", "--clock-control=none"] + ARG("ncu") if ARG("nsys") is not None: if not does_command_exist("nsys"): @@ -49,7 +49,7 @@ def __profiler_prepend() -> typing.List[str]: if not does_command_exist("rocprof-compute"): raise MFCException("Failed to locate [bold red]ROCM rocprof-compute[/bold red] (rocprof-compute).") - return ["rocprof-compute", "profile", "-n", ARG("name").replace('-', '_').replace('.', '_')] + ARG("rcu") + ["--"] + return ["rocprof-compute", "profile", "-n", ARG("name").replace("-", "_").replace(".", "_")] + ARG("rcu") + ["--"] if ARG("rsys") is not None: if not does_command_exist("rocprof"): @@ -61,23 +61,17 @@ def __profiler_prepend() -> typing.List[str]: def get_baked_templates() -> dict: - return { - os.path.splitext(os.path.basename(f))[0] : file_read(f) - for f in glob(os.path.join(MFC_TEMPLATE_DIR, "*.mako")) - } + return {os.path.splitext(os.path.basename(f))[0]: file_read(f) for f in glob(os.path.join(MFC_TEMPLATE_DIR, "*.mako"))} def __job_script_filepath() -> str: - return os.path.abspath(os.sep.join([ - os.path.dirname(ARG("input")), - f"{ARG('name')}.{'bat' if os.name == 'nt' else 'sh'}" - ])) + return os.path.abspath(os.sep.join([os.path.dirname(ARG("input")), f"{ARG('name')}.{'bat' if os.name == 'nt' else 'sh'}"])) def __get_template() -> Template: computer = ARG("computer") - lookup = TemplateLookup(directories=[MFC_TEMPLATE_DIR, os.path.join(MFC_TEMPLATE_DIR, "include")]) - baked = get_baked_templates() + lookup = TemplateLookup(directories=[MFC_TEMPLATE_DIR, os.path.join(MFC_TEMPLATE_DIR, "include")]) + baked = get_baked_templates() if (content := baked.get(computer)) is not None: cons.print(f"Using baked-in template for [magenta]{computer}[/magenta].") @@ -92,29 +86,24 @@ def __get_template() -> Template: def __generate_job_script(targets, case: input.MFCInputFile): env = {} - if ARG('gpus') is not None: - gpu_ids = ','.join([str(_) for _ in ARG('gpus')]) - env.update({ - 'CUDA_VISIBLE_DEVICES': gpu_ids, - 'HIP_VISIBLE_DEVICES': gpu_ids - }) + if ARG("gpus") is not None: + gpu_ids = ",".join([str(_) for _ in ARG("gpus")]) + env.update({"CUDA_VISIBLE_DEVICES": gpu_ids, "HIP_VISIBLE_DEVICES": gpu_ids}) # Compute GPU mode booleans for templates - gpu_mode = ARG('gpu') + gpu_mode = ARG("gpu") # Validate gpu_mode is one of the expected values valid_gpu_modes = {e.value for e in gpuConfigOptions} if gpu_mode not in valid_gpu_modes: - raise MFCException( - f"Invalid GPU mode '{gpu_mode}'. Must be one of: {', '.join(sorted(valid_gpu_modes))}" - ) + raise MFCException(f"Invalid GPU mode '{gpu_mode}'. Must be one of: {', '.join(sorted(valid_gpu_modes))}") gpu_enabled = gpu_mode != gpuConfigOptions.NONE.value gpu_acc = gpu_mode == gpuConfigOptions.ACC.value gpu_mp = gpu_mode == gpuConfigOptions.MP.value content = __get_template().render( - **{**ARGS(), 'targets': targets}, + **{**ARGS(), "targets": targets}, ARG=ARG, env=env, case=case, @@ -124,7 +113,7 @@ def __generate_job_script(targets, case: input.MFCInputFile): profiler=shlex.join(__profiler_prepend()), gpu_enabled=gpu_enabled, gpu_acc=gpu_acc, - gpu_mp=gpu_mp + gpu_mp=gpu_mp, ) file_write(__job_script_filepath(), content) @@ -144,7 +133,7 @@ def __execute_job_script(qsystem: queues.QueueSystem): # in the correct directory. cmd = qsystem.gen_submit_cmd(__job_script_filepath()) - verbosity = ARG('verbose') + verbosity = ARG("verbose") # At verbosity >= 1, show the command being executed if verbosity >= 1: @@ -159,13 +148,13 @@ def __execute_job_script(qsystem: queues.QueueSystem): raise MFCException(f"Submitting batch file for {qsystem.name} failed. It can be found here: {__job_script_filepath()}. Please check the file for errors.") -def run(targets = None, case = None): +def run(targets=None, case=None): targets = get_targets(list(REQUIRED_TARGETS) + (targets or ARG("targets"))) - case = case or input.load(ARG("input"), ARG("--")) + case = case or input.load(ARG("input"), ARG("--")) build(targets) - verbosity = ARG('verbose') + verbosity = ARG("verbose") cons.print("[bold]Run[/bold]") cons.indent() @@ -195,10 +184,7 @@ def run(targets = None, case = None): if not ARG("dry_run"): if ARG("output_summary") is not None: - file_dump_yaml(ARG("output_summary"), { - "invocation": sys.argv[1:], - "lock": dataclasses.asdict(CFG()) - }) + file_dump_yaml(ARG("output_summary"), {"invocation": sys.argv[1:], "lock": dataclasses.asdict(CFG())}) if verbosity >= 1: cons.print() diff --git a/toolchain/mfc/sched.py b/toolchain/mfc/sched.py index 7869468fff..f0618705a1 100644 --- a/toolchain/mfc/sched.py +++ b/toolchain/mfc/sched.py @@ -1,24 +1,30 @@ -import time, typing, threading, dataclasses -import rich, rich.progress +import dataclasses +import threading +import time import traceback +import typing + +import rich +import rich.progress from .printer import cons # Thresholds for long-running test notifications # Interactive mode: dimension-aware thresholds INTERACTIVE_THRESHOLDS = { - 1: 30.0, # 1D: 30 seconds - 2: 60.0, # 2D: 1 minute + 1: 30.0, # 1D: 30 seconds + 2: 60.0, # 2D: 1 minute 3: 120.0, # 3D: 2 minutes } # Headless mode: fixed time-based thresholds (regardless of dimensionality) HEADLESS_THRESHOLDS = ( - (2 * 60, "[italic yellow]Still running[/italic yellow] (>2min)"), - (10 * 60, "[italic yellow]Still running[/italic yellow] (>10min)"), - (30 * 60, "[bold red]Still running[/bold red] (>30min, may be hanging)"), + (2 * 60, "[italic yellow]Still running[/italic yellow] (>2min)"), + (10 * 60, "[italic yellow]Still running[/italic yellow] (>10min)"), + (30 * 60, "[bold red]Still running[/bold red] (>30min, may be hanging)"), ) + class WorkerThread(threading.Thread): def __init__(self, *args, **kwargs): self.exc = None @@ -39,46 +45,47 @@ def run(self): @dataclasses.dataclass -class WorkerThreadHolder: # pylint: disable=too-many-instance-attributes - thread: threading.Thread - ppn: int - load: float +class WorkerThreadHolder: + thread: threading.Thread + ppn: int + load: float devices: typing.Optional[typing.Set[int]] - task: typing.Optional['Task'] = None - start: float = 0.0 + task: typing.Optional["Task"] = None + start: float = 0.0 # Track which milestones we've already logged notified_interactive: bool = False # First notification in interactive mode (time varies by dimensionality) - notified_2m: bool = False # Headless mode: 2 minute milestone + notified_2m: bool = False # Headless mode: 2 minute milestone notified_10m: bool = False # Headless mode: 10 minute milestone notified_30m: bool = False # Headless mode: 30 minute milestone @dataclasses.dataclass class Task: - ppn: int + ppn: int func: typing.Callable args: typing.List[typing.Any] load: float -def sched(tasks: typing.List[Task], nThreads: int, devices: typing.Optional[typing.Set[int]] = None) -> None: # pylint: disable=too-many-locals,too-many-branches,too-many-statements + +def sched(tasks: typing.List[Task], nThreads: int, devices: typing.Optional[typing.Set[int]] = None) -> None: nAvailable: int = nThreads - threads: typing.List[WorkerThreadHolder] = [] + threads: typing.List[WorkerThreadHolder] = [] - sched.LOAD = { id: 0.0 for id in devices or [] } + sched.LOAD = {id: 0.0 for id in devices or []} def get_case_dimensionality(case: typing.Any) -> int: """ Determine if a test case is 1D, 2D, or 3D based on grid parameters. - + Grid parameters (m, n, p) represent the number of cells in x, y, z directions. Returns 3 if p != 0, 2 if n != 0, otherwise 1. Defaults to 1D if params unavailable. """ - if not hasattr(case, 'params'): + if not hasattr(case, "params"): return 1 # Default to 1D if we can't determine params = case.params - p = params.get('p', 0) - n = params.get('n', 0) + p = params.get("p", 0) + n = params.get("n", 0) if p != 0: return 3 # 3D @@ -89,17 +96,13 @@ def get_case_dimensionality(case: typing.Any) -> int: def get_threshold_for_case(case: typing.Any) -> float: """ Get the dimension-aware time threshold (in seconds) for interactive mode notifications. - + Returns 30s for 1D, 60s for 2D, 120s for 3D tests. """ dim = get_case_dimensionality(case) return INTERACTIVE_THRESHOLDS.get(dim, INTERACTIVE_THRESHOLDS[1]) - def notify_long_running_threads( # pylint: disable=too-many-branches - progress: rich.progress.Progress, - running_tracker: typing.Optional[rich.progress.TaskID], - interactive: bool - ) -> None: + def notify_long_running_threads(progress: rich.progress.Progress, running_tracker: typing.Optional[rich.progress.TaskID], interactive: bool) -> None: """ Monitor and notify about long-running tests. @@ -116,7 +119,7 @@ def notify_long_running_threads( # pylint: disable=too-many-branches elapsed = now - holder.start case = holder.task.args[0] if holder.task and holder.task.args else None - case_uuid = case.get_uuid() if hasattr(case, "get_uuid") else "unknown" + case_uuid = case.get_uuid() if hasattr(case, "get_uuid") else "unknown" case_trace = getattr(case, "trace", "") # --- interactive: dimension-aware thresholds --- @@ -130,37 +133,25 @@ def notify_long_running_threads( # pylint: disable=too-many-branches if not holder.notified_interactive: dim = get_case_dimensionality(case) dim_label = f"{dim}D" - time_label = f"{int(threshold)}s" if threshold < 60 else f"{threshold/60:.0f}min" - cons.print( - f" [italic yellow]Still running[/italic yellow] ({dim_label}, >{time_label}) " - f"[bold magenta]{case_uuid}[/bold magenta] {case_trace}" - ) + time_label = f"{int(threshold)}s" if threshold < 60 else f"{threshold / 60:.0f}min" + cons.print(f" [italic yellow]Still running[/italic yellow] ({dim_label}, >{time_label}) [bold magenta]{case_uuid}[/bold magenta] {case_trace}") holder.notified_interactive = True # --- headless: milestone notifications at 2, 10, 30 minutes --- else: # 2 minutes if (not holder.notified_2m) and elapsed >= 2 * 60: - cons.print( - f" {HEADLESS_THRESHOLDS[0][1]} " - f"[bold magenta]{case_uuid}[/bold magenta] {case_trace}" - ) + cons.print(f" {HEADLESS_THRESHOLDS[0][1]} [bold magenta]{case_uuid}[/bold magenta] {case_trace}") holder.notified_2m = True # 10 minutes if (not holder.notified_10m) and elapsed >= 10 * 60: - cons.print( - f" {HEADLESS_THRESHOLDS[1][1]} " - f"[bold magenta]{case_uuid}[/bold magenta] {case_trace}" - ) + cons.print(f" {HEADLESS_THRESHOLDS[1][1]} [bold magenta]{case_uuid}[/bold magenta] {case_trace}") holder.notified_10m = True # 30 minutes if (not holder.notified_30m) and elapsed >= 30 * 60: - cons.print( - f" {HEADLESS_THRESHOLDS[2][1]} " - f"[bold magenta]{case_uuid}[/bold magenta] {case_trace}" - ) + cons.print(f" {HEADLESS_THRESHOLDS[2][1]} [bold magenta]{case_uuid}[/bold magenta] {case_trace}") holder.notified_30m = True # update the interactive "Running" row @@ -188,13 +179,11 @@ def join_first_dead_thread(progress, complete_tracker, interactive: bool) -> Non # Double-check that thread actually finished joining if threadHolder.thread.is_alive(): # Thread didn't finish within timeout - this is a serious issue - raise RuntimeError(f"Thread {threadID} failed to join within 30 seconds timeout. " - f"Thread may be hung or in an inconsistent state.") + raise RuntimeError(f"Thread {threadID} failed to join within 30 seconds timeout. Thread may be hung or in an inconsistent state.") except Exception as join_exc: # Handle join-specific exceptions with more context - raise RuntimeError(f"Failed to join thread {threadID}: {join_exc}. " - f"This may indicate a system threading issue or hung test case.") from join_exc + raise RuntimeError(f"Failed to join thread {threadID}: {join_exc}. This may indicate a system threading issue or hung test case.") from join_exc # Check for and propagate any exceptions that occurred in the worker thread if threadHolder.thread.exc is not None: @@ -208,12 +197,9 @@ def join_first_dead_thread(progress, complete_tracker, interactive: bool) -> Non if interactive and threadHolder.notified_interactive: elapsed = time.time() - threadHolder.start case = threadHolder.task.args[0] if threadHolder.task and threadHolder.task.args else None - case_uuid = case.get_uuid() if hasattr(case, "get_uuid") else "unknown" + case_uuid = case.get_uuid() if hasattr(case, "get_uuid") else "unknown" case_trace = getattr(case, "trace", "") - cons.print( - f" [italic green]Completed[/italic green] (after {elapsed:.1f}s) " - f"[bold magenta]{case_uuid}[/bold magenta] {case_trace}" - ) + cons.print(f" [italic green]Completed[/italic green] (after {elapsed:.1f}s) [bold magenta]{case_uuid}[/bold magenta] {case_trace}") nAvailable += threadHolder.ppn for device in threadHolder.devices or set(): @@ -226,10 +212,10 @@ def join_first_dead_thread(progress, complete_tracker, interactive: bool) -> Non break with rich.progress.Progress(console=cons.raw, transient=True) as progress: - interactive = cons.raw.is_terminal - queue_tracker = progress.add_task("Queued ", total=len(tasks)) + interactive = cons.raw.is_terminal + queue_tracker = progress.add_task("Queued ", total=len(tasks)) complete_tracker = progress.add_task("Completed ", total=len(tasks)) - running_tracker = progress.add_task("Running ", total=None) if interactive else None + running_tracker = progress.add_task("Running ", total=None) if interactive else None # Queue Tests for task in tasks: @@ -288,4 +274,5 @@ def join_first_dead_thread(progress, complete_tracker, interactive: bool) -> Non # Do not overwhelm this core with this loop time.sleep(0.05) + sched.LOAD = {} diff --git a/toolchain/mfc/state.py b/toolchain/mfc/state.py index 826a48cb4f..37aacc88e1 100644 --- a/toolchain/mfc/state.py +++ b/toolchain/mfc/state.py @@ -1,28 +1,33 @@ -import typing, dataclasses +import dataclasses +import typing from enum import Enum, unique + @unique class gpuConfigOptions(Enum): - NONE = 'no' - ACC = 'acc' - MP = 'mp' + NONE = "no" + ACC = "acc" + MP = "mp" + @dataclasses.dataclass class MFCConfig: - # pylint: disable=too-many-instance-attributes - mpi: bool = True - gpu: str = gpuConfigOptions.NONE.value - debug: bool = False - gcov: bool = False - unified: bool = False - single: bool = False - mixed: bool = False + mpi: bool = True + gpu: str = gpuConfigOptions.NONE.value + debug: bool = False + gcov: bool = False + unified: bool = False + single: bool = False + mixed: bool = False fastmath: bool = False + def __hash__(self): + return hash(tuple(getattr(self, f.name) for f in dataclasses.fields(self))) + @staticmethod def from_dict(d: dict): - """ Create a MFCConfig object from a dictionary with the same keys - as the fields of MFCConfig """ + """Create a MFCConfig object from a dictionary with the same keys + as the fields of MFCConfig""" r = MFCConfig() for field in dataclasses.fields(MFCConfig): @@ -34,29 +39,31 @@ def items(self) -> typing.Iterable[typing.Tuple[str, typing.Any]]: return dataclasses.asdict(self).items() def make_options(self) -> typing.List[str]: - """ Returns a list of options that could be passed to mfc.sh again. - Example: --no-debug --mpi --no-gpu --no-gcov --no-unified""" + """Returns a list of options that could be passed to mfc.sh again. + Example: --no-debug --mpi --no-gpu --no-gcov --no-unified""" options = [] for k, v in self.items(): - if k == 'gpu': + if k == "gpu": options.append(f"--{v}-{k}") else: options.append(f"--{'no-' if not v else ''}{k}") return options def make_slug(self) -> str: - """ Sort the items by key, then join them with underscores. This uniquely - identifies the configuration. Example: no-debug_no-gpu_no_mpi_no-gcov """ + """Sort the items by key, then join them with underscores. This uniquely + identifies the configuration. Example: no-debug_no-gpu_no_mpi_no-gcov""" options = [] for k, v in sorted(self.items(), key=lambda x: x[0]): - if k == 'gpu': + if k == "gpu": options.append(f"--{v}-{k}") else: options.append(f"--{'no-' if not v else ''}{k}") - return '_'.join(options) + return "_".join(options) def __eq__(self, other) -> bool: - """ Check if two MFCConfig objects are equal, field by field. """ + """Check if two MFCConfig objects are equal, field by field.""" + if not isinstance(other, MFCConfig): + return NotImplemented for field in dataclasses.fields(self): if getattr(self, field.name) != getattr(other, field.name): return False @@ -64,9 +71,9 @@ def __eq__(self, other) -> bool: return True def __str__(self) -> str: - """ Returns a string like "mpi=No & gpu=No & debug=No & gcov=No & unified=No" """ + """Returns a string like "mpi=No & gpu=No & debug=No & gcov=No & unified=No" """ strings = [] - for k,v in self.items(): + for k, v in self.items(): if isinstance(v, bool): strings.append(f"{k}={'Yes' if v else 'No'}") elif isinstance(v, str): @@ -76,15 +83,15 @@ def __str__(self) -> str: else: strings.append(f"{k}={v.__str__()}") - return ' & '.join(strings) + return " & ".join(strings) gCFG: MFCConfig = MFCConfig() -gARG: dict = {"rdma_mpi": False} +gARG: dict = {"rdma_mpi": False} -def ARG(arg: str, dflt = None) -> typing.Any: - # pylint: disable=global-variable-not-assigned - global gARG + +def ARG(arg: str, dflt=None) -> typing.Any: + global gARG # noqa: PLW0603 if arg in gARG: return gARG[arg] if dflt is not None: @@ -92,12 +99,12 @@ def ARG(arg: str, dflt = None) -> typing.Any: raise KeyError(f"{arg} is not an argument.") + def ARGS() -> dict: - # pylint: disable=global-variable-not-assigned - global gARG + global gARG # noqa: PLW0603 return gARG + def CFG() -> MFCConfig: - # pylint: disable=global-variable-not-assigned - global gCFG + global gCFG # noqa: PLW0603 return gCFG diff --git a/toolchain/mfc/test/case.py b/toolchain/mfc/test/case.py index c5ffdd301a..6a46c50387 100644 --- a/toolchain/mfc/test/case.py +++ b/toolchain/mfc/test/case.py @@ -1,117 +1,117 @@ -import os, glob, hashlib, binascii, subprocess, itertools, dataclasses, shutil - -from typing import List, Set, Union, Callable, Optional - -from .. import case, common -from ..state import ARG -from ..run import input +import binascii +import dataclasses +import glob +import hashlib +import itertools +import os +import shutil +import subprocess +from typing import Callable, List, Optional, Set, Union + +from .. import case, common from ..build import MFCTarget, get_target +from ..run import input +from ..state import ARG Tend = 0.25 -Nt = 50 +Nt = 50 mydt = 0.0005 BASE_CFG = { - 'run_time_info' : 'T', - 'm' : 0, - 'n' : 0, - 'p' : 0, - 'dt' : mydt, - 't_step_start' : 0, - 't_step_stop' : int(Nt), - 't_step_save' : int(Nt), - 'num_patches' : 3, - 'model_eqns' : 2, - 'alt_soundspeed' : 'F', - 'num_fluids' : 1, - 'mpp_lim' : 'F', - 'mixture_err' : 'F', - 'time_stepper' : 3, - 'recon_type' : 1, - 'weno_order' : 5, - 'weno_eps' : 1.E-16, - 'mapped_weno' : 'F', - 'null_weights' : 'F', - 'mp_weno' : 'F', - 'riemann_solver' : 2, - 'wave_speeds' : 1, - 'avg_state' : 2, - 'format' : 1, - 'precision' : 2, - - 'patch_icpp(1)%pres' : 1.0, - 'patch_icpp(1)%alpha_rho(1)' : 1.E+00, - 'patch_icpp(1)%alpha(1)' : 1., - - 'patch_icpp(2)%pres' : 0.5, - 'patch_icpp(2)%alpha_rho(1)' : 0.5, - 'patch_icpp(2)%alpha(1)' : 1., - - 'patch_icpp(3)%pres' : 0.1, - 'patch_icpp(3)%alpha_rho(1)' : 0.125, - 'patch_icpp(3)%alpha(1)' : 1., - - 'fluid_pp(1)%gamma' : 1.E+00/(1.4-1.E+00), - 'fluid_pp(1)%pi_inf' : 0.0, - 'fluid_pp(1)%cv' : 0.0, - 'fluid_pp(1)%qv' : 0.0, - 'fluid_pp(1)%qvp' : 0.0, - 'bubbles_euler' : 'F', - 'pref' : 101325.0, - 'rhoref' : 1000.0, - 'bubble_model' : 3, - 'polytropic' : 'T', - 'polydisperse' : 'F', - 'thermal' : 3, - - 'patch_icpp(1)%r0' : 1, - 'patch_icpp(1)%v0' : 0, - 'patch_icpp(2)%r0' : 1, - 'patch_icpp(2)%v0' : 0, - 'patch_icpp(3)%r0' : 1, - 'patch_icpp(3)%v0' : 0, - - 'qbmm' : 'F', - 'dist_type' : 2, - 'poly_sigma' : 0.3, - 'sigR' : 0.1, - 'sigV' : 0.1, - 'rhoRV' : 0.0, - - 'acoustic_source' : 'F', - 'num_source' : 1, - 'acoustic(1)%loc(1)' : 0.5, - 'acoustic(1)%mag' : 0.2, - 'acoustic(1)%length' : 0.25, - 'acoustic(1)%dir' : 1.0, - 'acoustic(1)%npulse' : 1, - 'acoustic(1)%pulse' : 1, - 'rdma_mpi' : 'F', - - 'bubbles_lagrange' : 'F', - 'lag_params%nBubs_glb' : 1, - 'lag_params%solver_approach' : 0, - 'lag_params%cluster_type' : 2, - 'lag_params%pressure_corrector' : 'F', - 'lag_params%smooth_type' : 1, - 'lag_params%epsilonb' : 1.0, - 'lag_params%heatTransfer_model' : 'F', - 'lag_params%massTransfer_model' : 'F', - 'lag_params%valmaxvoid' : 0.9, + "run_time_info": "T", + "m": 0, + "n": 0, + "p": 0, + "dt": mydt, + "t_step_start": 0, + "t_step_stop": int(Nt), + "t_step_save": int(Nt), + "num_patches": 3, + "model_eqns": 2, + "alt_soundspeed": "F", + "num_fluids": 1, + "mpp_lim": "F", + "mixture_err": "F", + "time_stepper": 3, + "recon_type": 1, + "weno_order": 5, + "weno_eps": 1.0e-16, + "mapped_weno": "F", + "null_weights": "F", + "mp_weno": "F", + "riemann_solver": 2, + "wave_speeds": 1, + "avg_state": 2, + "format": 1, + "precision": 2, + "patch_icpp(1)%pres": 1.0, + "patch_icpp(1)%alpha_rho(1)": 1.0e00, + "patch_icpp(1)%alpha(1)": 1.0, + "patch_icpp(2)%pres": 0.5, + "patch_icpp(2)%alpha_rho(1)": 0.5, + "patch_icpp(2)%alpha(1)": 1.0, + "patch_icpp(3)%pres": 0.1, + "patch_icpp(3)%alpha_rho(1)": 0.125, + "patch_icpp(3)%alpha(1)": 1.0, + "fluid_pp(1)%gamma": 1.0e00 / (1.4 - 1.0e00), + "fluid_pp(1)%pi_inf": 0.0, + "fluid_pp(1)%cv": 0.0, + "fluid_pp(1)%qv": 0.0, + "fluid_pp(1)%qvp": 0.0, + "bubbles_euler": "F", + "pref": 101325.0, + "rhoref": 1000.0, + "bubble_model": 3, + "polytropic": "T", + "polydisperse": "F", + "thermal": 3, + "patch_icpp(1)%r0": 1, + "patch_icpp(1)%v0": 0, + "patch_icpp(2)%r0": 1, + "patch_icpp(2)%v0": 0, + "patch_icpp(3)%r0": 1, + "patch_icpp(3)%v0": 0, + "qbmm": "F", + "dist_type": 2, + "poly_sigma": 0.3, + "sigR": 0.1, + "sigV": 0.1, + "rhoRV": 0.0, + "acoustic_source": "F", + "num_source": 1, + "acoustic(1)%loc(1)": 0.5, + "acoustic(1)%mag": 0.2, + "acoustic(1)%length": 0.25, + "acoustic(1)%dir": 1.0, + "acoustic(1)%npulse": 1, + "acoustic(1)%pulse": 1, + "rdma_mpi": "F", + "bubbles_lagrange": "F", + "lag_params%nBubs_glb": 1, + "lag_params%solver_approach": 0, + "lag_params%cluster_type": 2, + "lag_params%pressure_corrector": "F", + "lag_params%smooth_type": 1, + "lag_params%epsilonb": 1.0, + "lag_params%heatTransfer_model": "F", + "lag_params%massTransfer_model": "F", + "lag_params%valmaxvoid": 0.9, } + def trace_to_uuid(trace: str) -> str: return hex(binascii.crc32(hashlib.sha1(str(trace).encode()).digest())).upper()[2:].zfill(8) + @dataclasses.dataclass(init=False) class TestCase(case.Case): - ppn: int - trace: str + ppn: int + trace: str override_tol: Optional[float] = None def __init__(self, trace: str, mods: dict, ppn: int = None, override_tol: float = None) -> None: - self.trace = trace - self.ppn = ppn or 1 + self.trace = trace + self.ppn = ppn or 1 self.override_tol = override_tol super().__init__({**BASE_CFG.copy(), **mods}) @@ -121,22 +121,19 @@ def run(self, targets: List[Union[str, MFCTarget]], gpus: Set[int]) -> subproces else: gpus_select = [] - filepath = f'{self.get_dirpath()}/case.py' - tasks = ["-n", str(self.ppn)] - jobs = ["-j", str(ARG("jobs"))] if ARG("case_optimization") else [] - case_optimization = ["--case-optimization"] if ARG("case_optimization") else [] + filepath = f"{self.get_dirpath()}/case.py" + tasks = ["-n", str(self.ppn)] + jobs = ["-j", str(ARG("jobs"))] if ARG("case_optimization") else [] + case_optimization = ["--case-optimization"] if ARG("case_optimization") else [] - if self.params.get("bubbles_lagrange", 'F') == 'T': + if self.params.get("bubbles_lagrange", "F") == "T": input_bubbles_lagrange(self) - mfc_script = ".\\mfc.bat" if os.name == 'nt' else "./mfc.sh" + mfc_script = ".\\mfc.bat" if os.name == "nt" else "./mfc.sh" - target_names = [ get_target(t).name for t in targets ] + target_names = [get_target(t).name for t in targets] - command = [ - mfc_script, "run", filepath, "--no-build", *tasks, *case_optimization, - *jobs, "-t", *target_names, *gpus_select, *ARG("--") - ] + command = [mfc_script, "run", filepath, "--no-build", *tasks, *case_optimization, *jobs, "-t", *target_names, *gpus_select, *ARG("--")] return common.system(command, print_cmd=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) @@ -151,8 +148,8 @@ def get_dirpath(self): def get_filepath(self): filepath = os.path.join(self.get_dirpath(), "case.py") - if os.name == 'nt': - return filepath.replace('\\', '\\\\') + if os.name == "nt": + return filepath.replace("\\", "\\\\") return filepath def delete_output(self): @@ -169,7 +166,7 @@ def delete_output(self): common.delete_directory(os.path.join(dirpath, "p_all")) common.delete_directory(os.path.join(dirpath, "silo_hdf5")) common.delete_directory(os.path.join(dirpath, "restart_data")) - if self.params.get("bubbles_lagrange", 'F') == 'T': + if self.params.get("bubbles_lagrange", "F") == "T": common.delete_directory(os.path.join(dirpath, "input")) common.delete_directory(os.path.join(dirpath, "lag_bubbles_post_process")) @@ -181,7 +178,9 @@ def create_directory(self): common.create_directory(dirpath) - common.file_write(self.get_filepath(), f"""\ + common.file_write( + self.get_filepath(), + f"""\ #!/usr/bin/env python3 # # {self.get_filepath()}: @@ -225,18 +224,15 @@ def create_directory(self): mods['prim_vars_wrt'] = 'F' print(json.dumps({{**case, **mods}})) -""") +""", + ) def __str__(self) -> str: return f"tests/[bold magenta]{self.get_uuid()}[/bold magenta]: {self.trace}" def to_input_file(self) -> input.MFCInputFile: - return input.MFCInputFile( - os.path.basename(self.get_filepath()), - self.get_dirpath(), - self.get_parameters()) + return input.MFCInputFile(os.path.basename(self.get_filepath()), self.get_dirpath(), self.get_parameters()) - # pylint: disable=too-many-return-statements def compute_tolerance(self) -> float: if self.override_tol: return self.override_tol @@ -248,35 +244,36 @@ def compute_tolerance(self) -> float: tolerance = 1e-3 elif "Cylindrical" in self.trace.split(" -> "): tolerance = 1e-9 - elif self.params.get("hypoelasticity", 'F') == 'T': + elif self.params.get("hypoelasticity", "F") == "T": tolerance = 1e-7 - elif self.params.get("mixlayer_perturb", 'F') == 'T': + elif self.params.get("mixlayer_perturb", "F") == "T": tolerance = 1e-7 - elif any(self.params.get(key, 'F') == 'T' for key in ['relax', 'ib', 'qbmm', 'bubbles_euler', 'bubbles_lagrange']): + elif any(self.params.get(key, "F") == "T" for key in ["relax", "ib", "qbmm", "bubbles_euler", "bubbles_lagrange"]): tolerance = 1e-10 elif self.params.get("low_Mach") in [1, 2]: tolerance = 1e-10 - elif self.params.get("acoustic_source", 'F') == 'T': + elif self.params.get("acoustic_source", "F") == "T": if self.params.get("acoustic(1)%pulse") == 3: # Square wave return 1e-1 if single else 1e-5 tolerance = 3e-12 elif self.params.get("weno_order") == 7: tolerance = 1e-9 - elif self.params.get("mhd", 'F') == 'T': + elif self.params.get("mhd", "F") == "T": tolerance = 1e-8 elif "Axisymmetric" in self.trace.split(" -> "): tolerance = 1e-11 return 1e8 * tolerance if single else tolerance + @dataclasses.dataclass class TestCaseBuilder: - trace: str - mods: dict - path: str - args: List[str] - ppn: int - functor: Optional[Callable] + trace: str + mods: dict + path: str + args: List[str] + ppn: int + functor: Optional[Callable] override_tol: Optional[float] = None def get_uuid(self) -> str: @@ -291,10 +288,10 @@ def to_case(self) -> TestCase: if not isinstance(value, str): continue - for path in [value, os.path.join(os.path.dirname(self.path), value)]: - path = os.path.abspath(path) - if os.path.exists(path): - dictionary[key] = path + for candidate in [value, os.path.join(os.path.dirname(self.path), value)]: + abspath = os.path.abspath(candidate) + if os.path.exists(abspath): + dictionary[key] = abspath if self.mods: dictionary.update(self.mods) @@ -307,8 +304,8 @@ def to_case(self) -> TestCase: @dataclasses.dataclass class CaseGeneratorStack: - trace: list # list of strs - mods: list # list of dicts + trace: list # list of strs + mods: list # list of dicts def __init__(self) -> None: self.trace, self.mods = [], [] @@ -324,12 +321,10 @@ def pop(self) -> None: return (self.mods.pop(), self.trace.pop()) -# pylint: disable=too-many-arguments, too-many-positional-arguments def define_case_f(trace: str, path: str, args: List[str] = None, ppn: int = None, mods: dict = None, functor: Callable = None, override_tol: float = None) -> TestCaseBuilder: return TestCaseBuilder(trace, mods or {}, path, args or [], ppn or 1, functor, override_tol) -# pylint: disable=too-many-arguments, too-many-positional-arguments def define_case_d(stack: CaseGeneratorStack, newTrace: str, newMods: dict, ppn: int = None, functor: Callable = None, override_tol: float = None) -> TestCaseBuilder: mods: dict = {} @@ -346,29 +341,32 @@ def define_case_d(stack: CaseGeneratorStack, newTrace: str, newMods: dict, ppn: if not common.isspace(trace): traces.append(trace) - return TestCaseBuilder(' -> '.join(traces), mods, None, None, ppn or 1, functor, override_tol) + return TestCaseBuilder(" -> ".join(traces), mods, None, None, ppn or 1, functor, override_tol) + def input_bubbles_lagrange(self): if "lagrange_bubblescreen" in self.trace: - copy_input_lagrange(f'/3D_lagrange_bubblescreen',f'{self.get_dirpath()}') + copy_input_lagrange("/3D_lagrange_bubblescreen", f"{self.get_dirpath()}") elif "lagrange_shbubcollapse" in self.trace: - copy_input_lagrange(f'/3D_lagrange_shbubcollapse',f'{self.get_dirpath()}') + copy_input_lagrange("/3D_lagrange_shbubcollapse", f"{self.get_dirpath()}") else: - create_input_lagrange(f'{self.get_dirpath()}') + create_input_lagrange(f"{self.get_dirpath()}") + def create_input_lagrange(path_test): - folder_path_lagrange = path_test + '/input' - file_path_lagrange = folder_path_lagrange + '/lag_bubbles.dat' + folder_path_lagrange = path_test + "/input" + file_path_lagrange = folder_path_lagrange + "/lag_bubbles.dat" if not os.path.exists(folder_path_lagrange): os.mkdir(folder_path_lagrange) with open(file_path_lagrange, "w") as file: - file.write('0.5\t0.5\t0.5\t0.0\t0.0\t0.0\t8.0e-03\t0.0') + file.write("0.5\t0.5\t0.5\t0.0\t0.0\t0.0\t8.0e-03\t0.0") + def copy_input_lagrange(path_example_input, path_test): - folder_path_dest = path_test + '/input/' - fite_path_dest = folder_path_dest + 'lag_bubbles.dat' - file_path_src = common.MFC_EXAMPLE_DIRPATH + path_example_input + '/input/lag_bubbles.dat' + folder_path_dest = path_test + "/input/" + fite_path_dest = folder_path_dest + "lag_bubbles.dat" + file_path_src = common.MFC_EXAMPLE_DIRPATH + path_example_input + "/input/lag_bubbles.dat" if not os.path.exists(folder_path_dest): os.mkdir(folder_path_dest) diff --git a/toolchain/mfc/test/cases.py b/toolchain/mfc/test/cases.py index 7835981151..052fa8d92a 100644 --- a/toolchain/mfc/test/cases.py +++ b/toolchain/mfc/test/cases.py @@ -1,17 +1,17 @@ -# pylint: disable=too-many-lines +import itertools import os import typing -import itertools from mfc import common -from .case import Nt, define_case_d, define_case_f, CaseGeneratorStack, TestCaseBuilder + from ..state import ARG +from .case import CaseGeneratorStack, Nt, TestCaseBuilder, define_case_d, define_case_f def get_bc_mods(bc: int, dimInfo): params = {} for dimCmp in dimInfo[0]: - params.update({f'bc_{dimCmp}%beg': bc, f'bc_{dimCmp}%end': bc}) + params.update({f"bc_{dimCmp}%beg": bc, f"bc_{dimCmp}%end": bc}) return params @@ -19,43 +19,57 @@ def get_bc_mods(bc: int, dimInfo): def get_dimensions(): r = [] - for dimInfo in [(["x"], {'m': 299, 'n': 0, 'p': 0}, {"geometry": 1}), - (["x", "y"], {'m': 49, 'n': 39, 'p': 0}, {"geometry": 3}), - (["x", "y", "z"], {'m': 24, 'n': 24, 'p': 24}, {"geometry": 9})]: + for dimInfo in [(["x"], {"m": 299, "n": 0, "p": 0}, {"geometry": 1}), (["x", "y"], {"m": 49, "n": 39, "p": 0}, {"geometry": 3}), (["x", "y", "z"], {"m": 24, "n": 24, "p": 24}, {"geometry": 9})]: dimParams = {**dimInfo[1]} for dimCmp in dimInfo[0]: - dimParams.update({ - f"{dimCmp}_domain%beg": 0.E+00, f"{dimCmp}_domain%end": 1.E+00 - }) + dimParams.update({f"{dimCmp}_domain%beg": 0.0e00, f"{dimCmp}_domain%end": 1.0e00}) dimParams.update(get_bc_mods(-3, dimInfo)) - for patchID in range(1, 3+1): + for patchID in range(1, 3 + 1): dimParams[f"patch_icpp({patchID})%geometry"] = dimInfo[2].get("geometry") if "z" in dimInfo[0]: - dimParams.update({ - f"patch_icpp({1})%z_centroid": 0.05, f"patch_icpp({1})%length_z": 0.1, - f"patch_icpp({2})%z_centroid": 0.45, f"patch_icpp({2})%length_z": 0.7, - f"patch_icpp({3})%z_centroid": 0.9, f"patch_icpp({3})%length_z": 0.2, - f"patch_icpp({patchID})%y_centroid": 0.5, f"patch_icpp({patchID})%length_y": 1, - f"patch_icpp({patchID})%x_centroid": 0.5, f"patch_icpp({patchID})%length_x": 1 - }) + dimParams.update( + { + f"patch_icpp({1})%z_centroid": 0.05, + f"patch_icpp({1})%length_z": 0.1, + f"patch_icpp({2})%z_centroid": 0.45, + f"patch_icpp({2})%length_z": 0.7, + f"patch_icpp({3})%z_centroid": 0.9, + f"patch_icpp({3})%length_z": 0.2, + f"patch_icpp({patchID})%y_centroid": 0.5, + f"patch_icpp({patchID})%length_y": 1, + f"patch_icpp({patchID})%x_centroid": 0.5, + f"patch_icpp({patchID})%length_x": 1, + } + ) elif "y" in dimInfo[0]: - dimParams.update({ - f"patch_icpp({1})%y_centroid": 0.05, f"patch_icpp({1})%length_y": 0.1, - f"patch_icpp({2})%y_centroid": 0.45, f"patch_icpp({2})%length_y": 0.7, - f"patch_icpp({3})%y_centroid": 0.9, f"patch_icpp({3})%length_y": 0.2, - f"patch_icpp({patchID})%x_centroid": 0.5, f"patch_icpp({patchID})%length_x": 1 - }) + dimParams.update( + { + f"patch_icpp({1})%y_centroid": 0.05, + f"patch_icpp({1})%length_y": 0.1, + f"patch_icpp({2})%y_centroid": 0.45, + f"patch_icpp({2})%length_y": 0.7, + f"patch_icpp({3})%y_centroid": 0.9, + f"patch_icpp({3})%length_y": 0.2, + f"patch_icpp({patchID})%x_centroid": 0.5, + f"patch_icpp({patchID})%length_x": 1, + } + ) else: - dimParams.update({ - f"patch_icpp({1})%x_centroid": 0.05, f"patch_icpp({1})%length_x": 0.1, - f"patch_icpp({2})%x_centroid": 0.45, f"patch_icpp({2})%length_x": 0.7, - f"patch_icpp({3})%x_centroid": 0.9, f"patch_icpp({3})%length_x": 0.2 - }) + dimParams.update( + { + f"patch_icpp({1})%x_centroid": 0.05, + f"patch_icpp({1})%length_x": 0.1, + f"patch_icpp({2})%x_centroid": 0.45, + f"patch_icpp({2})%length_x": 0.7, + f"patch_icpp({3})%x_centroid": 0.9, + f"patch_icpp({3})%length_x": 0.2, + } + ) if "x" in dimInfo[0]: dimParams[f"patch_icpp({patchID})%vel(1)"] = 0.0 @@ -70,8 +84,6 @@ def get_dimensions(): return r -# pylint: disable=too-many-locals, too-many-statements - def list_cases() -> typing.List[TestCaseBuilder]: stack, cases = CaseGeneratorStack(), [] @@ -82,68 +94,180 @@ def alter_bcs(dimInfo): def alter_grcbc(dimInfo): if len(dimInfo[0]) == 1: - stack.push('', {'patch_icpp(1)%vel(1)': 1.0, 'patch_icpp(2)%vel(1)': 1.0, 'patch_icpp(3)%vel(1)': 1.0, - 'bc_x%beg': -7, 'bc_x%end': -8, 'bc_x%grcbc_in': 'T', 'bc_x%grcbc_out': 'T', 'bc_x%grcbc_vel_out': 'T', - 'bc_x%vel_in(1)': 1.0, 'bc_x%vel_in(2)': 0.0, 'bc_x%vel_in(3)': 0.0, 'bc_x%vel_out(1)': 1.0, 'bc_x%vel_out(2)': 0.0, 'bc_x%vel_out(3)': 0.0, - 'bc_x%pres_in': 1.0, 'bc_x%pres_out': 1.0, 'bc_x%alpha_in(1)': 1.0, 'bc_x%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc x"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(1)": 1.0, + "patch_icpp(2)%vel(1)": 1.0, + "patch_icpp(3)%vel(1)": 1.0, + "bc_x%beg": -7, + "bc_x%end": -8, + "bc_x%grcbc_in": "T", + "bc_x%grcbc_out": "T", + "bc_x%grcbc_vel_out": "T", + "bc_x%vel_in(1)": 1.0, + "bc_x%vel_in(2)": 0.0, + "bc_x%vel_in(3)": 0.0, + "bc_x%vel_out(1)": 1.0, + "bc_x%vel_out(2)": 0.0, + "bc_x%vel_out(3)": 0.0, + "bc_x%pres_in": 1.0, + "bc_x%pres_out": 1.0, + "bc_x%alpha_in(1)": 1.0, + "bc_x%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc x"], {})) stack.pop() elif len(dimInfo[0]) == 2: - stack.push('', {'patch_icpp(1)%vel(1)': 1.0, 'patch_icpp(2)%vel(1)': 1.0, 'patch_icpp(3)%vel(1)': 1.0, - 'bc_x%beg': -7, 'bc_x%end': -8, 'bc_x%grcbc_in': 'T', 'bc_x%grcbc_out': 'T', 'bc_x%grcbc_vel_out': 'T', - 'bc_x%vel_in(1)': 1.0, 'bc_x%vel_in(2)': 0.0, 'bc_x%vel_in(3)': 0.0, 'bc_x%vel_out(1)': 1.0, 'bc_x%vel_out(2)': 0.0, 'bc_x%vel_out(3)': 0.0, - 'bc_x%pres_in': 1.0, 'bc_x%pres_out': 1.0, 'bc_x%alpha_in(1)': 1.0, 'bc_x%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc x"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(1)": 1.0, + "patch_icpp(2)%vel(1)": 1.0, + "patch_icpp(3)%vel(1)": 1.0, + "bc_x%beg": -7, + "bc_x%end": -8, + "bc_x%grcbc_in": "T", + "bc_x%grcbc_out": "T", + "bc_x%grcbc_vel_out": "T", + "bc_x%vel_in(1)": 1.0, + "bc_x%vel_in(2)": 0.0, + "bc_x%vel_in(3)": 0.0, + "bc_x%vel_out(1)": 1.0, + "bc_x%vel_out(2)": 0.0, + "bc_x%vel_out(3)": 0.0, + "bc_x%pres_in": 1.0, + "bc_x%pres_out": 1.0, + "bc_x%alpha_in(1)": 1.0, + "bc_x%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc x"], {})) stack.pop() - stack.push('', {'patch_icpp(1)%vel(2)': 1.0, 'patch_icpp(2)%vel(2)': 1.0, 'patch_icpp(3)%vel(2)': 1.0, - 'bc_y%beg': -7, 'bc_y%end': -8, 'bc_y%grcbc_in': 'T', 'bc_y%grcbc_out': 'T', 'bc_y%grcbc_vel_out': 'T', - 'bc_y%vel_in(1)': 0.0, 'bc_y%vel_in(2)': 1.0, 'bc_y%vel_in(3)': 0.0, 'bc_y%vel_out(1)': 0.0, 'bc_y%vel_out(2)': 1.0, 'bc_y%vel_out(3)': 0.0, - 'bc_y%pres_in': 1.0, 'bc_y%pres_out': 1.0, 'bc_y%alpha_in(1)': 1.0, 'bc_y%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc y"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(2)": 1.0, + "patch_icpp(2)%vel(2)": 1.0, + "patch_icpp(3)%vel(2)": 1.0, + "bc_y%beg": -7, + "bc_y%end": -8, + "bc_y%grcbc_in": "T", + "bc_y%grcbc_out": "T", + "bc_y%grcbc_vel_out": "T", + "bc_y%vel_in(1)": 0.0, + "bc_y%vel_in(2)": 1.0, + "bc_y%vel_in(3)": 0.0, + "bc_y%vel_out(1)": 0.0, + "bc_y%vel_out(2)": 1.0, + "bc_y%vel_out(3)": 0.0, + "bc_y%pres_in": 1.0, + "bc_y%pres_out": 1.0, + "bc_y%alpha_in(1)": 1.0, + "bc_y%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc y"], {})) stack.pop() elif len(dimInfo[0]) == 3: - stack.push('', {'patch_icpp(1)%vel(1)': 1.0, 'patch_icpp(2)%vel(1)': 1.0, 'patch_icpp(3)%vel(1)': 1.0, - 'bc_x%beg': -7, 'bc_x%end': -8, 'bc_x%grcbc_in': 'T', 'bc_x%grcbc_out': 'T', 'bc_x%grcbc_vel_out': 'T', - 'bc_x%vel_in(1)': 1.0, 'bc_x%vel_in(2)': 0.0, 'bc_x%vel_in(3)': 0.0, 'bc_x%vel_out(1)': 1.0, 'bc_x%vel_out(2)': 0.0, 'bc_x%vel_out(3)': 0.0, - 'bc_x%pres_in': 1.0, 'bc_x%pres_out': 1.0, 'bc_x%alpha_in(1)': 1.0, 'bc_x%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc x"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(1)": 1.0, + "patch_icpp(2)%vel(1)": 1.0, + "patch_icpp(3)%vel(1)": 1.0, + "bc_x%beg": -7, + "bc_x%end": -8, + "bc_x%grcbc_in": "T", + "bc_x%grcbc_out": "T", + "bc_x%grcbc_vel_out": "T", + "bc_x%vel_in(1)": 1.0, + "bc_x%vel_in(2)": 0.0, + "bc_x%vel_in(3)": 0.0, + "bc_x%vel_out(1)": 1.0, + "bc_x%vel_out(2)": 0.0, + "bc_x%vel_out(3)": 0.0, + "bc_x%pres_in": 1.0, + "bc_x%pres_out": 1.0, + "bc_x%alpha_in(1)": 1.0, + "bc_x%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc x"], {})) stack.pop() - stack.push('', {'patch_icpp(1)%vel(2)': 1.0, 'patch_icpp(2)%vel(2)': 1.0, 'patch_icpp(3)%vel(2)': 1.0, - 'bc_y%beg': -7, 'bc_y%end': -8, 'bc_y%grcbc_in': 'T', 'bc_y%grcbc_out': 'T', 'bc_y%grcbc_vel_out': 'T', - 'bc_y%vel_in(1)': 0.0, 'bc_y%vel_in(2)': 1.0, 'bc_y%vel_in(3)': 0.0, 'bc_y%vel_out(1)': 0.0, 'bc_y%vel_out(2)': 1.0, 'bc_y%vel_out(3)': 0.0, - 'bc_y%pres_in': 1.0, 'bc_y%pres_out': 1.0, 'bc_y%alpha_in(1)': 1.0, 'bc_y%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc y"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(2)": 1.0, + "patch_icpp(2)%vel(2)": 1.0, + "patch_icpp(3)%vel(2)": 1.0, + "bc_y%beg": -7, + "bc_y%end": -8, + "bc_y%grcbc_in": "T", + "bc_y%grcbc_out": "T", + "bc_y%grcbc_vel_out": "T", + "bc_y%vel_in(1)": 0.0, + "bc_y%vel_in(2)": 1.0, + "bc_y%vel_in(3)": 0.0, + "bc_y%vel_out(1)": 0.0, + "bc_y%vel_out(2)": 1.0, + "bc_y%vel_out(3)": 0.0, + "bc_y%pres_in": 1.0, + "bc_y%pres_out": 1.0, + "bc_y%alpha_in(1)": 1.0, + "bc_y%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc y"], {})) stack.pop() - stack.push('', {'patch_icpp(1)%vel(3)': 1.0, 'patch_icpp(2)%vel(3)': 1.0, 'patch_icpp(3)%vel(3)': 1.0, - 'bc_z%beg': -7, 'bc_z%end': -8, 'bc_z%grcbc_in': 'T', 'bc_z%grcbc_out': 'T', 'bc_z%grcbc_vel_out': 'T', - 'bc_z%vel_in(1)': 0.0, 'bc_z%vel_in(2)': 0.0, 'bc_z%vel_in(3)': 1.0, 'bc_z%vel_out(1)': 0.0, 'bc_z%vel_out(2)': 0.0, 'bc_z%vel_out(3)': 1.0, - 'bc_z%pres_in': 1.0, 'bc_z%pres_out': 1.0, 'bc_z%alpha_in(1)': 1.0, 'bc_z%alpha_rho_in(1)': 1.0}) - cases.append(define_case_d(stack, [f"grcbc z"], {})) + stack.push( + "", + { + "patch_icpp(1)%vel(3)": 1.0, + "patch_icpp(2)%vel(3)": 1.0, + "patch_icpp(3)%vel(3)": 1.0, + "bc_z%beg": -7, + "bc_z%end": -8, + "bc_z%grcbc_in": "T", + "bc_z%grcbc_out": "T", + "bc_z%grcbc_vel_out": "T", + "bc_z%vel_in(1)": 0.0, + "bc_z%vel_in(2)": 0.0, + "bc_z%vel_in(3)": 1.0, + "bc_z%vel_out(1)": 0.0, + "bc_z%vel_out(2)": 0.0, + "bc_z%vel_out(3)": 1.0, + "bc_z%pres_in": 1.0, + "bc_z%pres_out": 1.0, + "bc_z%alpha_in(1)": 1.0, + "bc_z%alpha_rho_in(1)": 1.0, + }, + ) + cases.append(define_case_d(stack, ["grcbc z"], {})) stack.pop() def alter_capillary(): - stack.push('', {'patch_icpp(1)%cf_val': 1, 'patch_icpp(2)%cf_val': 0, 'patch_icpp(3)%cf_val': 1, - 'sigma': 1, 'model_eqns': 3, 'surface_tension': 'T'}) - cases.append(define_case_d(stack, [f"capillary=T", "model_eqns=3"], {})) + stack.push("", {"patch_icpp(1)%cf_val": 1, "patch_icpp(2)%cf_val": 0, "patch_icpp(3)%cf_val": 1, "sigma": 1, "model_eqns": 3, "surface_tension": "T"}) + cases.append(define_case_d(stack, ["capillary=T", "model_eqns=3"], {})) stack.pop() def alter_weno(dimInfo): for weno_order in [3, 5, 7]: - stack.push(f"weno_order={weno_order}", {'weno_order': weno_order}) - for mapped_weno, wenoz, teno, mp_weno in itertools.product('FT', repeat=4): - - if sum(var == 'T' for var in [mapped_weno, wenoz, teno, mp_weno]) > 1: + stack.push(f"weno_order={weno_order}", {"weno_order": weno_order}) + for mapped_weno, wenoz, teno, mp_weno in itertools.product("FT", repeat=4): + if sum(var == "T" for var in [mapped_weno, wenoz, teno, mp_weno]) > 1: continue - if mp_weno == 'T' and weno_order != 5: + if mp_weno == "T" and weno_order != 5: continue - if teno == 'T' and weno_order == 3: + if teno == "T" and weno_order == 3: continue - trace = [f"{var}={val}" for var, val in zip(["mapped_weno", "wenoz", "teno", "mp_weno"], [mapped_weno, wenoz, teno, mp_weno]) if val == 'T'] - data = {var: 'T' for var, val in zip(["mapped_weno", "wenoz", "teno", "mp_weno"], [mapped_weno, wenoz, teno, mp_weno]) if val == 'T'} + trace = [f"{var}={val}" for var, val in zip(["mapped_weno", "wenoz", "teno", "mp_weno"], [mapped_weno, wenoz, teno, mp_weno]) if val == "T"] + data = {var: "T" for var, val in zip(["mapped_weno", "wenoz", "teno", "mp_weno"], [mapped_weno, wenoz, teno, mp_weno]) if val == "T"} if "teno" in data: data["teno_CT"] = 1e-6 @@ -151,26 +275,24 @@ def alter_weno(dimInfo): data["wenoz_q"] = 3.0 if weno_order == 7: - data = {**data, 'weno_eps': 1e-6} # increase damping for stability + data = {**data, "weno_eps": 1e-6} # increase damping for stability if "z" in dimInfo[0]: - data = {**data, 'm': 35, 'n': 35, 'p': 35} + data = {**data, "m": 35, "n": 35, "p": 35} cases.append(define_case_d(stack, trace, data)) stack.pop() def alter_igr(): - stack.push('IGR', {'igr': 'T', 'alf_factor': 10, 'num_igr_iters': 10, - 'elliptic_smoothing': 'T', 'elliptic_smoothing_iters': 10, - 'num_igr_warm_start_iters': 10}) + stack.push("IGR", {"igr": "T", "alf_factor": 10, "num_igr_iters": 10, "elliptic_smoothing": "T", "elliptic_smoothing_iters": 10, "num_igr_warm_start_iters": 10}) for order in [3, 5]: - stack.push(f"igr_order={order}", {'igr_order': order}) + stack.push(f"igr_order={order}", {"igr_order": order}) - cases.append(define_case_d(stack, 'Jacobi', {'igr_iter_solver': 1})) + cases.append(define_case_d(stack, "Jacobi", {"igr_iter_solver": 1})) if order == 5: - cases.append(define_case_d(stack, 'Gauss Seidel', {'igr_iter_solver': 2})) + cases.append(define_case_d(stack, "Gauss Seidel", {"igr_iter_solver": 2})) stack.pop() @@ -178,50 +300,50 @@ def alter_igr(): def alter_muscl(): for muscl_order in [1, 2]: - stack.push(f"muscl_order={muscl_order}", {'muscl_order': muscl_order, 'recon_type': 2, 'weno_order': 0}) + stack.push(f"muscl_order={muscl_order}", {"muscl_order": muscl_order, "recon_type": 2, "weno_order": 0}) if muscl_order == 1: for int_comp in ["T", "F"]: - cases.append(define_case_d(stack, f"int_comp={int_comp}", {'int_comp': int_comp})) + cases.append(define_case_d(stack, f"int_comp={int_comp}", {"int_comp": int_comp})) elif muscl_order == 2: for int_comp in ["T", "F"]: - stack.push(f"int_comp={int_comp}", {'int_comp': int_comp}) - cases.append(define_case_d(stack, f"muscl_lim=1", {'muscl_lim': 1})) + stack.push(f"int_comp={int_comp}", {"int_comp": int_comp}) + cases.append(define_case_d(stack, "muscl_lim=1", {"muscl_lim": 1})) stack.pop() for muscl_lim in [2, 3, 4, 5]: - cases.append(define_case_d(stack, f"muscl_lim={muscl_lim}", {'muscl_lim': muscl_lim})) + cases.append(define_case_d(stack, f"muscl_lim={muscl_lim}", {"muscl_lim": muscl_lim})) stack.pop() def alter_riemann_solvers(num_fluids): for riemann_solver in [1, 5, 2]: - stack.push(f"riemann_solver={riemann_solver}", {'riemann_solver': riemann_solver}) + stack.push(f"riemann_solver={riemann_solver}", {"riemann_solver": riemann_solver}) - cases.append(define_case_d(stack, "mixture_err", {'mixture_err': 'T'})) + cases.append(define_case_d(stack, "mixture_err", {"mixture_err": "T"})) if riemann_solver in (1, 2): - cases.append(define_case_d(stack, "avg_state=1", {'avg_state': 1})) - cases.append(define_case_d(stack, "wave_speeds=2", {'wave_speeds': 2})) + cases.append(define_case_d(stack, "avg_state=1", {"avg_state": 1})) + cases.append(define_case_d(stack, "wave_speeds=2", {"wave_speeds": 2})) if riemann_solver == 2: - cases.append(define_case_d(stack, "model_eqns=3", {'model_eqns': 3})) + cases.append(define_case_d(stack, "model_eqns=3", {"model_eqns": 3})) if num_fluids == 2: if riemann_solver == 2: - cases.append(define_case_d(stack, 'alt_soundspeed', {'alt_soundspeed': 'T'})) + cases.append(define_case_d(stack, "alt_soundspeed", {"alt_soundspeed": "T"})) - cases.append(define_case_d(stack, 'mpp_lim', {'mpp_lim': 'T'})) + cases.append(define_case_d(stack, "mpp_lim", {"mpp_lim": "T"})) stack.pop() def alter_low_Mach_correction(): - stack.push('', {'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0, 'dt': 1e-7}) + stack.push("", {"fluid_pp(1)%gamma": 0.16, "fluid_pp(1)%pi_inf": 3515.0, "dt": 1e-7}) - stack.push(f"riemann_solver=1", {'riemann_solver': 1}) - cases.append(define_case_d(stack, 'low_Mach=1', {'low_Mach': 1})) + stack.push("riemann_solver=1", {"riemann_solver": 1}) + cases.append(define_case_d(stack, "low_Mach=1", {"low_Mach": 1})) stack.pop() - stack.push(f"riemann_solver=2", {'riemann_solver': 2}) - cases.append(define_case_d(stack, 'low_Mach=1', {'low_Mach': 1})) - cases.append(define_case_d(stack, 'low_Mach=2', {'low_Mach': 2})) + stack.push("riemann_solver=2", {"riemann_solver": 2}) + cases.append(define_case_d(stack, "low_Mach=1", {"low_Mach": 1})) + cases.append(define_case_d(stack, "low_Mach=2", {"low_Mach": 2})) stack.pop() stack.pop() @@ -231,13 +353,25 @@ def alter_num_fluids(dimInfo): stack.push(f"{num_fluids} Fluid(s)", {"num_fluids": num_fluids}) if num_fluids == 2: - stack.push("", { - 'fluid_pp(2)%gamma': 2.5, 'fluid_pp(2)%pi_inf': 0.0, 'patch_icpp(1)%alpha_rho(1)': 0.81, - 'patch_icpp(1)%alpha(1)': 0.9, 'patch_icpp(1)%alpha_rho(2)': 0.19, 'patch_icpp(1)%alpha(2)': 0.1, - 'patch_icpp(2)%alpha_rho(1)': 0.25, 'patch_icpp(2)%alpha(1)': 0.5, 'patch_icpp(2)%alpha_rho(2)': 0.25, - 'patch_icpp(2)%alpha(2)': 0.5, 'patch_icpp(3)%alpha_rho(1)': 0.08, 'patch_icpp(3)%alpha(1)': 0.2, - 'patch_icpp(3)%alpha_rho(2)': 0.0225, 'patch_icpp(3)%alpha(2)': 0.8 - }) + stack.push( + "", + { + "fluid_pp(2)%gamma": 2.5, + "fluid_pp(2)%pi_inf": 0.0, + "patch_icpp(1)%alpha_rho(1)": 0.81, + "patch_icpp(1)%alpha(1)": 0.9, + "patch_icpp(1)%alpha_rho(2)": 0.19, + "patch_icpp(1)%alpha(2)": 0.1, + "patch_icpp(2)%alpha_rho(1)": 0.25, + "patch_icpp(2)%alpha(1)": 0.5, + "patch_icpp(2)%alpha_rho(2)": 0.25, + "patch_icpp(2)%alpha(2)": 0.5, + "patch_icpp(3)%alpha_rho(1)": 0.08, + "patch_icpp(3)%alpha(1)": 0.2, + "patch_icpp(3)%alpha_rho(2)": 0.0225, + "patch_icpp(3)%alpha(2)": 0.8, + }, + ) if len(dimInfo[0]) > 1: alter_capillary() @@ -249,44 +383,41 @@ def alter_num_fluids(dimInfo): alter_igr() if num_fluids == 1: - - stack.push("Viscous", { - 'fluid_pp(1)%Re(1)': 0.0001, 'dt': 1e-11, 'patch_icpp(1)%vel(1)': 1.0, - 'viscous': 'T'}) + stack.push("Viscous", {"fluid_pp(1)%Re(1)": 0.0001, "dt": 1e-11, "patch_icpp(1)%vel(1)": 1.0, "viscous": "T"}) alter_ib(dimInfo, six_eqn_model=True, viscous=True) if len(dimInfo[0]) > 1: alter_igr() - cases.append(define_case_d(stack, "", {'weno_Re_flux': 'F'})) - cases.append(define_case_d(stack, "weno_Re_flux", {'weno_Re_flux': 'T'})) - cases.append(define_case_d(stack, "riemann_solver=5", {'riemann_solver': 5})) + cases.append(define_case_d(stack, "", {"weno_Re_flux": "F"})) + cases.append(define_case_d(stack, "weno_Re_flux", {"weno_Re_flux": "T"})) + cases.append(define_case_d(stack, "riemann_solver=5", {"riemann_solver": 5})) - for weno_Re_flux in ['T']: - stack.push("weno_Re_flux" if weno_Re_flux == 'T' else '', {'weno_Re_flux': 'T'}) - cases.append(define_case_d(stack, "weno_avg", {'weno_avg': 'T'})) + for weno_Re_flux in ["T"]: + stack.push("weno_Re_flux" if weno_Re_flux == "T" else "", {"weno_Re_flux": "T"}) + cases.append(define_case_d(stack, "weno_avg", {"weno_avg": "T"})) stack.pop() stack.pop() if num_fluids == 2: - stack.push("Viscous", { - 'fluid_pp(1)%Re(1)': 0.001, 'fluid_pp(1)%Re(2)': 0.001, - 'fluid_pp(2)%Re(1)': 0.001, 'fluid_pp(2)%Re(2)': 0.001, 'dt': 1e-11, - 'patch_icpp(1)%vel(1)': 1.0, 'viscous': 'T'}) + stack.push( + "Viscous", + {"fluid_pp(1)%Re(1)": 0.001, "fluid_pp(1)%Re(2)": 0.001, "fluid_pp(2)%Re(1)": 0.001, "fluid_pp(2)%Re(2)": 0.001, "dt": 1e-11, "patch_icpp(1)%vel(1)": 1.0, "viscous": "T"}, + ) alter_ib(dimInfo, six_eqn_model=True, viscous=True) if len(dimInfo[0]) > 1: alter_igr() - cases.append(define_case_d(stack, "", {'weno_Re_flux': 'F'})) - cases.append(define_case_d(stack, "weno_Re_flux", {'weno_Re_flux': 'T'})) - cases.append(define_case_d(stack, "riemann_solver=5", {'riemann_solver': 5})) - for weno_Re_flux in ['T']: - stack.push("weno_Re_flux" if weno_Re_flux == 'T' else '', {'weno_Re_flux': 'T'}) - cases.append(define_case_d(stack, "weno_avg", {'weno_avg': 'T'})) + cases.append(define_case_d(stack, "", {"weno_Re_flux": "F"})) + cases.append(define_case_d(stack, "weno_Re_flux", {"weno_Re_flux": "T"})) + cases.append(define_case_d(stack, "riemann_solver=5", {"riemann_solver": 5})) + for weno_Re_flux in ["T"]: + stack.push("weno_Re_flux" if weno_Re_flux == "T" else "", {"weno_Re_flux": "T"}) + cases.append(define_case_d(stack, "weno_avg", {"weno_avg": "T"})) stack.pop() stack.pop() @@ -295,74 +426,120 @@ def alter_num_fluids(dimInfo): stack.pop() def alter_2d(): - stack.push("Axisymmetric", { - 'num_fluids': 2, 'bc_y%beg': -2, 'cyl_coord': 'T', - 'fluid_pp(2)%gamma': 2.5, 'fluid_pp(2)%pi_inf': 0.0, 'patch_icpp(1)%alpha_rho(1)': 0.81, - 'patch_icpp(1)%alpha(1)': 0.9, 'patch_icpp(1)%alpha_rho(2)': 0.19, 'patch_icpp(1)%alpha(2)': 0.1, - 'patch_icpp(2)%alpha_rho(1)': 0.25, 'patch_icpp(2)%alpha(1)': 0.5, 'patch_icpp(2)%alpha_rho(2)': 0.25, - 'patch_icpp(2)%alpha(2)': 0.5, 'patch_icpp(3)%alpha_rho(1)': 0.08, 'patch_icpp(3)%alpha(1)': 0.2, - 'patch_icpp(3)%alpha_rho(2)': 0.0225, 'patch_icpp(3)%alpha(2)': 0.8, 'patch_icpp(1)%vel(1)': 0.0 - }) - - cases.append(define_case_d(stack, "model_eqns=2", {'model_eqns': 2})) - cases.append(define_case_d(stack, "model_eqns=3", {'model_eqns': 3})) - cases.append(define_case_d(stack, "HLL", {'riemann_solver': 1})) - - stack.push("Viscous", { - 'fluid_pp(1)%Re(1)': 0.0001, 'fluid_pp(1)%Re(2)': 0.0001, - 'fluid_pp(2)%Re(1)': 0.0001, 'fluid_pp(2)%Re(2)': 0.0001, 'dt': 1e-11, - 'viscous': 'T'}) - - cases.append(define_case_d(stack, "", {'weno_Re_flux': 'F'})) - cases.append(define_case_d(stack, "weno_Re_flux", {'weno_Re_flux': 'T'})) - for weno_Re_flux in ['T']: - stack.push("weno_Re_flux" if weno_Re_flux == 'T' else '', {'weno_Re_flux': 'T'}) - cases.append(define_case_d(stack, "weno_avg", {'weno_avg': 'T'})) + stack.push( + "Axisymmetric", + { + "num_fluids": 2, + "bc_y%beg": -2, + "cyl_coord": "T", + "fluid_pp(2)%gamma": 2.5, + "fluid_pp(2)%pi_inf": 0.0, + "patch_icpp(1)%alpha_rho(1)": 0.81, + "patch_icpp(1)%alpha(1)": 0.9, + "patch_icpp(1)%alpha_rho(2)": 0.19, + "patch_icpp(1)%alpha(2)": 0.1, + "patch_icpp(2)%alpha_rho(1)": 0.25, + "patch_icpp(2)%alpha(1)": 0.5, + "patch_icpp(2)%alpha_rho(2)": 0.25, + "patch_icpp(2)%alpha(2)": 0.5, + "patch_icpp(3)%alpha_rho(1)": 0.08, + "patch_icpp(3)%alpha(1)": 0.2, + "patch_icpp(3)%alpha_rho(2)": 0.0225, + "patch_icpp(3)%alpha(2)": 0.8, + "patch_icpp(1)%vel(1)": 0.0, + }, + ) + + cases.append(define_case_d(stack, "model_eqns=2", {"model_eqns": 2})) + cases.append(define_case_d(stack, "model_eqns=3", {"model_eqns": 3})) + cases.append(define_case_d(stack, "HLL", {"riemann_solver": 1})) + + stack.push("Viscous", {"fluid_pp(1)%Re(1)": 0.0001, "fluid_pp(1)%Re(2)": 0.0001, "fluid_pp(2)%Re(1)": 0.0001, "fluid_pp(2)%Re(2)": 0.0001, "dt": 1e-11, "viscous": "T"}) + + cases.append(define_case_d(stack, "", {"weno_Re_flux": "F"})) + cases.append(define_case_d(stack, "weno_Re_flux", {"weno_Re_flux": "T"})) + for weno_Re_flux in ["T"]: + stack.push("weno_Re_flux" if weno_Re_flux == "T" else "", {"weno_Re_flux": "T"}) + cases.append(define_case_d(stack, "weno_avg", {"weno_avg": "T"})) stack.pop() stack.pop() stack.pop() def alter_3d(): - stack.push("Cylindrical", { - 'bc_y%beg': -14, 'bc_z%beg': -1, 'bc_z%end': -1, 'cyl_coord': 'T', 'x_domain%beg': 0.E+00, - 'x_domain%end': 5.E+00, 'y_domain%beg': 0.E+00, 'y_domain%end': 1.E+00, 'z_domain%beg': 0.E+00, - 'z_domain%end': 2.0*3.141592653589793E+00, 'm': 29, 'n': 29, 'p': 29, - 'patch_icpp(1)%geometry': 10, 'patch_icpp(1)%x_centroid': 0.5, 'patch_icpp(1)%y_centroid': 0.E+00, - 'patch_icpp(1)%z_centroid': 0.E+00, 'patch_icpp(1)%radius': 1.0, 'patch_icpp(1)%length_x': 1.0, - 'patch_icpp(1)%length_y': -1E+6, 'patch_icpp(1)%length_z': -1E+6, - 'patch_icpp(2)%geometry': 10, 'patch_icpp(2)%x_centroid': 2.5, 'patch_icpp(2)%y_centroid': 0.E+00, - 'patch_icpp(2)%z_centroid': 0.E+00, 'patch_icpp(2)%radius': 1.0, 'patch_icpp(2)%length_x': 3.0, - 'patch_icpp(2)%length_y': -1E+6, 'patch_icpp(2)%length_z': -1E+6, - 'patch_icpp(3)%geometry': 10, 'patch_icpp(3)%x_centroid': 4.5, 'patch_icpp(3)%y_centroid': 0.E+00, - 'patch_icpp(3)%z_centroid': 0.E+00, 'patch_icpp(3)%radius': 1.0, 'patch_icpp(3)%length_x': 1.0, - 'patch_icpp(3)%length_y': -1E+6, 'patch_icpp(3)%length_z': -1E+6, 'patch_icpp(1)%vel(1)': 0.0, - 'num_fluids': 2, - 'fluid_pp(2)%gamma': 2.5, 'fluid_pp(2)%pi_inf': 0.0, 'patch_icpp(1)%alpha_rho(1)': 0.81, - 'patch_icpp(1)%alpha(1)': 0.9, 'patch_icpp(1)%alpha_rho(2)': 0.19, 'patch_icpp(1)%alpha(2)': 0.1, - 'patch_icpp(2)%alpha_rho(1)': 0.25, 'patch_icpp(2)%alpha(1)': 0.5, 'patch_icpp(2)%alpha_rho(2)': 0.25, - 'patch_icpp(2)%alpha(2)': 0.5, 'patch_icpp(3)%alpha_rho(1)': 0.08, 'patch_icpp(3)%alpha(1)': 0.2, - 'patch_icpp(3)%alpha_rho(2)': 0.0225, 'patch_icpp(3)%alpha(2)': 0.8 - }) - - cases.append(define_case_d(stack, "model_eqns=2", {'model_eqns': 2})) - - stack.push('cfl_adap_dt=T', {'cfl_adap_dt': 'T', 'cfl_target': 0.08, 't_save': 0.1, 'n_start': 0, 't_stop': 0.1}) - cases.append(define_case_d(stack, '', {})) + stack.push( + "Cylindrical", + { + "bc_y%beg": -14, + "bc_z%beg": -1, + "bc_z%end": -1, + "cyl_coord": "T", + "x_domain%beg": 0.0e00, + "x_domain%end": 5.0e00, + "y_domain%beg": 0.0e00, + "y_domain%end": 1.0e00, + "z_domain%beg": 0.0e00, + "z_domain%end": 2.0 * 3.141592653589793e00, + "m": 29, + "n": 29, + "p": 29, + "patch_icpp(1)%geometry": 10, + "patch_icpp(1)%x_centroid": 0.5, + "patch_icpp(1)%y_centroid": 0.0e00, + "patch_icpp(1)%z_centroid": 0.0e00, + "patch_icpp(1)%radius": 1.0, + "patch_icpp(1)%length_x": 1.0, + "patch_icpp(1)%length_y": -1e6, + "patch_icpp(1)%length_z": -1e6, + "patch_icpp(2)%geometry": 10, + "patch_icpp(2)%x_centroid": 2.5, + "patch_icpp(2)%y_centroid": 0.0e00, + "patch_icpp(2)%z_centroid": 0.0e00, + "patch_icpp(2)%radius": 1.0, + "patch_icpp(2)%length_x": 3.0, + "patch_icpp(2)%length_y": -1e6, + "patch_icpp(2)%length_z": -1e6, + "patch_icpp(3)%geometry": 10, + "patch_icpp(3)%x_centroid": 4.5, + "patch_icpp(3)%y_centroid": 0.0e00, + "patch_icpp(3)%z_centroid": 0.0e00, + "patch_icpp(3)%radius": 1.0, + "patch_icpp(3)%length_x": 1.0, + "patch_icpp(3)%length_y": -1e6, + "patch_icpp(3)%length_z": -1e6, + "patch_icpp(1)%vel(1)": 0.0, + "num_fluids": 2, + "fluid_pp(2)%gamma": 2.5, + "fluid_pp(2)%pi_inf": 0.0, + "patch_icpp(1)%alpha_rho(1)": 0.81, + "patch_icpp(1)%alpha(1)": 0.9, + "patch_icpp(1)%alpha_rho(2)": 0.19, + "patch_icpp(1)%alpha(2)": 0.1, + "patch_icpp(2)%alpha_rho(1)": 0.25, + "patch_icpp(2)%alpha(1)": 0.5, + "patch_icpp(2)%alpha_rho(2)": 0.25, + "patch_icpp(2)%alpha(2)": 0.5, + "patch_icpp(3)%alpha_rho(1)": 0.08, + "patch_icpp(3)%alpha(1)": 0.2, + "patch_icpp(3)%alpha_rho(2)": 0.0225, + "patch_icpp(3)%alpha(2)": 0.8, + }, + ) + + cases.append(define_case_d(stack, "model_eqns=2", {"model_eqns": 2})) + + stack.push("cfl_adap_dt=T", {"cfl_adap_dt": "T", "cfl_target": 0.08, "t_save": 0.1, "n_start": 0, "t_stop": 0.1}) + cases.append(define_case_d(stack, "", {})) stack.pop() - stack.push("Viscous", { - 'fluid_pp(1)%Re(1)': 0.0001, 'fluid_pp(1)%Re(2)': 0.0001, - 'fluid_pp(2)%Re(1)': 0.0001, 'fluid_pp(2)%Re(2)': 0.0001, 'dt': 1e-10, - 'viscous': 'T' - }) - - cases.append(define_case_d(stack, "", {'weno_Re_flux': 'F'})) - cases.append(define_case_d(stack, "weno_Re_flux", {'weno_Re_flux': 'T'})) - for weno_Re_flux in ['T']: - stack.push("weno_Re_flux" if weno_Re_flux == 'T' else '', {'weno_Re_flux': 'T'}) - cases.append(define_case_d(stack, "weno_avg", {'weno_avg': 'T'})) + stack.push("Viscous", {"fluid_pp(1)%Re(1)": 0.0001, "fluid_pp(1)%Re(2)": 0.0001, "fluid_pp(2)%Re(1)": 0.0001, "fluid_pp(2)%Re(2)": 0.0001, "dt": 1e-10, "viscous": "T"}) + + cases.append(define_case_d(stack, "", {"weno_Re_flux": "F"})) + cases.append(define_case_d(stack, "weno_Re_flux", {"weno_Re_flux": "T"})) + for weno_Re_flux in ["T"]: + stack.push("weno_Re_flux" if weno_Re_flux == "T" else "", {"weno_Re_flux": "T"}) + cases.append(define_case_d(stack, "weno_avg", {"weno_avg": "T"})) stack.pop() stack.pop() @@ -370,179 +547,229 @@ def alter_3d(): def alter_ppn(dimInfo): if len(dimInfo[0]) == 3: - cases.append(define_case_d(stack, '2 MPI Ranks', {'m': 29, 'n': 29, 'p': 49}, ppn=2)) + cases.append(define_case_d(stack, "2 MPI Ranks", {"m": 29, "n": 29, "p": 49}, ppn=2)) if ARG("rdma_mpi"): - cases.append(define_case_d(stack, '2 MPI Ranks -> RDMA MPI', {'m': 29, 'n': 29, 'p': 49, 'rdma_mpi': 'T'}, ppn=2)) - cases.append(define_case_d(stack, '2 MPI Ranks -> IBM Sphere', { - 'm': 29, 'n': 29, 'p': 49, - 'ib': 'T', 'num_ibs': 1, - 'patch_ib(1)%geometry': 8, - 'patch_ib(1)%x_centroid': 0.5, - 'patch_ib(1)%y_centroid': 0.5, - 'patch_ib(1)%z_centroid': 0.5, - 'patch_ib(1)%radius': 0.1, - 'patch_icpp(1)%vel(1)': 0.001, - 'patch_icpp(2)%vel(1)': 0.001, - 'patch_icpp(3)%vel(1)': 0.001, - 'patch_ib(1)%slip': 'F', - }, ppn=2)) + cases.append(define_case_d(stack, "2 MPI Ranks -> RDMA MPI", {"m": 29, "n": 29, "p": 49, "rdma_mpi": "T"}, ppn=2)) + cases.append( + define_case_d( + stack, + "2 MPI Ranks -> IBM Sphere", + { + "m": 29, + "n": 29, + "p": 49, + "ib": "T", + "num_ibs": 1, + "patch_ib(1)%geometry": 8, + "patch_ib(1)%x_centroid": 0.5, + "patch_ib(1)%y_centroid": 0.5, + "patch_ib(1)%z_centroid": 0.5, + "patch_ib(1)%radius": 0.1, + "patch_icpp(1)%vel(1)": 0.001, + "patch_icpp(2)%vel(1)": 0.001, + "patch_icpp(3)%vel(1)": 0.001, + "patch_ib(1)%slip": "F", + }, + ppn=2, + ) + ) else: - cases.append(define_case_d(stack, '2 MPI Ranks', {}, ppn=2)) + cases.append(define_case_d(stack, "2 MPI Ranks", {}, ppn=2)) if ARG("rdma_mpi"): - cases.append(define_case_d(stack, '2 MPI Ranks -> RDMA MPI', {'rdma_mpi': 'T'}, ppn=2)) + cases.append(define_case_d(stack, "2 MPI Ranks -> RDMA MPI", {"rdma_mpi": "T"}, ppn=2)) def alter_ib(dimInfo, six_eqn_model=False, viscous=False): for slip in [True, False]: - stack.push(f'IBM', { - 'ib': 'T', 'num_ibs': 1, - 'patch_ib(1)%x_centroid': 0.5, 'patch_ib(1)%y_centroid': 0.5, - 'patch_ib(1)%radius': 0.1, 'patch_icpp(1)%vel(1)': 0.001, - 'patch_icpp(2)%vel(1)': 0.001, 'patch_icpp(3)%vel(1)': 0.001, - 'patch_ib(1)%slip': 'T' if slip else 'F', - }) + stack.push( + "IBM", + { + "ib": "T", + "num_ibs": 1, + "patch_ib(1)%x_centroid": 0.5, + "patch_ib(1)%y_centroid": 0.5, + "patch_ib(1)%radius": 0.1, + "patch_icpp(1)%vel(1)": 0.001, + "patch_icpp(2)%vel(1)": 0.001, + "patch_icpp(3)%vel(1)": 0.001, + "patch_ib(1)%slip": "T" if slip else "F", + }, + ) suffix = " -> slip" if slip else "" if len(dimInfo[0]) == 3: - cases.append(define_case_d(stack, f'Sphere{suffix}', { - 'patch_ib(1)%z_centroid': 0.5, - 'patch_ib(1)%geometry': 8, - })) - - cases.append(define_case_d(stack, f'Cuboid{suffix}', { - 'patch_ib(1)%z_centroid': 0.5, - 'patch_ib(1)%length_x': 0.1, - 'patch_ib(1)%length_y': 0.1, - 'patch_ib(1)%length_z': 0.1, - 'patch_ib(1)%geometry': 9, - })) - - cases.append(define_case_d(stack, f'Cylinder{suffix}', { - 'patch_ib(1)%z_centroid': 0.5, - 'patch_ib(1)%length_x': 0.1, - 'patch_ib(1)%geometry': 10, - })) + cases.append( + define_case_d( + stack, + f"Sphere{suffix}", + { + "patch_ib(1)%z_centroid": 0.5, + "patch_ib(1)%geometry": 8, + }, + ) + ) + + cases.append( + define_case_d( + stack, + f"Cuboid{suffix}", + { + "patch_ib(1)%z_centroid": 0.5, + "patch_ib(1)%length_x": 0.1, + "patch_ib(1)%length_y": 0.1, + "patch_ib(1)%length_z": 0.1, + "patch_ib(1)%geometry": 9, + }, + ) + ) + + cases.append( + define_case_d( + stack, + f"Cylinder{suffix}", + { + "patch_ib(1)%z_centroid": 0.5, + "patch_ib(1)%length_x": 0.1, + "patch_ib(1)%geometry": 10, + }, + ) + ) elif len(dimInfo[0]) == 2: - cases.append(define_case_d(stack, f'Rectangle{suffix}', { - 'patch_ib(1)%length_x': 0.05, - 'patch_ib(1)%length_y': 0.05, - 'patch_ib(1)%geometry': 3, - })) - cases.append(define_case_d(stack, f'Circle{suffix}', { - 'patch_ib(1)%geometry': 2, - 'n': 49 - })) + cases.append( + define_case_d( + stack, + f"Rectangle{suffix}", + { + "patch_ib(1)%length_x": 0.05, + "patch_ib(1)%length_y": 0.05, + "patch_ib(1)%geometry": 3, + }, + ) + ) + cases.append(define_case_d(stack, f"Circle{suffix}", {"patch_ib(1)%geometry": 2, "n": 49})) if six_eqn_model: - cases.append(define_case_d(stack, f'model_eqns=3{suffix}', { - 'patch_ib(1)%geometry': 2, - 'model_eqns': 3, - 'n': 49, # there is a machine-level precision sensitivity to circles with n=39 - })) + cases.append( + define_case_d( + stack, + f"model_eqns=3{suffix}", + { + "patch_ib(1)%geometry": 2, + "model_eqns": 3, + "n": 49, # there is a machine-level precision sensitivity to circles with n=39 + }, + ) + ) stack.pop() if len(dimInfo[0]) == 2 and not viscous: - cases.append(define_case_d(stack, 'IBM -> Periodic Circle', { - 'ib': 'T', 'num_ibs': 1, - 'bc_x%beg': -1, 'bc_x%end': -1, - 'bc_y%beg': -1, 'bc_y%end': -1, - 'patch_ib(1)%geometry': 2, - 'patch_ib(1)%x_centroid': 0., - 'patch_ib(1)%y_centroid': 0., - 'patch_ib(1)%radius': 0.1, - 'patch_icpp(1)%vel(1)': 0.001, - 'patch_icpp(2)%vel(1)': 0.001, - 'patch_icpp(3)%vel(1)': 0.001, - 'patch_ib(1)%slip': 'F', - 'n': 49 - })) + cases.append( + define_case_d( + stack, + "IBM -> Periodic Circle", + { + "ib": "T", + "num_ibs": 1, + "bc_x%beg": -1, + "bc_x%end": -1, + "bc_y%beg": -1, + "bc_y%end": -1, + "patch_ib(1)%geometry": 2, + "patch_ib(1)%x_centroid": 0.0, + "patch_ib(1)%y_centroid": 0.0, + "patch_ib(1)%radius": 0.1, + "patch_icpp(1)%vel(1)": 0.001, + "patch_icpp(2)%vel(1)": 0.001, + "patch_icpp(3)%vel(1)": 0.001, + "patch_ib(1)%slip": "F", + "n": 49, + }, + ) + ) def ibm_stl(): common_mods = { - 't_step_stop': Nt, 't_step_save': Nt, - 'patch_ib(1)%model_scale(1)': 5., - 'patch_ib(1)%model_scale(2)': 5., - 'patch_ib(1)%model_scale(3)': 5., - 'patch_ib(1)%model_threshold': 0.5, + "t_step_stop": Nt, + "t_step_save": Nt, + "patch_ib(1)%model_scale(1)": 5.0, + "patch_ib(1)%model_scale(2)": 5.0, + "patch_ib(1)%model_scale(3)": 5.0, + "patch_ib(1)%model_threshold": 0.5, } for ndim in range(2, 4): - cases.append(define_case_f( - f'{ndim}D -> IBM -> STL', - f'examples/{ndim}D_ibm_stl_test/case.py', - ['--ndim', str(ndim)], - mods=common_mods - )) + cases.append(define_case_f(f"{ndim}D -> IBM -> STL", f"examples/{ndim}D_ibm_stl_test/case.py", ["--ndim", str(ndim)], mods=common_mods)) + ibm_stl() def alter_acoustic_src(dimInfo): - stack.push("Acoustic Source", {"acoustic_source": 'T', 'acoustic(1)%support': 1, 'dt': 1e-3, 't_step_stop': 50, 't_step_save': 50}) + stack.push("Acoustic Source", {"acoustic_source": "T", "acoustic(1)%support": 1, "dt": 1e-3, "t_step_stop": 50, "t_step_save": 50}) - transducer_params = {'acoustic(1)%loc(1)': 0.2, 'acoustic(1)%foc_length': 0.4, 'acoustic(1)%aperture': 0.6} + transducer_params = {"acoustic(1)%loc(1)": 0.2, "acoustic(1)%foc_length": 0.4, "acoustic(1)%aperture": 0.6} if len(dimInfo[0]) == 1: - for pulse_type in ['Sine', 'Square']: - stack.push(pulse_type, {'acoustic(1)%pulse': 1 if pulse_type == 'Sine' else 3}) - cases.append(define_case_d(stack, 'Frequency', {'acoustic(1)%frequency': 50})) - cases.append(define_case_d(stack, 'Wavelength', {'acoustic(1)%wavelength': 0.02})) - cases.append(define_case_d(stack, 'Delay', {'acoustic(1)%delay': 0.02, 'acoustic(1)%wavelength': 0.02})) - cases.append(define_case_d(stack, 'Number of Pulses', {'acoustic(1)%npulse': 2, 'acoustic(1)%wavelength': 0.01})) + for pulse_type in ["Sine", "Square"]: + stack.push(pulse_type, {"acoustic(1)%pulse": 1 if pulse_type == "Sine" else 3}) + cases.append(define_case_d(stack, "Frequency", {"acoustic(1)%frequency": 50})) + cases.append(define_case_d(stack, "Wavelength", {"acoustic(1)%wavelength": 0.02})) + cases.append(define_case_d(stack, "Delay", {"acoustic(1)%delay": 0.02, "acoustic(1)%wavelength": 0.02})) + cases.append(define_case_d(stack, "Number of Pulses", {"acoustic(1)%npulse": 2, "acoustic(1)%wavelength": 0.01})) stack.pop() - stack.push('Gaussian', {'acoustic(1)%pulse': 2, 'acoustic(1)%delay': 0.02}) - cases.append(define_case_d(stack, 'Sigma Time', {'acoustic(1)%gauss_sigma_time': 0.01})) - cases.append(define_case_d(stack, 'Sigma Dist', {'acoustic(1)%gauss_sigma_dist': 0.01})) - cases.append(define_case_d(stack, 'Dipole', {'acoustic(1)%gauss_sigma_dist': 0.01, 'acoustic(1)%dipole': 'T'})) + stack.push("Gaussian", {"acoustic(1)%pulse": 2, "acoustic(1)%delay": 0.02}) + cases.append(define_case_d(stack, "Sigma Time", {"acoustic(1)%gauss_sigma_time": 0.01})) + cases.append(define_case_d(stack, "Sigma Dist", {"acoustic(1)%gauss_sigma_dist": 0.01})) + cases.append(define_case_d(stack, "Dipole", {"acoustic(1)%gauss_sigma_dist": 0.01, "acoustic(1)%dipole": "T"})) stack.pop() elif len(dimInfo[0]) == 2: - stack.push('', {'acoustic(1)%loc(2)': 0.5, 'acoustic(1)%wavelength': 0.02}) + stack.push("", {"acoustic(1)%loc(2)": 0.5, "acoustic(1)%wavelength": 0.02}) - stack.push('Planar', {}) - stack.push('support=2', {'acoustic(1)%support': 2}) - cases.append(define_case_d(stack, '', {})) - cases.append(define_case_d(stack, 'Dipole', {'acoustic(1)%dipole': 'T'})) + stack.push("Planar", {}) + stack.push("support=2", {"acoustic(1)%support": 2}) + cases.append(define_case_d(stack, "", {})) + cases.append(define_case_d(stack, "Dipole", {"acoustic(1)%dipole": "T"})) stack.pop() stack.pop() - stack.push('Transducer', transducer_params) + stack.push("Transducer", transducer_params) for support in [5, 6]: - stack.push(f'support={support}', {'acoustic(1)%support': support, 'cyl_coord': 'T' if support == 6 else 'F', 'bc_y%beg': -2 if support == 6 else -3}) - cases.append(define_case_d(stack, 'Sine', {})) - cases.append(define_case_d(stack, 'Gaussian', {'acoustic(1)%pulse': 2, 'acoustic(1)%delay': 0.02, 'acoustic(1)%gauss_sigma_dist': 0.01})) - cases.append(define_case_d(stack, 'Delay', {'acoustic(1)%delay': 0.02})) + stack.push(f"support={support}", {"acoustic(1)%support": support, "cyl_coord": "T" if support == 6 else "F", "bc_y%beg": -2 if support == 6 else -3}) + cases.append(define_case_d(stack, "Sine", {})) + cases.append(define_case_d(stack, "Gaussian", {"acoustic(1)%pulse": 2, "acoustic(1)%delay": 0.02, "acoustic(1)%gauss_sigma_dist": 0.01})) + cases.append(define_case_d(stack, "Delay", {"acoustic(1)%delay": 0.02})) stack.pop() stack.pop() - stack.push('Transducer Array', {**transducer_params, 'acoustic(1)%num_elements': 4, 'acoustic(1)%element_spacing_angle': 0.05, 'acoustic(1)%element_on': 0}) - stack.push('support=9', {'acoustic(1)%support': 9}) - cases.append(define_case_d(stack, 'All Elements', {})) - cases.append(define_case_d(stack, 'One element', {'acoustic(1)%element_on': 1})) + stack.push("Transducer Array", {**transducer_params, "acoustic(1)%num_elements": 4, "acoustic(1)%element_spacing_angle": 0.05, "acoustic(1)%element_on": 0}) + stack.push("support=9", {"acoustic(1)%support": 9}) + cases.append(define_case_d(stack, "All Elements", {})) + cases.append(define_case_d(stack, "One element", {"acoustic(1)%element_on": 1})) stack.pop() - cases.append(define_case_d(stack, 'support=10', {'acoustic(1)%support': 10, 'cyl_coord': 'T', 'bc_y%beg': -2})) + cases.append(define_case_d(stack, "support=10", {"acoustic(1)%support": 10, "cyl_coord": "T", "bc_y%beg": -2})) stack.pop() stack.pop() elif len(dimInfo[0]) == 3: - stack.push('', {'acoustic(1)%loc(2)': 0.5, 'acoustic(1)%loc(3)': 0.5, 'acoustic(1)%wavelength': 0.02}) + stack.push("", {"acoustic(1)%loc(2)": 0.5, "acoustic(1)%loc(3)": 0.5, "acoustic(1)%wavelength": 0.02}) - stack.push('Planar', {}) - stack.push('support=3', {'acoustic(1)%support': 3, 'acoustic(1)%height': 0.25}) - cases.append(define_case_d(stack, '', {})) - cases.append(define_case_d(stack, 'Dipole', {'acoustic(1)%dipole': 'T'})) + stack.push("Planar", {}) + stack.push("support=3", {"acoustic(1)%support": 3, "acoustic(1)%height": 0.25}) + cases.append(define_case_d(stack, "", {})) + cases.append(define_case_d(stack, "Dipole", {"acoustic(1)%dipole": "T"})) stack.pop() stack.pop() - stack.push('Transducer', transducer_params) - cases.append(define_case_d(stack, 'support=7', {'acoustic(1)%support': 7})) + stack.push("Transducer", transducer_params) + cases.append(define_case_d(stack, "support=7", {"acoustic(1)%support": 7})) stack.pop() - stack.push('Transducer Array', {**transducer_params, 'acoustic(1)%num_elements': 6, 'acoustic(1)%element_polygon_ratio': 0.7}) - stack.push('support=11', {'acoustic(1)%support': 11}) - cases.append(define_case_d(stack, 'All Elements', {})) - cases.append(define_case_d(stack, 'One element', {'acoustic(1)%element_on': 1})) + stack.push("Transducer Array", {**transducer_params, "acoustic(1)%num_elements": 6, "acoustic(1)%element_polygon_ratio": 0.7}) + stack.push("support=11", {"acoustic(1)%support": 11}) + cases.append(define_case_d(stack, "All Elements", {})) + cases.append(define_case_d(stack, "One element", {"acoustic(1)%element_on": 1})) stack.pop() stack.pop() @@ -552,72 +779,96 @@ def alter_acoustic_src(dimInfo): def alter_bubbles(dimInfo): if len(dimInfo[0]) > 0: - stack.push("Bubbles", {"bubbles_euler": 'T'}) - - stack.push('', { - 'nb': 3, 'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0, - 'bub_pp%R0ref': 1.0, 'bub_pp%p0ref': 1.0, 'bub_pp%rho0ref': 1.0, 'bub_pp%T0ref': 1.0, - 'bub_pp%ss': 0.07179866765358993, 'bub_pp%pv': 0.02308216136195411, 'bub_pp%vd': 0.2404125083932959, - 'bub_pp%mu_l': 0.009954269975623244, 'bub_pp%mu_v': 8.758168074360729e-05, - 'bub_pp%mu_g': 0.00017881922111898042, 'bub_pp%gam_v': 1.33, 'bub_pp%gam_g': 1.4, - 'bub_pp%M_v': 18.02, 'bub_pp%M_g': 28.97, 'bub_pp%k_v': 0.5583395141263873, - 'bub_pp%k_g': 0.7346421281308791, 'bub_pp%R_v': 1334.8378710170155, 'bub_pp%R_g': 830.2995663005393, - 'patch_icpp(1)%alpha_rho(1)': 0.96, 'patch_icpp(1)%alpha(1)': 4e-02, - 'patch_icpp(2)%alpha_rho(1)': 0.96, 'patch_icpp(2)%alpha(1)': 4e-02, 'patch_icpp(3)%alpha_rho(1)': 0.96, - 'patch_icpp(3)%alpha(1)': 4e-02, 'patch_icpp(1)%pres': 1.0, 'patch_icpp(2)%pres': 1.0, - 'patch_icpp(3)%pres': 1.0, 'acoustic(1)%support': 1, 'acoustic(1)%wavelength': 0.25 - }) - - stack.push('', {"acoustic_source": 'T'}) + stack.push("Bubbles", {"bubbles_euler": "T"}) + + stack.push( + "", + { + "nb": 3, + "fluid_pp(1)%gamma": 0.16, + "fluid_pp(1)%pi_inf": 3515.0, + "bub_pp%R0ref": 1.0, + "bub_pp%p0ref": 1.0, + "bub_pp%rho0ref": 1.0, + "bub_pp%T0ref": 1.0, + "bub_pp%ss": 0.07179866765358993, + "bub_pp%pv": 0.02308216136195411, + "bub_pp%vd": 0.2404125083932959, + "bub_pp%mu_l": 0.009954269975623244, + "bub_pp%mu_v": 8.758168074360729e-05, + "bub_pp%mu_g": 0.00017881922111898042, + "bub_pp%gam_v": 1.33, + "bub_pp%gam_g": 1.4, + "bub_pp%M_v": 18.02, + "bub_pp%M_g": 28.97, + "bub_pp%k_v": 0.5583395141263873, + "bub_pp%k_g": 0.7346421281308791, + "bub_pp%R_v": 1334.8378710170155, + "bub_pp%R_g": 830.2995663005393, + "patch_icpp(1)%alpha_rho(1)": 0.96, + "patch_icpp(1)%alpha(1)": 4e-02, + "patch_icpp(2)%alpha_rho(1)": 0.96, + "patch_icpp(2)%alpha(1)": 4e-02, + "patch_icpp(3)%alpha_rho(1)": 0.96, + "patch_icpp(3)%alpha(1)": 4e-02, + "patch_icpp(1)%pres": 1.0, + "patch_icpp(2)%pres": 1.0, + "patch_icpp(3)%pres": 1.0, + "acoustic(1)%support": 1, + "acoustic(1)%wavelength": 0.25, + }, + ) + + stack.push("", {"acoustic_source": "T"}) if len(dimInfo[0]) >= 2: - stack.push("", {'acoustic(1)%loc(2)': 0.5, 'acoustic(1)%support': 2}) + stack.push("", {"acoustic(1)%loc(2)": 0.5, "acoustic(1)%support": 2}) if len(dimInfo[0]) >= 3: - stack.push("", {'acoustic(1)%support': 3, 'acoustic(1)%height': 1e10}) + stack.push("", {"acoustic(1)%support": 3, "acoustic(1)%height": 1e10}) - for polytropic in ['T', 'F']: - stack.push("Polytropic" if polytropic == 'T' else '', {'polytropic': polytropic}) + for polytropic in ["T", "F"]: + stack.push("Polytropic" if polytropic == "T" else "", {"polytropic": polytropic}) for bubble_model in [3, 2]: - stack.push(f"bubble_model={bubble_model}", {'bubble_model': bubble_model}) + stack.push(f"bubble_model={bubble_model}", {"bubble_model": bubble_model}) - if not (polytropic == 'F' and bubble_model == 3): - cases.append(define_case_d(stack, '', {})) + if not (polytropic == "F" and bubble_model == 3): + cases.append(define_case_d(stack, "", {})) stack.pop() stack.pop() - stack.push('', {'polytropic': 'T', 'bubble_model': 2}) - cases.append(define_case_d(stack, 'nb=1', {'nb': 1})) + stack.push("", {"polytropic": "T", "bubble_model": 2}) + cases.append(define_case_d(stack, "nb=1", {"nb": 1})) - stack.push("adv_n=T", {'adv_n': 'T'}) - cases.append(define_case_d(stack, '', {})) - cases.append(define_case_d(stack, 'adap_dt=T', {'adap_dt': 'T'})) + stack.push("adv_n=T", {"adv_n": "T"}) + cases.append(define_case_d(stack, "", {})) + cases.append(define_case_d(stack, "adap_dt=T", {"adap_dt": "T"})) stack.pop() - stack.push('', {'fluid_pp(1)%pi_inf': 351.5}) - cases.append(define_case_d(stack, 'artificial_Ma', {'pi_fac': 0.1})) + stack.push("", {"fluid_pp(1)%pi_inf": 351.5}) + cases.append(define_case_d(stack, "artificial_Ma", {"pi_fac": 0.1})) stack.pop() - cases.append(define_case_d(stack, 'low_Mach=1', {'low_Mach': 1})) - cases.append(define_case_d(stack, 'low_Mach=2', {'low_Mach': 2})) + cases.append(define_case_d(stack, "low_Mach=1", {"low_Mach": 1})) + cases.append(define_case_d(stack, "low_Mach=2", {"low_Mach": 2})) - stack.push("QBMM", {'qbmm': 'T'}) - cases.append(define_case_d(stack, '', {})) + stack.push("QBMM", {"qbmm": "T"}) + cases.append(define_case_d(stack, "", {})) - stack.push("Non-polytropic", {'polytropic': 'F'}) - cases.append(define_case_d(stack, '', {})) + stack.push("Non-polytropic", {"polytropic": "F"}) + cases.append(define_case_d(stack, "", {})) stack.pop() - stack.push('bubble_model=3', {'bubble_model': 3, 'polytropic': 'T'}) - cases.append(define_case_d(stack, '', {})) + stack.push("bubble_model=3", {"bubble_model": 3, "polytropic": "T"}) + cases.append(define_case_d(stack, "", {})) - stack.push('Non-polytropic', {'polytropic': 'F'}) - cases.append(define_case_d(stack, '', {})) + stack.push("Non-polytropic", {"polytropic": "F"}) + cases.append(define_case_d(stack, "", {})) for _ in range(7): stack.pop() @@ -631,54 +882,91 @@ def alter_bubbles(dimInfo): def alter_hypoelasticity(dimInfo): # Hypoelasticity checks for num_fluids in [1, 2]: - stack.push(f"Hypoelasticity -> {num_fluids} Fluid(s)", { - "hypoelasticity": 'T', "num_fluids": num_fluids, - 'riemann_solver': 1, - 'fd_order': 4, - 'fluid_pp(1)%gamma': 0.3, 'fluid_pp(1)%pi_inf': 7.8E+05, - 'patch_icpp(1)%pres': 1.E+06, 'patch_icpp(1)%alpha_rho(1)': 1000.E+00, - 'patch_icpp(2)%pres': 1.E+05, 'patch_icpp(2)%alpha_rho(1)': 1000.E+00, - 'patch_icpp(3)%pres': 5.E+05, 'patch_icpp(3)%alpha_rho(1)': 1000.E+00, - 'patch_icpp(1)%tau_e(1)': 0.E-00, 'patch_icpp(2)%tau_e(1)': 0.E-00, - 'patch_icpp(3)%tau_e(1)': 0.E-00, 'fluid_pp(1)%G': 1.E+05, - }) + stack.push( + f"Hypoelasticity -> {num_fluids} Fluid(s)", + { + "hypoelasticity": "T", + "num_fluids": num_fluids, + "riemann_solver": 1, + "fd_order": 4, + "fluid_pp(1)%gamma": 0.3, + "fluid_pp(1)%pi_inf": 7.8e05, + "patch_icpp(1)%pres": 1.0e06, + "patch_icpp(1)%alpha_rho(1)": 1000.0e00, + "patch_icpp(2)%pres": 1.0e05, + "patch_icpp(2)%alpha_rho(1)": 1000.0e00, + "patch_icpp(3)%pres": 5.0e05, + "patch_icpp(3)%alpha_rho(1)": 1000.0e00, + "patch_icpp(1)%tau_e(1)": 0.0e-00, + "patch_icpp(2)%tau_e(1)": 0.0e-00, + "patch_icpp(3)%tau_e(1)": 0.0e-00, + "fluid_pp(1)%G": 1.0e05, + }, + ) if num_fluids == 2: - stack.push("", { - 'fluid_pp(2)%gamma': 0.3, 'fluid_pp(2)%pi_inf': 7.8E+05, 'patch_icpp(1)%alpha_rho(1)': 900.E+00, - 'patch_icpp(1)%alpha(1)': 0.9, 'patch_icpp(1)%alpha_rho(2)': 100, 'patch_icpp(1)%alpha(2)': 0.1, - 'patch_icpp(2)%alpha_rho(1)': 100, 'patch_icpp(2)%alpha(1)': 0.1, 'patch_icpp(2)%alpha_rho(2)': 900, - 'patch_icpp(2)%alpha(2)': 0.9, 'patch_icpp(3)%alpha_rho(1)': 900, 'patch_icpp(3)%alpha(1)': 0.9, - 'patch_icpp(3)%alpha_rho(2)': 100, 'patch_icpp(3)%alpha(2)': 0.1, - 'fluid_pp(2)%G': 5.E+04 - }) + stack.push( + "", + { + "fluid_pp(2)%gamma": 0.3, + "fluid_pp(2)%pi_inf": 7.8e05, + "patch_icpp(1)%alpha_rho(1)": 900.0e00, + "patch_icpp(1)%alpha(1)": 0.9, + "patch_icpp(1)%alpha_rho(2)": 100, + "patch_icpp(1)%alpha(2)": 0.1, + "patch_icpp(2)%alpha_rho(1)": 100, + "patch_icpp(2)%alpha(1)": 0.1, + "patch_icpp(2)%alpha_rho(2)": 900, + "patch_icpp(2)%alpha(2)": 0.9, + "patch_icpp(3)%alpha_rho(1)": 900, + "patch_icpp(3)%alpha(1)": 0.9, + "patch_icpp(3)%alpha_rho(2)": 100, + "patch_icpp(3)%alpha(2)": 0.1, + "fluid_pp(2)%G": 5.0e04, + }, + ) if len(dimInfo[0]) >= 2: - stack.push("", { - 'patch_icpp(1)%tau_e(2)': 0.E+00, 'patch_icpp(1)%tau_e(3)': 0.0E+00, - 'patch_icpp(2)%tau_e(2)': 0.E+00, 'patch_icpp(2)%tau_e(3)': 0.0E+00, - 'patch_icpp(3)%tau_e(2)': 0.E+00, 'patch_icpp(3)%tau_e(3)': 0.0E+00 - }) + stack.push( + "", + { + "patch_icpp(1)%tau_e(2)": 0.0e00, + "patch_icpp(1)%tau_e(3)": 0.0e00, + "patch_icpp(2)%tau_e(2)": 0.0e00, + "patch_icpp(2)%tau_e(3)": 0.0e00, + "patch_icpp(3)%tau_e(2)": 0.0e00, + "patch_icpp(3)%tau_e(3)": 0.0e00, + }, + ) if len(dimInfo[0]) == 3: - stack.push("", { - 'patch_icpp(1)%tau_e(4)': 0.E+00, 'patch_icpp(1)%tau_e(5)': 0.0E+00, 'patch_icpp(1)%tau_e(6)': 0.0E+00, - 'patch_icpp(2)%tau_e(4)': 0.E+00, 'patch_icpp(2)%tau_e(5)': 0.0E+00, 'patch_icpp(2)%tau_e(6)': 0.0E+00, - 'patch_icpp(3)%tau_e(4)': 0.E+00, 'patch_icpp(3)%tau_e(5)': 0.0E+00, 'patch_icpp(3)%tau_e(6)': 0.0E+00 - }) - - cases.append(define_case_d(stack, '', {})) - - reflective_params = {'bc_x%beg': -2, 'bc_x%end': -2, 'bc_y%beg': -2, 'bc_y%end': -2} + stack.push( + "", + { + "patch_icpp(1)%tau_e(4)": 0.0e00, + "patch_icpp(1)%tau_e(5)": 0.0e00, + "patch_icpp(1)%tau_e(6)": 0.0e00, + "patch_icpp(2)%tau_e(4)": 0.0e00, + "patch_icpp(2)%tau_e(5)": 0.0e00, + "patch_icpp(2)%tau_e(6)": 0.0e00, + "patch_icpp(3)%tau_e(4)": 0.0e00, + "patch_icpp(3)%tau_e(5)": 0.0e00, + "patch_icpp(3)%tau_e(6)": 0.0e00, + }, + ) + + cases.append(define_case_d(stack, "", {})) + + reflective_params = {"bc_x%beg": -2, "bc_x%end": -2, "bc_y%beg": -2, "bc_y%end": -2} if len(dimInfo[0]) == 3: - reflective_params.update({'bc_z%beg': -2, 'bc_z%end': -2}) + reflective_params.update({"bc_z%beg": -2, "bc_z%end": -2}) if num_fluids == 1: - cases.append(define_case_d(stack, 'cont_damage', {'cont_damage': 'T', 'tau_star': 0.0, 'cont_damage_s': 2.0, 'alpha_bar': 1e-4})) + cases.append(define_case_d(stack, "cont_damage", {"cont_damage": "T", "tau_star": 0.0, "cont_damage_s": 2.0, "alpha_bar": 1e-4})) if len(dimInfo[0]) >= 2: - cases.append(define_case_d(stack, 'bc=-2', reflective_params)) + cases.append(define_case_d(stack, "bc=-2", reflective_params)) if len(dimInfo[0]) == 2: - cases.append(define_case_d(stack, 'Axisymmetric', {**reflective_params, 'cyl_coord': 'T'})) + cases.append(define_case_d(stack, "Axisymmetric", {**reflective_params, "cyl_coord": "T"})) stack.pop() @@ -695,24 +983,18 @@ def alter_hypoelasticity(dimInfo): def alter_body_forces(dimInfo): ndims = len(dimInfo[0]) - stack.push("Bodyforces", { - 'bf_x': 'T', 'k_x': 1, 'w_x': 1, 'p_x': 1, 'g_x': 10 - }) + stack.push("Bodyforces", {"bf_x": "T", "k_x": 1, "w_x": 1, "p_x": 1, "g_x": 10}) if ndims >= 2: - stack.push("", { - 'bf_y': 'T', 'k_y': 1, 'w_y': 1, 'p_y': 1, 'g_y': 10 - }) + stack.push("", {"bf_y": "T", "k_y": 1, "w_y": 1, "p_y": 1, "g_y": 10}) if ndims == 3: - stack.push("", { - 'bf_z': 'T', 'k_z': 1, 'w_z': 1, 'p_z': 1, 'g_z': 10 - }) + stack.push("", {"bf_z": "T", "k_z": 1, "w_z": 1, "p_z": 1, "g_z": 10}) - cases.append(define_case_d(stack, '', {})) + cases.append(define_case_d(stack, "", {})) - stack.push('cfl_adap_dt=T', {'cfl_adap_dt': 'T', 'cfl_target': 0.08, 't_save': 0.025, 'n_start': 0, 't_stop': 0.025}) - cases.append(define_case_d(stack, '', {})) + stack.push("cfl_adap_dt=T", {"cfl_adap_dt": "T", "cfl_target": 0.08, "t_save": 0.025, "n_start": 0, "t_stop": 0.025}) + cases.append(define_case_d(stack, "", {})) stack.pop() @@ -726,36 +1008,80 @@ def alter_body_forces(dimInfo): def alter_mixlayer_perturb(dimInfo): if len(dimInfo[0]) == 3: - cases.append(define_case_d(stack, 'mixlayer_perturb', { - 'm': 24, 'n': 64, 'p': 24, 'dt': 1e-2, - 'num_patches': 1, 'num_fluids': 1, - 'x_domain%beg': 0.0, 'x_domain%end': 20.0, 'bc_x%beg': -1, 'bc_x%end': -1, - 'y_domain%beg': -10.0, 'y_domain%end': 10.0, 'bc_y%beg': -6, 'bc_y%end': -6, - 'z_domain%beg': 0.0, 'z_domain%end': 20.0, 'bc_z%beg': -1, 'bc_z%end': -1, - 'mixlayer_vel_profile': 'T', 'mixlayer_perturb': 'T', - 'weno_Re_flux': 'F', 'weno_avg': 'T', 'wenoz': 'T', - 'fluid_pp(1)%gamma': 2.5, 'fluid_pp(1)%pi_inf': 0.0, - 'fluid_pp(1)%Re(1)': 1.6881644098979287, 'viscous': 'T', - 'patch_icpp(1)%geometry': 9, - 'patch_icpp(1)%x_centroid': 10.0, 'patch_icpp(1)%length_x': 20.0, - 'patch_icpp(1)%y_centroid': 0.0, 'patch_icpp(1)%length_y': 20.0, - 'patch_icpp(1)%z_centroid': 10.0, 'patch_icpp(1)%length_z': 20.0, - 'patch_icpp(1)%vel(1)': 1.0, 'patch_icpp(1)%vel(2)': 0.0, 'patch_icpp(1)%vel(3)': 0.0, - 'patch_icpp(1)%pres': 17.8571428571, 'patch_icpp(1)%alpha_rho(1)': 1.0, 'patch_icpp(1)%alpha(1)': 1.0, - 'patch_icpp(1)%r0': -1e6, 'patch_icpp(1)%v0': -1e6, - 'patch_icpp(2)%geometry': -100, - 'patch_icpp(2)%x_centroid': -1e6, 'patch_icpp(2)%length_x': -1e6, - 'patch_icpp(2)%y_centroid': -1e6, 'patch_icpp(2)%length_y': -1e6, - 'patch_icpp(2)%z_centroid': -1e6, 'patch_icpp(2)%length_z': -1e6, - 'patch_icpp(2)%vel(1)': -1e6, 'patch_icpp(2)%vel(2)': -1e6, 'patch_icpp(2)%vel(3)': -1e6, - 'patch_icpp(2)%r0': -1e6, 'patch_icpp(2)%v0': -1e6, - 'patch_icpp(3)%geometry': -100, - 'patch_icpp(3)%x_centroid': -1e6, 'patch_icpp(3)%length_x': -1e6, - 'patch_icpp(3)%y_centroid': -1e6, 'patch_icpp(3)%length_y': -1e6, - 'patch_icpp(3)%z_centroid': -1e6, 'patch_icpp(3)%length_z': -1e6, - 'patch_icpp(3)%vel(1)': -1e6, 'patch_icpp(3)%vel(2)': -1e6, 'patch_icpp(3)%vel(3)': -1e6, - 'patch_icpp(3)%r0': -1e6, 'patch_icpp(3)%v0': -1e6 - })) + cases.append( + define_case_d( + stack, + "mixlayer_perturb", + { + "m": 24, + "n": 64, + "p": 24, + "dt": 1e-2, + "num_patches": 1, + "num_fluids": 1, + "x_domain%beg": 0.0, + "x_domain%end": 20.0, + "bc_x%beg": -1, + "bc_x%end": -1, + "y_domain%beg": -10.0, + "y_domain%end": 10.0, + "bc_y%beg": -6, + "bc_y%end": -6, + "z_domain%beg": 0.0, + "z_domain%end": 20.0, + "bc_z%beg": -1, + "bc_z%end": -1, + "mixlayer_vel_profile": "T", + "mixlayer_perturb": "T", + "weno_Re_flux": "F", + "weno_avg": "T", + "wenoz": "T", + "fluid_pp(1)%gamma": 2.5, + "fluid_pp(1)%pi_inf": 0.0, + "fluid_pp(1)%Re(1)": 1.6881644098979287, + "viscous": "T", + "patch_icpp(1)%geometry": 9, + "patch_icpp(1)%x_centroid": 10.0, + "patch_icpp(1)%length_x": 20.0, + "patch_icpp(1)%y_centroid": 0.0, + "patch_icpp(1)%length_y": 20.0, + "patch_icpp(1)%z_centroid": 10.0, + "patch_icpp(1)%length_z": 20.0, + "patch_icpp(1)%vel(1)": 1.0, + "patch_icpp(1)%vel(2)": 0.0, + "patch_icpp(1)%vel(3)": 0.0, + "patch_icpp(1)%pres": 17.8571428571, + "patch_icpp(1)%alpha_rho(1)": 1.0, + "patch_icpp(1)%alpha(1)": 1.0, + "patch_icpp(1)%r0": -1e6, + "patch_icpp(1)%v0": -1e6, + "patch_icpp(2)%geometry": -100, + "patch_icpp(2)%x_centroid": -1e6, + "patch_icpp(2)%length_x": -1e6, + "patch_icpp(2)%y_centroid": -1e6, + "patch_icpp(2)%length_y": -1e6, + "patch_icpp(2)%z_centroid": -1e6, + "patch_icpp(2)%length_z": -1e6, + "patch_icpp(2)%vel(1)": -1e6, + "patch_icpp(2)%vel(2)": -1e6, + "patch_icpp(2)%vel(3)": -1e6, + "patch_icpp(2)%r0": -1e6, + "patch_icpp(2)%v0": -1e6, + "patch_icpp(3)%geometry": -100, + "patch_icpp(3)%x_centroid": -1e6, + "patch_icpp(3)%length_x": -1e6, + "patch_icpp(3)%y_centroid": -1e6, + "patch_icpp(3)%length_y": -1e6, + "patch_icpp(3)%z_centroid": -1e6, + "patch_icpp(3)%length_z": -1e6, + "patch_icpp(3)%vel(1)": -1e6, + "patch_icpp(3)%vel(2)": -1e6, + "patch_icpp(3)%vel(3)": -1e6, + "patch_icpp(3)%r0": -1e6, + "patch_icpp(3)%v0": -1e6, + }, + ) + ) def alter_phasechange(dimInfo): ndims = len(dimInfo[0]) @@ -764,61 +1090,99 @@ def alter_phasechange(dimInfo): for relax_model in [5] + ([6] if ndims <= 2 else []): for num_fluids in ([2] if ndims == 1 or relax_model == 5 else []) + [3]: for model_eqns in [3, 2]: - stack.push(f"Phase Change model {relax_model} -> {num_fluids} Fluid(s) -> model equation -> {model_eqns}", { - "relax": 'T', - "relax_model": relax_model, - 'model_eqns': model_eqns, - 'palpha_eps': 1E-02, - 'ptgalpha_eps': 1E-02, - "num_fluids": num_fluids, - 'riemann_solver': 2, - 'fluid_pp(1)%gamma': 0.7409, 'fluid_pp(1)%pi_inf': 1.7409E+09, - 'fluid_pp(1)%cv': 1816, 'fluid_pp(1)%qv': -1167000, - 'fluid_pp(1)%qvp': 0.0, - 'fluid_pp(2)%gamma': 2.3266, 'fluid_pp(2)%pi_inf': 0.0E+00, - 'fluid_pp(2)%cv': 1040, 'fluid_pp(2)%qv': 2030000, - 'fluid_pp(2)%qvp': -23400, - 'patch_icpp(1)%pres': 4.3755E+05, - 'patch_icpp(1)%alpha(1)': 8.7149E-06, 'patch_icpp(1)%alpha_rho(1)': 9.6457E+02 * 8.7149E-06, - 'patch_icpp(1)%alpha(2)': 1-8.7149E-06, 'patch_icpp(1)%alpha_rho(2)': 2.3132 * (1 - 8.7149E-06), - 'patch_icpp(2)%pres': 9.6602E+04, - 'patch_icpp(2)%alpha(1)': 3.6749E-05, 'patch_icpp(2)%alpha_rho(1)': 1.0957E+03 * 3.6749E-05, - 'patch_icpp(2)%alpha(2)': 1-3.6749E-05, 'patch_icpp(2)%alpha_rho(2)': 0.5803 * (1 - 3.6749E-05), - 'patch_icpp(3)%pres': 9.6602E+04, - 'patch_icpp(3)%alpha(1)': 3.6749E-05, 'patch_icpp(3)%alpha_rho(1)': 1.0957E+03 * 3.6749E-05, - 'patch_icpp(3)%alpha(2)': 1-3.6749E-05, 'patch_icpp(3)%alpha_rho(2)': 0.5803 * (1 - 3.6749E-05) - }) + stack.push( + f"Phase Change model {relax_model} -> {num_fluids} Fluid(s) -> model equation -> {model_eqns}", + { + "relax": "T", + "relax_model": relax_model, + "model_eqns": model_eqns, + "palpha_eps": 1e-02, + "ptgalpha_eps": 1e-02, + "num_fluids": num_fluids, + "riemann_solver": 2, + "fluid_pp(1)%gamma": 0.7409, + "fluid_pp(1)%pi_inf": 1.7409e09, + "fluid_pp(1)%cv": 1816, + "fluid_pp(1)%qv": -1167000, + "fluid_pp(1)%qvp": 0.0, + "fluid_pp(2)%gamma": 2.3266, + "fluid_pp(2)%pi_inf": 0.0e00, + "fluid_pp(2)%cv": 1040, + "fluid_pp(2)%qv": 2030000, + "fluid_pp(2)%qvp": -23400, + "patch_icpp(1)%pres": 4.3755e05, + "patch_icpp(1)%alpha(1)": 8.7149e-06, + "patch_icpp(1)%alpha_rho(1)": 9.6457e02 * 8.7149e-06, + "patch_icpp(1)%alpha(2)": 1 - 8.7149e-06, + "patch_icpp(1)%alpha_rho(2)": 2.3132 * (1 - 8.7149e-06), + "patch_icpp(2)%pres": 9.6602e04, + "patch_icpp(2)%alpha(1)": 3.6749e-05, + "patch_icpp(2)%alpha_rho(1)": 1.0957e03 * 3.6749e-05, + "patch_icpp(2)%alpha(2)": 1 - 3.6749e-05, + "patch_icpp(2)%alpha_rho(2)": 0.5803 * (1 - 3.6749e-05), + "patch_icpp(3)%pres": 9.6602e04, + "patch_icpp(3)%alpha(1)": 3.6749e-05, + "patch_icpp(3)%alpha_rho(1)": 1.0957e03 * 3.6749e-05, + "patch_icpp(3)%alpha(2)": 1 - 3.6749e-05, + "patch_icpp(3)%alpha_rho(2)": 0.5803 * (1 - 3.6749e-05), + }, + ) if num_fluids == 3: - stack.push("", { - 'fluid_pp(3)%gamma': 2.4870, 'fluid_pp(3)%pi_inf': 0.0E+00, - 'fluid_pp(3)%cv': 717.5, 'fluid_pp(3)%qv': 0.0E+00, - 'fluid_pp(3)%qvp': 0.0, - 'patch_icpp(1)%alpha(2)': 2.5893E-02, 'patch_icpp(1)%alpha_rho(2)': 2.3132 * 2.5893E-02, - 'patch_icpp(2)%alpha(2)': 2.8728E-02, 'patch_icpp(2)%alpha_rho(2)': 0.5803 * 2.8728E-02, - 'patch_icpp(3)%alpha(2)': 2.8728E-02, 'patch_icpp(3)%alpha_rho(2)': 0.5803 * 2.8728E-02, - 'patch_icpp(1)%alpha(3)': 1-8.7149E-06-2.5893E-02, 'patch_icpp(1)%alpha_rho(3)': 3.5840 * (1-8.7149E-06-2.5893E-02), - 'patch_icpp(2)%alpha(3)': 1-3.6749E-05-2.8728E-02, 'patch_icpp(2)%alpha_rho(3)': 0.8991 * (1-3.6749E-05-2.8728E-02), - 'patch_icpp(3)%alpha(3)': 1-3.6749E-05-2.8728E-02, 'patch_icpp(3)%alpha_rho(3)': 0.8991 * (1-3.6749E-05-2.8728E-02) - }) + stack.push( + "", + { + "fluid_pp(3)%gamma": 2.4870, + "fluid_pp(3)%pi_inf": 0.0e00, + "fluid_pp(3)%cv": 717.5, + "fluid_pp(3)%qv": 0.0e00, + "fluid_pp(3)%qvp": 0.0, + "patch_icpp(1)%alpha(2)": 2.5893e-02, + "patch_icpp(1)%alpha_rho(2)": 2.3132 * 2.5893e-02, + "patch_icpp(2)%alpha(2)": 2.8728e-02, + "patch_icpp(2)%alpha_rho(2)": 0.5803 * 2.8728e-02, + "patch_icpp(3)%alpha(2)": 2.8728e-02, + "patch_icpp(3)%alpha_rho(2)": 0.5803 * 2.8728e-02, + "patch_icpp(1)%alpha(3)": 1 - 8.7149e-06 - 2.5893e-02, + "patch_icpp(1)%alpha_rho(3)": 3.5840 * (1 - 8.7149e-06 - 2.5893e-02), + "patch_icpp(2)%alpha(3)": 1 - 3.6749e-05 - 2.8728e-02, + "patch_icpp(2)%alpha_rho(3)": 0.8991 * (1 - 3.6749e-05 - 2.8728e-02), + "patch_icpp(3)%alpha(3)": 1 - 3.6749e-05 - 2.8728e-02, + "patch_icpp(3)%alpha_rho(3)": 0.8991 * (1 - 3.6749e-05 - 2.8728e-02), + }, + ) if ndims == 1: - stack.push("", { - 'patch_icpp(1)%vel(1)': 606.15, 'patch_icpp(2)%vel(1)': 10.0, 'patch_icpp(3)%vel(1)': 10.0 - }) + stack.push("", {"patch_icpp(1)%vel(1)": 606.15, "patch_icpp(2)%vel(1)": 10.0, "patch_icpp(3)%vel(1)": 10.0}) elif ndims == 2: - stack.push("", { - 'patch_icpp(1)%vel(1)': 0.0, 'patch_icpp(2)%vel(1)': 0.0, 'patch_icpp(3)%vel(1)': 0.0, - 'patch_icpp(1)%vel(2)': 606.15, 'patch_icpp(2)%vel(2)': 10.0, 'patch_icpp(3)%vel(2)': 10.0 - }) + stack.push( + "", + { + "patch_icpp(1)%vel(1)": 0.0, + "patch_icpp(2)%vel(1)": 0.0, + "patch_icpp(3)%vel(1)": 0.0, + "patch_icpp(1)%vel(2)": 606.15, + "patch_icpp(2)%vel(2)": 10.0, + "patch_icpp(3)%vel(2)": 10.0, + }, + ) elif ndims == 3: - stack.push("", { - 'patch_icpp(1)%vel(1)': 0.0, 'patch_icpp(2)%vel(1)': 0.0, 'patch_icpp(3)%vel(1)': 0.0, - 'patch_icpp(1)%vel(2)': 0.0, 'patch_icpp(2)%vel(2)': 0.0, 'patch_icpp(3)%vel(2)': 0.0, - 'patch_icpp(1)%vel(3)': 606.15, 'patch_icpp(2)%vel(3)': 10.0, 'patch_icpp(3)%vel(3)': 10.0 - }) - - cases.append(define_case_d(stack, '', {})) + stack.push( + "", + { + "patch_icpp(1)%vel(1)": 0.0, + "patch_icpp(2)%vel(1)": 0.0, + "patch_icpp(3)%vel(1)": 0.0, + "patch_icpp(1)%vel(2)": 0.0, + "patch_icpp(2)%vel(2)": 0.0, + "patch_icpp(3)%vel(2)": 0.0, + "patch_icpp(1)%vel(3)": 606.15, + "patch_icpp(2)%vel(3)": 10.0, + "patch_icpp(3)%vel(3)": 10.0, + }, + ) + + cases.append(define_case_d(stack, "", {})) stack.pop() stack.pop() @@ -829,52 +1193,73 @@ def alter_phasechange(dimInfo): def alter_viscosity(dimInfo): # Viscosity & bubbles checks if len(dimInfo[0]) > 0: - stack.push("Viscosity -> Bubbles", - {"fluid_pp(1)%Re(1)": 50, "bubbles_euler": 'T', "viscous": 'T'}) - - stack.push('', { - 'nb': 1, 'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0, - 'bub_pp%R0ref': 1.0, 'bub_pp%p0ref': 1.0, 'bub_pp%rho0ref': 1.0, 'bub_pp%T0ref': 1.0, - 'bub_pp%ss': 0.07179866765358993, 'bub_pp%pv': 0.02308216136195411, 'bub_pp%vd': 0.2404125083932959, - 'bub_pp%mu_l': 0.009954269975623244, 'bub_pp%mu_v': 8.758168074360729e-05, - 'bub_pp%mu_g': 0.00017881922111898042, 'bub_pp%gam_v': 1.33, 'bub_pp%gam_g': 1.4, - 'bub_pp%M_v': 18.02, 'bub_pp%M_g': 28.97, 'bub_pp%k_v': 0.5583395141263873, - 'bub_pp%k_g': 0.7346421281308791, 'bub_pp%R_v': 1334.8378710170155, 'bub_pp%R_g': 830.2995663005393, - 'patch_icpp(1)%alpha_rho(1)': 0.96, 'patch_icpp(1)%alpha(1)': 4e-02, - 'patch_icpp(2)%alpha_rho(1)': 0.96, 'patch_icpp(2)%alpha(1)': 4e-02, 'patch_icpp(3)%alpha_rho(1)': 0.96, - 'patch_icpp(3)%alpha(1)': 4e-02, 'patch_icpp(1)%pres': 1.0, 'patch_icpp(2)%pres': 1.0, - 'patch_icpp(3)%pres': 1.0 - }) - - for polytropic in ['T', 'F']: - stack.push("Polytropic" if polytropic == 'T' else '', {'polytropic': polytropic}) + stack.push("Viscosity -> Bubbles", {"fluid_pp(1)%Re(1)": 50, "bubbles_euler": "T", "viscous": "T"}) + + stack.push( + "", + { + "nb": 1, + "fluid_pp(1)%gamma": 0.16, + "fluid_pp(1)%pi_inf": 3515.0, + "bub_pp%R0ref": 1.0, + "bub_pp%p0ref": 1.0, + "bub_pp%rho0ref": 1.0, + "bub_pp%T0ref": 1.0, + "bub_pp%ss": 0.07179866765358993, + "bub_pp%pv": 0.02308216136195411, + "bub_pp%vd": 0.2404125083932959, + "bub_pp%mu_l": 0.009954269975623244, + "bub_pp%mu_v": 8.758168074360729e-05, + "bub_pp%mu_g": 0.00017881922111898042, + "bub_pp%gam_v": 1.33, + "bub_pp%gam_g": 1.4, + "bub_pp%M_v": 18.02, + "bub_pp%M_g": 28.97, + "bub_pp%k_v": 0.5583395141263873, + "bub_pp%k_g": 0.7346421281308791, + "bub_pp%R_v": 1334.8378710170155, + "bub_pp%R_g": 830.2995663005393, + "patch_icpp(1)%alpha_rho(1)": 0.96, + "patch_icpp(1)%alpha(1)": 4e-02, + "patch_icpp(2)%alpha_rho(1)": 0.96, + "patch_icpp(2)%alpha(1)": 4e-02, + "patch_icpp(3)%alpha_rho(1)": 0.96, + "patch_icpp(3)%alpha(1)": 4e-02, + "patch_icpp(1)%pres": 1.0, + "patch_icpp(2)%pres": 1.0, + "patch_icpp(3)%pres": 1.0, + }, + ) + + for polytropic in ["T", "F"]: + stack.push("Polytropic" if polytropic == "T" else "", {"polytropic": polytropic}) for bubble_model in [3, 2]: - stack.push(f"bubble_model={bubble_model}", {'bubble_model': bubble_model}) + stack.push(f"bubble_model={bubble_model}", {"bubble_model": bubble_model}) - if not (polytropic == 'F' and bubble_model == 3): - cases.append(define_case_d(stack, '', {})) + if not (polytropic == "F" and bubble_model == 3): + cases.append(define_case_d(stack, "", {})) stack.pop() stack.pop() - stack.push('', {'polytropic': 'T', 'bubble_model': 2}) - cases.append(define_case_d(stack, 'nb=1', {'nb': 1})) + stack.push("", {"polytropic": "T", "bubble_model": 2}) + cases.append(define_case_d(stack, "nb=1", {"nb": 1})) - stack.push("QBMM", {'qbmm': 'T'}) - cases.append(define_case_d(stack, '', {})) + stack.push("QBMM", {"qbmm": "T"}) + cases.append(define_case_d(stack, "", {})) - stack.push('bubble_model=3', {'bubble_model': 3}) - cases.append(define_case_d(stack, '', {})) + stack.push("bubble_model=3", {"bubble_model": 3}) + cases.append(define_case_d(stack, "", {})) - stack.push('cfl_adap_dt=T', {'cfl_adap_dt': 'T', 'cfl_target': 0.8, 't_save': 0.01, 'n_start': 0, 't_stop': 0.01, 'm': 24}) - cases.append(define_case_d(stack, '', {})) + stack.push("cfl_adap_dt=T", {"cfl_adap_dt": "T", "cfl_target": 0.8, "t_save": 0.01, "n_start": 0, "t_stop": 0.01, "m": 24}) + cases.append(define_case_d(stack, "", {})) stack.pop() - stack.push('cfl_const_dt=T', {'cfl_const_dt': 'T', 'cfl_target': 0.8, 't_save': 0.01, 'n_start': 0, 't_stop': 0.01, 'm': 24}) - cases.append(define_case_d(stack, '', {})) + stack.push("cfl_const_dt=T", {"cfl_const_dt": "T", "cfl_target": 0.8, "t_save": 0.01, "n_start": 0, "t_stop": 0.01, "m": 24}) + cases.append(define_case_d(stack, "", {})) for _ in range(6): stack.pop() @@ -882,50 +1267,102 @@ def alter_viscosity(dimInfo): def alter_lag_bubbles(dimInfo): # Lagrangian bubbles if len(dimInfo[0]) > 1: - for adap_dt in ['F', 'T']: + for adap_dt in ["F", "T"]: for couplingMethod in [1, 2]: - stack.push("Lagrange Bubbles", {"bubbles_lagrange": 'T', - 'dt': 1e-06, 'lag_params%pressure_corrector': 'T', 'bubble_model': 2, - 'num_fluids': 2, 'lag_params%heatTransfer_model': 'T', 'lag_params%massTransfer_model': 'T', - 'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0, 'fluid_pp(2)%gamma': 2.5, - 'fluid_pp(2)%pi_inf': 0.0, - 'patch_icpp(1)%alpha_rho(1)': 0.96, - 'patch_icpp(1)%alpha(1)': 4e-02, 'patch_icpp(1)%alpha_rho(2)': 0., 'patch_icpp(1)%alpha(2)': 0., - 'patch_icpp(2)%alpha_rho(1)': 0.96, 'patch_icpp(2)%alpha(1)': 4e-02, 'patch_icpp(2)%alpha_rho(2)': 0., - 'patch_icpp(2)%alpha(2)': 0., 'patch_icpp(3)%alpha_rho(1)': 0.96, 'patch_icpp(3)%alpha(1)': 4e-02, - 'patch_icpp(3)%alpha_rho(2)': 0., 'patch_icpp(3)%alpha(2)': 0., 'patch_icpp(1)%pres': 1.0, - 'patch_icpp(2)%pres': 1.0, 'patch_icpp(3)%pres': 1.0, 'acoustic_source': 'T', 'acoustic(1)%loc(2)': 0.5, - 'acoustic(1)%wavelength': 0.25, 'acoustic(1)%mag': 2e+04, 't_step_start': 0, 't_step_stop': 50, - 't_step_save': 50, 'lag_txt_wrt': "T", 'lag_header': "T", 'lag_db_wrt': "T", 'lag_id_wrt': "T", - 'lag_pos_wrt': "T", 'lag_pos_prev_wrt': "T", 'lag_vel_wrt': "T", 'lag_rad_wrt': "T", - 'lag_rvel_wrt': "T", 'lag_r0_wrt': "T", 'lag_rmax_wrt': "T", 'lag_rmin_wrt': "T", - 'lag_dphidt_wrt': "T", 'lag_pres_wrt': "T", 'lag_mv_wrt': "T", 'lag_mg_wrt': "T", - 'lag_betaT_wrt': "T", 'lag_betaC_wrt': "T", 'lag_params%write_bubbles': "T", - 'lag_params%write_bubbles_stats': "T", "polytropic": "F", - 'bub_pp%R0ref': 1.0, 'bub_pp%p0ref': 1.0, 'bub_pp%rho0ref': 1.0, 'bub_pp%T0ref': 1.0, - 'bub_pp%ss': 7.131653759435349e-07, 'bub_pp%pv': 0.02292716400352907, 'bub_pp%vd': 2.4752475247524753e-06, - 'bub_pp%mu_l': 9.920792079207921e-08, 'bub_pp%gam_v': 1.33, 'bub_pp%gam_g': 1.4, - 'bub_pp%M_v': 18.02, 'bub_pp%M_g': 28.97, 'bub_pp%k_v': 5.618695895665441e-06, - 'bub_pp%k_g': 7.392868685947116e-06, 'bub_pp%R_v': 1347.810235139403, 'bub_pp%R_g': 838.3686723235085, - 'bub_pp%cp_g': 2921.2822272326243, 'bub_pp%cp_v': 6134.692677188511 - }) + stack.push( + "Lagrange Bubbles", + { + "bubbles_lagrange": "T", + "dt": 1e-06, + "lag_params%pressure_corrector": "T", + "bubble_model": 2, + "num_fluids": 2, + "lag_params%heatTransfer_model": "T", + "lag_params%massTransfer_model": "T", + "fluid_pp(1)%gamma": 0.16, + "fluid_pp(1)%pi_inf": 3515.0, + "fluid_pp(2)%gamma": 2.5, + "fluid_pp(2)%pi_inf": 0.0, + "patch_icpp(1)%alpha_rho(1)": 0.96, + "patch_icpp(1)%alpha(1)": 4e-02, + "patch_icpp(1)%alpha_rho(2)": 0.0, + "patch_icpp(1)%alpha(2)": 0.0, + "patch_icpp(2)%alpha_rho(1)": 0.96, + "patch_icpp(2)%alpha(1)": 4e-02, + "patch_icpp(2)%alpha_rho(2)": 0.0, + "patch_icpp(2)%alpha(2)": 0.0, + "patch_icpp(3)%alpha_rho(1)": 0.96, + "patch_icpp(3)%alpha(1)": 4e-02, + "patch_icpp(3)%alpha_rho(2)": 0.0, + "patch_icpp(3)%alpha(2)": 0.0, + "patch_icpp(1)%pres": 1.0, + "patch_icpp(2)%pres": 1.0, + "patch_icpp(3)%pres": 1.0, + "acoustic_source": "T", + "acoustic(1)%loc(2)": 0.5, + "acoustic(1)%wavelength": 0.25, + "acoustic(1)%mag": 2e04, + "t_step_start": 0, + "t_step_stop": 50, + "t_step_save": 50, + "lag_txt_wrt": "T", + "lag_header": "T", + "lag_db_wrt": "T", + "lag_id_wrt": "T", + "lag_pos_wrt": "T", + "lag_pos_prev_wrt": "T", + "lag_vel_wrt": "T", + "lag_rad_wrt": "T", + "lag_rvel_wrt": "T", + "lag_r0_wrt": "T", + "lag_rmax_wrt": "T", + "lag_rmin_wrt": "T", + "lag_dphidt_wrt": "T", + "lag_pres_wrt": "T", + "lag_mv_wrt": "T", + "lag_mg_wrt": "T", + "lag_betaT_wrt": "T", + "lag_betaC_wrt": "T", + "lag_params%write_bubbles": "T", + "lag_params%write_bubbles_stats": "T", + "polytropic": "F", + "bub_pp%R0ref": 1.0, + "bub_pp%p0ref": 1.0, + "bub_pp%rho0ref": 1.0, + "bub_pp%T0ref": 1.0, + "bub_pp%ss": 7.131653759435349e-07, + "bub_pp%pv": 0.02292716400352907, + "bub_pp%vd": 2.4752475247524753e-06, + "bub_pp%mu_l": 9.920792079207921e-08, + "bub_pp%gam_v": 1.33, + "bub_pp%gam_g": 1.4, + "bub_pp%M_v": 18.02, + "bub_pp%M_g": 28.97, + "bub_pp%k_v": 5.618695895665441e-06, + "bub_pp%k_g": 7.392868685947116e-06, + "bub_pp%R_v": 1347.810235139403, + "bub_pp%R_g": 838.3686723235085, + "bub_pp%cp_g": 2921.2822272326243, + "bub_pp%cp_v": 6134.692677188511, + }, + ) if len(dimInfo[0]) == 2: - stack.push("", {'acoustic(1)%support': 2}) + stack.push("", {"acoustic(1)%support": 2}) else: - stack.push("", {'acoustic(1)%support': 3, 'acoustic(1)%height': 1e10}) + stack.push("", {"acoustic(1)%support": 3, "acoustic(1)%height": 1e10}) if couplingMethod == 1: - stack.push('One-way Coupling', {'lag_params%solver_approach': 1}) + stack.push("One-way Coupling", {"lag_params%solver_approach": 1}) else: - stack.push('Two-way Coupling', {'lag_params%solver_approach': 2}) + stack.push("Two-way Coupling", {"lag_params%solver_approach": 2}) - if adap_dt == 'F': - stack.push('', {}) + if adap_dt == "F": + stack.push("", {}) else: - stack.push('adap_dt=T', {'adap_dt': 'T'}) + stack.push("adap_dt=T", {"adap_dt": "T"}) - cases.append(define_case_d(stack, '', {})) + cases.append(define_case_d(stack, "", {})) stack.pop() @@ -938,36 +1375,58 @@ def alter_lag_bubbles(dimInfo): def alter_elliptic_smoothing(): # Elliptic Smoothing - stack.push("Smoothing", { - 'elliptic_smoothing': 'T', 'elliptic_smoothing_iters': 10 - }) + stack.push("Smoothing", {"elliptic_smoothing": "T", "elliptic_smoothing_iters": 10}) - cases.append(define_case_d(stack, '', {})) + cases.append(define_case_d(stack, "", {})) stack.pop() def alter_bc_patches(dimInfo): - # BC_Patches + # BC_Patches - stack.push('BC Patches', { - 'num_bc_patches': 1 - }) + stack.push("BC Patches", {"num_bc_patches": 1}) if len(dimInfo[0]) > 2: for direc in [1, 2, 3]: - stack.push('Circle', { - 'patch_bc(1)%geometry': 2, 'patch_bc(1)%dir': direc, - 'patch_bc(1)%type': -17, 'patch_bc(1)%loc': -1, - }) + stack.push( + "Circle", + { + "patch_bc(1)%geometry": 2, + "patch_bc(1)%dir": direc, + "patch_bc(1)%type": -17, + "patch_bc(1)%loc": -1, + }, + ) if direc == 1: - stack.push('X', {'patch_bc(1)%centroid(2)': 0, 'patch_bc(1)%centroid(3)': 0, "patch_bc(1)%radius": 0.000125, }) + stack.push( + "X", + { + "patch_bc(1)%centroid(2)": 0, + "patch_bc(1)%centroid(3)": 0, + "patch_bc(1)%radius": 0.000125, + }, + ) elif direc == 2: - stack.push('Y', {'patch_bc(1)%centroid(1)': 0, 'patch_bc(1)%centroid(3)': 0, "patch_bc(1)%radius": 0.000125, }) + stack.push( + "Y", + { + "patch_bc(1)%centroid(1)": 0, + "patch_bc(1)%centroid(3)": 0, + "patch_bc(1)%radius": 0.000125, + }, + ) else: - stack.push('Z', {'patch_bc(1)%centroid(1)': 0, 'patch_bc(1)%centroid(2)': 0, "patch_bc(1)%radius": 0.000125, }) + stack.push( + "Z", + { + "patch_bc(1)%centroid(1)": 0, + "patch_bc(1)%centroid(2)": 0, + "patch_bc(1)%radius": 0.000125, + }, + ) - cases.append(define_case_d(stack, '', {})) + cases.append(define_case_d(stack, "", {})) stack.pop() @@ -975,17 +1434,14 @@ def alter_bc_patches(dimInfo): elif len(dimInfo[0]) > 1: for direc in [1, 2]: - stack.push('Line Segment', { - 'patch_bc(1)%geometry': 1, 'patch_bc(1)%dir': direc, - 'patch_bc(1)%type': -17, 'patch_bc(1)%loc': -1 - }) + stack.push("Line Segment", {"patch_bc(1)%geometry": 1, "patch_bc(1)%dir": direc, "patch_bc(1)%type": -17, "patch_bc(1)%loc": -1}) if direc == 1: - stack.push('X', {'patch_bc(1)%centroid(2)': 0.0, 'patch_bc(1)%length(2)': 0.0025}) + stack.push("X", {"patch_bc(1)%centroid(2)": 0.0, "patch_bc(1)%length(2)": 0.0025}) else: - stack.push('Y', {'patch_bc(1)%centroid(1)': 0.0, 'patch_bc(1)%length(1)': 0.0025}) + stack.push("Y", {"patch_bc(1)%centroid(1)": 0.0, "patch_bc(1)%length(1)": 0.0025}) - cases.append(define_case_d(stack, '', {})) + cases.append(define_case_d(stack, "", {})) stack.pop() @@ -995,21 +1451,21 @@ def alter_bc_patches(dimInfo): def mhd_cases(): params = { - '1D': {"m": 200, "dt": 0.001, "t_step_stop": 200, "t_step_save": 200}, - '2D': {"m": 50, "n": 50, "dt": 0.002, "t_step_stop": 500, "t_step_save": 500}, - '3D': {"m": 25, "n": 25, "p": 25, "dt": 0.005, "t_step_stop": 200, "t_step_save": 200}, + "1D": {"m": 200, "dt": 0.001, "t_step_stop": 200, "t_step_save": 200}, + "2D": {"m": 50, "n": 50, "dt": 0.002, "t_step_stop": 500, "t_step_save": 500}, + "3D": {"m": 25, "n": 25, "p": 25, "dt": 0.005, "t_step_stop": 200, "t_step_save": 200}, } case_specs = [ - ("1D -> MHD -> HLL", "examples/1D_brio_wu/case.py", params['1D']), - ("1D -> MHD -> HLLD", "examples/1D_brio_wu_hlld/case.py", params['1D']), - ("1D -> RMHD", "examples/1D_brio_wu_rmhd/case.py", params['1D']), - ("2D -> MHD -> HLL", "examples/2D_orszag_tang/case.py", params['2D']), - ("2D -> MHD -> HLLD", "examples/2D_orszag_tang/case.py", {**params['2D'], 'riemann_solver': 4}), - ("2D -> MHD -> hyper_cleaning", "examples/2D_orszag_tang_hyper_cleaning/case.py", params['2D']), - ("2D -> RMHD", "examples/2D_shock_cloud_rmhd/case.py", params['2D']), - ("3D -> MHD", "examples/3D_brio_wu/case.py", params['3D']), - ("3D -> RMHD", "examples/3D_brio_wu/case.py", {**params['3D'], 'relativity': 'T'}), + ("1D -> MHD -> HLL", "examples/1D_brio_wu/case.py", params["1D"]), + ("1D -> MHD -> HLLD", "examples/1D_brio_wu_hlld/case.py", params["1D"]), + ("1D -> RMHD", "examples/1D_brio_wu_rmhd/case.py", params["1D"]), + ("2D -> MHD -> HLL", "examples/2D_orszag_tang/case.py", params["2D"]), + ("2D -> MHD -> HLLD", "examples/2D_orszag_tang/case.py", {**params["2D"], "riemann_solver": 4}), + ("2D -> MHD -> hyper_cleaning", "examples/2D_orszag_tang_hyper_cleaning/case.py", params["2D"]), + ("2D -> RMHD", "examples/2D_shock_cloud_rmhd/case.py", params["2D"]), + ("3D -> MHD", "examples/3D_brio_wu/case.py", params["3D"]), + ("3D -> RMHD", "examples/3D_brio_wu/case.py", {**params["3D"], "relativity": "T"}), ] for name, path, param in case_specs: @@ -1029,7 +1485,7 @@ def foreach_dimension(): alter_3d() alter_lag_bubbles(dimInfo) alter_ppn(dimInfo) - stack.push('', {'dt': [1e-07, 1e-06, 1e-06][len(dimInfo[0])-1]}) + stack.push("", {"dt": [1e-07, 1e-06, 1e-06][len(dimInfo[0]) - 1]}) alter_acoustic_src(dimInfo) alter_bubbles(dimInfo) alter_hypoelasticity(dimInfo) @@ -1048,93 +1504,127 @@ def foreach_example(): continue # # List of all example cases that will be skipped during testing - casesToSkip = ["2D_ibm_cfl_dt", "1D_sodHypo", "2D_viscous", - "2D_laplace_pressure_jump", "2D_bubbly_steady_shock", - "2D_advection", "2D_hardcoded_ic", - "2D_ibm_multiphase", "2D_acoustic_broadband", - "1D_inert_shocktube", "1D_reactive_shocktube", - "2D_ibm_steady_shock", "3D_performance_test", - "3D_ibm_stl_ellipsoid", "3D_sphbubcollapse", - "2D_ibm_stl_wedge", "3D_ibm_stl_pyramid", - "3D_ibm_bowshock", "3D_turb_mixing", - "2D_mixing_artificial_Ma", - "2D_lagrange_bubblescreen", - "3D_lagrange_bubblescreen", "2D_triple_point", - "1D_shuosher_analytical", - "1D_titarevtorro_analytical", - "2D_acoustic_pulse_analytical", - "2D_isentropicvortex_analytical", - "2D_zero_circ_vortex_analytical", - "3D_TaylorGreenVortex_analytical", - "3D_IGR_TaylorGreenVortex_nvidia", - "2D_backward_facing_step", - "2D_forward_facing_step", - "1D_convergence", - "3D_IGR_33jet", "1D_multispecies_diffusion", - "2D_ibm_stl_MFCCharacter"] + casesToSkip = [ + "2D_ibm_cfl_dt", + "1D_sodHypo", + "2D_viscous", + "2D_laplace_pressure_jump", + "2D_bubbly_steady_shock", + "2D_advection", + "2D_hardcoded_ic", + "2D_ibm_multiphase", + "2D_acoustic_broadband", + "1D_inert_shocktube", + "1D_reactive_shocktube", + "2D_ibm_steady_shock", + "3D_performance_test", + "3D_ibm_stl_ellipsoid", + "3D_sphbubcollapse", + "2D_ibm_stl_wedge", + "3D_ibm_stl_pyramid", + "3D_ibm_bowshock", + "3D_turb_mixing", + "2D_mixing_artificial_Ma", + "2D_lagrange_bubblescreen", + "3D_lagrange_bubblescreen", + "2D_triple_point", + "1D_shuosher_analytical", + "1D_titarevtorro_analytical", + "2D_acoustic_pulse_analytical", + "2D_isentropicvortex_analytical", + "2D_zero_circ_vortex_analytical", + "3D_TaylorGreenVortex_analytical", + "3D_IGR_TaylorGreenVortex_nvidia", + "2D_backward_facing_step", + "2D_forward_facing_step", + "1D_convergence", + "3D_IGR_33jet", + "1D_multispecies_diffusion", + "2D_ibm_stl_MFCCharacter", + ] if path in casesToSkip: continue name = f"{path.split('_')[0]} -> Example -> {'_'.join(path.split('_')[1:])}" - path = os.path.join(common.MFC_EXAMPLE_DIRPATH, path, "case.py") - if not os.path.isfile(path): + case_path = os.path.join(common.MFC_EXAMPLE_DIRPATH, path, "case.py") + if not os.path.isfile(case_path): continue def modify_example_case(case: dict): - case['parallel_io'] = 'F' - if 't_step_stop' in case and case['t_step_stop'] >= 50: - case['t_step_start'] = 0 - case['t_step_stop'] = 50 - case['t_step_save'] = 50 + case["parallel_io"] = "F" + if "t_step_stop" in case and case["t_step_stop"] >= 50: + case["t_step_start"] = 0 + case["t_step_stop"] = 50 + case["t_step_save"] = 50 - caseSize = case['m'] * max(case['n'], 1) * max(case['p'], 1) + caseSize = case["m"] * max(case["n"], 1) * max(case["p"], 1) if caseSize > 25 * 25: - if case['n'] == 0 and case['p'] == 0: - case['m'] = 25 * 25 - elif case['p'] == 0: - case['m'] = 25 - case['n'] = 25 + if case["n"] == 0 and case["p"] == 0: + case["m"] = 25 * 25 + elif case["p"] == 0: + case["m"] = 25 + case["n"] = 25 elif caseSize > 25 * 25 * 25: - case['m'] = 25 - case['n'] = 25 - case['p'] = 25 + case["m"] = 25 + case["n"] = 25 + case["p"] = 25 - cases.append(define_case_f(name, path, [], {}, functor=modify_example_case)) + cases.append(define_case_f(name, case_path, [], {}, functor=modify_example_case)) def chemistry_cases(): - common_mods = { - 't_step_stop': Nt, 't_step_save': Nt - } + common_mods = {"t_step_stop": Nt, "t_step_save": Nt} for ndim in range(1, 4): - cases.append(define_case_f( - f'{ndim}D -> Chemistry -> Perfect Reactor', - 'examples/nD_perfect_reactor/case.py', - ['--ndim', str(ndim)], - mods=common_mods - )) + cases.append(define_case_f(f"{ndim}D -> Chemistry -> Perfect Reactor", "examples/nD_perfect_reactor/case.py", ["--ndim", str(ndim)], mods=common_mods)) for riemann_solver, gamma_method in itertools.product([1, 2], [1, 2]): - cases.append(define_case_f( - f'1D -> Chemistry -> Inert Shocktube -> Riemann Solver {riemann_solver} -> Gamma Method {gamma_method}', - 'examples/1D_inert_shocktube/case.py', - mods={ - **common_mods, - 'riemann_solver': riemann_solver, - 'chem_params%gamma_method': gamma_method, - 'weno_order': 3, "mapped_weno": 'F', 'mp_weno': 'F' - }, - override_tol=10**(-10) - )) - - stack.push(f'1D -> Chemistry -> MultiComponent Diffusion', {'m': 200, - 'dt': 0.1e-06, 'num_patches': 1, 'num_fluids': 1, 'x_domain%beg': 0.0, 'x_domain%end': 0.05, - 'bc_x%beg': -1, 'bc_x%end': -1, 'weno_order': 5, 'weno_eps': 1e-16, 'weno_avg': 'F', - 'mapped_weno': 'T', 'mp_weno': 'T', 'weno_Re_flux': 'F', 'riemann_solver': 2, 'wave_speeds': 1, - 'avg_state': 1, 'chemistry': 'T', 'chem_params%diffusion': 'T', 'chem_params%reactions': 'F', 'chem_wrt_T': 'T', - 'patch_icpp(1)%geometry': 1, 'patch_icpp(1)%hcid': 182, 'patch_icpp(1)%x_centroid': 0.05 / 2, - 'patch_icpp(1)%length_x': 0.05, 'patch_icpp(1)%vel(1)': '0', 'patch_icpp(1)%pres': 1.01325e5, 'patch_icpp(1)%alpha(1)': 1, - 'fluid_pp(1)%gamma': 1.0e00 / (1.9326e00 - 1.0e00), 'fluid_pp(1)%pi_inf': 0, 'cantera_file': 'h2o2.yaml', 't_step_start': 0, 't_step_stop': 50, 't_step_save': 50 - }) - cases.append(define_case_d(stack, '', {}, override_tol=10**(-9))) + cases.append( + define_case_f( + f"1D -> Chemistry -> Inert Shocktube -> Riemann Solver {riemann_solver} -> Gamma Method {gamma_method}", + "examples/1D_inert_shocktube/case.py", + mods={**common_mods, "riemann_solver": riemann_solver, "chem_params%gamma_method": gamma_method, "weno_order": 3, "mapped_weno": "F", "mp_weno": "F"}, + override_tol=10 ** (-10), + ) + ) + + stack.push( + "1D -> Chemistry -> MultiComponent Diffusion", + { + "m": 200, + "dt": 0.1e-06, + "num_patches": 1, + "num_fluids": 1, + "x_domain%beg": 0.0, + "x_domain%end": 0.05, + "bc_x%beg": -1, + "bc_x%end": -1, + "weno_order": 5, + "weno_eps": 1e-16, + "weno_avg": "F", + "mapped_weno": "T", + "mp_weno": "T", + "weno_Re_flux": "F", + "riemann_solver": 2, + "wave_speeds": 1, + "avg_state": 1, + "chemistry": "T", + "chem_params%diffusion": "T", + "chem_params%reactions": "F", + "chem_wrt_T": "T", + "patch_icpp(1)%geometry": 1, + "patch_icpp(1)%hcid": 182, + "patch_icpp(1)%x_centroid": 0.05 / 2, + "patch_icpp(1)%length_x": 0.05, + "patch_icpp(1)%vel(1)": "0", + "patch_icpp(1)%pres": 1.01325e5, + "patch_icpp(1)%alpha(1)": 1, + "fluid_pp(1)%gamma": 1.0e00 / (1.9326e00 - 1.0e00), + "fluid_pp(1)%pi_inf": 0, + "cantera_file": "h2o2.yaml", + "t_step_start": 0, + "t_step_stop": 50, + "t_step_save": 50, + }, + ) + cases.append(define_case_d(stack, "", {}, override_tol=10 ** (-9))) stack.pop() diff --git a/toolchain/mfc/test/test.py b/toolchain/mfc/test/test.py index 2193e677b4..6565aa303d 100644 --- a/toolchain/mfc/test/test.py +++ b/toolchain/mfc/test/test.py @@ -1,21 +1,25 @@ -import os, typing, shutil, time, itertools, threading +import itertools +import os +import shutil +import sys +import threading +import time +import typing from random import sample, seed -import rich, rich.table +import rich +import rich.table from rich.panel import Panel -from ..printer import cons -from .. import common -from ..state import ARG -from .case import TestCase -from .cases import list_cases -from .. import sched -from ..common import MFCException, does_command_exist, format_list_to_string, get_program_output -from ..build import build, HDF5, PRE_PROCESS, SIMULATION, POST_PROCESS - -from ..packer import tol as packtol +from .. import common, sched +from ..build import HDF5, POST_PROCESS, PRE_PROCESS, SIMULATION, build +from ..common import MFCException, does_command_exist, format_list_to_string, get_program_output from ..packer import packer - +from ..packer import tol as packtol +from ..printer import cons +from ..state import ARG +from .case import TestCase +from .cases import list_cases nFAIL = 0 nPASS = 0 @@ -39,9 +43,11 @@ # from worker threads which could leave the scheduler in an undefined state. abort_tests = threading.Event() + class TestTimeoutError(MFCException): pass + def _filter_only(cases, skipped_cases): """Filter cases by --only terms using AND for labels, OR for UUIDs. @@ -49,10 +55,11 @@ def _filter_only(cases, skipped_cases): UUIDs (8-char hex terms): case must match ANY UUID (OR logic). Mixed: keep case if all labels match OR any UUID matches. """ + def is_uuid(term): - return len(term) == 8 and all(c in '0123456789abcdefABCDEF' for c in term) + return len(term) == 8 and all(c in "0123456789abcdefABCDEF" for c in term) - uuids = [t for t in ARG("only") if is_uuid(t)] + uuids = [t for t in ARG("only") if is_uuid(t)] labels = [t for t in ARG("only") if not is_uuid(t)] for case in cases[:]: @@ -60,7 +67,7 @@ def is_uuid(term): check.add(case.get_uuid()) label_ok = all(label in check for label in labels) if labels else True - uuid_ok = any(u in check for u in uuids) if uuids else True + uuid_ok = any(u in check for u in uuids) if uuids else True if labels and uuids: keep = label_ok or uuid_ok @@ -76,22 +83,21 @@ def is_uuid(term): return cases, skipped_cases -# pylint: disable=too-many-branches, too-many-statements, trailing-whitespace def __filter(cases_) -> typing.List[TestCase]: cases = cases_[:] selected_cases = [] - skipped_cases = [] + skipped_cases = [] # Check "--from" and "--to" exist and are in the right order bFoundFrom, bFoundTo = (False, False) from_i = -1 for i, case in enumerate(cases): if case.get_uuid() == ARG("from"): - from_i = i + from_i = i bFoundFrom = True # Do not "continue" because "--to" might be the same as "--from" if bFoundFrom and case.get_uuid() == ARG("to"): - cases = cases[from_i:i+1] + cases = cases[from_i : i + 1] skipped_cases = [case for case in cases_ if case not in cases] bFoundTo = True break @@ -103,10 +109,7 @@ def __filter(cases_) -> typing.List[TestCase]: cases, skipped_cases = _filter_only(cases, skipped_cases) if not cases: - raise MFCException( - f"--only filter matched zero test cases. " - f"Specified: {ARG('only')}. Check that UUIDs/names are valid." - ) + raise MFCException(f"--only filter matched zero test cases. Specified: {ARG('only')}. Check that UUIDs/names are valid.") for case in cases[:]: if case.ppn > 1 and not ARG("mpi"): @@ -115,15 +118,14 @@ def __filter(cases_) -> typing.List[TestCase]: for case in cases[:]: if ARG("single"): - skip = ['low_Mach', 'Hypoelasticity', 'teno', 'Chemistry', 'Phase Change model 6' - ,'Axisymmetric', 'Transducer', 'Transducer Array', 'Cylindrical', 'HLLD', 'Example'] + skip = ["low_Mach", "Hypoelasticity", "teno", "Chemistry", "Phase Change model 6", "Axisymmetric", "Transducer", "Transducer Array", "Cylindrical", "HLLD", "Example"] if any(label in case.trace for label in skip): cases.remove(case) skipped_cases.append(case) for case in cases[:]: if ARG("gpu"): - skip = ['Gauss Seidel'] + skip = ["Gauss Seidel"] if any(label in case.trace for label in skip): cases.remove(case) @@ -141,25 +143,22 @@ def __filter(cases_) -> typing.List[TestCase]: cases = [c for i, c in enumerate(cases) if i % shard_count == shard_idx - 1] if not cases: - raise MFCException( - f"--shard {ARG('shard')} matched zero test cases. " - f"Total cases before sharding may be less than shard count." - ) + raise MFCException(f"--shard {ARG('shard')} matched zero test cases. Total cases before sharding may be less than shard count.") if ARG("percent") == 100: return cases, skipped_cases seed(time.time()) - selected_cases = sample(cases, k=int(len(cases)*ARG("percent")/100.0)) + selected_cases = sample(cases, k=int(len(cases) * ARG("percent") / 100.0)) skipped_cases += [item for item in cases if item not in selected_cases] return selected_cases, skipped_cases + def test(): - # pylint: disable=global-statement, global-variable-not-assigned, too-many-statements, too-many-locals - global nFAIL, nPASS, nSKIP, total_test_count - global errors, failed_tests, test_start_time + global nFAIL, nPASS, nSKIP, total_test_count # noqa: PLW0603 + global errors, failed_tests, test_start_time # noqa: PLW0603 test_start_time = time.time() # Start timing failed_uuids_path = os.path.join(common.MFC_TEST_DIR, "failed_uuids.txt") @@ -168,7 +167,7 @@ def test(): # Delete UUIDs that are not in the list of cases from tests/ if ARG("remove_old_tests"): dir_uuids = set(os.listdir(common.MFC_TEST_DIR)) - new_uuids = { case.get_uuid() for case in cases } + new_uuids = {case.get_uuid() for case in cases} for old_uuid in dir_uuids - new_uuids: cons.print(f"[bold red]Deleting:[/bold red] {old_uuid}") @@ -177,7 +176,7 @@ def test(): return cases, skipped_cases = __filter(cases) - cases = [ _.to_case() for _ in cases ] + cases = [_.to_case() for _ in cases] total_test_count = len(cases) if ARG("list"): @@ -196,7 +195,7 @@ def test(): # Some cases require a specific build of MFC for features like Chemistry, # Analytically defined patches, and --case-optimization. Here, we build all # the unique versions of MFC we need to run cases. - codes = [PRE_PROCESS, SIMULATION] + ([POST_PROCESS] if ARG('test_all') else []) + codes = [PRE_PROCESS, SIMULATION] + ([POST_PROCESS] if ARG("test_all") else []) unique_builds = set() for case, code in itertools.product(cases, codes): slug = code.get_slug(case.to_input_file()) @@ -211,7 +210,7 @@ def test(): if len(ARG("only")) > 0: range_str = "Only " + format_list_to_string(ARG("only"), "bold magenta", "Nothing to run") - cons.print(f"[bold]Test {format_list_to_string([ x.name for x in codes ], 'magenta')}[/bold] | {range_str} ({len(cases)} test{'s' if len(cases) != 1 else ''})") + cons.print(f"[bold]Test {format_list_to_string([x.name for x in codes], 'magenta')}[/bold] | {range_str} ({len(cases)} test{'s' if len(cases) != 1 else ''})") cons.indent() # Run cases with multiple threads (if available) @@ -224,9 +223,7 @@ def test(): # because running a test case may cause it to rebuild, and thus # interfere with the other test cases. It is a niche feature so we won't # engineer around this issue (for now). - sched.sched( - [ sched.Task(ppn=case.ppn, func=handle_case, args=[case], load=case.get_cell_count()) for case in cases ], - ARG("jobs"), ARG("gpus")) + sched.sched([sched.Task(ppn=case.ppn, func=handle_case, args=[case], load=case.get_cell_count()) for case in cases], ARG("jobs"), ARG("gpus")) # Check if we aborted due to high failure rate if abort_tests.is_set(): @@ -241,13 +238,8 @@ def test(): cons.print() cons.unindent() if total_completed > 0: - raise MFCException( - f"Excessive test failures: {nFAIL}/{total_completed} " - f"failed ({nFAIL/total_completed*100:.1f}%)" - ) - raise MFCException( - f"Excessive test failures: {nFAIL} failed, but no tests were completed." - ) + raise MFCException(f"Excessive test failures: {nFAIL}/{total_completed} failed ({nFAIL / total_completed * 100:.1f}%)") + raise MFCException(f"Excessive test failures: {nFAIL} failed, but no tests were completed.") nSKIP = len(skipped_cases) cons.print() @@ -265,16 +257,14 @@ def test(): if failed_tests: with open(failed_uuids_path, "w") as f: for test_info in failed_tests: - f.write(test_info['uuid'] + "\n") + f.write(test_info["uuid"] + "\n") elif os.path.exists(failed_uuids_path): os.remove(failed_uuids_path) - exit(nFAIL) + sys.exit(nFAIL) -def _print_test_summary(passed: int, failed: int, skipped: int, minutes: int, seconds: float, - failed_test_list: list, _skipped_cases: list): - # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals +def _print_test_summary(passed: int, failed: int, skipped: int, minutes: int, seconds: float, failed_test_list: list, _skipped_cases: list): """Print a comprehensive test summary report.""" total = passed + failed + skipped @@ -312,9 +302,9 @@ def _print_test_summary(passed: int, failed: int, skipped: int, minutes: int, se summary_lines.append("") summary_lines.append(" [bold red]Failed Tests:[/bold red]") for test_info in failed_test_list[:10]: # Limit to first 10 - trace = test_info.get('trace', 'Unknown') - uuid = test_info.get('uuid', 'Unknown') - error_type = test_info.get('error_type', '') + trace = test_info.get("trace", "Unknown") + uuid = test_info.get("uuid", "Unknown") + error_type = test_info.get("error_type", "") if len(trace) > 40: trace = trace[:37] + "..." summary_lines.append(f" [red]•[/red] {trace}") @@ -333,16 +323,10 @@ def _print_test_summary(passed: int, failed: int, skipped: int, minutes: int, se summary_lines.append(" • Run specific test: [cyan]./mfc.sh test --only [/cyan]") cons.print() - cons.raw.print(Panel( - "\n".join(summary_lines), - title="[bold]Test Summary[/bold]", - border_style=border_style, - padding=(1, 2) - )) + cons.raw.print(Panel("\n".join(summary_lines), title="[bold]Test Summary[/bold]", border_style=border_style, padding=(1, 2))) cons.print() -# pylint: disable=too-many-locals, too-many-branches, too-many-statements, trailing-whitespace def _process_silo_file(silo_filepath: str, case: TestCase, out_filepath: str): """Process a single silo file with h5dump and check for NaNs/Infinities.""" h5dump = f"{HDF5.get_install_dirpath(case.to_input_file())}/bin/h5dump" @@ -355,27 +339,17 @@ def _process_silo_file(silo_filepath: str, case: TestCase, out_filepath: str): output, err = get_program_output([h5dump, silo_filepath]) if err != 0: - raise MFCException( - f"Test {case}: Failed to run h5dump. You can find the run's output in {out_filepath}, " - f"and the case dictionary in {case.get_filepath()}." - ) + raise MFCException(f"Test {case}: Failed to run h5dump. You can find the run's output in {out_filepath}, and the case dictionary in {case.get_filepath()}.") if "nan," in output: - raise MFCException( - f"Test {case}: Post Process has detected a NaN. You can find the run's output in {out_filepath}, " - f"and the case dictionary in {case.get_filepath()}." - ) + raise MFCException(f"Test {case}: Post Process has detected a NaN. You can find the run's output in {out_filepath}, and the case dictionary in {case.get_filepath()}.") if "inf," in output: - raise MFCException( - f"Test {case}: Post Process has detected an Infinity. You can find the run's output in {out_filepath}, " - f"and the case dictionary in {case.get_filepath()}." - ) + raise MFCException(f"Test {case}: Post Process has detected an Infinity. You can find the run's output in {out_filepath}, and the case dictionary in {case.get_filepath()}.") def _handle_case(case: TestCase, devices: typing.Set[int]): - # pylint: disable=global-statement, global-variable-not-assigned - global current_test_number + global current_test_number # noqa: PLW0603 start_time = time.time() # Set timeout using threading.Timer (works in worker threads) @@ -456,7 +430,7 @@ def _handle_case(case: TestCase, devices: typing.Set[int]): out_filepath = os.path.join(case.get_dirpath(), "out_post.txt") common.file_write(out_filepath, cmd.stdout) - silo_dir = os.path.join(case.get_dirpath(), 'silo_hdf5', 'p0') + silo_dir = os.path.join(case.get_dirpath(), "silo_hdf5", "p0") if os.path.isdir(silo_dir): for silo_filename in os.listdir(silo_dir): silo_filepath = os.path.join(silo_dir, silo_filename) @@ -474,36 +448,29 @@ def _handle_case(case: TestCase, devices: typing.Set[int]): cons.print(f" {progress_str} {trace_display:50s} {duration:6.2f} [magenta]{case.get_uuid()}[/magenta]") except TestTimeoutError as exc: - log_path = os.path.join(case.get_dirpath(), 'out_pre_sim.txt') + log_path = os.path.join(case.get_dirpath(), "out_pre_sim.txt") if os.path.exists(log_path): log_msg = f"Check the log at: {log_path}" else: - log_msg = ( - f"Log file ({log_path}) may not exist if the timeout occurred early." - ) - raise MFCException( - f"Test {case} exceeded 1 hour timeout. " - f"This may indicate a hung simulation or misconfigured case. " - f"{log_msg}" - ) from exc + log_msg = f"Log file ({log_path}) may not exist if the timeout occurred early." + raise MFCException(f"Test {case} exceeded 1 hour timeout. This may indicate a hung simulation or misconfigured case. {log_msg}") from exc finally: timeout_timer.cancel() # Cancel timeout timer def handle_case(case: TestCase, devices: typing.Set[int]): - # pylint: disable=global-statement, global-variable-not-assigned - global nFAIL, nPASS, nSKIP - global errors, failed_tests + global nFAIL, nPASS, nSKIP # noqa: PLW0603 + global errors, failed_tests # noqa: PLW0603 # Check if we should abort before processing this case if abort_tests.is_set(): return # Exit gracefully if abort was requested nAttempts = 0 - if ARG('single'): - max_attempts = max(ARG('max_attempts'), 3) + if ARG("single"): + max_attempts = max(ARG("max_attempts"), 3) else: - max_attempts = ARG('max_attempts') + max_attempts = ARG("max_attempts") while True: nAttempts += 1 @@ -535,13 +502,13 @@ def handle_case(case: TestCase, devices: typing.Set[int]): # Provide helpful hints based on error type exc_lower = str(exc).lower() if "tolerance" in exc_lower or "golden" in exc_lower or "mismatch" in exc_lower: - cons.print(f" [dim]Hint: Consider --generate to update golden files or check tolerances[/dim]") + cons.print(" [dim]Hint: Consider --generate to update golden files or check tolerances[/dim]") elif "timeout" in exc_lower: - cons.print(f" [dim]Hint: Test may be hanging - check case configuration[/dim]") + cons.print(" [dim]Hint: Test may be hanging - check case configuration[/dim]") elif "nan" in exc_lower: - cons.print(f" [dim]Hint: NaN detected - check numerical stability of the case[/dim]") + cons.print(" [dim]Hint: NaN detected - check numerical stability of the case[/dim]") elif "failed to execute" in exc_lower: - cons.print(f" [dim]Hint: Check build logs and case parameters[/dim]") + cons.print(" [dim]Hint: Check build logs and case parameters[/dim]") cons.print() # Track failed test details for summary @@ -556,12 +523,7 @@ def handle_case(case: TestCase, devices: typing.Set[int]): elif "failed to execute" in exc_lower: error_type = "execution failed" - failed_tests.append({ - 'trace': case.trace, - 'uuid': case.get_uuid(), - 'error_type': error_type, - 'attempts': nAttempts - }) + failed_tests.append({"trace": case.trace, "uuid": case.get_uuid(), "error_type": error_type, "attempts": nAttempts}) # Still collect for final summary errors.append(f"[bold red]Failed test {case} after {nAttempts} attempt(s).[/bold red]") @@ -574,7 +536,7 @@ def handle_case(case: TestCase, devices: typing.Set[int]): if total_completed >= MIN_CASES_BEFORE_ABORT: failure_rate = nFAIL / total_completed if failure_rate >= FAILURE_RATE_THRESHOLD: - cons.print(f"\n[bold red]CRITICAL: {failure_rate*100:.1f}% failure rate detected after {total_completed} tests.[/bold red]") + cons.print(f"\n[bold red]CRITICAL: {failure_rate * 100:.1f}% failure rate detected after {total_completed} tests.[/bold red]") cons.print("[bold red]This suggests a systemic issue (bad build, broken environment, etc.)[/bold red]") cons.print("[bold red]Aborting remaining tests to fail fast.[/bold red]\n") # Set abort flag instead of raising exception from worker thread diff --git a/toolchain/mfc/user_guide.py b/toolchain/mfc/user_guide.py index 1beb7cc4d2..fa40cc0025 100644 --- a/toolchain/mfc/user_guide.py +++ b/toolchain/mfc/user_guide.py @@ -10,21 +10,19 @@ """ import os -import subprocess import re +import subprocess +from rich import box +from rich.markdown import Markdown from rich.panel import Panel -from rich.table import Table from rich.prompt import Prompt -from rich.markdown import Markdown -from rich import box - -from .printer import cons -from .common import MFC_ROOT_DIR +from rich.table import Table # Import command definitions from CLI schema (SINGLE SOURCE OF TRUTH) from .cli.commands import COMMANDS - +from .common import MFC_ROOT_DIR +from .printer import cons # ============================================================================= # DYNAMIC CLUSTER HELP GENERATION @@ -79,8 +77,8 @@ def _parse_modules_file(): try: with open(modules_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() + for raw_line in f: + line = raw_line.strip() # Skip comments and empty lines if not line or line.startswith("#"): continue @@ -89,7 +87,7 @@ def _parse_modules_file(): continue # Parse cluster definition lines: "slug System Name" - match = re.match(r'^(\S+)\s+(.+)$', line) + match = re.match(r"^(\S+)\s+(.+)$", line) if match: slug = match.group(1) full_name = match.group(2).strip() @@ -120,7 +118,7 @@ def _get_cluster_short_name(slug, full_name): # Strip org prefix if present for prefix in CLUSTER_ORGS: if full_name.startswith(prefix + " "): - return full_name[len(prefix) + 1:] + return full_name[len(prefix) + 1 :] return full_name @@ -145,17 +143,14 @@ def _generate_clusters_content(): if not org_clusters.get(org): continue # Format: " [yellow]ORG:[/yellow] [cyan]slug[/cyan]=Name [cyan]slug2[/cyan]=Name2" - entries = [ - f"[cyan]{slug}[/cyan]={_get_cluster_short_name(slug, name)}" - for slug, name in org_clusters[org] - ] - color = ORG_COLORS.get(org, 'yellow') + entries = [f"[cyan]{slug}[/cyan]={_get_cluster_short_name(slug, name)}" for slug, name in org_clusters[org]] + color = ORG_COLORS.get(org, "yellow") cluster_lines.append(f" [{color}]{org}:[/{color}] " + " ".join(entries)) # Handle "Other" if any if org_clusters.get("Other"): entries = [f"[cyan]{slug}[/cyan]={name}" for slug, name in org_clusters["Other"]] - cluster_lines.append(f" [yellow]Other:[/yellow] " + " ".join(entries)) + cluster_lines.append(" [yellow]Other:[/yellow] " + " ".join(entries)) cluster_list = "\n".join(cluster_lines) if cluster_lines else " [dim]No clusters found in modules file[/dim]" @@ -209,7 +204,7 @@ def _extract_markdown_section(content: str, section_heading: str) -> str: """ # Find the section heading (## or ###) # Note: In f-strings, literal braces must be doubled: {{1,3}} -> {1,3} - pattern = rf'^(#{{1,3}})\s+{re.escape(section_heading)}\s*$' + pattern = rf"^(#{{1,3}})\s+{re.escape(section_heading)}\s*$" match = re.search(pattern, content, re.MULTILINE) if not match: return None @@ -219,11 +214,11 @@ def _extract_markdown_section(content: str, section_heading: str) -> str: # Find the end: horizontal rule (---) which separates major sections # Note: We use --- instead of heading detection because shell comments # inside code blocks (# comment) look like markdown headings to regex - end_pattern = r'^---' + end_pattern = r"^---" end_match = re.search(end_pattern, content[start_pos:], re.MULTILINE) if end_match: - section = content[start_pos:start_pos + end_match.start()] + section = content[start_pos : start_pos + end_match.start()] else: section = content[start_pos:] @@ -256,20 +251,22 @@ def _load_markdown_help(topic: str) -> str: # Strip Doxygen-specific syntax # Remove @page directives - content = re.sub(r'^@page\s+\S+\s+.*$', '', content, flags=re.MULTILINE) + content = re.sub(r"^@page\s+\S+\s+.*$", "", content, flags=re.MULTILINE) # Remove @ref, @see directives (but keep the text after them readable) - content = re.sub(r'@(ref|see)\s+"([^"]+)"', r'\2', content) # @ref "Text" -> Text - content = re.sub(r'@(ref|see)\s+(\S+)', '', content) # @ref name -> (remove) + content = re.sub(r'@(ref|see)\s+"([^"]+)"', r"\2", content) # @ref "Text" -> Text + content = re.sub(r"@(ref|see)\s+(\S+)", "", content) # @ref name -> (remove) # Clean up any resulting empty lines at the start - content = content.lstrip('\n') + content = content.lstrip("\n") return content def _generate_markdown_help(topic: str): """Generate a function that loads markdown help for a topic.""" + def loader(): return _load_markdown_help(topic) + return loader @@ -342,23 +339,14 @@ def print_topic_help(topic: str): cons.raw.print(Markdown(content)) else: # Render as Rich markup in a panel - cons.raw.print(Panel( - content, - title=f"[bold]{topic_info['title']}[/bold]", - box=box.ROUNDED, - padding=(1, 2) - )) + cons.raw.print(Panel(content, title=f"[bold]{topic_info['title']}[/bold]", box=box.ROUNDED, padding=(1, 2))) cons.print() def print_help_topics(): """Print list of available help topics.""" cons.print() - cons.raw.print(Panel( - "[bold cyan]MFC Help System[/bold cyan]", - box=box.ROUNDED, - padding=(0, 2) - )) + cons.raw.print(Panel("[bold cyan]MFC Help System[/bold cyan]", box=box.ROUNDED, padding=(0, 2))) cons.print() table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) @@ -379,11 +367,12 @@ def print_help_topics(): # ENHANCED HELP OUTPUT # ============================================================================= + def _truncate_desc(desc: str, max_len: int = 50) -> str: """Truncate description to fit compact display.""" if len(desc) <= max_len: return desc - return desc[:max_len-3] + "..." + return desc[: max_len - 3] + "..." def print_help(): @@ -439,12 +428,7 @@ def print_command_help(command: str, show_argparse: bool = True): # Header panel cons.print() - cons.raw.print(Panel( - f"[bold cyan]{command}[/bold cyan]{alias_str}\n" - f"[dim]{cmd['description']}[/dim]", - box=box.ROUNDED, - padding=(0, 2) - )) + cons.raw.print(Panel(f"[bold cyan]{command}[/bold cyan]{alias_str}\n[dim]{cmd['description']}[/dim]", box=box.ROUNDED, padding=(0, 2))) cons.print() # Examples @@ -475,6 +459,7 @@ def print_command_help(command: str, show_argparse: bool = True): # CONTEXTUAL TIPS # ============================================================================= + class Tips: """Contextual tips shown after various events.""" @@ -482,16 +467,18 @@ class Tips: def after_build_failure(): """Show tips after a build failure.""" cons.print() - cons.raw.print(Panel( - "[bold yellow]Troubleshooting Tips[/bold yellow]\n\n" - " [cyan]1.[/cyan] Rebuild with [green]--debug[/green] for debug compiler flags and verbose output\n" - " [cyan]2.[/cyan] Check [green]docs/documentation/troubleshooting.md[/green]\n" - " [cyan]3.[/cyan] Ensure required modules are loaded: [green]source ./mfc.sh load -c -m [/green]\n" - " [cyan]4.[/cyan] Try [green]./mfc.sh clean[/green] and rebuild", - box=box.ROUNDED, - border_style="yellow", - padding=(0, 2) - )) + cons.raw.print( + Panel( + "[bold yellow]Troubleshooting Tips[/bold yellow]\n\n" + " [cyan]1.[/cyan] Rebuild with [green]--debug[/green] for debug compiler flags and verbose output\n" + " [cyan]2.[/cyan] Check [green]docs/documentation/troubleshooting.md[/green]\n" + " [cyan]3.[/cyan] Ensure required modules are loaded: [green]source ./mfc.sh load -c -m [/green]\n" + " [cyan]4.[/cyan] Try [green]./mfc.sh clean[/green] and rebuild", + box=box.ROUNDED, + border_style="yellow", + padding=(0, 2), + ) + ) @staticmethod def after_case_error(case_path: str = None): @@ -528,16 +515,18 @@ def after_test_failure(failed_uuids: list = None): def after_run_failure(): """Show tips after a run failure.""" cons.print() - cons.raw.print(Panel( - "[bold yellow]Troubleshooting Tips[/bold yellow]\n\n" - " [cyan]1.[/cyan] Validate your case: [green]./mfc.sh validate case.py[/green]\n" - " [cyan]2.[/cyan] Check the output in [green]/[/green]\n" - " [cyan]3.[/cyan] Rebuild with [green]--debug[/green] for debug compiler flags\n" - " [cyan]4.[/cyan] Check MFC documentation: [green]docs/[/green]", - box=box.ROUNDED, - border_style="yellow", - padding=(0, 2) - )) + cons.raw.print( + Panel( + "[bold yellow]Troubleshooting Tips[/bold yellow]\n\n" + " [cyan]1.[/cyan] Validate your case: [green]./mfc.sh validate case.py[/green]\n" + " [cyan]2.[/cyan] Check the output in [green]/[/green]\n" + " [cyan]3.[/cyan] Rebuild with [green]--debug[/green] for debug compiler flags\n" + " [cyan]4.[/cyan] Check MFC documentation: [green]docs/[/green]", + box=box.ROUNDED, + border_style="yellow", + padding=(0, 2), + ) + ) @staticmethod def suggest_validate(): @@ -550,6 +539,7 @@ def suggest_validate(): # ONBOARDING FOR NEW USERS # ============================================================================= + def is_first_time_user() -> bool: """Check if this is a first-time user (no build directory).""" build_dir = os.path.join(MFC_ROOT_DIR, "build") @@ -559,27 +549,29 @@ def is_first_time_user() -> bool: def print_welcome(): """Print welcome message for new users.""" cons.print() - cons.raw.print(Panel( - "[bold cyan]Welcome to MFC![/bold cyan]\n\n" - "It looks like this is your first time using MFC. Here's how to get started:\n\n" - " [green]1.[/green] [bold]Load environment[/bold] (HPC clusters):\n" - " [cyan]source ./mfc.sh load -c -m [/cyan]\n" - " Example: [dim]source ./mfc.sh load -c p -m g[/dim] (Phoenix, GPU)\n\n" - " [green]2.[/green] [bold]Create a new case[/bold]:\n" - " [cyan]./mfc.sh new my_first_case[/cyan]\n\n" - " [green]3.[/green] [bold]Build MFC[/bold]:\n" - " [cyan]./mfc.sh build -j $(nproc)[/cyan]\n\n" - " [green]4.[/green] [bold]Run your simulation[/bold]:\n" - " [cyan]./mfc.sh run my_first_case/case.py[/cyan]\n\n" - "[bold yellow]Optional:[/bold yellow] Enable tab completion for your shell:\n" - " [cyan]./mfc.sh completion install[/cyan]\n\n" - "[dim]Run [cyan]./mfc.sh --help[/cyan] for all available commands[/dim]\n" - "[dim]Run [cyan]./mfc.sh interactive[/cyan] for a guided menu[/dim]", - title="[bold]Getting Started[/bold]", - box=box.DOUBLE, - border_style="cyan", - padding=(1, 2) - )) + cons.raw.print( + Panel( + "[bold cyan]Welcome to MFC![/bold cyan]\n\n" + "It looks like this is your first time using MFC. Here's how to get started:\n\n" + " [green]1.[/green] [bold]Load environment[/bold] (HPC clusters):\n" + " [cyan]source ./mfc.sh load -c -m [/cyan]\n" + " Example: [dim]source ./mfc.sh load -c p -m g[/dim] (Phoenix, GPU)\n\n" + " [green]2.[/green] [bold]Create a new case[/bold]:\n" + " [cyan]./mfc.sh new my_first_case[/cyan]\n\n" + " [green]3.[/green] [bold]Build MFC[/bold]:\n" + " [cyan]./mfc.sh build -j $(nproc)[/cyan]\n\n" + " [green]4.[/green] [bold]Run your simulation[/bold]:\n" + " [cyan]./mfc.sh run my_first_case/case.py[/cyan]\n\n" + "[bold yellow]Optional:[/bold yellow] Enable tab completion for your shell:\n" + " [cyan]./mfc.sh completion install[/cyan]\n\n" + "[dim]Run [cyan]./mfc.sh --help[/cyan] for all available commands[/dim]\n" + "[dim]Run [cyan]./mfc.sh interactive[/cyan] for a guided menu[/dim]", + title="[bold]Getting Started[/bold]", + box=box.DOUBLE, + border_style="cyan", + padding=(1, 2), + ) + ) cons.print() @@ -587,16 +579,13 @@ def print_welcome(): # INTERACTIVE MODE # ============================================================================= + def interactive_mode(): """Run interactive menu-driven interface.""" while True: cons.print() - cons.raw.print(Panel( - "[bold cyan]MFC Interactive Mode[/bold cyan]", - box=box.ROUNDED, - padding=(0, 2) - )) + cons.raw.print(Panel("[bold cyan]MFC Interactive Mode[/bold cyan]", box=box.ROUNDED, padding=(0, 2))) cons.print() # Menu options diff --git a/toolchain/mfc/validate.py b/toolchain/mfc/validate.py index 517144a71d..c07ffec8b3 100644 --- a/toolchain/mfc/validate.py +++ b/toolchain/mfc/validate.py @@ -3,12 +3,13 @@ """ import os +import sys -from .state import ARG +from .case_validator import CaseConstraintError, CaseValidator +from .common import MFCException from .printer import cons from .run import input as run_input -from .case_validator import CaseValidator, CaseConstraintError -from .common import MFCException +from .state import ARG def validate(): @@ -17,7 +18,7 @@ def validate(): if not os.path.isfile(input_file): cons.print(f"[bold red]Error:[/bold red] File not found: {input_file}") - exit(1) + sys.exit(1) cons.print(f"Validating [bold magenta]{input_file}[/bold magenta]...\n") @@ -28,7 +29,7 @@ def validate(): cons.print(f" [dim]Loaded {len(case.params)} parameters[/dim]") # Step 2: Run constraint validation for each stage - stages = ['pre_process', 'simulation', 'post_process'] + stages = ["pre_process", "simulation", "post_process"] all_passed = True for stage in stages: @@ -45,7 +46,7 @@ def validate(): all_passed = False cons.print(f"[bold yellow]![/bold yellow] {stage} constraints: issues found") # Show the constraint violations indented - for line in str(e).split('\n'): + for line in str(e).split("\n"): if line.strip(): cons.print(f" [dim]{line}[/dim]") @@ -58,6 +59,6 @@ def validate(): cons.print("[dim]Note: Some constraint violations may be OK if you're not using that stage.[/dim]") except MFCException as e: - cons.print(f"\n[bold red]✗ Validation failed:[/bold red]") + cons.print("\n[bold red]✗ Validation failed:[/bold red]") cons.print(f"{e}") - exit(1) + sys.exit(1) diff --git a/toolchain/mfc/viz/_step_cache.py b/toolchain/mfc/viz/_step_cache.py index 0d301f7b0e..f58351ffba 100644 --- a/toolchain/mfc/viz/_step_cache.py +++ b/toolchain/mfc/viz/_step_cache.py @@ -31,7 +31,7 @@ CACHE_MAX: int = 40 _cache: dict = {} _cache_order: list = [] -_in_flight: set = set() # steps currently being prefetched +_in_flight: set = set() # steps currently being prefetched _lock = threading.Lock() _prefetch_pool: Optional[ThreadPoolExecutor] = None @@ -40,11 +40,10 @@ def _get_prefetch_pool() -> ThreadPoolExecutor: """Return the prefetch pool, creating it lazily on first use.""" - global _prefetch_pool # pylint: disable=global-statement + global _prefetch_pool # noqa: PLW0603 with _prefetch_pool_lock: if _prefetch_pool is None: - _prefetch_pool = ThreadPoolExecutor( - max_workers=3, thread_name_prefix='mfc_prefetch') + _prefetch_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="mfc_prefetch") atexit.register(_prefetch_pool.shutdown, wait=False) return _prefetch_pool @@ -117,7 +116,7 @@ def _bg_load(key: object, read_func: Callable) -> None: _cache.pop(evict, None) _cache[key] = data _cache_order.append(key) - except Exception: # pylint: disable=broad-except + except Exception: logger.debug("Prefetch failed for key %s", key, exc_info=True) finally: with _lock: diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 12b30f2d6f..8db81e9241 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -6,7 +6,6 @@ controls for slice position, isosurface thresholds, volume opacity, colormap, log scale, vmin/vmax, and timestep playback. """ -# pylint: disable=use-dict-literal,too-many-lines import atexit import base64 @@ -16,15 +15,16 @@ import sys import threading import time -from typing import List, Callable, Optional +from typing import Callable, List, Optional import numpy as np import plotly.graph_objects as go -from dash import Dash, Patch, dcc, html, Input, Output, State, callback_context, no_update -from skimage.measure import marching_cubes as _marching_cubes # type: ignore[import] # pylint: disable=no-name-in-module -from skimage.measure import find_contours as _find_contours # type: ignore[import] # pylint: disable=no-name-in-module +from dash import Dash, Input, Output, Patch, State, callback_context, dcc, html, no_update +from skimage.measure import find_contours as _find_contours # type: ignore[import] +from skimage.measure import marching_cubes as _marching_cubes # type: ignore[import] from mfc.printer import cons + from . import _step_cache from ._step_cache import prefetch_one as _prefetch_one @@ -36,6 +36,7 @@ # installed. Fall back to Pillow in that case. try: from turbojpeg import TurboJPEG as _TurboJPEG # type: ignore[import] + _tj = _TurboJPEG() except (ImportError, OSError): _tj = None @@ -47,8 +48,8 @@ # Both caches grow to at most one entry per named colormap (~10 entries max). # Concurrent writes are benign: both threads produce identical data for the # same colormap name, so no lock is needed. -_lut_cache: dict = {} # cmap_name → (256, 3) uint8 LUT -_cscale_cache: dict = {} # cmap_name → plotly colorscale list +_lut_cache: dict = {} # cmap_name → (256, 3) uint8 LUT +_cscale_cache: dict = {} # cmap_name → plotly colorscale list # Downsampled-3D cache: (step, var, max_total) → (raw_ds, x_ds, y_ds, z_ds) _ds3_cache: dict = {} @@ -73,22 +74,20 @@ def _get_jpeg_pool() -> concurrent.futures.ThreadPoolExecutor: """Return the JPEG prefetch pool, creating it lazily on first use.""" - global _jpeg_pool # pylint: disable=global-statement + global _jpeg_pool # noqa: PLW0603 with _jpeg_pool_lock: if _jpeg_pool is None: - _jpeg_pool = concurrent.futures.ThreadPoolExecutor( - max_workers=1, thread_name_prefix='mfc_jpeg') + _jpeg_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="mfc_jpeg") atexit.register(_jpeg_pool.shutdown, wait=False) return _jpeg_pool def _get_mesh3_pool() -> concurrent.futures.ThreadPoolExecutor: """Return the 3D mesh prefetch pool, creating it lazily on first use.""" - global _mesh3_pool # pylint: disable=global-statement + global _mesh3_pool # noqa: PLW0603 with _mesh3_pool_lock: if _mesh3_pool is None: - _mesh3_pool = concurrent.futures.ThreadPoolExecutor( - max_workers=3, thread_name_prefix='mfc_mesh3') + _mesh3_pool = concurrent.futures.ThreadPoolExecutor(max_workers=3, thread_name_prefix="mfc_mesh3") atexit.register(_mesh3_pool.shutdown, wait=False) return _mesh3_pool @@ -97,10 +96,12 @@ def _get_lut(cmap_name: str) -> np.ndarray: """Return a (256, 3) uint8 LUT for the named matplotlib colormap.""" if cmap_name not in _lut_cache: try: - import matplotlib as mpl # pylint: disable=import-outside-toplevel + import matplotlib as mpl + cm = mpl.colormaps.get_cmap(cmap_name) except (ImportError, KeyError): - import matplotlib.cm as mcm # pylint: disable=import-outside-toplevel + import matplotlib.cm as mcm + cm = mcm.get_cmap(cmap_name) t = np.linspace(0.0, 1.0, 256) _lut_cache[cmap_name] = (cm(t)[:, :3] * 255 + 0.5).astype(np.uint8) @@ -118,10 +119,7 @@ def _lut_to_plotly_colorscale(cmap_name: str) -> list: return _cscale_cache[cmap_name] lut = _get_lut(cmap_name) indices = np.linspace(0, 255, 32, dtype=int) - result = [ - [float(i) / 31.0, f'rgb({lut[idx,0]},{lut[idx,1]},{lut[idx,2]})'] - for i, idx in enumerate(indices) - ] + result = [[float(i) / 31.0, f"rgb({lut[idx, 0]},{lut[idx, 1]},{lut[idx, 2]})"] for i, idx in enumerate(indices)] _cscale_cache[cmap_name] = result return result @@ -133,15 +131,16 @@ def _encode_jpeg(rgb: np.ndarray) -> bytes: """ if _tj is not None: return _tj.encode(rgb, quality=90) - from PIL import Image as _PIL # pylint: disable=import-outside-toplevel - import io as _io # pylint: disable=import-outside-toplevel + import io as _io + + from PIL import Image as _PIL + buf = _io.BytesIO() - _PIL.fromarray(rgb, 'RGB').save(buf, format='jpeg', quality=90, optimize=False) + _PIL.fromarray(rgb, "RGB").save(buf, format="jpeg", quality=90, optimize=False) return buf.getvalue() -def _make_png_source(arr_yx: np.ndarray, cmap_name: str, - vmin: float, vmax: float) -> str: +def _make_png_source(arr_yx: np.ndarray, cmap_name: str, vmin: float, vmax: float) -> str: """Encode a (ny, nx) float array as a colorized base64 JPEG data URI. Uses a 256-entry LUT for fast colormap application and libjpeg-turbo @@ -153,17 +152,13 @@ def _make_png_source(arr_yx: np.ndarray, cmap_name: str, """ lut = _get_lut(cmap_name) scale = max(float(vmax - vmin), 1e-30) - normed = np.clip( - (arr_yx - vmin) / scale * 255.0 + 0.5, 0, 255 - ).astype(np.uint8) - rgb = lut[normed] # (ny, nx, 3) uint8 + normed = np.clip((arr_yx - vmin) / scale * 255.0 + 0.5, 0, 255).astype(np.uint8) + rgb = lut[normed] # (ny, nx, 3) uint8 b64 = base64.b64encode(_encode_jpeg(rgb)).decode() return f"data:image/jpeg;base64,{b64}" -def _compute_isomesh(raw_ds: np.ndarray, x_ds: np.ndarray, y_ds: np.ndarray, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals - z_ds: np.ndarray, log_fn, ilo: float, ihi: float, - iso_n: int): +def _compute_isomesh(raw_ds: np.ndarray, x_ds: np.ndarray, y_ds: np.ndarray, z_ds: np.ndarray, log_fn, ilo: float, ihi: float, iso_n: int): """Server-side marching cubes for *iso_n* levels between *ilo* and *ihi*. Returns flat arrays (vx, vy, vz, fi, fj, fk, intensity) ready for @@ -192,7 +187,9 @@ def _compute_isomesh(raw_ds: np.ndarray, x_ds: np.ndarray, y_ds: np.ndarray, # for level in levels: try: verts, faces, _, _ = _marching_cubes( - vol, level=float(level), spacing=spacing, + vol, + level=float(level), + spacing=spacing, allow_degenerate=False, ) except (ValueError, RuntimeError): @@ -203,7 +200,9 @@ def _compute_isomesh(raw_ds: np.ndarray, x_ds: np.ndarray, y_ds: np.ndarray, # verts[:, 0] += float(x_ds[0]) verts[:, 1] += float(y_ds[0]) verts[:, 2] += float(z_ds[0]) - xs.append(verts[:, 0]); ys.append(verts[:, 1]); zs.append(verts[:, 2]) + xs.append(verts[:, 0]) + ys.append(verts[:, 1]) + zs.append(verts[:, 2]) ii.append(faces[:, 0] + offset) jj.append(faces[:, 1] + offset) kk.append(faces[:, 2] + offset) @@ -216,17 +215,18 @@ def _compute_isomesh(raw_ds: np.ndarray, x_ds: np.ndarray, y_ds: np.ndarray, # return dummy, dummy, dummy, np.array([0]), np.array([1]), np.array([2]), dummy # float32 halves the JSON payload vs float64 (~50% smaller .tolist()) - return (np.concatenate(xs).astype(np.float32), - np.concatenate(ys).astype(np.float32), - np.concatenate(zs).astype(np.float32), - np.concatenate(ii).astype(np.int32), - np.concatenate(jj).astype(np.int32), - np.concatenate(kk).astype(np.int32), - np.concatenate(intens).astype(np.float32)) + return ( + np.concatenate(xs).astype(np.float32), + np.concatenate(ys).astype(np.float32), + np.concatenate(zs).astype(np.float32), + np.concatenate(ii).astype(np.int32), + np.concatenate(jj).astype(np.int32), + np.concatenate(kk).astype(np.int32), + np.concatenate(intens).astype(np.float32), + ) -def _downsample_3d(raw: np.ndarray, x_cc: np.ndarray, y_cc: np.ndarray, - z_cc: np.ndarray, max_total: int = 150_000): +def _downsample_3d(raw: np.ndarray, x_cc: np.ndarray, y_cc: np.ndarray, z_cc: np.ndarray, max_total: int = 150_000): """Stride a (nx, ny, nz) array to stay within a total cell budget. Uses a **uniform** stride s = ceil((nx*ny*nz / max_total)^(1/3)) so that @@ -243,7 +243,7 @@ def _downsample_3d(raw: np.ndarray, x_cc: np.ndarray, y_cc: np.ndarray, return raw[::s, ::s, ::s], x_cc[::s], y_cc[::s], z_cc[::s] -def _get_ds3(step, var, raw, x_cc, y_cc, z_cc, max_total): # pylint: disable=too-many-arguments,too-many-positional-arguments +def _get_ds3(step, var, raw, x_cc, y_cc, z_cc, max_total): """Downsampled 3D array with bounded LRU caching. Avoids re-striding the same large array on every iso threshold / volume @@ -266,8 +266,7 @@ def _get_ds3(step, var, raw, x_cc, y_cc, z_cc, max_total): # pylint: disable=to return result -def _prefetch_jpeg(step, var, get_ad_fn, cmap, vmin_in, vmax_in, log_bool, # pylint: disable=too-many-arguments,too-many-positional-arguments - max_nx=1200, max_ny=600): +def _prefetch_jpeg(step, var, get_ad_fn, cmap, vmin_in, vmax_in, log_bool, max_nx=1200, max_ny=600): """Pre-encode JPEG for *step/var* in a background thread. Called immediately after data prefetch so the JPEG is ready by the time @@ -280,7 +279,7 @@ def _prefetch_jpeg(step, var, get_ad_fn, cmap, vmin_in, vmax_in, log_bool, # py if key in _jpeg_cache: return - def _bg(): # pylint: disable=too-many-locals + def _bg(): try: ad = get_ad_fn(step) if var not in ad.variables: @@ -319,16 +318,26 @@ def _bg(): # pylint: disable=too-many-locals if len(_jpeg_cache) >= _JPEG_CACHE_MAX: _jpeg_cache.pop(next(iter(_jpeg_cache))) _jpeg_cache[key] = src - except Exception: # pylint: disable=broad-except + except Exception: logger.debug("JPEG prefetch failed for step %s var %s", step, var, exc_info=True) _get_jpeg_pool().submit(_bg) -def _prefetch_3d_mesh(step, var, get_ad_fn, mode, log_bool, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements - vmin_in, vmax_in, - iso_min_frac, iso_max_frac, iso_n, - vol_min_frac, vol_max_frac): +def _prefetch_3d_mesh( + step, + var, + get_ad_fn, + mode, + log_bool, + vmin_in, + vmax_in, + iso_min_frac, + iso_max_frac, + iso_n, + vol_min_frac, + vol_max_frac, +): """Pre-compute 3D isomesh or volume data for *step* in a background thread. Caches the result so the next playback frame can skip marching cubes. @@ -340,15 +349,13 @@ def _prefetch_3d_mesh(step, var, get_ad_fn, mode, log_bool, # pylint: disable=t callback recomputes at full 500K resolution. The cache key omits the budget so the coarse entry is replaced on the full-res recompute. """ - key = (step, var, mode, log_bool, vmin_in, vmax_in, - iso_min_frac, iso_max_frac, iso_n, - vol_min_frac, vol_max_frac) + key = (step, var, mode, log_bool, vmin_in, vmax_in, iso_min_frac, iso_max_frac, iso_n, vol_min_frac, vol_max_frac) with _mesh3_lock: if key in _mesh3_cache or key in _mesh3_in_flight: return _mesh3_in_flight.add(key) - def _bg(): # pylint: disable=too-many-locals + def _bg(): try: ad = get_ad_fn(step) if var not in ad.variables: @@ -370,25 +377,27 @@ def _bg(): # pylint: disable=too-many-locals if vmax <= vmin: vmax = vmin + 1e-10 if log_bool: + def _tf(arr): return np.where(arr > 0, np.log10(np.maximum(arr, 1e-300)), np.nan) + cmin = float(np.log10(max(vmin, 1e-300))) cmax = float(np.log10(max(vmax, 1e-300))) else: - def _tf(arr): return arr + + def _tf(arr): + return arr + cmin, cmax = vmin, vmax rng = cmax - cmin if cmax > cmin else 1.0 - if mode == 'isosurface': - raw_ds, x_ds, y_ds, z_ds = _get_ds3( - step, var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 50_000) + if mode == "isosurface": + raw_ds, x_ds, y_ds, z_ds = _get_ds3(step, var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 50_000) ilo = cmin + rng * float(iso_min_frac) ihi = cmin + rng * max(float(iso_max_frac), float(iso_min_frac) + 0.01) - result = _compute_isomesh( - raw_ds, x_ds, y_ds, z_ds, _tf, ilo, ihi, int(iso_n)) + result = _compute_isomesh(raw_ds, x_ds, y_ds, z_ds, _tf, ilo, ihi, int(iso_n)) else: # volume - raw_ds, _, _, _ = _get_ds3( - step, var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 150_000) + raw_ds, _, _, _ = _get_ds3(step, var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 150_000) vf = _tf(raw_ds).ravel().astype(np.float32) vlo = cmin + rng * float(vol_min_frac) vhi = cmin + rng * max(float(vol_max_frac), float(vol_min_frac) + 0.01) @@ -399,7 +408,7 @@ def _tf(arr): return arr if len(_mesh3_cache) >= _MESH3_CACHE_MAX: _mesh3_cache.pop(next(iter(_mesh3_cache))) _mesh3_cache[key] = result - except Exception: # pylint: disable=broad-except + except Exception: logger.debug("3D mesh prefetch failed for step %s var %s", step, var, exc_info=True) finally: with _mesh3_lock: @@ -408,13 +417,21 @@ def _tf(arr): return arr _get_mesh3_pool().submit(_bg) -def _get_cached_3d_mesh(step, var, mode, log_bool, vmin_in, vmax_in, # pylint: disable=too-many-arguments,too-many-positional-arguments - iso_min_frac, iso_max_frac, iso_n, - vol_min_frac, vol_max_frac): +def _get_cached_3d_mesh( + step, + var, + mode, + log_bool, + vmin_in, + vmax_in, + iso_min_frac, + iso_max_frac, + iso_n, + vol_min_frac, + vol_max_frac, +): """Return cached 3D mesh result or None if not yet computed.""" - key = (step, var, mode, log_bool, vmin_in, vmax_in, - iso_min_frac, iso_max_frac, iso_n, - vol_min_frac, vol_max_frac) + key = (step, var, mode, log_bool, vmin_in, vmax_in, iso_min_frac, iso_max_frac, iso_n, vol_min_frac, vol_max_frac) with _mesh3_lock: return _mesh3_cache.get(key) @@ -428,33 +445,71 @@ def _get_cached_3d_mesh(step, var, mode, log_bool, vmin_in, vmax_in, # pylint: # Colormaps available in the picker # --------------------------------------------------------------------------- _CMAPS = [ - "viridis", "plasma", "inferno", "magma", "cividis", - "turbo", "jet", "rainbow", "nipy_spectral", - "RdBu", "RdYlBu", "RdYlGn", "coolwarm", "bwr", "seismic", "Spectral", - "hot", "afmhot", "gist_heat", "copper", - "bone", "gray", "spring", "summer", "autumn", "winter", "cool", "pink", - "Blues", "Greens", "Oranges", "Reds", "Purples", "Greys", - "twilight", "twilight_shifted", "hsv", - "tab10", "tab20", "terrain", "ocean", "gist_earth", - "gnuplot", "gnuplot2", "CMRmap", "cubehelix", "Wistia", + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + "turbo", + "jet", + "rainbow", + "nipy_spectral", + "RdBu", + "RdYlBu", + "RdYlGn", + "coolwarm", + "bwr", + "seismic", + "Spectral", + "hot", + "afmhot", + "gist_heat", + "copper", + "bone", + "gray", + "spring", + "summer", + "autumn", + "winter", + "cool", + "pink", + "Blues", + "Greens", + "Oranges", + "Reds", + "Purples", + "Greys", + "twilight", + "twilight_shifted", + "hsv", + "tab10", + "tab20", + "terrain", + "ocean", + "gist_earth", + "gnuplot", + "gnuplot2", + "CMRmap", + "cubehelix", + "Wistia", ] # --------------------------------------------------------------------------- # Catppuccin Mocha palette # --------------------------------------------------------------------------- -_BG = '#181825' -_SURF = '#1e1e2e' -_OVER = '#313244' -_BORDER = '#45475a' -_TEXT = '#cdd6f4' -_SUB = '#a6adc8' -_MUTED = '#6c7086' -_ACCENT = '#cba6f7' -_GREEN = '#a6e3a1' -_RED = '#f38ba8' -_BLUE = '#89b4fa' -_TEAL = '#94e2d5' -_YELLOW = '#f9e2af' +_BG = "#181825" +_SURF = "#1e1e2e" +_OVER = "#313244" +_BORDER = "#45475a" +_TEXT = "#cdd6f4" +_SUB = "#a6adc8" +_MUTED = "#6c7086" +_ACCENT = "#cba6f7" +_GREEN = "#a6e3a1" +_RED = "#f38ba8" +_BLUE = "#89b4fa" +_TEAL = "#94e2d5" +_YELLOW = "#f9e2af" # --------------------------------------------------------------------------- # Server-side data cache {step -> AssembledData} (bounded to avoid OOM) @@ -467,49 +522,89 @@ def _get_cached_3d_mesh(step, var, mode, log_bool, vmin_in, vmax_in, # pylint: # Layout helpers # --------------------------------------------------------------------------- + def _section(title, *children): - return html.Div([ - html.Div(title, style={ - 'fontSize': '10px', 'fontWeight': 'bold', - 'textTransform': 'uppercase', 'letterSpacing': '0.08em', - 'color': _MUTED, 'borderBottom': f'1px solid {_OVER}', - 'paddingBottom': '4px', 'marginTop': '16px', 'marginBottom': '6px', - }), - *children, - ]) + return html.Div( + [ + html.Div( + title, + style={ + "fontSize": "10px", + "fontWeight": "bold", + "textTransform": "uppercase", + "letterSpacing": "0.08em", + "color": _MUTED, + "borderBottom": f"1px solid {_OVER}", + "paddingBottom": "4px", + "marginTop": "16px", + "marginBottom": "6px", + }, + ), + *children, + ] + ) def _lbl(text): - return html.Div(text, style={ - 'fontSize': '11px', 'color': _SUB, - 'marginBottom': '2px', 'marginTop': '6px', - }) + return html.Div( + text, + style={ + "fontSize": "11px", + "color": _SUB, + "marginBottom": "2px", + "marginTop": "6px", + }, + ) -def _slider(sid, lo, hi, step, val, marks=None): # pylint: disable=too-many-arguments,too-many-positional-arguments +def _slider(sid, lo, hi, step, val, marks=None): return dcc.Slider( - id=sid, min=lo, max=hi, step=step, value=val, - marks=marks or {}, updatemode='mouseup', + id=sid, + min=lo, + max=hi, + step=step, + value=val, + marks=marks or {}, + updatemode="mouseup", ) def _btn(bid, label, color=_TEXT): - return html.Button(label, id=bid, n_clicks=0, style={ - 'flex': '1', 'padding': '5px 8px', 'fontSize': '12px', - 'backgroundColor': _OVER, 'color': color, - 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', - 'cursor': 'pointer', 'fontFamily': 'monospace', - }) + return html.Button( + label, + id=bid, + n_clicks=0, + style={ + "flex": "1", + "padding": "5px 8px", + "fontSize": "12px", + "backgroundColor": _OVER, + "color": color, + "border": f"1px solid {_BORDER}", + "borderRadius": "4px", + "cursor": "pointer", + "fontFamily": "monospace", + }, + ) -def _num(sid, placeholder='auto'): +def _num(sid, placeholder="auto"): return dcc.Input( - id=sid, type='number', placeholder=placeholder, debounce=True, + id=sid, + type="number", + placeholder=placeholder, + debounce=True, style={ - 'width': '100%', 'backgroundColor': _OVER, 'color': _TEXT, - 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', - 'padding': '4px 6px', 'fontSize': '12px', 'fontFamily': 'monospace', - 'boxSizing': 'border-box', 'colorScheme': 'dark', + "width": "100%", + "backgroundColor": _OVER, + "color": _TEXT, + "border": f"1px solid {_BORDER}", + "borderRadius": "4px", + "padding": "4px 6px", + "fontSize": "12px", + "fontFamily": "monospace", + "boxSizing": "border-box", + "colorScheme": "dark", }, ) @@ -518,6 +613,7 @@ def _num(sid, placeholder='auto'): # 3D figure builder # --------------------------------------------------------------------------- + def _make_cbar(title_text: str, cmin: float, cmax: float, n: int = 6) -> dict: """Colorbar dict with Python-formatted tick labels. @@ -526,41 +622,42 @@ def _make_cbar(title_text: str, cmin: float, cmax: float, n: int = 6) -> dict: exponents (e.g. 1.23e+4). Custom tickvals/ticktext override d3. """ tickvals = np.linspace(cmin, cmax, n).tolist() - ticktext = [f'{v:.2e}' for v in tickvals] + ticktext = [f"{v:.2e}" for v in tickvals] return dict( title=dict(text=title_text, font=dict(color=_TEXT)), tickfont=dict(color=_TEXT), - tickmode='array', + tickmode="array", tickvals=tickvals, ticktext=ticktext, - len=0.75, lenmode='fraction', - yanchor='middle', y=0.5, + len=0.75, + lenmode="fraction", + yanchor="middle", + y=0.5, thickness=15, ) -def _slice_3d(raw, log_fn, x_cc, y_cc, z_cc, slice_axis, slice_pos, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals - max_pts=(600, 300)): +def _slice_3d(raw, log_fn, x_cc, y_cc, z_cc, slice_axis, slice_pos, max_pts=(600, 300)): """Extract and downsample a 2-D slice from a 3-D array. Returns (sliced_ds, coord1_ds, coord2_ds, actual, const_axis_value) where sliced_ds is the downsampled surfacecolor array and coord*_ds are the downsampled coordinate vectors for the two varying axes. """ - axis_coords = {'x': x_cc, 'y': y_cc, 'z': z_cc} + axis_coords = {"x": x_cc, "y": y_cc, "z": z_cc} coords = axis_coords[slice_axis] coord_val = coords[0] + (coords[-1] - coords[0]) * slice_pos idx = int(np.clip(np.argmin(np.abs(coords - coord_val)), 0, len(coords) - 1)) actual = float(coords[idx]) - if slice_axis == 'x': - sliced = log_fn(raw[idx, :, :]) # (ny, nz) + if slice_axis == "x": + sliced = log_fn(raw[idx, :, :]) # (ny, nz) c1, c2 = y_cc, z_cc - elif slice_axis == 'y': - sliced = log_fn(raw[:, idx, :]) # (nx, nz) + elif slice_axis == "y": + sliced = log_fn(raw[:, idx, :]) # (nx, nz) c1, c2 = x_cc, z_cc else: - sliced = log_fn(raw[:, :, idx]) # (nx, ny) + sliced = log_fn(raw[:, :, idx]) # (nx, ny) c1, c2 = x_cc, y_cc s1 = max(1, sliced.shape[0] // max_pts[0]) @@ -568,14 +665,32 @@ def _slice_3d(raw, log_fn, x_cc, y_cc, z_cc, slice_axis, slice_pos, # pylint: d return sliced[::s1, ::s2], c1[::s1], c2[::s2], actual -def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements,unused-argument - log_fn, cmin, cmax, cbar_title, - slice_axis, slice_pos, - iso_min_frac, iso_max_frac, iso_n, _iso_caps, - vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac, - iso_solid_color=None, iso_opacity=1.0, - cached_mesh=None, - max_total_3d: int = 150_000): +def _build_3d( + ad, + raw, + varname, + step, + mode, + cmap, + log_fn, + cmin, + cmax, + cbar_title, + slice_axis, + slice_pos, + iso_min_frac, + iso_max_frac, + iso_n, + _iso_caps, + vol_opacity, + vol_nsurf, + vol_min_frac, + vol_max_frac, + iso_solid_color=None, + iso_opacity=1.0, + cached_mesh=None, + max_total_3d: int = 150_000, +): """Return (trace, title) for a 3D assembled dataset. Downsamples iso/volume data via a uniform stride targeting *max_total_3d* @@ -586,10 +701,15 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar cbar = _make_cbar(cbar_title, cmin, cmax) rng = cmax - cmin if cmax > cmin else 1.0 - if mode == 'slice': + if mode == "slice": sliced, c1, c2, actual = _slice_3d( - raw, log_fn, ad.x_cc, ad.y_cc, ad.z_cc, - slice_axis, slice_pos, + raw, + log_fn, + ad.x_cc, + ad.y_cc, + ad.z_cc, + slice_axis, + slice_pos, ) # Use float32 for all arrays — halves JSON payload vs float64. # For z-slices (the default), also pass 1D x/y coordinate vectors: @@ -598,24 +718,31 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar # Convention: z[j,i] → (x[i], y[j]), so surfacecolor must be sliced.T. # x and y slices keep 2D coords but use float32. n1, n2 = len(c1), len(c2) - if slice_axis == 'x': - YY, ZZ = np.meshgrid(c1.astype(np.float32), c2.astype(np.float32), - indexing='ij') + if slice_axis == "x": + YY, ZZ = np.meshgrid(c1.astype(np.float32), c2.astype(np.float32), indexing="ij") trace = go.Surface( x=np.full((n1, n2), actual, dtype=np.float32), - y=YY, z=ZZ, + y=YY, + z=ZZ, surfacecolor=sliced.astype(np.float32), - cmin=cmin, cmax=cmax, - colorscale=cscale, colorbar=cbar, showscale=True, + cmin=cmin, + cmax=cmax, + colorscale=cscale, + colorbar=cbar, + showscale=True, ) - elif slice_axis == 'y': - XX, ZZ = np.meshgrid(c1.astype(np.float32), c2.astype(np.float32), - indexing='ij') + elif slice_axis == "y": + XX, ZZ = np.meshgrid(c1.astype(np.float32), c2.astype(np.float32), indexing="ij") trace = go.Surface( - x=XX, y=np.full((n1, n2), actual, dtype=np.float32), z=ZZ, + x=XX, + y=np.full((n1, n2), actual, dtype=np.float32), + z=ZZ, surfacecolor=sliced.astype(np.float32), - cmin=cmin, cmax=cmax, - colorscale=cscale, colorbar=cbar, showscale=True, + cmin=cmin, + cmax=cmax, + colorscale=cscale, + colorbar=cbar, + showscale=True, ) else: # z-slice: x and y both vary — use 1D vectors. @@ -625,12 +752,15 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar y=c2.astype(np.float32), z=np.full((n2, n1), actual, dtype=np.float32), surfacecolor=sliced.T.astype(np.float32), - cmin=cmin, cmax=cmax, - colorscale=cscale, colorbar=cbar, showscale=True, + cmin=cmin, + cmax=cmax, + colorscale=cscale, + colorbar=cbar, + showscale=True, ) - title = f'{varname} · {slice_axis} = {actual:.4g} · step {step}' + title = f"{varname} · {slice_axis} = {actual:.4g} · step {step}" - elif mode == 'isosurface': + elif mode == "isosurface": # Use a higher-resolution grid for server-side marching cubes. # skimage.marching_cubes runs in compiled C — 10–100× faster than # Plotly's go.Isosurface which runs marching cubes in JavaScript. @@ -644,7 +774,14 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar _iso_budget = max_total_3d # allow caller to reduce for playback raw_ds, x_ds, y_ds, z_ds = _get_ds3(step, varname, raw, ad.x_cc, ad.y_cc, ad.z_cc, _iso_budget) vx, vy, vz, fi, fj, fk, intens = _compute_isomesh( - raw_ds, x_ds, y_ds, z_ds, log_fn, ilo, ihi, iso_n, + raw_ds, + x_ds, + y_ds, + z_ds, + log_fn, + ilo, + ihi, + iso_n, ) _iso_op = float(iso_opacity) if iso_opacity is not None else 1.0 if iso_solid_color: @@ -654,33 +791,48 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar # default colorscale behavior). _solid_cs = [[0, iso_solid_color], [1, iso_solid_color]] trace = go.Mesh3d( - x=vx, y=vy, z=vz, i=fi, j=fj, k=fk, + x=vx, + y=vy, + z=vz, + i=fi, + j=fj, + k=fk, intensity=np.zeros(len(vx), dtype=np.float32), - intensitymode='vertex', - colorscale=_solid_cs, cmin=0, cmax=1, - showscale=False, opacity=_iso_op, - lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, - roughness=0.5, fresnel=0.2), + intensitymode="vertex", + colorscale=_solid_cs, + cmin=0, + cmax=1, + showscale=False, + opacity=_iso_op, + lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, roughness=0.5, fresnel=0.2), lightposition=dict(x=1000, y=500, z=500), flatshading=False, ) else: trace = go.Mesh3d( - x=vx, y=vy, z=vz, i=fi, j=fj, k=fk, - intensity=intens, intensitymode='vertex', - colorscale=cscale, cmin=ilo, cmax=ihi, - colorbar=_make_cbar(cbar_title, ilo, ihi), showscale=True, + x=vx, + y=vy, + z=vz, + i=fi, + j=fj, + k=fk, + intensity=intens, + intensitymode="vertex", + colorscale=cscale, + cmin=ilo, + cmax=ihi, + colorbar=_make_cbar(cbar_title, ilo, ihi), + showscale=True, opacity=_iso_op, - lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, - roughness=0.5, fresnel=0.2), + lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, roughness=0.5, fresnel=0.2), lightposition=dict(x=1000, y=500, z=500), flatshading=False, ) - title = f'{varname} · {int(iso_n)} isosurfaces · step {step}' + title = f"{varname} · {int(iso_n)} isosurfaces · step {step}" - else: # volume + else: # volume raw_ds, x_ds, y_ds, z_ds = _get_ds3(step, varname, raw, ad.x_cc, ad.y_cc, ad.z_cc, max_total_3d) - X3, Y3, Z3 = np.meshgrid(x_ds, y_ds, z_ds, indexing='ij') + X3, Y3, Z3 = np.meshgrid(x_ds, y_ds, z_ds, indexing="ij") vf = log_fn(raw_ds.ravel()).astype(np.float32) vlo = cmin + rng * vol_min_frac vhi = cmin + rng * max(vol_max_frac, vol_min_frac + 0.01) @@ -689,11 +841,16 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar y=Y3.ravel().astype(np.float32), z=Z3.ravel().astype(np.float32), value=vf, - isomin=vlo, isomax=vhi, - opacity=float(vol_opacity), surface_count=int(vol_nsurf), - colorscale=cscale, cmin=cmin, cmax=cmax, colorbar=cbar, + isomin=vlo, + isomax=vhi, + opacity=float(vol_opacity), + surface_count=int(vol_nsurf), + colorscale=cscale, + cmin=cmin, + cmax=cmax, + colorbar=cbar, ) - title = f'{varname} · volume · step {step}' + title = f"{varname} · volume · step {step}" return trace, title @@ -702,26 +859,31 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar # Kaleido server-side Plotly rendering (fast playback for 3D) # --------------------------------------------------------------------------- + def _kaleido_available() -> bool: """Return True if plotly's kaleido engine can export images.""" try: - import kaleido # pylint: disable=import-outside-toplevel,unused-import + import kaleido # noqa: F401 + return True except ImportError: return False + _KALEIDO_OK: Optional[bool] = None # lazy probe -def _kaleido_render(fig: go.Figure, width: int = 1280, # pylint: disable=too-many-arguments,too-many-positional-arguments - height: int = 960) -> str: +def _kaleido_render( + fig: go.Figure, + width: int = 1280, + height: int = 960, +) -> str: """Render a Plotly figure to a JPEG base64 data URI via kaleido. Returns a ``data:image/jpeg;base64,...`` string suitable for an ``html.Img`` ``src`` attribute. """ - img_bytes = fig.to_image(format='jpeg', width=width, height=height, - scale=1, engine='kaleido') + img_bytes = fig.to_image(format="jpeg", width=width, height=height, scale=1, engine="kaleido") b64 = base64.b64encode(img_bytes).decode() return f"data:image/jpeg;base64,{b64}" @@ -730,6 +892,7 @@ def _kaleido_render(fig: go.Figure, width: int = 1280, # pylint: disable=too-ma # Contour overlay helpers # --------------------------------------------------------------------------- + def _interp_indices(indices, coords): """Map fractional array indices to physical coordinates via linear interp.""" cl = np.clip(indices, 0, len(coords) - 1) @@ -739,7 +902,7 @@ def _interp_indices(indices, coords): return coords[fl] * (1 - frac) + coords[ce] * frac -def _compute_contour_traces(data_2d, x_cc, y_cc, nlevels, color, lw): # pylint: disable=too-many-arguments,too-many-positional-arguments +def _compute_contour_traces(data_2d, x_cc, y_cc, nlevels, color, lw): """Compute isocontour lines on a 2D array via skimage.find_contours. Returns a list of go.Scatter traces (one per contour segment) that can be @@ -757,32 +920,45 @@ def _compute_contour_traces(data_2d, x_cc, y_cc, nlevels, color, lw): # pylint: # contour is (N, 2) in row/col index space px = _interp_indices(contour[:, 0], x_cc) py = _interp_indices(contour[:, 1], y_cc) - traces.append(go.Scatter( - x=px, y=py, mode='lines', - line=dict(color=color, width=lw), - showlegend=False, hoverinfo='skip', - )) + traces.append( + go.Scatter( + x=px, + y=py, + mode="lines", + line=dict(color=color, width=lw), + showlegend=False, + hoverinfo="skip", + ) + ) return traces -def _compute_contour_traces_3d(data_3d, x_cc, y_cc, z_cc, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals - slice_axis, slice_pos, - nlevels, color, lw): +def _compute_contour_traces_3d( + data_3d, + x_cc, + y_cc, + z_cc, + slice_axis, + slice_pos, + nlevels, + color, + lw, +): """Compute isocontour lines on a 3D slice, returned as Scatter3d traces.""" - axis_coords = {'x': x_cc, 'y': y_cc, 'z': z_cc} + axis_coords = {"x": x_cc, "y": y_cc, "z": z_cc} coords = axis_coords[slice_axis] coord_val = coords[0] + (coords[-1] - coords[0]) * slice_pos idx = int(np.clip(np.argmin(np.abs(coords - coord_val)), 0, len(coords) - 1)) actual = float(coords[idx]) - if slice_axis == 'x': - sliced = data_3d[idx, :, :] # (ny, nz) + if slice_axis == "x": + sliced = data_3d[idx, :, :] # (ny, nz) c1, c2 = y_cc, z_cc - elif slice_axis == 'y': - sliced = data_3d[:, idx, :] # (nx, nz) + elif slice_axis == "y": + sliced = data_3d[:, idx, :] # (nx, nz) c1, c2 = x_cc, z_cc else: - sliced = data_3d[:, :, idx] # (nx, ny) + sliced = data_3d[:, :, idx] # (nx, ny) c1, c2 = x_cc, y_cc vmin_c, vmax_c = float(np.nanmin(sliced)), float(np.nanmax(sliced)) @@ -797,17 +973,23 @@ def _compute_contour_traces_3d(data_3d, x_cc, y_cc, z_cc, # pylint: disable=too p2 = _interp_indices(contour[:, 1], c2) n = len(p1) const = np.full(n, actual, dtype=np.float32) - if slice_axis == 'x': + if slice_axis == "x": sx, sy, sz = const, p1, p2 - elif slice_axis == 'y': + elif slice_axis == "y": sx, sy, sz = p1, const, p2 else: sx, sy, sz = p1, p2, const - traces.append(go.Scatter3d( - x=sx, y=sy, z=sz, mode='lines', - line=dict(color=color, width=lw * 2), # thicker in 3D - showlegend=False, hoverinfo='skip', - )) + traces.append( + go.Scatter3d( + x=sx, + y=sy, + z=sz, + mode="lines", + line=dict(color=color, width=lw * 2), # thicker in 3D + showlegend=False, + hoverinfo="skip", + ) + ) return traces @@ -815,14 +997,15 @@ def _compute_contour_traces_3d(data_3d, x_cc, y_cc, z_cc, # pylint: disable=too # Main entry point # --------------------------------------------------------------------------- -def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too-many-arguments,too-many-positional-arguments - varname: str, - steps: List[int], - read_func: Callable, - port: int = 8050, - host: str = '127.0.0.1', - bubble_func: Optional[Callable] = None, - read_one_var_func: Optional[Callable] = None, + +def run_interactive( + varname: str, + steps: List[int], + read_func: Callable, + port: int = 8050, + host: str = "127.0.0.1", + bubble_func: Optional[Callable] = None, + read_one_var_func: Optional[Callable] = None, ): """Launch the interactive Dash visualization server. @@ -834,7 +1017,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- """ app = Dash( __name__, - title=f'MFC viz · {varname}', + title=f"MFC viz · {varname}", suppress_callback_exceptions=True, ) @@ -842,8 +1025,9 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- # Dash components (Dropdown, Input, Slider) render internal DOM that # ignores inline style props. We inject CSS via app.index_string. # Build CSS using %-formatting to avoid f-string brace conflicts. - _V = {'bg': _OVER, 'tx': _TEXT, 'bd': _BORDER, 'ac': _ACCENT} - _dark_css = """ + _V = {"bg": _OVER, "tx": _TEXT, "bd": _BORDER, "ac": _ACCENT} + _dark_css = ( + """ * { color-scheme: dark; } /* Dropdowns — target by known IDs + universal child selectors */ #var-sel *, #cmap-sel *, #overlay-var-sel *, #overlay-color-sel *, #iso-solid-color *, #overlay-mode-sel *, @@ -905,14 +1089,16 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- background-color: %(ac)s !important; color: #11111b !important; } -""" % _V +""" + % _V + ) app.index_string = ( - '\n\n\n' - '{%metas%}\n{%title%}\n{%favicon%}\n{%css%}\n' - '\n' - '\n\n' - '{%app_entry%}\n
\n{%config%}\n{%scripts%}\n{%renderer%}\n' - '
\n\n' + "\n\n\n" + "{%metas%}\n{%title%}\n{%favicon%}\n{%css%}\n" + "\n" + "\n\n" + "{%app_entry%}\n
\n{%config%}\n{%scripts%}\n{%renderer%}\n" + "
\n\n" ) # Load first step to know dimensionality and available variables @@ -922,327 +1108,403 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- if varname not in all_varnames: varname = all_varnames[0] if all_varnames else varname - var_opts = [{'label': v, 'value': v} for v in all_varnames] - cmap_opts = [{'label': c, 'value': c} for c in _CMAPS] + var_opts = [{"label": v, "value": v} for v in all_varnames] + cmap_opts = [{"label": c, "value": c} for c in _CMAPS] if ndim == 3: mode_opts = [ - {'label': ' Slice', 'value': 'slice'}, - {'label': ' Isosurface', 'value': 'isosurface'}, - {'label': ' Volume', 'value': 'volume'}, + {"label": " Slice", "value": "slice"}, + {"label": " Isosurface", "value": "isosurface"}, + {"label": " Volume", "value": "volume"}, ] elif ndim == 2: - mode_opts = [{'label': ' Heatmap', 'value': 'heatmap'}] + mode_opts = [{"label": " Heatmap", "value": "heatmap"}] else: - mode_opts = [{'label': ' Line', 'value': 'line'}] - default_mode = mode_opts[0]['value'] + mode_opts = [{"label": " Line", "value": "line"}] + default_mode = mode_opts[0]["value"] # ------------------------------------------------------------------ # Sidebar layout # ------------------------------------------------------------------ - sidebar = html.Div([ - - # Header - html.Div('MFC viz', style={ - 'fontSize': '16px', 'fontWeight': 'bold', 'color': _ACCENT, - }), - html.Div( - f'{ndim}D · {len(steps)} step{"s" if len(steps) != 1 else ""}', - style={'fontSize': '11px', 'color': _MUTED}, - ), - - # ── Variable ────────────────────────────────────────────────── - _section('Variable', - dcc.Dropdown( - id='var-sel', options=var_opts, value=varname, clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, - ), - ), - - # ── Timestep ────────────────────────────────────────────────── - _section('Timestep', - dcc.Slider( - id='step-sl', - min=0, max=len(steps) - 1, step=1, value=0, - marks={i: {'label': str(s), 'style': {'fontSize': '9px', 'color': _MUTED}} - for i, s in enumerate(steps)} - if len(steps) <= 10 - else {0: {'label': str(steps[0]), 'style': {'fontSize': '9px', 'color': _MUTED}}, - len(steps) - 1: {'label': str(steps[-1]), 'style': {'fontSize': '9px', 'color': _MUTED}}}, - updatemode='mouseup', - ), - html.Div(id='step-label', style={ - 'fontSize': '11px', 'color': _YELLOW, 'textAlign': 'center', - 'marginTop': '2px', - }), - # Hidden store that holds the actual step value (not the index) - dcc.Store(id='step-sel', data=steps[0]), - html.Div([ - _btn('play-btn', '▶ Play', _GREEN), - html.Div(style={'width': '6px'}), - _btn('stop-btn', '■ Stop', _RED), - ], style={'display': 'flex', 'marginTop': '6px'}), - _lbl('Playback speed (fps)'), - _slider('fps-sl', 0.5, 10, 0.5, 2, - marks={0.5: '0.5', 2: '2', 5: '5', 10: '10'}), - dcc.Checklist( - id='loop-chk', - options=[{'label': ' Loop', 'value': 'loop'}], value=['loop'], - style={'fontSize': '12px', 'color': _SUB, 'marginTop': '4px'}, + sidebar = html.Div( + [ + # Header + html.Div( + "MFC viz", + style={ + "fontSize": "16px", + "fontWeight": "bold", + "color": _ACCENT, + }, ), - ), - - # ── Viz mode ────────────────────────────────────────────────── - _section('Viz Mode', - dcc.RadioItems( - id='mode-sel', options=mode_opts, value=default_mode, - style={'fontSize': '12px', 'color': _TEXT}, - inputStyle={'marginRight': '6px', 'cursor': 'pointer'}, - labelStyle={'display': 'block', 'marginBottom': '5px', - 'cursor': 'pointer', 'color': _TEXT}, + html.Div( + f"{ndim}D · {len(steps)} step{'s' if len(steps) != 1 else ''}", + style={"fontSize": "11px", "color": _MUTED}, ), - ), - - # ── Slice ───────────────────────────────────────────────────── - html.Div(id='ctrl-slice', children=[ - _section('Slice', - _lbl('Axis'), - dcc.RadioItems( - id='slice-axis', options=['x', 'y', 'z'], value='z', - inline=True, style={'fontSize': '12px', 'color': _TEXT}, - inputStyle={'marginRight': '4px'}, - labelStyle={'marginRight': '14px', 'color': _TEXT}, + # ── Variable ────────────────────────────────────────────────── + _section( + "Variable", + dcc.Dropdown( + id="var-sel", + options=var_opts, + value=varname, + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, ), - _lbl('Position (0 = start, 1 = end)'), - _slider('slice-pos', 0.0, 1.0, 0.01, 0.5, - marks={0: '0', 0.5: '½', 1: '1'}), ), - ]), - - # ── Isosurface ──────────────────────────────────────────────── - html.Div(id='ctrl-iso', style={'display': 'none'}, children=[ - _section('Isosurface', - _lbl('Min threshold (fraction of color range)'), - _slider('iso-min', 0.0, 1.0, 0.01, 0.2, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Max threshold (fraction of color range)'), - _slider('iso-max', 0.0, 1.0, 0.01, 0.8, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Number of isosurfaces'), - _slider('iso-n', 1, 10, 1, 3, - marks={1: '1', 3: '3', 5: '5', 10: '10'}), - _lbl('Opacity'), - _slider('iso-opacity', 0.05, 1.0, 0.05, 1.0, - marks={0.05: '0', 0.5: '0.5', 1.0: '1'}), - dcc.Checklist( - id='iso-caps', - options=[{'label': ' Show end-caps', 'value': 'caps'}], value=[], - style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, + # ── Timestep ────────────────────────────────────────────────── + _section( + "Timestep", + dcc.Slider( + id="step-sl", + min=0, + max=len(steps) - 1, + step=1, + value=0, + marks={i: {"label": str(s), "style": {"fontSize": "9px", "color": _MUTED}} for i, s in enumerate(steps)} + if len(steps) <= 10 + else {0: {"label": str(steps[0]), "style": {"fontSize": "9px", "color": _MUTED}}, len(steps) - 1: {"label": str(steps[-1]), "style": {"fontSize": "9px", "color": _MUTED}}}, + updatemode="mouseup", ), + html.Div( + id="step-label", + style={ + "fontSize": "11px", + "color": _YELLOW, + "textAlign": "center", + "marginTop": "2px", + }, + ), + # Hidden store that holds the actual step value (not the index) + dcc.Store(id="step-sel", data=steps[0]), + html.Div( + [ + _btn("play-btn", "▶ Play", _GREEN), + html.Div(style={"width": "6px"}), + _btn("stop-btn", "■ Stop", _RED), + ], + style={"display": "flex", "marginTop": "6px"}, + ), + _lbl("Playback speed (fps)"), + _slider("fps-sl", 0.5, 10, 0.5, 2, marks={0.5: "0.5", 2: "2", 5: "5", 10: "10"}), dcc.Checklist( - id='iso-solid-chk', - options=[{'label': ' Solid color', 'value': 'solid'}], value=[], - style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, + id="loop-chk", + options=[{"label": " Loop", "value": "loop"}], + value=["loop"], + style={"fontSize": "12px", "color": _SUB, "marginTop": "4px"}, ), - html.Div(id='ctrl-iso-solid-color', style={'display': 'none'}, children=[ - _lbl('Surface color'), - dcc.Dropdown( - id='iso-solid-color', - options=[ - {'label': 'white', 'value': 'white'}, - {'label': 'gray', 'value': '#6c7086'}, - {'label': 'red', 'value': '#f38ba8'}, - {'label': 'cyan', 'value': '#94e2d5'}, - {'label': 'yellow', 'value': '#f9e2af'}, - {'label': 'green', 'value': '#a6e3a1'}, - {'label': 'blue', 'value': '#89b4fa'}, - {'label': 'mauve', 'value': '#cba6f7'}, - ], - value='#89b4fa', clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, - ), - ]), ), - ]), - - # ── Volume ──────────────────────────────────────────────────── - html.Div(id='ctrl-vol', style={'display': 'none'}, children=[ - _section('Volume', - _lbl('Opacity per shell'), - _slider('vol-opacity', 0.01, 0.5, 0.01, 0.1, - marks={0.01: '0', 0.25: '.25', 0.5: '.5'}), - _lbl('Number of shells'), - _slider('vol-nsurf', 3, 30, 1, 15, - marks={3: '3', 15: '15', 30: '30'}), - _lbl('Isomin (fraction of color range)'), - _slider('vol-min', 0.0, 1.0, 0.01, 0.0, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Isomax (fraction of color range)'), - _slider('vol-max', 0.0, 1.0, 0.01, 1.0, - marks={0: '0', 0.5: '0.5', 1: '1'}), + # ── Viz mode ────────────────────────────────────────────────── + _section( + "Viz Mode", + dcc.RadioItems( + id="mode-sel", + options=mode_opts, + value=default_mode, + style={"fontSize": "12px", "color": _TEXT}, + inputStyle={"marginRight": "6px", "cursor": "pointer"}, + labelStyle={"display": "block", "marginBottom": "5px", "cursor": "pointer", "color": _TEXT}, + ), ), - ]), - - # ── Color ───────────────────────────────────────────────────── - _section('Color', - _lbl('Colormap'), - dcc.Dropdown( - id='cmap-sel', options=cmap_opts, value='viridis', clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, + # ── Slice ───────────────────────────────────────────────────── + html.Div( + id="ctrl-slice", + children=[ + _section( + "Slice", + _lbl("Axis"), + dcc.RadioItems( + id="slice-axis", + options=["x", "y", "z"], + value="z", + inline=True, + style={"fontSize": "12px", "color": _TEXT}, + inputStyle={"marginRight": "4px"}, + labelStyle={"marginRight": "14px", "color": _TEXT}, + ), + _lbl("Position (0 = start, 1 = end)"), + _slider("slice-pos", 0.0, 1.0, 0.01, 0.5, marks={0: "0", 0.5: "½", 1: "1"}), + ), + ], ), - dcc.Checklist( - id='log-chk', - options=[{'label': ' Log scale', 'value': 'log'}], value=[], - style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, + # ── Isosurface ──────────────────────────────────────────────── + html.Div( + id="ctrl-iso", + style={"display": "none"}, + children=[ + _section( + "Isosurface", + _lbl("Min threshold (fraction of color range)"), + _slider("iso-min", 0.0, 1.0, 0.01, 0.2, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Max threshold (fraction of color range)"), + _slider("iso-max", 0.0, 1.0, 0.01, 0.8, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Number of isosurfaces"), + _slider("iso-n", 1, 10, 1, 3, marks={1: "1", 3: "3", 5: "5", 10: "10"}), + _lbl("Opacity"), + _slider("iso-opacity", 0.05, 1.0, 0.05, 1.0, marks={0.05: "0", 0.5: "0.5", 1.0: "1"}), + dcc.Checklist( + id="iso-caps", + options=[{"label": " Show end-caps", "value": "caps"}], + value=[], + style={"fontSize": "12px", "color": _SUB, "marginTop": "6px"}, + ), + dcc.Checklist( + id="iso-solid-chk", + options=[{"label": " Solid color", "value": "solid"}], + value=[], + style={"fontSize": "12px", "color": _SUB, "marginTop": "6px"}, + ), + html.Div( + id="ctrl-iso-solid-color", + style={"display": "none"}, + children=[ + _lbl("Surface color"), + dcc.Dropdown( + id="iso-solid-color", + options=[ + {"label": "white", "value": "white"}, + {"label": "gray", "value": "#6c7086"}, + {"label": "red", "value": "#f38ba8"}, + {"label": "cyan", "value": "#94e2d5"}, + {"label": "yellow", "value": "#f9e2af"}, + {"label": "green", "value": "#a6e3a1"}, + {"label": "blue", "value": "#89b4fa"}, + {"label": "mauve", "value": "#cba6f7"}, + ], + value="#89b4fa", + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, + ), + ], + ), + ), + ], ), - html.Div([ - html.Div([_lbl('vmin'), _num('vmin-inp')], - style={'flex': 1, 'marginRight': '6px'}), - html.Div([_lbl('vmax'), _num('vmax-inp')], - style={'flex': 1}), - ], style={'display': 'flex'}), - html.Button('↺ Auto range', id='reset-btn', n_clicks=0, style={ - 'marginTop': '8px', 'padding': '4px 8px', 'fontSize': '11px', - 'width': '100%', 'backgroundColor': _OVER, 'color': _TEAL, - 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', - 'cursor': 'pointer', 'fontFamily': 'monospace', - }), - ), - - # ── Overlay ─────────────────────────────────────────────────── - html.Div(id='ctrl-overlay', children=[ - _section('Overlay', - _lbl('Variable'), + # ── Volume ──────────────────────────────────────────────────── + html.Div( + id="ctrl-vol", + style={"display": "none"}, + children=[ + _section( + "Volume", + _lbl("Opacity per shell"), + _slider("vol-opacity", 0.01, 0.5, 0.01, 0.1, marks={0.01: "0", 0.25: ".25", 0.5: ".5"}), + _lbl("Number of shells"), + _slider("vol-nsurf", 3, 30, 1, 15, marks={3: "3", 15: "15", 30: "30"}), + _lbl("Isomin (fraction of color range)"), + _slider("vol-min", 0.0, 1.0, 0.01, 0.0, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Isomax (fraction of color range)"), + _slider("vol-max", 0.0, 1.0, 0.01, 1.0, marks={0: "0", 0.5: "0.5", 1: "1"}), + ), + ], + ), + # ── Color ───────────────────────────────────────────────────── + _section( + "Color", + _lbl("Colormap"), dcc.Dropdown( - id='overlay-var-sel', - options=[{'label': 'None', 'value': '__none__'}] + var_opts, - value='__none__', clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, + id="cmap-sel", + options=cmap_opts, + value="viridis", + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, ), - _lbl('Levels'), - _slider('overlay-nlevels', 1, 20, 1, 5, - marks={1: '1', 5: '5', 10: '10', 20: '20'}), - _lbl('Color'), - dcc.Dropdown( - id='overlay-color-sel', - options=[ - {'label': 'white', 'value': 'white'}, - {'label': 'black', 'value': 'black'}, - {'label': 'red', 'value': '#f38ba8'}, - {'label': 'cyan', 'value': '#94e2d5'}, - {'label': 'yellow', 'value': '#f9e2af'}, - {'label': 'green', 'value': '#a6e3a1'}, - {'label': 'blue', 'value': '#89b4fa'}, - {'label': 'mauve', 'value': '#cba6f7'}, + dcc.Checklist( + id="log-chk", + options=[{"label": " Log scale", "value": "log"}], + value=[], + style={"fontSize": "12px", "color": _SUB, "marginTop": "6px"}, + ), + html.Div( + [ + html.Div([_lbl("vmin"), _num("vmin-inp")], style={"flex": 1, "marginRight": "6px"}), + html.Div([_lbl("vmax"), _num("vmax-inp")], style={"flex": 1}), ], - value='white', clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, + style={"display": "flex"}, + ), + html.Button( + "↺ Auto range", + id="reset-btn", + n_clicks=0, + style={ + "marginTop": "8px", + "padding": "4px 8px", + "fontSize": "11px", + "width": "100%", + "backgroundColor": _OVER, + "color": _TEAL, + "border": f"1px solid {_BORDER}", + "borderRadius": "4px", + "cursor": "pointer", + "fontFamily": "monospace", + }, ), - # Contour-specific: line width (hidden in isosurface/volume mode) - html.Div(id='ctrl-overlay-contour', children=[ - _lbl('Line width'), - _slider('overlay-lw', 0.5, 3.0, 0.5, 1.0, - marks={0.5: '0.5', 1.0: '1', 2.0: '2', 3.0: '3'}), - ]), - # Overlay mode selector for 3D isosurface/volume modes - html.Div(id='ctrl-overlay-mode', style={'display': 'none'}, children=[ - _lbl('Overlay type'), - dcc.Dropdown( - id='overlay-mode-sel', - options=[ - {'label': 'Isosurface', 'value': 'isosurface'}, - {'label': 'Isovolume', 'value': 'volume'}, - ], - value='isosurface', clearable=False, - style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORDER}'}, - ), - ]), - # Isosurface overlay controls - html.Div(id='ctrl-overlay-iso', style={'display': 'none'}, children=[ - _lbl('Min threshold (fraction of range)'), - _slider('overlay-iso-min', 0.0, 1.0, 0.01, 0.2, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Max threshold (fraction of range)'), - _slider('overlay-iso-max', 0.0, 1.0, 0.01, 0.8, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Opacity'), - _slider('overlay-iso-opacity', 0.05, 1.0, 0.05, 0.6, - marks={0.05: '0', 0.5: '0.5', 1.0: '1'}), - dcc.Checklist( - id='overlay-iso-byval', - options=[{'label': ' Color by value', 'value': 'byval'}], - value=[], - style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, - ), - ]), - # Isovolume overlay controls - html.Div(id='ctrl-overlay-vol', style={'display': 'none'}, children=[ - _lbl('Min threshold (fraction of range)'), - _slider('overlay-vol-min', 0.0, 1.0, 0.01, 0.0, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Max threshold (fraction of range)'), - _slider('overlay-vol-max', 0.0, 1.0, 0.01, 1.0, - marks={0: '0', 0.5: '0.5', 1: '1'}), - _lbl('Opacity per shell'), - _slider('overlay-vol-opacity', 0.01, 0.5, 0.01, 0.1, - marks={0.01: '0', 0.25: '.25', 0.5: '.5'}), - _lbl('Number of shells'), - _slider('overlay-vol-nsurf', 3, 30, 1, 15, - marks={3: '3', 15: '15', 30: '30'}), - ]), ), - ], style={'display': 'block' if ndim >= 2 else 'none'}), - - # ── Status ──────────────────────────────────────────────────── - html.Div(id='status-bar', style={ - 'marginTop': 'auto', 'paddingTop': '12px', - 'fontSize': '11px', 'color': _MUTED, - 'borderTop': f'1px solid {_OVER}', 'lineHeight': '1.7', - }), - - ], style={ - 'width': '265px', 'minWidth': '265px', - 'backgroundColor': _SURF, 'padding': '14px', - 'height': '100vh', 'overflowY': 'auto', - 'display': 'flex', 'flexDirection': 'column', - 'fontFamily': 'monospace', 'color': _TEXT, - 'boxSizing': 'border-box', - }) - - app.layout = html.Div([ - sidebar, - html.Div([ - dcc.Graph( - id='viz-graph', style={'height': '100vh'}, - config={ - 'displayModeBar': True, 'scrollZoom': True, - 'modeBarButtonsToRemove': ['select2d', 'lasso2d'], - 'toImageButtonOptions': {'format': 'png', 'scale': 2}, - }, + # ── Overlay ─────────────────────────────────────────────────── + html.Div( + id="ctrl-overlay", + children=[ + _section( + "Overlay", + _lbl("Variable"), + dcc.Dropdown( + id="overlay-var-sel", + options=[{"label": "None", "value": "__none__"}] + var_opts, + value="__none__", + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, + ), + _lbl("Levels"), + _slider("overlay-nlevels", 1, 20, 1, 5, marks={1: "1", 5: "5", 10: "10", 20: "20"}), + _lbl("Color"), + dcc.Dropdown( + id="overlay-color-sel", + options=[ + {"label": "white", "value": "white"}, + {"label": "black", "value": "black"}, + {"label": "red", "value": "#f38ba8"}, + {"label": "cyan", "value": "#94e2d5"}, + {"label": "yellow", "value": "#f9e2af"}, + {"label": "green", "value": "#a6e3a1"}, + {"label": "blue", "value": "#89b4fa"}, + {"label": "mauve", "value": "#cba6f7"}, + ], + value="white", + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, + ), + # Contour-specific: line width (hidden in isosurface/volume mode) + html.Div( + id="ctrl-overlay-contour", + children=[ + _lbl("Line width"), + _slider("overlay-lw", 0.5, 3.0, 0.5, 1.0, marks={0.5: "0.5", 1.0: "1", 2.0: "2", 3.0: "3"}), + ], + ), + # Overlay mode selector for 3D isosurface/volume modes + html.Div( + id="ctrl-overlay-mode", + style={"display": "none"}, + children=[ + _lbl("Overlay type"), + dcc.Dropdown( + id="overlay-mode-sel", + options=[ + {"label": "Isosurface", "value": "isosurface"}, + {"label": "Isovolume", "value": "volume"}, + ], + value="isosurface", + clearable=False, + style={"fontSize": "12px", "backgroundColor": _OVER, "border": f"1px solid {_BORDER}"}, + ), + ], + ), + # Isosurface overlay controls + html.Div( + id="ctrl-overlay-iso", + style={"display": "none"}, + children=[ + _lbl("Min threshold (fraction of range)"), + _slider("overlay-iso-min", 0.0, 1.0, 0.01, 0.2, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Max threshold (fraction of range)"), + _slider("overlay-iso-max", 0.0, 1.0, 0.01, 0.8, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Opacity"), + _slider("overlay-iso-opacity", 0.05, 1.0, 0.05, 0.6, marks={0.05: "0", 0.5: "0.5", 1.0: "1"}), + dcc.Checklist( + id="overlay-iso-byval", + options=[{"label": " Color by value", "value": "byval"}], + value=[], + style={"fontSize": "12px", "color": _SUB, "marginTop": "6px"}, + ), + ], + ), + # Isovolume overlay controls + html.Div( + id="ctrl-overlay-vol", + style={"display": "none"}, + children=[ + _lbl("Min threshold (fraction of range)"), + _slider("overlay-vol-min", 0.0, 1.0, 0.01, 0.0, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Max threshold (fraction of range)"), + _slider("overlay-vol-max", 0.0, 1.0, 0.01, 1.0, marks={0: "0", 0.5: "0.5", 1: "1"}), + _lbl("Opacity per shell"), + _slider("overlay-vol-opacity", 0.01, 0.5, 0.01, 0.1, marks={0.01: "0", 0.25: ".25", 0.5: ".5"}), + _lbl("Number of shells"), + _slider("overlay-vol-nsurf", 3, 30, 1, 15, marks={3: "3", 15: "15", 30: "30"}), + ], + ), + ), + ], + style={"display": "block" if ndim >= 2 else "none"}, ), - html.Img( - id='srv-img', + # ── Status ──────────────────────────────────────────────────── + html.Div( + id="status-bar", style={ - 'display': 'none', 'width': '100%', 'height': '100vh', - 'objectFit': 'contain', 'backgroundColor': _BG, - 'cursor': 'grab', + "marginTop": "auto", + "paddingTop": "12px", + "fontSize": "11px", + "color": _MUTED, + "borderTop": f"1px solid {_OVER}", + "lineHeight": "1.7", }, ), - ], style={'flex': '1', 'overflow': 'hidden', 'backgroundColor': _BG, - 'position': 'relative'}), + ], + style={ + "width": "265px", + "minWidth": "265px", + "backgroundColor": _SURF, + "padding": "14px", + "height": "100vh", + "overflowY": "auto", + "display": "flex", + "flexDirection": "column", + "fontFamily": "monospace", + "color": _TEXT, + "boxSizing": "border-box", + }, + ) - dcc.Interval(id='play-iv', interval=500, n_intervals=0, disabled=True), - dcc.Store(id='playing-st', data=False), - ], style={ - 'display': 'flex', 'height': '100vh', 'overflow': 'hidden', - 'backgroundColor': _BG, 'fontFamily': 'monospace', - }) + app.layout = html.Div( + [ + sidebar, + html.Div( + [ + dcc.Graph( + id="viz-graph", + style={"height": "100vh"}, + config={ + "displayModeBar": True, + "scrollZoom": True, + "modeBarButtonsToRemove": ["select2d", "lasso2d"], + "toImageButtonOptions": {"format": "png", "scale": 2}, + }, + ), + html.Img( + id="srv-img", + style={ + "display": "none", + "width": "100%", + "height": "100vh", + "objectFit": "contain", + "backgroundColor": _BG, + "cursor": "grab", + }, + ), + ], + style={"flex": "1", "overflow": "hidden", "backgroundColor": _BG, "position": "relative"}, + ), + dcc.Interval(id="play-iv", interval=500, n_intervals=0, disabled=True), + dcc.Store(id="playing-st", data=False), + ], + style={ + "display": "flex", + "height": "100vh", + "overflow": "hidden", + "backgroundColor": _BG, + "fontFamily": "monospace", + }, + ) # ------------------------------------------------------------------ # Callbacks @@ -1254,26 +1516,26 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- _is_playing = [False] @app.callback( - Output('play-iv', 'disabled'), - Output('play-iv', 'interval'), - Output('playing-st', 'data'), - Output('play-btn', 'children'), - Input('play-btn', 'n_clicks'), - Input('stop-btn', 'n_clicks'), - Input('fps-sl', 'value'), - State('playing-st', 'data'), + Output("play-iv", "disabled"), + Output("play-iv", "interval"), + Output("playing-st", "data"), + Output("play-btn", "children"), + Input("play-btn", "n_clicks"), + Input("stop-btn", "n_clicks"), + Input("fps-sl", "value"), + State("playing-st", "data"), prevent_initial_call=True, ) - def _toggle_play(_, __, fps, is_playing): # pylint: disable=unused-argument + def _toggle_play(_, __, fps, is_playing): iv = max(int(1000 / max(float(fps or 2), 0.1)), 50) - trig = (callback_context.triggered or [{}])[0].get('prop_id', '') - if 'stop-btn' in trig: + trig = (callback_context.triggered or [{}])[0].get("prop_id", "") + if "stop-btn" in trig: _is_playing[0] = False - return True, iv, False, '▶ Play' - if 'play-btn' in trig: + return True, iv, False, "▶ Play" + if "play-btn" in trig: playing = not is_playing _is_playing[0] = playing - return not playing, iv, playing, ('⏸ Pause' if playing else '▶ Play') + return not playing, iv, playing, ("⏸ Pause" if playing else "▶ Play") return not is_playing, iv, is_playing, no_update # fps-only change # Playback advances the slider position. @@ -1285,24 +1547,19 @@ def _toggle_play(_, __, fps, is_playing): # pylint: disable=unused-argument _last_update_t = [0.0] _min_frame_gap = [0.3] # Kaleido availability — probed once at startup for fast 3D playback. - global _KALEIDO_OK # pylint: disable=global-statement + global _KALEIDO_OK # noqa: PLW0603 if ndim == 3: _KALEIDO_OK = _kaleido_available() if _KALEIDO_OK: - cons.print('[dim][green]Kaleido OK[/green] — ' - 'server-side 3D rendering enabled for playback.[/dim]') + cons.print("[dim][green]Kaleido OK[/green] — server-side 3D rendering enabled for playback.[/dim]") else: - cons.print( - '[yellow]Kaleido not found[/yellow] — ' - '3D playback will use Plotly (slower).\n' - ' Install: pip install kaleido' - ) + cons.print("[yellow]Kaleido not found[/yellow] — 3D playback will use Plotly (slower).\n Install: pip install kaleido") @app.callback( - Output('step-sl', 'value'), - Input('play-iv', 'n_intervals'), - State('step-sl', 'value'), - State('loop-chk', 'value'), + Output("step-sl", "value"), + Input("play-iv", "n_intervals"), + State("step-sl", "value"), + State("loop-chk", "value"), prevent_initial_call=True, ) def _advance_step(_, current_idx, loop_val): @@ -1313,148 +1570,179 @@ def _advance_step(_, current_idx, loop_val): idx = int(current_idx or 0) nxt = idx + 1 if nxt >= len(steps): - return 0 if ('loop' in (loop_val or [])) else no_update + return 0 if ("loop" in (loop_val or [])) else no_update return nxt # Slider index → actual step value + label @app.callback( - Output('step-sel', 'data'), - Output('step-label', 'children'), - Input('step-sl', 'value'), + Output("step-sel", "data"), + Output("step-label", "children"), + Input("step-sl", "value"), ) def _sync_step(sl_idx): idx = int(sl_idx) if sl_idx is not None else 0 idx = max(0, min(idx, len(steps) - 1)) - return steps[idx], f'step {steps[idx]}' + return steps[idx], f"step {steps[idx]}" @app.callback( - Output('ctrl-slice', 'style'), - Output('ctrl-iso', 'style'), - Output('ctrl-vol', 'style'), - Output('ctrl-overlay', 'style'), - Output('ctrl-overlay-contour', 'style'), - Output('ctrl-overlay-mode', 'style'), - Input('mode-sel', 'value'), + Output("ctrl-slice", "style"), + Output("ctrl-iso", "style"), + Output("ctrl-vol", "style"), + Output("ctrl-overlay", "style"), + Output("ctrl-overlay-contour", "style"), + Output("ctrl-overlay-mode", "style"), + Input("mode-sel", "value"), ) def _toggle_controls(mode): - show, hide = {'display': 'block'}, {'display': 'none'} - overlay_ok = mode in ('heatmap', 'slice', 'isosurface', 'volume') and ndim >= 2 - is_3d_surf = mode in ('isosurface', 'volume') + show, hide = {"display": "block"}, {"display": "none"} + overlay_ok = mode in ("heatmap", "slice", "isosurface", "volume") and ndim >= 2 + is_3d_surf = mode in ("isosurface", "volume") return ( - show if mode == 'slice' else hide, - show if mode == 'isosurface' else hide, - show if mode == 'volume' else hide, - show if overlay_ok else hide, - show if not is_3d_surf else hide, # contour line controls - show if is_3d_surf else hide, # overlay type selector + show if mode == "slice" else hide, + show if mode == "isosurface" else hide, + show if mode == "volume" else hide, + show if overlay_ok else hide, + show if not is_3d_surf else hide, # contour line controls + show if is_3d_surf else hide, # overlay type selector ) @app.callback( - Output('ctrl-overlay-iso', 'style'), - Output('ctrl-overlay-vol', 'style'), - Input('mode-sel', 'value'), - Input('overlay-mode-sel', 'value'), + Output("ctrl-overlay-iso", "style"), + Output("ctrl-overlay-vol", "style"), + Input("mode-sel", "value"), + Input("overlay-mode-sel", "value"), ) def _toggle_overlay_subs(mode, ov_mode): - show, hide = {'display': 'block'}, {'display': 'none'} - is_3d_surf = mode in ('isosurface', 'volume') + show, hide = {"display": "block"}, {"display": "none"} + is_3d_surf = mode in ("isosurface", "volume") if not is_3d_surf: return hide, hide - ov = ov_mode or 'isosurface' + ov = ov_mode or "isosurface" return ( - show if ov == 'isosurface' else hide, - show if ov == 'volume' else hide, + show if ov == "isosurface" else hide, + show if ov == "volume" else hide, ) @app.callback( - Output('ctrl-iso-solid-color', 'style'), - Input('iso-solid-chk', 'value'), + Output("ctrl-iso-solid-color", "style"), + Input("iso-solid-chk", "value"), ) def _toggle_iso_solid(chk): - show, hide = {'display': 'block'}, {'display': 'none'} - return show if chk and 'solid' in chk else hide + show, hide = {"display": "block"}, {"display": "none"} + return show if chk and "solid" in chk else hide @app.callback( - Output('vmin-inp', 'value'), - Output('vmax-inp', 'value'), - Input('reset-btn', 'n_clicks'), + Output("vmin-inp", "value"), + Output("vmax-inp", "value"), + Input("reset-btn", "n_clicks"), prevent_initial_call=True, ) def _reset_range(_reset): return None, None @app.callback( - Output('viz-graph', 'figure'), - Output('status-bar', 'children'), - Output('srv-img', 'src'), - Output('srv-img', 'style'), - Output('viz-graph', 'style'), - Input('var-sel', 'value'), - Input('step-sel', 'data'), - Input('mode-sel', 'value'), - Input('slice-axis', 'value'), - Input('slice-pos', 'value'), - Input('iso-min', 'value'), - Input('iso-max', 'value'), - Input('iso-n', 'value'), - Input('iso-opacity', 'value'), - Input('iso-caps', 'value'), - Input('vol-opacity', 'value'), - Input('vol-nsurf', 'value'), - Input('vol-min', 'value'), - Input('vol-max', 'value'), - Input('cmap-sel', 'value'), - Input('log-chk', 'value'), - Input('vmin-inp', 'value'), - Input('vmax-inp', 'value'), - Input('iso-solid-chk', 'value'), - Input('iso-solid-color', 'value'), - Input('overlay-var-sel', 'value'), - Input('overlay-nlevels', 'value'), - Input('overlay-color-sel', 'value'), - Input('overlay-lw', 'value'), - Input('overlay-mode-sel', 'value'), - Input('overlay-iso-min', 'value'), - Input('overlay-iso-max', 'value'), - Input('overlay-iso-opacity', 'value'), - Input('overlay-iso-byval', 'value'), - Input('overlay-vol-min', 'value'), - Input('overlay-vol-max', 'value'), - Input('overlay-vol-opacity', 'value'), - Input('overlay-vol-nsurf', 'value'), - State('playing-st', 'data'), + Output("viz-graph", "figure"), + Output("status-bar", "children"), + Output("srv-img", "src"), + Output("srv-img", "style"), + Output("viz-graph", "style"), + Input("var-sel", "value"), + Input("step-sel", "data"), + Input("mode-sel", "value"), + Input("slice-axis", "value"), + Input("slice-pos", "value"), + Input("iso-min", "value"), + Input("iso-max", "value"), + Input("iso-n", "value"), + Input("iso-opacity", "value"), + Input("iso-caps", "value"), + Input("vol-opacity", "value"), + Input("vol-nsurf", "value"), + Input("vol-min", "value"), + Input("vol-max", "value"), + Input("cmap-sel", "value"), + Input("log-chk", "value"), + Input("vmin-inp", "value"), + Input("vmax-inp", "value"), + Input("iso-solid-chk", "value"), + Input("iso-solid-color", "value"), + Input("overlay-var-sel", "value"), + Input("overlay-nlevels", "value"), + Input("overlay-color-sel", "value"), + Input("overlay-lw", "value"), + Input("overlay-mode-sel", "value"), + Input("overlay-iso-min", "value"), + Input("overlay-iso-max", "value"), + Input("overlay-iso-opacity", "value"), + Input("overlay-iso-byval", "value"), + Input("overlay-vol-min", "value"), + Input("overlay-vol-max", "value"), + Input("overlay-vol-opacity", "value"), + Input("overlay-vol-nsurf", "value"), + State("playing-st", "data"), ) - def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements - slice_axis, slice_pos, - iso_min_frac, iso_max_frac, iso_n, iso_opacity, iso_caps, - vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac, - cmap, log_chk, vmin_in, vmax_in, - iso_solid_chk, iso_solid_color, - overlay_var, overlay_nlevels, overlay_color, overlay_lw, - overlay_mode_sel, - overlay_iso_min, overlay_iso_max, overlay_iso_opacity, - overlay_iso_byval, - overlay_vol_min, overlay_vol_max, overlay_vol_opacity, - overlay_vol_nsurf, - playing_st): # pylint: disable=unused-argument + def _update( + var_sel, + step, + mode, + slice_axis, + slice_pos, + iso_min_frac, + iso_max_frac, + iso_n, + iso_opacity, + iso_caps, + vol_opacity, + vol_nsurf, + vol_min_frac, + vol_max_frac, + cmap, + log_chk, + vmin_in, + vmax_in, + iso_solid_chk, + iso_solid_color, + overlay_var, + overlay_nlevels, + overlay_color, + overlay_lw, + overlay_mode_sel, + overlay_iso_min, + overlay_iso_max, + overlay_iso_opacity, + overlay_iso_byval, + overlay_vol_min, + overlay_vol_max, + overlay_vol_opacity, + overlay_vol_nsurf, + playing_st, + ): _t0 = time.perf_counter() - _GRAPH_SHOW = {'height': '100vh', 'display': 'block'} - _GRAPH_HIDE = {'height': '100vh', 'display': 'none'} + _GRAPH_SHOW = {"height": "100vh", "display": "block"} + _GRAPH_HIDE = {"height": "100vh", "display": "none"} _SRV_SHOW = { - 'display': 'block', 'width': '100%', 'height': '100vh', - 'objectFit': 'contain', 'backgroundColor': _BG, 'cursor': 'grab', + "display": "block", + "width": "100%", + "height": "100vh", + "objectFit": "contain", + "backgroundColor": _BG, + "cursor": "grab", } _SRV_HIDE = { - 'display': 'none', 'width': '100%', 'height': '100vh', - 'objectFit': 'contain', 'backgroundColor': _BG, 'cursor': 'grab', + "display": "none", + "width": "100%", + "height": "100vh", + "objectFit": "contain", + "backgroundColor": _BG, + "cursor": "grab", } selected_var = var_sel or varname # When the variable selector is the trigger, ignore any stale manual # range values — they belong to the previous variable. - trig = (callback_context.triggered or [{}])[0].get('prop_id', '') - if 'var-sel' in trig: + trig = (callback_context.triggered or [{}])[0].get("prop_id", "") + if "var-sel" in trig: vmin_in = vmax_in = None # Use single-variable loading when available: reads only the selected @@ -1462,20 +1750,16 @@ def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many- # Cache key is (step, var) so switching variables is also a fast read. try: if read_one_var_func is not None: - ad = _load((step, selected_var), - lambda key: read_one_var_func(key[0], key[1])) + ad = _load((step, selected_var), lambda key: read_one_var_func(key[0], key[1])) else: ad = _load(step, read_func) except (OSError, ValueError, EOFError) as exc: - return (no_update, - [html.Span(f' Error loading step {step}: {exc}', - style={'color': _RED})], - no_update, no_update, no_update) + return (no_update, [html.Span(f" Error loading step {step}: {exc}", style={"color": _RED})], no_update, no_update, no_update) _t_load = time.perf_counter() # Resolve log/cmap early — needed by the prefetch JPEG encoder below. - log = bool(log_chk and 'log' in log_chk) - cmap = cmap or 'viridis' + log = bool(log_chk and "log" in log_chk) + cmap = cmap or "viridis" # Eagerly pre-load the next steps in the background so that # navigation feels instant after the structure cache is warm. @@ -1489,69 +1773,81 @@ def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many- _playing = _is_playing[0] if _playing: _pf_depth = 15 - elif ad.ndim == 3 and mode in ('isosurface', 'volume'): + elif ad.ndim == 3 and mode in ("isosurface", "volume"): _pf_depth = 4 else: _pf_depth = 2 if read_one_var_func is not None: try: _idx = steps.index(step) - for _ns in steps[_idx + 1: _idx + 1 + _pf_depth]: + for _ns in steps[_idx + 1 : _idx + 1 + _pf_depth]: _nk = (_ns, selected_var) _prefetch_one(_nk, lambda k=_nk: read_one_var_func(k[0], k[1])) if ad.ndim == 2: _sv = selected_var _prefetch_jpeg( - _ns, _sv, - lambda s, sv=_sv: _step_cache.load( - (s, sv), lambda k: read_one_var_func(k[0], k[1])), - cmap, vmin_in, vmax_in, log, + _ns, + _sv, + lambda s, sv=_sv: _step_cache.load((s, sv), lambda k: read_one_var_func(k[0], k[1])), + cmap, + vmin_in, + vmax_in, + log, ) - elif ad.ndim == 3 and mode in ('isosurface', 'volume'): + elif ad.ndim == 3 and mode in ("isosurface", "volume"): _sv = selected_var _prefetch_3d_mesh( - _ns, _sv, - lambda s, sv=_sv: _step_cache.load( - (s, sv), lambda k: read_one_var_func(k[0], k[1])), - mode, log, vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + _ns, + _sv, + lambda s, sv=_sv: _step_cache.load((s, sv), lambda k: read_one_var_func(k[0], k[1])), + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), - + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), ) except (ValueError, IndexError): pass else: try: _idx = steps.index(step) - for _ns in steps[_idx + 1: _idx + 1 + _pf_depth]: + for _ns in steps[_idx + 1 : _idx + 1 + _pf_depth]: _prefetch_one(_ns, read_func) if ad.ndim == 2: _prefetch_jpeg( - _ns, selected_var, + _ns, + selected_var, lambda s: _step_cache.load(s, read_func), - cmap, vmin_in, vmax_in, log, + cmap, + vmin_in, + vmax_in, + log, ) - elif ad.ndim == 3 and mode in ('isosurface', 'volume'): + elif ad.ndim == 3 and mode in ("isosurface", "volume"): _prefetch_3d_mesh( - _ns, selected_var, + _ns, + selected_var, lambda s: _step_cache.load(s, read_func), - mode, log, vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), - + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), ) except (ValueError, IndexError): pass if selected_var not in ad.variables: - avail = ', '.join(sorted(ad.variables)) - return (no_update, - [html.Span( - f' Variable {selected_var!r} not in step {step} ' - f'(available: {avail})', style={'color': _RED})], - no_update, no_update, no_update) + avail = ", ".join(sorted(ad.variables)) + return (no_update, [html.Span(f" Variable {selected_var!r} not in step {step} (available: {avail})", style={"color": _RED})], no_update, no_update, no_update) raw = ad.variables[selected_var] # Color range — subsample large arrays for speed (nanmin/nanmax on @@ -1578,13 +1874,18 @@ def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many- vmax = vmin + 1e-10 if log: + def _tf(arr): return np.where(arr > 0, np.log10(np.maximum(arr, 1e-300)), np.nan) + cmin = float(np.log10(max(vmin, 1e-300))) cmax = float(np.log10(max(vmax, 1e-300))) - cbar_title = f'log\u2081\u2080({selected_var})' + cbar_title = f"log\u2081\u2080({selected_var})" else: - def _tf(arr): return arr + + def _tf(arr): + return arr + cmin, cmax = vmin, vmax cbar_title = selected_var @@ -1598,37 +1899,58 @@ def _tf(arr): return arr # Only active during playback; static views use Plotly for # interactive rotation and hover. # ---------------------------------------------------------------------- - global _KALEIDO_OK # pylint: disable=global-statement + global _KALEIDO_OK # noqa: PLW0603 if _KALEIDO_OK is None: _KALEIDO_OK = _kaleido_available() _use_kaleido = ( _KALEIDO_OK - and sys.platform == 'linux' # only over SSH; local macOS Plotly is faster + and sys.platform == "linux" # only over SSH; local macOS Plotly is faster and _is_playing[0] and ad.ndim == 3 - and mode in ('isosurface', 'volume') + and mode in ("isosurface", "volume") ) if _use_kaleido: try: _kfig = go.Figure() - _iso_solid_k = (iso_solid_color or '#89b4fa') if ( - iso_solid_chk and 'solid' in iso_solid_chk) else None - _cached_k = _get_cached_3d_mesh( - step, selected_var, mode, log, - vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), - int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), - ) if mode == 'isosurface' else None + _iso_solid_k = (iso_solid_color or "#89b4fa") if (iso_solid_chk and "solid" in iso_solid_chk) else None + _cached_k = ( + _get_cached_3d_mesh( + step, + selected_var, + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), + int(iso_n or 3), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), + ) + if mode == "isosurface" + else None + ) _ktrace, _ktitle = _build_3d( - ad, raw, selected_var, step, mode, cmap, - _tf, cmin, cmax, cbar_title, - slice_axis or 'z', float(slice_pos or 0.5), - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + ad, + raw, + selected_var, + step, + mode, + cmap, + _tf, + cmin, + cmax, + cbar_title, + slice_axis or "z", + float(slice_pos or 0.5), + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - bool(iso_caps and 'caps' in iso_caps), - float(vol_opacity or 0.1), int(vol_nsurf or 15), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + bool(iso_caps and "caps" in iso_caps), + float(vol_opacity or 0.1), + int(vol_nsurf or 15), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), iso_solid_color=_iso_solid_k, iso_opacity=float(iso_opacity or 1.0), cached_mesh=_cached_k, @@ -1641,41 +1963,24 @@ def _tf(arr): return arr _kmax = max(_kdx, _kdy, _kdz, 1e-30) _kfig.update_layout( scene=dict( - xaxis=dict(title='x', - range=[float(ad.x_cc[0]), float(ad.x_cc[-1])], - autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, - color=_TEXT), - yaxis=dict(title='y', - range=[float(ad.y_cc[0]), float(ad.y_cc[-1])], - autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, - color=_TEXT), - zaxis=dict(title='z', - range=[float(ad.z_cc[0]), float(ad.z_cc[-1])], - autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, - color=_TEXT), + xaxis=dict(title="x", range=[float(ad.x_cc[0]), float(ad.x_cc[-1])], autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), + yaxis=dict(title="y", range=[float(ad.y_cc[0]), float(ad.y_cc[-1])], autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), + zaxis=dict(title="z", range=[float(ad.z_cc[0]), float(ad.z_cc[-1])], autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), bgcolor=_BG, - aspectmode='manual', - aspectratio=dict(x=_kdx/_kmax, y=_kdy/_kmax, - z=_kdz/_kmax), + aspectmode="manual", + aspectratio=dict(x=_kdx / _kmax, y=_kdy / _kmax, z=_kdz / _kmax), ), - title=dict(text=_ktitle, - font=dict(color=_TEXT, size=13, - family='monospace')), + title=dict(text=_ktitle, font=dict(color=_TEXT, size=13, family="monospace")), paper_bgcolor=_BG, - font=dict(color=_TEXT, family='monospace'), + font=dict(color=_TEXT, family="monospace"), margin=dict(l=0, r=130, t=36, b=0), ) _t_fig = time.perf_counter() kal_src = _kaleido_render(_kfig) _t_kal = time.perf_counter() - except Exception as _kal_exc: # pylint: disable=broad-except + except Exception as _kal_exc: _KALEIDO_OK = False - cons.print( - '[dim][yellow]Kaleido render failed, falling back to ' - f'Plotly:[/yellow] {_kal_exc}[/dim]') + cons.print(f"[dim][yellow]Kaleido render failed, falling back to Plotly:[/yellow] {_kal_exc}[/dim]") kal_src = None _t_prep = time.perf_counter() @@ -1683,23 +1988,24 @@ def _tf(arr): return arr dmin_k = float(np.nanmin(_raw_range)) dmax_k = float(np.nanmax(_raw_range)) cons.print( - f'[dim]viz timing step={step} shape={raw.shape}' - f' load={_t_load-_t0:.3f}s' - f' prep={_t_prep-_t_load:.3f}s' - f' figure={_t_fig-_t_prep:.3f}s' - f' kaleido={_t_kal-_t_fig:.3f}s' - f' total={_t_kal-_t0:.3f}s [KALEIDO][/dim]' + f"[dim]viz timing step={step} shape={raw.shape}" + f" load={_t_load - _t0:.3f}s" + f" prep={_t_prep - _t_load:.3f}s" + f" figure={_t_fig - _t_prep:.3f}s" + f" kaleido={_t_kal - _t_fig:.3f}s" + f" total={_t_kal - _t0:.3f}s [KALEIDO][/dim]" + ) + status_k = html.Div( + [ + html.Span(f"step {step}", style={"color": _YELLOW}), + html.Span(f" · shape {raw.shape}", style={"color": _MUTED}), + html.Br(), + html.Span("min ", style={"color": _MUTED}), + html.Span(f"{dmin_k:.4g}", style={"color": _BLUE}), + html.Span(" max ", style={"color": _MUTED}), + html.Span(f"{dmax_k:.4g}", style={"color": _RED}), + ] ) - status_k = html.Div([ - html.Span(f'step {step}', style={'color': _YELLOW}), - html.Span(f' · shape {raw.shape}', - style={'color': _MUTED}), - html.Br(), - html.Span('min ', style={'color': _MUTED}), - html.Span(f'{dmin_k:.4g}', style={'color': _BLUE}), - html.Span(' max ', style={'color': _MUTED}), - html.Span(f'{dmax_k:.4g}', style={'color': _RED}), - ]) _now_k = time.perf_counter() _last_update_t[0] = _now_k _server_k = _now_k - _t0 @@ -1707,7 +2013,7 @@ def _tf(arr): return arr return no_update, status_k, kal_src, _SRV_SHOW, _GRAPH_HIDE fig = go.Figure() - title = '' + title = "" if ad.ndim == 3: # ------------------------------------------------------------------ @@ -1717,14 +2023,11 @@ def _tf(arr): return arr # Colormap, variable, mode, and axis changes always trigger a full # render because they require new coordinate arrays or trace types. # ------------------------------------------------------------------ - _has_overlay_3d = overlay_var and overlay_var != '__none__' - _trig3 = {t.get('prop_id', '') for t in (callback_context.triggered or [])} - _PT_BASE = {'step-sel.data', 'vmin-inp.value', 'vmax-inp.value'} - _PT_ISO = _PT_BASE | {'iso-min.value', 'iso-max.value', - 'iso-n.value', 'iso-caps.value', - 'iso-opacity.value'} - _PT_VOL = _PT_BASE | {'vol-min.value', 'vol-max.value', - 'vol-opacity.value', 'vol-nsurf.value'} + _has_overlay_3d = overlay_var and overlay_var != "__none__" + _trig3 = {t.get("prop_id", "") for t in (callback_context.triggered or [])} + _PT_BASE = {"step-sel.data", "vmin-inp.value", "vmax-inp.value"} + _PT_ISO = _PT_BASE | {"iso-min.value", "iso-max.value", "iso-n.value", "iso-caps.value", "iso-opacity.value"} + _PT_VOL = _PT_BASE | {"vol-min.value", "vol-max.value", "vol-opacity.value", "vol-nsurf.value"} # Slice mode always does a full render — Plotly does not reliably # re-render go.Surface when surfacecolor is updated via Patch(). # Overlay forces full render (adds extra traces). @@ -1734,30 +2037,32 @@ def _tf(arr): return arr # to the expensive full-render path. _do_patch_3d = ( _trig3 - and '.' not in _trig3 + and "." not in _trig3 and not _use_kaleido # kaleido handles its own fast path - and not (_has_overlay_3d and mode in ('isosurface', 'volume')) - and ( - (_playing and mode in ('isosurface', 'volume')) - or (mode == 'isosurface' and _trig3.issubset(_PT_ISO)) - or (mode == 'volume' and _trig3.issubset(_PT_VOL)) - ) + and not (_has_overlay_3d and mode in ("isosurface", "volume")) + and ((_playing and mode in ("isosurface", "volume")) or (mode == "isosurface" and _trig3.issubset(_PT_ISO)) or (mode == "volume" and _trig3.issubset(_PT_VOL))) ) if _do_patch_3d: _cscale3 = _lut_to_plotly_colorscale(cmap) rng3 = cmax - cmin if cmax > cmin else 1.0 patch = Patch() _cache_hit = False - if mode == 'isosurface': + if mode == "isosurface": ilo = cmin + rng3 * float(iso_min_frac or 0.2) ihi = cmin + rng3 * max(float(iso_max_frac or 0.8), ilo + 0.01) # Try pre-computed mesh first, fall back to computing now _cached = _get_cached_3d_mesh( - step, selected_var, mode, log, - vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + step, + selected_var, + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), ) if _cached is not None: vx, vy, vz, fi, fj, fk, intens = _cached @@ -1765,69 +2070,93 @@ def _tf(arr): return arr else: _pb = 50_000 if _playing else 500_000 raw_ds, x_ds3, y_ds3, z_ds3 = _get_ds3( - step, selected_var, raw, ad.x_cc, ad.y_cc, ad.z_cc, _pb, + step, + selected_var, + raw, + ad.x_cc, + ad.y_cc, + ad.z_cc, + _pb, ) vx, vy, vz, fi, fj, fk, intens = _compute_isomesh( - raw_ds, x_ds3, y_ds3, z_ds3, _tf, ilo, ihi, + raw_ds, + x_ds3, + y_ds3, + z_ds3, + _tf, + ilo, + ihi, int(iso_n or 3), ) - patch['data'][0]['x'] = vx - patch['data'][0]['y'] = vy - patch['data'][0]['z'] = vz - patch['data'][0]['i'] = fi - patch['data'][0]['j'] = fj - patch['data'][0]['k'] = fk - patch['data'][0]['intensity'] = intens - patch['data'][0]['cmin'] = ilo - patch['data'][0]['cmax'] = ihi - patch['data'][0]['colorscale'] = _cscale3 - patch['data'][0]['opacity'] = float(iso_opacity or 1.0) + patch["data"][0]["x"] = vx + patch["data"][0]["y"] = vy + patch["data"][0]["z"] = vz + patch["data"][0]["i"] = fi + patch["data"][0]["j"] = fj + patch["data"][0]["k"] = fk + patch["data"][0]["intensity"] = intens + patch["data"][0]["cmin"] = ilo + patch["data"][0]["cmax"] = ihi + patch["data"][0]["colorscale"] = _cscale3 + patch["data"][0]["opacity"] = float(iso_opacity or 1.0) else: # volume # Try pre-computed volume data first _cached = _get_cached_3d_mesh( - step, selected_var, mode, log, - vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + step, + selected_var, + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), ) if _cached is not None: vf, vlo, vhi, _, _ = _cached _cache_hit = True else: raw_ds, _, _, _ = _get_ds3( - step, selected_var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 150_000, + step, + selected_var, + raw, + ad.x_cc, + ad.y_cc, + ad.z_cc, + 150_000, ) vf = _tf(raw_ds).ravel() vlo = cmin + rng3 * float(vol_min_frac or 0.0) vhi = cmin + rng3 * max(float(vol_max_frac or 1.0), vlo + 0.01) - patch['data'][0]['value'] = vf - patch['data'][0]['isomin'] = vlo - patch['data'][0]['isomax'] = vhi - patch['data'][0]['opacity'] = float(vol_opacity or 0.1) - patch['data'][0]['surface_count'] = int(vol_nsurf or 15) - patch['data'][0]['colorscale'] = _cscale3 - patch['layout']['title']['text'] = ( - f'{selected_var} · step {step}' - ) + patch["data"][0]["value"] = vf + patch["data"][0]["isomin"] = vlo + patch["data"][0]["isomax"] = vhi + patch["data"][0]["opacity"] = float(vol_opacity or 0.1) + patch["data"][0]["surface_count"] = int(vol_nsurf or 15) + patch["data"][0]["colorscale"] = _cscale3 + patch["layout"]["title"]["text"] = f"{selected_var} · step {step}" _t_trace = time.perf_counter() dmin3, dmax3 = float(np.nanmin(_raw_range)), float(np.nanmax(_raw_range)) cons.print( - f'[dim]viz timing step={step} shape={raw.shape}' - f' load={_t_load-_t0:.3f}s' - f' prep={_t_prep-_t_load:.3f}s' - f' patch={_t_trace-_t_prep:.3f}s [PATCH-3D{"·HIT" if _cache_hit else ""}]' - f' total={_t_trace-_t0:.3f}s[/dim]' + f"[dim]viz timing step={step} shape={raw.shape}" + f" load={_t_load - _t0:.3f}s" + f" prep={_t_prep - _t_load:.3f}s" + f" patch={_t_trace - _t_prep:.3f}s [PATCH-3D{'·HIT' if _cache_hit else ''}]" + f" total={_t_trace - _t0:.3f}s[/dim]" + ) + status = html.Div( + [ + html.Span(f"step {step}", style={"color": _YELLOW}), + html.Span(f" · shape {raw.shape}", style={"color": _MUTED}), + html.Br(), + html.Span("min ", style={"color": _MUTED}), + html.Span(f"{dmin3:.4g}", style={"color": _BLUE}), + html.Span(" max ", style={"color": _MUTED}), + html.Span(f"{dmax3:.4g}", style={"color": _RED}), + ] ) - status = html.Div([ - html.Span(f'step {step}', style={'color': _YELLOW}), - html.Span(f' · shape {raw.shape}', style={'color': _MUTED}), - html.Br(), - html.Span('min ', style={'color': _MUTED}), - html.Span(f'{dmin3:.4g}', style={'color': _BLUE}), - html.Span(' max ', style={'color': _MUTED}), - html.Span(f'{dmax3:.4g}', style={'color': _RED}), - ]) _now = time.perf_counter() _last_update_t[0] = _now # Patch path: browser overhead is low (no figure rebuild). @@ -1836,27 +2165,46 @@ def _tf(arr): return arr return patch, status, no_update, _SRV_HIDE, _GRAPH_SHOW # Full 3D render — check mesh prefetch cache for isosurface mode - _iso_solid = (iso_solid_color or '#89b4fa') if ( - iso_solid_chk and 'solid' in iso_solid_chk) else None + _iso_solid = (iso_solid_color or "#89b4fa") if (iso_solid_chk and "solid" in iso_solid_chk) else None _cached_primary = None - if mode == 'isosurface': + if mode == "isosurface": _cached_primary = _get_cached_3d_mesh( - step, selected_var, mode, log, - vmin_in, vmax_in, - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + step, + selected_var, + mode, + log, + vmin_in, + vmax_in, + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), int(iso_n or 3), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), ) # During playback, use a smaller cell budget for faster marching # cubes (~1s instead of ~4.5s). Full resolution when paused. _iso_3d_budget = 50_000 if _playing else 500_000 trace, title = _build_3d( - ad, raw, selected_var, step, mode, cmap, _tf, cmin, cmax, cbar_title, - slice_axis or 'z', float(slice_pos or 0.5), - float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), - int(iso_n or 3), bool(iso_caps and 'caps' in iso_caps), - float(vol_opacity or 0.1), int(vol_nsurf or 15), - float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + ad, + raw, + selected_var, + step, + mode, + cmap, + _tf, + cmin, + cmax, + cbar_title, + slice_axis or "z", + float(slice_pos or 0.5), + float(iso_min_frac or 0.2), + float(iso_max_frac or 0.8), + int(iso_n or 3), + bool(iso_caps and "caps" in iso_caps), + float(vol_opacity or 0.1), + int(vol_nsurf or 15), + float(vol_min_frac or 0.0), + float(vol_max_frac or 1.0), iso_solid_color=_iso_solid, iso_opacity=float(iso_opacity or 1.0), cached_mesh=_cached_primary, @@ -1868,36 +2216,39 @@ def _tf(arr): return arr try: bubbles = bubble_func(step) if bubbles is not None and len(bubbles) > 0: - if mode == 'slice': - s_axis = slice_axis or 'z' - s_col = {'x': 0, 'y': 1, 'z': 2}[s_axis] - ax_coords = {'x': ad.x_cc, 'y': ad.y_cc, 'z': ad.z_cc}[s_axis] + if mode == "slice": + s_axis = slice_axis or "z" + s_col = {"x": 0, "y": 1, "z": 2}[s_axis] + ax_coords = {"x": ad.x_cc, "y": ad.y_cc, "z": ad.z_cc}[s_axis] s_coord = ax_coords[0] + (ax_coords[-1] - ax_coords[0]) * float(slice_pos or 0.5) near = np.abs(bubbles[:, s_col] - s_coord) <= bubbles[:, 3] vis = bubbles[near] if near.any() else None else: vis = bubbles if vis is not None and len(vis) > 0: - fig.add_trace(go.Scatter3d( - x=vis[:, 0], y=vis[:, 1], z=vis[:, 2], - mode='markers', - marker=dict(size=4, color='white', opacity=0.6, symbol='circle'), - showlegend=False, - hovertemplate='x=%{x:.3g}
y=%{y:.3g}
z=%{z:.3g}bubble', - )) + fig.add_trace( + go.Scatter3d( + x=vis[:, 0], + y=vis[:, 1], + z=vis[:, 2], + mode="markers", + marker=dict(size=4, color="white", opacity=0.6, symbol="circle"), + showlegend=False, + hovertemplate="x=%{x:.3g}
y=%{y:.3g}
z=%{z:.3g}bubble", + ) + ) except (OSError, ValueError): pass # bubble overlay is best-effort; skip on read errors # Overlay for 3D: contour lines (slice), isosurface, or isovolume - _has_overlay = overlay_var and overlay_var != '__none__' - if _has_overlay and mode in ('slice', 'isosurface', 'volume'): + _has_overlay = overlay_var and overlay_var != "__none__" + if _has_overlay and mode in ("slice", "isosurface", "volume"): _ov_3d_raw = None _ov_3d_ad = ad if overlay_var in ad.variables: _ov_3d_raw = ad.variables[overlay_var] elif read_one_var_func is not None: try: - _ov_3d_ad = _load((step, overlay_var), - lambda k: read_one_var_func(k[0], k[1])) + _ov_3d_ad = _load((step, overlay_var), lambda k: read_one_var_func(k[0], k[1])) if overlay_var in _ov_3d_ad.variables: _ov_3d_raw = _ov_3d_ad.variables[overlay_var] except (OSError, ValueError, EOFError): @@ -1909,72 +2260,100 @@ def _tf(arr): return arr _ov_3d_raw = _ov_3d_ad.variables[overlay_var] except (OSError, ValueError, EOFError): pass - if _ov_3d_raw is not None and mode == 'slice': + if _ov_3d_raw is not None and mode == "slice": _ov3_traces = _compute_contour_traces_3d( - _ov_3d_raw, _ov_3d_ad.x_cc, _ov_3d_ad.y_cc, _ov_3d_ad.z_cc, - slice_axis or 'z', float(slice_pos or 0.5), + _ov_3d_raw, + _ov_3d_ad.x_cc, + _ov_3d_ad.y_cc, + _ov_3d_ad.z_cc, + slice_axis or "z", + float(slice_pos or 0.5), int(overlay_nlevels or 5), - overlay_color or 'white', + overlay_color or "white", float(overlay_lw or 1.0), ) for _ct in _ov3_traces: fig.add_trace(_ct) - elif _ov_3d_raw is not None and mode in ('isosurface', 'volume'): - _ov_type = overlay_mode_sel or 'isosurface' + elif _ov_3d_raw is not None and mode in ("isosurface", "volume"): + _ov_type = overlay_mode_sel or "isosurface" # Compute range in transformed space so thresholds match # what _compute_isomesh sees after applying _tf internally. _ov_tf = _tf(_ov_3d_raw) _ov_vmin = float(np.nanmin(_ov_tf)) _ov_vmax = float(np.nanmax(_ov_tf)) _ov_rng = _ov_vmax - _ov_vmin if _ov_vmax > _ov_vmin else 1.0 - if _ov_type == 'isosurface': + if _ov_type == "isosurface": # Overlay isosurfaces of the second variable _ov_min_f = float(overlay_iso_min or 0.2) _ov_max_f = float(overlay_iso_max or 0.8) _ov_ilo = _ov_vmin + _ov_rng * _ov_min_f _ov_ihi = _ov_vmin + _ov_rng * max(_ov_max_f, _ov_min_f + 0.01) _ov_ds, _ox, _oy, _oz = _get_ds3( - step, overlay_var, _ov_3d_raw, - _ov_3d_ad.x_cc, _ov_3d_ad.y_cc, _ov_3d_ad.z_cc, + step, + overlay_var, + _ov_3d_raw, + _ov_3d_ad.x_cc, + _ov_3d_ad.y_cc, + _ov_3d_ad.z_cc, 500_000, ) - _ov_vx, _ov_vy, _ov_vz, _ov_fi, _ov_fj, _ov_fk, _ov_int = \ - _compute_isomesh( - _ov_ds, _ox, _oy, _oz, - _tf, _ov_ilo, _ov_ihi, - int(overlay_nlevels or 3), - ) - _ov_byval = overlay_iso_byval and 'byval' in overlay_iso_byval + _ov_vx, _ov_vy, _ov_vz, _ov_fi, _ov_fj, _ov_fk, _ov_int = _compute_isomesh( + _ov_ds, + _ox, + _oy, + _oz, + _tf, + _ov_ilo, + _ov_ihi, + int(overlay_nlevels or 3), + ) + _ov_byval = overlay_iso_byval and "byval" in overlay_iso_byval _ov_op = float(overlay_iso_opacity or 0.6) if _ov_byval: _ov_cscale = _lut_to_plotly_colorscale(cmap) - fig.add_trace(go.Mesh3d( - x=_ov_vx, y=_ov_vy, z=_ov_vz, - i=_ov_fi, j=_ov_fj, k=_ov_fk, - intensity=_ov_int, intensitymode='vertex', - colorscale=_ov_cscale, cmin=_ov_ilo, cmax=_ov_ihi, - colorbar=_make_cbar(overlay_var, _ov_ilo, _ov_ihi), - showscale=True, opacity=_ov_op, - lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, - roughness=0.5, fresnel=0.2), - lightposition=dict(x=1000, y=500, z=500), - flatshading=False, - )) + fig.add_trace( + go.Mesh3d( + x=_ov_vx, + y=_ov_vy, + z=_ov_vz, + i=_ov_fi, + j=_ov_fj, + k=_ov_fk, + intensity=_ov_int, + intensitymode="vertex", + colorscale=_ov_cscale, + cmin=_ov_ilo, + cmax=_ov_ihi, + colorbar=_make_cbar(overlay_var, _ov_ilo, _ov_ihi), + showscale=True, + opacity=_ov_op, + lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, roughness=0.5, fresnel=0.2), + lightposition=dict(x=1000, y=500, z=500), + flatshading=False, + ) + ) else: - _ov_solid_cs = [[0, overlay_color or 'white'], - [1, overlay_color or 'white']] - fig.add_trace(go.Mesh3d( - x=_ov_vx, y=_ov_vy, z=_ov_vz, - i=_ov_fi, j=_ov_fj, k=_ov_fk, - intensity=np.zeros(len(_ov_vx), dtype=np.float32), - intensitymode='vertex', - colorscale=_ov_solid_cs, cmin=0, cmax=1, - showscale=False, opacity=_ov_op, - lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, - roughness=0.5, fresnel=0.2), - lightposition=dict(x=1000, y=500, z=500), - flatshading=False, - )) + _ov_solid_cs = [[0, overlay_color or "white"], [1, overlay_color or "white"]] + fig.add_trace( + go.Mesh3d( + x=_ov_vx, + y=_ov_vy, + z=_ov_vz, + i=_ov_fi, + j=_ov_fj, + k=_ov_fk, + intensity=np.zeros(len(_ov_vx), dtype=np.float32), + intensitymode="vertex", + colorscale=_ov_solid_cs, + cmin=0, + cmax=1, + showscale=False, + opacity=_ov_op, + lighting=dict(ambient=0.7, diffuse=0.9, specular=0.3, roughness=0.5, fresnel=0.2), + lightposition=dict(x=1000, y=500, z=500), + flatshading=False, + ) + ) else: # Overlay isovolume of the second variable _ov_vlo_f = float(overlay_vol_min or 0.0) @@ -1982,26 +2361,35 @@ def _tf(arr): return arr _ov_vlo = _ov_vmin + _ov_rng * _ov_vlo_f _ov_vhi = _ov_vmin + _ov_rng * max(_ov_vhi_f, _ov_vlo_f + 0.01) _ov_ds, _ox, _oy, _oz = _get_ds3( - step, overlay_var, _ov_3d_raw, - _ov_3d_ad.x_cc, _ov_3d_ad.y_cc, _ov_3d_ad.z_cc, + step, + overlay_var, + _ov_3d_raw, + _ov_3d_ad.x_cc, + _ov_3d_ad.y_cc, + _ov_3d_ad.z_cc, 150_000, ) - _ov_X, _ov_Y, _ov_Z = np.meshgrid( - _ox, _oy, _oz, indexing='ij') + _ov_X, _ov_Y, _ov_Z = np.meshgrid(_ox, _oy, _oz, indexing="ij") _ov_vf = _tf(_ov_ds.ravel()).astype(np.float32) _ov_vol_op = float(overlay_vol_opacity or 0.1) _ov_vol_ns = int(overlay_vol_nsurf or 15) _ov_cscale = _lut_to_plotly_colorscale(cmap) - fig.add_trace(go.Volume( - x=_ov_X.ravel().astype(np.float32), - y=_ov_Y.ravel().astype(np.float32), - z=_ov_Z.ravel().astype(np.float32), - value=_ov_vf, - isomin=_ov_vlo, isomax=_ov_vhi, - opacity=_ov_vol_op, surface_count=_ov_vol_ns, - colorscale=_ov_cscale, cmin=_ov_vmin, cmax=_ov_vmax, - colorbar=_make_cbar(overlay_var, _ov_vmin, _ov_vmax), - )) + fig.add_trace( + go.Volume( + x=_ov_X.ravel().astype(np.float32), + y=_ov_Y.ravel().astype(np.float32), + z=_ov_Z.ravel().astype(np.float32), + value=_ov_vf, + isomin=_ov_vlo, + isomax=_ov_vhi, + opacity=_ov_vol_op, + surface_count=_ov_vol_ns, + colorscale=_ov_cscale, + cmin=_ov_vmin, + cmax=_ov_vmax, + colorbar=_make_cbar(overlay_var, _ov_vmin, _ov_vmax), + ) + ) # Compute aspect ratio from domain extents so slices (which # have a constant coordinate on one axis) don't collapse that axis. dx = float(ad.x_cc[-1] - ad.x_cc[0]) if len(ad.x_cc) > 1 else 1.0 @@ -2011,17 +2399,16 @@ def _tf(arr): return arr _xr = [float(ad.x_cc[0]), float(ad.x_cc[-1])] _yr = [float(ad.y_cc[0]), float(ad.y_cc[-1])] _zr = [float(ad.z_cc[0]), float(ad.z_cc[-1])] - fig.update_layout(scene=dict( - xaxis=dict(title='x', range=_xr, autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), - yaxis=dict(title='y', range=_yr, autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), - zaxis=dict(title='z', range=_zr, autorange=False, - backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), - bgcolor=_BG, - aspectmode='manual', - aspectratio=dict(x=dx/max_d, y=dy/max_d, z=dz/max_d), - )) + fig.update_layout( + scene=dict( + xaxis=dict(title="x", range=_xr, autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), + yaxis=dict(title="y", range=_yr, autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), + zaxis=dict(title="z", range=_zr, autorange=False, backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), + bgcolor=_BG, + aspectmode="manual", + aspectratio=dict(x=dx / max_d, y=dy / max_d, z=dz / max_d), + ) + ) elif ad.ndim == 2: # Downsample for display. The array may have more pixels than the @@ -2040,7 +2427,7 @@ def _tf(arr): return arr else: _x_disp = ad.x_cc _y_disp = ad.y_cc - title = f'{selected_var} · step {step}' + title = f"{selected_var} · step {step}" # Patch fast path: when only step or color-range changes, skip # rebuilding the full Plotly figure. Dash merges the diff with @@ -2048,14 +2435,14 @@ def _tf(arr): return arr # saving ~100–200 ms of browser-side figure reconstruction per step. # Disabled when contour overlay is active — contour traces change # with step and are hard to patch surgically. - _has_overlay = overlay_var and overlay_var != '__none__' - _trig = {t.get('prop_id', '') for t in (callback_context.triggered or [])} - _PATCH_OK = {'step-sel.data', 'vmin-inp.value', 'vmax-inp.value'} + _has_overlay = overlay_var and overlay_var != "__none__" + _trig = {t.get("prop_id", "") for t in (callback_context.triggered or [])} + _PATCH_OK = {"step-sel.data", "vmin-inp.value", "vmax-inp.value"} _do_patch = ( - _trig # not initial render (empty set) - and '.' not in _trig # not Dash synthetic init trigger + _trig # not initial render (empty set) + and "." not in _trig # not Dash synthetic init trigger and _trig.issubset(_PATCH_OK) # only step/range changed - and not _has_overlay # no contour overlay active + and not _has_overlay # no contour overlay active ) if _do_patch: # Check pre-encode cache first (populated by background prefetch) @@ -2066,54 +2453,60 @@ def _tf(arr): return arr if not _jpeg_hit: png_src = _make_png_source(_display_arr.T, cmap, cmin, cmax) patch = Patch() - patch['data'][0]['source'] = png_src + patch["data"][0]["source"] = png_src # Update colorbar (data[1] = dummy scatter colorbar) _cb = _make_cbar(cbar_title, cmin, cmax) - patch['data'][1]['marker']['colorscale'] = _lut_to_plotly_colorscale(cmap) - patch['data'][1]['marker']['cmin'] = cmin - patch['data'][1]['marker']['cmax'] = cmax - patch['data'][1]['marker']['color'] = [cmin] - patch['data'][1]['marker']['colorbar'] = _cb - patch['layout']['title']['text'] = title + patch["data"][1]["marker"]["colorscale"] = _lut_to_plotly_colorscale(cmap) + patch["data"][1]["marker"]["cmin"] = cmin + patch["data"][1]["marker"]["cmax"] = cmax + patch["data"][1]["marker"]["color"] = [cmin] + patch["data"][1]["marker"]["colorbar"] = _cb + patch["layout"]["title"]["text"] = title # Update bubble overlay shapes (they change with step) if bubble_func is not None: try: bubbles = bubble_func(step) if bubbles is not None and len(bubbles) > 0: - patch['layout']['shapes'] = [ + patch["layout"]["shapes"] = [ dict( - type='circle', xref='x', yref='y', - x0=float(b[0] - b[3]), y0=float(b[1] - b[3]), - x1=float(b[0] + b[3]), y1=float(b[1] + b[3]), - line=dict(color='white', width=0.8), - fillcolor='rgba(0,0,0,0)', + type="circle", + xref="x", + yref="y", + x0=float(b[0] - b[3]), + y0=float(b[1] - b[3]), + x1=float(b[0] + b[3]), + y1=float(b[1] + b[3]), + line=dict(color="white", width=0.8), + fillcolor="rgba(0,0,0,0)", ) for b in bubbles ] else: - patch['layout']['shapes'] = [] + patch["layout"]["shapes"] = [] except (OSError, ValueError): - patch['layout']['shapes'] = [] + patch["layout"]["shapes"] = [] _t_trace = time.perf_counter() dmin, dmax = float(np.nanmin(_raw_range)), float(np.nanmax(_raw_range)) - _tag = 'PATCH-JPEG-HIT' if _jpeg_hit else 'PATCH' + _tag = "PATCH-JPEG-HIT" if _jpeg_hit else "PATCH" cons.print( - f'[dim]viz timing step={step} shape={raw.shape}' - f' load={_t_load-_t0:.3f}s' - f' prep={_t_prep-_t_load:.3f}s' - f' png={_t_trace-_t_prep:.3f}s [{_tag}]' - f' payload={len(png_src)//1024}KB' - f' total={_t_trace-_t0:.3f}s[/dim]' + f"[dim]viz timing step={step} shape={raw.shape}" + f" load={_t_load - _t0:.3f}s" + f" prep={_t_prep - _t_load:.3f}s" + f" png={_t_trace - _t_prep:.3f}s [{_tag}]" + f" payload={len(png_src) // 1024}KB" + f" total={_t_trace - _t0:.3f}s[/dim]" + ) + status = html.Div( + [ + html.Span(f"step {step}", style={"color": _YELLOW}), + html.Span(f" · shape {raw.shape}", style={"color": _MUTED}), + html.Br(), + html.Span("min ", style={"color": _MUTED}), + html.Span(f"{dmin:.4g}", style={"color": _BLUE}), + html.Span(" max ", style={"color": _MUTED}), + html.Span(f"{dmax:.4g}", style={"color": _RED}), + ] ) - status = html.Div([ - html.Span(f'step {step}', style={'color': _YELLOW}), - html.Span(f' · shape {raw.shape}', style={'color': _MUTED}), - html.Br(), - html.Span('min ', style={'color': _MUTED}), - html.Span(f'{dmin:.4g}', style={'color': _BLUE}), - html.Span(' max ', style={'color': _MUTED}), - html.Span(f'{dmax:.4g}', style={'color': _RED}), - ]) _last_update_t[0] = time.perf_counter() return patch, status, no_update, _SRV_HIDE, _GRAPH_SHOW @@ -2128,30 +2521,38 @@ def _tf(arr): return arr png_src = _jpeg_cache.get(_jkey2) if png_src is None: png_src = _make_png_source(_display_arr.T, cmap, cmin, cmax) - fig = go.Figure([ - go.Image( - source=png_src, - x0=float(_x_disp[0]), dx=_dx_val, - y0=float(_y_disp[0]), dy=_dy_val, - hoverinfo='skip', - ), - # Invisible scatter whose sole purpose is rendering the colorbar. - # go.Image has no native colorscale support. - go.Scatter( - x=[None], y=[None], mode='markers', - showlegend=False, hoverinfo='skip', - marker=dict( - colorscale=_lut_to_plotly_colorscale(cmap), cmin=cmin, cmax=cmax, - color=[cmin], showscale=True, - colorbar=_make_cbar(cbar_title, cmin, cmax), + fig = go.Figure( + [ + go.Image( + source=png_src, + x0=float(_x_disp[0]), + dx=_dx_val, + y0=float(_y_disp[0]), + dy=_dy_val, + hoverinfo="skip", ), - ), - ]) + # Invisible scatter whose sole purpose is rendering the colorbar. + # go.Image has no native colorscale support. + go.Scatter( + x=[None], + y=[None], + mode="markers", + showlegend=False, + hoverinfo="skip", + marker=dict( + colorscale=_lut_to_plotly_colorscale(cmap), + cmin=cmin, + cmax=cmax, + color=[cmin], + showscale=True, + colorbar=_make_cbar(cbar_title, cmin, cmax), + ), + ), + ] + ) fig.update_layout( - xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, - scaleanchor='y', exponentformat='e'), - yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER, - exponentformat='e'), + xaxis=dict(title="x", color=_TEXT, gridcolor=_OVER, scaleanchor="y", exponentformat="e"), + yaxis=dict(title="y", color=_TEXT, gridcolor=_OVER, exponentformat="e"), plot_bgcolor=_BG, ) # Bubble overlay for 2D @@ -2161,12 +2562,15 @@ def _tf(arr): return arr if bubbles is not None and len(bubbles) > 0: shapes = [ dict( - type='circle', - xref='x', yref='y', - x0=float(b[0] - b[3]), y0=float(b[1] - b[3]), - x1=float(b[0] + b[3]), y1=float(b[1] + b[3]), - line=dict(color='white', width=0.8), - fillcolor='rgba(0,0,0,0)', + type="circle", + xref="x", + yref="y", + x0=float(b[0] - b[3]), + y0=float(b[1] - b[3]), + x1=float(b[0] + b[3]), + y1=float(b[1] + b[3]), + line=dict(color="white", width=0.8), + fillcolor="rgba(0,0,0,0)", ) for b in bubbles ] @@ -2180,9 +2584,11 @@ def _tf(arr): return arr # If loaded via single-var mode, we need to load the overlay # variable separately. _ov_traces = _compute_contour_traces( - _ov_raw, ad.x_cc, ad.y_cc, + _ov_raw, + ad.x_cc, + ad.y_cc, int(overlay_nlevels or 5), - overlay_color or 'white', + overlay_color or "white", float(overlay_lw or 1.0), ) for _ct in _ov_traces: @@ -2191,16 +2597,17 @@ def _tf(arr): return arr # Single-var loading mode: overlay var needs a separate load try: if read_one_var_func is not None: - _ov_ad = _load((step, overlay_var), - lambda k: read_one_var_func(k[0], k[1])) + _ov_ad = _load((step, overlay_var), lambda k: read_one_var_func(k[0], k[1])) else: _ov_ad = _load(step, read_func) if overlay_var in _ov_ad.variables: _ov_raw = _ov_ad.variables[overlay_var] _ov_traces = _compute_contour_traces( - _ov_raw, _ov_ad.x_cc, _ov_ad.y_cc, + _ov_raw, + _ov_ad.x_cc, + _ov_ad.y_cc, int(overlay_nlevels or 5), - overlay_color or 'white', + overlay_color or "white", float(overlay_lw or 1.0), ) for _ct in _ov_traces: @@ -2208,50 +2615,54 @@ def _tf(arr): return arr except (OSError, ValueError, EOFError): pass # contour overlay is best-effort - else: # 1D + else: # 1D plot_y = _tf(raw) if log else raw - fig.add_trace(go.Scatter( - x=ad.x_cc, y=plot_y, mode='lines', - line=dict(color=_ACCENT, width=2), name=selected_var, - )) + fig.add_trace( + go.Scatter( + x=ad.x_cc, + y=plot_y, + mode="lines", + line=dict(color=_ACCENT, width=2), + name=selected_var, + ) + ) fig.update_layout( - xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, exponentformat='e'), - yaxis=dict(title=cbar_title, color=_TEXT, gridcolor=_OVER, - tickformat='.2e', - range=[cmin, cmax] if (vmin_in is not None or vmax_in is not None) else None), + xaxis=dict(title="x", color=_TEXT, gridcolor=_OVER, exponentformat="e"), + yaxis=dict(title=cbar_title, color=_TEXT, gridcolor=_OVER, tickformat=".2e", range=[cmin, cmax] if (vmin_in is not None or vmax_in is not None) else None), plot_bgcolor=_BG, ) - title = f'{selected_var} · step {step}' + title = f"{selected_var} · step {step}" _t_trace = time.perf_counter() fig.update_layout( - title=dict(text=title, font=dict(color=_TEXT, size=13, family='monospace')), + title=dict(text=title, font=dict(color=_TEXT, size=13, family="monospace")), paper_bgcolor=_BG, - font=dict(color=_TEXT, family='monospace'), + font=dict(color=_TEXT, family="monospace"), margin=dict(l=0, r=130, t=36, b=0), - uirevision=mode, # preserve camera angle within a mode + uirevision=mode, # preserve camera angle within a mode ) _t_layout = time.perf_counter() dmin, dmax = float(np.nanmin(_raw_range)), float(np.nanmax(_raw_range)) cons.print( - f'[dim]viz timing step={step} shape={raw.shape}' - f' load={_t_load-_t0:.3f}s' - f' prep={_t_prep-_t_load:.3f}s' - f' trace={_t_trace-_t_prep:.3f}s' - f' layout={_t_layout-_t_trace:.3f}s' - f' total={_t_layout-_t0:.3f}s[/dim]' + f"[dim]viz timing step={step} shape={raw.shape}" + f" load={_t_load - _t0:.3f}s" + f" prep={_t_prep - _t_load:.3f}s" + f" trace={_t_trace - _t_prep:.3f}s" + f" layout={_t_layout - _t_trace:.3f}s" + f" total={_t_layout - _t0:.3f}s[/dim]" + ) + status = html.Div( + [ + html.Span(f"step {step}", style={"color": _YELLOW}), + html.Span(f" · shape {raw.shape}", style={"color": _MUTED}), + html.Br(), + html.Span("min ", style={"color": _MUTED}), + html.Span(f"{dmin:.4g}", style={"color": _BLUE}), + html.Span(" max ", style={"color": _MUTED}), + html.Span(f"{dmax:.4g}", style={"color": _RED}), + ] ) - status = html.Div([ - html.Span(f'step {step}', style={'color': _YELLOW}), - html.Span(f' · shape {raw.shape}', style={'color': _MUTED}), - html.Br(), - html.Span('min ', style={'color': _MUTED}), - html.Span(f'{dmin:.4g}', style={'color': _BLUE}), - html.Span(' max ', style={'color': _MUTED}), - html.Span(f'{dmax:.4g}', style={'color': _RED}), - ]) - _now = time.perf_counter() _last_update_t[0] = _now @@ -2263,25 +2674,20 @@ def _tf(arr): return arr return fig, status, no_update, _SRV_HIDE, _GRAPH_SHOW # ------------------------------------------------------------------ - cons.print(f'\n[bold green]Interactive viz server:[/bold green] ' - f'[bold]http://{host}:{port}[/bold]') - if host in ('127.0.0.1', 'localhost'): + cons.print(f"\n[bold green]Interactive viz server:[/bold green] [bold]http://{host}:{port}[/bold]") + if host in ("127.0.0.1", "localhost"): cons.print( - f'\n[dim]To view from your laptop/desktop, open a [bold]new terminal on your local machine[/bold] and run:[/dim]\n' - f'\n [bold]ssh -L {port}:localhost:{port} user@cluster-hostname[/bold]\n' - f'\n[dim] Replace [bold]user@cluster-hostname[/bold] with whatever you normally pass to [bold]ssh[/bold][/dim]\n' - f'[dim] to reach this cluster (e.g. [bold]jdoe@login.delta.ncsa.illinois.edu[/bold] or an[/dim]\n' - f'[dim] alias from your [bold]~/.ssh/config[/bold]).[/dim]\n' - f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]\n' - f'[dim] If you see [bold]Address already in use[/bold], free the port with:[/dim]\n' - f' [bold]lsof -ti :{port} | xargs kill[/bold]' + f"\n[dim]To view from your laptop/desktop, open a [bold]new terminal on your local machine[/bold] and run:[/dim]\n" + f"\n [bold]ssh -L {port}:localhost:{port} user@cluster-hostname[/bold]\n" + f"\n[dim] Replace [bold]user@cluster-hostname[/bold] with whatever you normally pass to [bold]ssh[/bold][/dim]\n" + f"[dim] to reach this cluster (e.g. [bold]jdoe@login.delta.ncsa.illinois.edu[/bold] or an[/dim]\n" + f"[dim] alias from your [bold]~/.ssh/config[/bold]).[/dim]\n" + f"[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]\n" + f"[dim] If you see [bold]Address already in use[/bold], free the port with:[/dim]\n" + f" [bold]lsof -ti :{port} | xargs kill[/bold]" ) if ndim == 3 and not _KALEIDO_OK: - cons.print( - '[dim][yellow]Note:[/yellow] Kaleido is not available. ' - '3D playback will use Plotly WebGL (slower over SSH).\n' - ' Install: pip install kaleido[/dim]' - ) + cons.print("[dim][yellow]Note:[/yellow] Kaleido is not available. 3D playback will use Plotly WebGL (slower over SSH).\n Install: pip install kaleido[/dim]") - cons.print('[dim]\nCtrl+C to stop.[/dim]\n') + cons.print("[dim]\nCtrl+C to stop.[/dim]\n") app.run(debug=False, port=port, host=host) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index a7f865d636..25ec20a284 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -25,7 +25,6 @@ import numpy as np - NAME_LEN = 50 # Fortran character length for variable names _READ_POOL: Optional[ThreadPoolExecutor] = None @@ -34,12 +33,10 @@ def _get_pool() -> ThreadPoolExecutor: """Return a persistent module-level thread pool, creating it on first use.""" - global _READ_POOL # pylint: disable=global-statement + global _READ_POOL # noqa: PLW0603 with _POOL_LOCK: if _READ_POOL is None: - _READ_POOL = ThreadPoolExecutor( - max_workers=32, thread_name_prefix='mfc_binary' - ) + _READ_POOL = ThreadPoolExecutor(max_workers=32, thread_name_prefix="mfc_binary") atexit.register(_READ_POOL.shutdown, wait=False) return _READ_POOL @@ -57,6 +54,7 @@ class ProcessorData: it derives everything from x_cb/y_cb/z_cb lengths. If future code needs m directly, this discrepancy must be resolved. """ + m: int n: int p: int @@ -69,6 +67,7 @@ class ProcessorData: @dataclass class AssembledData: """Assembled multi-processor data on a global grid.""" + ndim: int x_cc: np.ndarray y_cc: np.ndarray @@ -76,26 +75,23 @@ class AssembledData: variables: Dict[str, np.ndarray] = field(default_factory=dict) - def _detect_endianness(path: str) -> str: """Detect endianness from the first record marker. The header record contains 4 int32s (m, n, p, dbvars) = 16 bytes, so the leading Fortran record marker must be 16. """ - with open(path, 'rb') as f: + with open(path, "rb") as f: raw = f.read(4) if len(raw) < 4: raise EOFError(f"File too short to detect endianness: {path}") - le = struct.unpack('i', raw)[0] + return "<" + be = struct.unpack(">i", raw)[0] if be == 16: - return '>' - raise ValueError( - f"Cannot detect endianness: first record marker is {le} (LE) / {be} (BE), expected 16" - ) + return ">" + raise ValueError(f"Cannot detect endianness: first record marker is {le} (LE) / {be} (BE), expected 16") def _read_record_endian(f, endian: str) -> bytes: @@ -103,7 +99,7 @@ def _read_record_endian(f, endian: str) -> bytes: raw = f.read(4) if len(raw) < 4: raise EOFError("Unexpected end of file reading record marker") - rec_len = struct.unpack(f'{endian}i', raw)[0] + rec_len = struct.unpack(f"{endian}i", raw)[0] if rec_len < 0: raise ValueError(f"Invalid Fortran record length: {rec_len}") payload = f.read(rec_len) @@ -112,16 +108,13 @@ def _read_record_endian(f, endian: str) -> bytes: trail = f.read(4) if len(trail) < 4: raise EOFError("Unexpected end of file reading trailing record marker") - trail_len = struct.unpack(f'{endian}i', trail)[0] + trail_len = struct.unpack(f"{endian}i", trail)[0] if trail_len != rec_len: - raise ValueError( - f"Fortran record marker mismatch: leading={rec_len}, trailing={trail_len}. " - "File may be corrupted." - ) + raise ValueError(f"Fortran record marker mismatch: leading={rec_len}, trailing={trail_len}. File may be corrupted.") return payload -def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: # pylint: disable=too-many-locals,too-many-statements +def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: """ Read a single MFC binary post-process file. @@ -134,14 +127,12 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa """ endian = _detect_endianness(path) - with open(path, 'rb') as f: + with open(path, "rb") as f: # Record 1: header [m, n, p, dbvars] — 4 int32 hdr = _read_record_endian(f, endian) - m, n, p, dbvars = struct.unpack(f'{endian}4i', hdr) + m, n, p, dbvars = struct.unpack(f"{endian}4i", hdr) if m < 0 or n < 0 or p < 0 or dbvars < 0: - raise ValueError( - f"Invalid header in {path}: m={m}, n={n}, p={p}, dbvars={dbvars}" - ) + raise ValueError(f"Invalid header in {path}: m={m}, n={n}, p={p}, dbvars={dbvars}") # Record 2: grid coordinates — all in one record grid_raw = _read_record_endian(f, endian) @@ -157,29 +148,26 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa # Auto-detect grid precision from record size if grid_bytes == n_vals * 8: - grid_dtype = np.dtype(f'{endian}f8') + grid_dtype = np.dtype(f"{endian}f8") elif grid_bytes == n_vals * 4: - grid_dtype = np.dtype(f'{endian}f4') + grid_dtype = np.dtype(f"{endian}f4") else: bytes_per_val = grid_bytes / n_vals if n_vals else 0 - raise ValueError( - f"Cannot determine grid precision: {grid_bytes} bytes for {n_vals} values " - f"({bytes_per_val:.1f} bytes/value)" - ) + raise ValueError(f"Cannot determine grid precision: {grid_bytes} bytes for {n_vals} values ({bytes_per_val:.1f} bytes/value)") grid_arr = np.frombuffer(grid_raw, dtype=grid_dtype) # Split into x_cb, y_cb, z_cb offset = 0 - x_cb = grid_arr[offset:offset + m + 2].astype(np.float64) + x_cb = grid_arr[offset : offset + m + 2].astype(np.float64) offset += m + 2 if n > 0: - y_cb = grid_arr[offset:offset + n + 2].astype(np.float64) + y_cb = grid_arr[offset : offset + n + 2].astype(np.float64) offset += n + 2 else: y_cb = np.array([0.0]) if p > 0: - z_cb = grid_arr[offset:offset + p + 2].astype(np.float64) + z_cb = grid_arr[offset : offset + p + 2].astype(np.float64) else: z_cb = np.array([0.0]) @@ -194,14 +182,14 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa raw_len = f.read(4) if len(raw_len) < 4: raise EOFError("Unexpected end of file reading variable record marker") - rec_len = struct.unpack(f'{endian}i', raw_len)[0] + rec_len = struct.unpack(f"{endian}i", raw_len)[0] if rec_len < NAME_LEN: raise ValueError(f"Variable record too short: {rec_len} bytes") name_raw = f.read(NAME_LEN) if len(name_raw) < NAME_LEN: raise EOFError("Unexpected end of file reading variable name") - varname = name_raw.decode('ascii', errors='replace').strip() + varname = name_raw.decode("ascii", errors="replace").strip() data_bytes = rec_len - NAME_LEN if var_filter is not None and varname != var_filter: @@ -215,37 +203,28 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa trail = f.read(4) if len(trail) < 4: raise EOFError("Unexpected end of file reading trailing variable record marker") - trail_len = struct.unpack(f'{endian}i', trail)[0] + trail_len = struct.unpack(f"{endian}i", trail)[0] if trail_len != rec_len: - raise ValueError( - f"Fortran record marker mismatch for '{varname}': " - f"leading={rec_len}, trailing={trail_len}" - ) + raise ValueError(f"Fortran record marker mismatch for '{varname}': leading={rec_len}, trailing={trail_len}") # Auto-detect variable data precision from record size if data_bytes == data_size * 8: - var_dtype = np.dtype(f'{endian}f8') + var_dtype = np.dtype(f"{endian}f8") elif data_bytes == data_size * 4: - var_dtype = np.dtype(f'{endian}f4') + var_dtype = np.dtype(f"{endian}f4") elif data_bytes == data_size * 2: - raise ValueError( - f"Variable '{varname}' appears to be half-precision (2 bytes/value). " - "This is typical of --mixed builds. Half-precision viz is not yet supported." - ) + raise ValueError(f"Variable '{varname}' appears to be half-precision (2 bytes/value). This is typical of --mixed builds. Half-precision viz is not yet supported.") else: var_bpv = data_bytes / data_size if data_size else 0 - raise ValueError( - f"Cannot determine variable precision for '{varname}': " - f"{data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)" - ) + raise ValueError(f"Cannot determine variable precision for '{varname}': {data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)") data = np.frombuffer(data_raw, dtype=var_dtype).astype(np.float64) # Reshape for multi-dimensional data (Fortran column-major order) if p > 0: - data = data.reshape((m + 1, n + 1, p + 1), order='F') + data = data.reshape((m + 1, n + 1, p + 1), order="F") elif n > 0: - data = data.reshape((m + 1, n + 1), order='F') + data = data.reshape((m + 1, n + 1), order="F") variables[varname] = data @@ -258,36 +237,32 @@ def discover_format(case_dir: str) -> str: Returns 'binary' or 'silo'. Raises FileNotFoundError if neither exists. When both exist, emits a warnings.warn so callers can surface it. """ - has_binary = os.path.isdir(os.path.join(case_dir, 'binary')) - has_silo = os.path.isdir(os.path.join(case_dir, 'silo_hdf5')) + has_binary = os.path.isdir(os.path.join(case_dir, "binary")) + has_silo = os.path.isdir(os.path.join(case_dir, "silo_hdf5")) if has_binary and has_silo: warnings.warn( - "Both binary/ and silo_hdf5/ found; using binary. " - "Pass --format silo to override.", + "Both binary/ and silo_hdf5/ found; using binary. Pass --format silo to override.", stacklevel=2, ) if has_binary: - return 'binary' + return "binary" if has_silo: - return 'silo' - raise FileNotFoundError( - f"No 'binary/' or 'silo_hdf5/' directory found in {case_dir}. " - "Run post_process with format=1 (Silo) or format=2 (binary) first." - ) + return "silo" + raise FileNotFoundError(f"No 'binary/' or 'silo_hdf5/' directory found in {case_dir}. Run post_process with format=1 (Silo) or format=2 (binary) first.") def discover_timesteps(case_dir: str, fmt: str) -> List[int]: """Return sorted list of available timesteps.""" - if fmt not in ('binary', 'silo'): + if fmt not in ("binary", "silo"): raise ValueError(f"Unknown format '{fmt}'. Supported: 'binary', 'silo'.") - if fmt == 'binary': + if fmt == "binary": # Check root/ first (1D), then p0/ - root_dir = os.path.join(case_dir, 'binary', 'root') + root_dir = os.path.join(case_dir, "binary", "root") if os.path.isdir(root_dir): steps = set() for fname in os.listdir(root_dir): - if fname.endswith('.dat'): + if fname.endswith(".dat"): try: steps.add(int(fname[:-4])) except ValueError: @@ -296,23 +271,23 @@ def discover_timesteps(case_dir: str, fmt: str) -> List[int]: return sorted(steps) # Multi-dimensional: look in p0/ - p0_dir = os.path.join(case_dir, 'binary', 'p0') + p0_dir = os.path.join(case_dir, "binary", "p0") if os.path.isdir(p0_dir): steps = set() for fname in os.listdir(p0_dir): - if fname.endswith('.dat'): + if fname.endswith(".dat"): try: steps.add(int(fname[:-4])) except ValueError: pass return sorted(steps) - elif fmt == 'silo': - p0_dir = os.path.join(case_dir, 'silo_hdf5', 'p0') + elif fmt == "silo": + p0_dir = os.path.join(case_dir, "silo_hdf5", "p0") if os.path.isdir(p0_dir): steps = set() for fname in os.listdir(p0_dir): - if fname.endswith('.silo') and not fname.startswith('collection'): + if fname.endswith(".silo") and not fname.startswith("collection"): try: steps.add(int(fname[:-5])) except ValueError: @@ -324,30 +299,28 @@ def discover_timesteps(case_dir: str, fmt: str) -> List[int]: def _discover_processors(case_dir: str, fmt: str) -> List[int]: """Return sorted list of processor ranks.""" - if fmt == 'binary': - base = os.path.join(case_dir, 'binary') + if fmt == "binary": + base = os.path.join(case_dir, "binary") else: - base = os.path.join(case_dir, 'silo_hdf5') + base = os.path.join(case_dir, "silo_hdf5") ranks = [] if not os.path.isdir(base): return ranks for entry in os.listdir(base): - if entry.startswith('p') and entry[1:].isdigit(): + if entry.startswith("p") and entry[1:].isdigit(): ranks.append(int(entry[1:])) return sorted(ranks) def _is_1d(case_dir: str) -> bool: """Check if the output is 1D (binary/root/ exists with .dat files, no p0/ present).""" - root = os.path.join(case_dir, 'binary', 'root') - p0 = os.path.join(case_dir, 'binary', 'p0') - return (os.path.isdir(root) - and any(f.endswith('.dat') for f in os.listdir(root)) - and not os.path.isdir(p0)) + root = os.path.join(case_dir, "binary", "root") + p0 = os.path.join(case_dir, "binary", "p0") + return os.path.isdir(root) and any(f.endswith(".dat") for f in os.listdir(root)) and not os.path.isdir(p0) -def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-statements +def assemble_from_proc_data( proc_data: List[Tuple[int, ProcessorData]], ) -> AssembledData: """ @@ -368,7 +341,10 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-stateme z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0]) ndim = 1 + (pd.n > 0) + (pd.p > 0) return AssembledData( - ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc, + ndim=ndim, + x_cc=x_cc, + y_cc=y_cc, + z_cc=z_cc, variables=pd.variables, ) @@ -449,32 +425,36 @@ def _norm_round(arr, origin, extent): global_vars[vn][xi] = data return AssembledData( - ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, + ndim=ndim, + x_cc=global_x, + y_cc=global_y, + z_cc=global_z, variables=global_vars, ) -def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals - var: Optional[str] = None) -> AssembledData: +def assemble(case_dir: str, step: int, fmt: str = "binary", var: Optional[str] = None) -> AssembledData: """ Read and assemble multi-processor data for a given timestep. For 1D, reads the root file directly. For 2D/3D, reads all processor files and assembles into global arrays. """ - if fmt != 'binary': + if fmt != "binary": raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.") # 1D case: read root file directly if _is_1d(case_dir): - root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat') + root_path = os.path.join(case_dir, "binary", "root", f"{step}.dat") if not os.path.isfile(root_path): raise FileNotFoundError(f"Root file not found: {root_path}") pdata = read_binary_file(root_path, var_filter=var) x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0 return AssembledData( - ndim=1, x_cc=x_cc, - y_cc=np.array([0.0]), z_cc=np.array([0.0]), + ndim=1, + x_cc=x_cc, + y_cc=np.array([0.0]), + z_cc=np.array([0.0]), variables=pdata.variables, ) @@ -486,12 +466,9 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t # Validate all paths exist before spawning threads so errors are synchronous. rank_paths: List[tuple] = [] for rank in ranks: - fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') + fpath = os.path.join(case_dir, "binary", f"p{rank}", f"{step}.dat") if not os.path.isfile(fpath): - raise FileNotFoundError( - f"Processor file not found: {fpath}. " - "Incomplete output (missing rank) would produce incorrect data." - ) + raise FileNotFoundError(f"Processor file not found: {fpath}. Incomplete output (missing rank) would produce incorrect data.") rank_paths.append((rank, fpath)) def _read_one(rank_fpath): @@ -516,6 +493,7 @@ def _read_one(rank_fpath): # Lagrange bubble position reader # --------------------------------------------------------------------------- + @lru_cache(maxsize=32) def _nBubs_per_step(path: str) -> int: """Count how many bubble rows share the first time value in *path*. @@ -523,7 +501,7 @@ def _nBubs_per_step(path: str) -> int: The result is cached so repeated calls for the same file (across different steps in an MP4 render) only scan the file once. """ - with open(path, encoding='ascii', errors='replace') as f: + with open(path, encoding="ascii", errors="replace") as f: f.readline() # skip header first = f.readline() if not first.strip(): @@ -552,11 +530,11 @@ def read_lag_bubbles_at_step(case_dir: str, step: int) -> Optional[np.ndarray]: simulation-normalized units across all MPI ranks, or ``None`` when no bubble data is found. """ - d_dir = os.path.join(case_dir, 'D') + d_dir = os.path.join(case_dir, "D") if not os.path.isdir(d_dir): return None - files = sorted(glob.glob(os.path.join(d_dir, 'lag_bubble_evol_*.dat'))) + files = sorted(glob.glob(os.path.join(d_dir, "lag_bubble_evol_*.dat"))) if not files: return None @@ -575,15 +553,14 @@ def read_lag_bubbles_at_step(case_dir: str, step: int) -> Optional[np.ndarray]: # not yet implemented. skip = 1 + step * nBubs rows = [] - with open(fpath, encoding='ascii', errors='replace') as f: + with open(fpath, encoding="ascii", errors="replace") as f: for _ in itertools.islice(f, skip): pass for line in itertools.islice(f, nBubs): parts = line.split() if len(parts) >= 8: # cols: time id x y z mv conc r [rdot p] - rows.append((float(parts[2]), float(parts[3]), - float(parts[4]), float(parts[7]))) + rows.append((float(parts[2]), float(parts[3]), float(parts[4]), float(parts[7]))) if rows: chunks.append(np.array(rows, dtype=np.float64)) except (OSError, ValueError): @@ -594,5 +571,5 @@ def read_lag_bubbles_at_step(case_dir: str, step: int) -> Optional[np.ndarray]: def has_lag_bubble_evol(case_dir: str) -> bool: """Return True if ``D/lag_bubble_evol_*.dat`` files exist in *case_dir*.""" - d_dir = os.path.join(case_dir, 'D') - return bool(glob.glob(os.path.join(d_dir, 'lag_bubble_evol_*.dat'))) + d_dir = os.path.join(case_dir, "D") + return bool(glob.glob(os.path.join(d_dir, "lag_bubble_evol_*.dat"))) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index a6f4d4edbd..8d4cfa1ac5 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -11,65 +11,66 @@ import re import tempfile -import numpy as np - import imageio - import matplotlib +import numpy as np + try: - matplotlib.use('Agg') + matplotlib.use("Agg") except ValueError: pass -import matplotlib.pyplot as plt # pylint: disable=wrong-import-position -from matplotlib.colors import LogNorm # pylint: disable=wrong-import-position +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.colors import LogNorm # noqa: E402 -matplotlib.rcParams.update({ - 'mathtext.fontset': 'cm', - 'font.family': 'serif', -}) +matplotlib.rcParams.update( + { + "mathtext.fontset": "cm", + "font.family": "serif", + } +) # LaTeX-style labels for known MFC variable names _LABEL_MAP = { - 'pres': r'$p$', - 'rho': r'$\rho$', - 'E': r'$E$', - 'T': r'$T$', - 'D': r'$D$', - 'c': r'$c$', - 'gamma': r'$\gamma$', - 'pi_inf': r'$\pi_\infty$', - 'pres_inf': r'$p_\infty$', - 'heat_ratio': r'$\gamma$', - 'schlieren': r'$|\nabla \rho|$', - 'psi': r'$\psi$', - 'n': r'$n$', - 'qm': r'$q_m$', - 'Bx': r'$B_x$', 'By': r'$B_y$', 'Bz': r'$B_z$', - 'voidFraction': r'void fraction', - 'liutex_mag': r'$|\lambda|$', - 'damage_state': r'damage', + "pres": r"$p$", + "rho": r"$\rho$", + "E": r"$E$", + "T": r"$T$", + "D": r"$D$", + "c": r"$c$", + "gamma": r"$\gamma$", + "pi_inf": r"$\pi_\infty$", + "pres_inf": r"$p_\infty$", + "heat_ratio": r"$\gamma$", + "schlieren": r"$|\nabla \rho|$", + "psi": r"$\psi$", + "n": r"$n$", + "qm": r"$q_m$", + "Bx": r"$B_x$", + "By": r"$B_y$", + "Bz": r"$B_z$", + "voidFraction": r"void fraction", + "liutex_mag": r"$|\lambda|$", + "damage_state": r"damage", } _INDEXED_PATTERNS = [ - (r'^vel(\d+)$', lambda m: [r'$u$', r'$v$', r'$w$'][int(m.group(1)) - 1] - if int(m.group(1)) <= 3 else rf'$v_{m.group(1)}$'), - (r'^mom(\d+)$', lambda m: rf'$\rho {["u", "v", "w"][int(m.group(1)) - 1]}$' - if int(m.group(1)) <= 3 else rf'$m_{m.group(1)}$'), - (r'^alpha(\d+)$', lambda m: rf'$\alpha_{m.group(1)}$'), - (r'^alpha_rho(\d+)$', lambda m: rf'$\alpha_{m.group(1)}\rho_{m.group(1)}$'), - (r'^alpha_rho_e(\d+)$', lambda m: rf'$\alpha_{m.group(1)}\rho_{m.group(1)}e_{m.group(1)}$'), - (r'^omega(\d+)$', lambda m: rf'$\omega_{m.group(1)}$'), - (r'^tau(\d+)$', lambda m: rf'$\tau_{m.group(1)}$'), - (r'^xi(\d+)$', lambda m: rf'$\xi_{m.group(1)}$'), - (r'^flux(\d+)$', lambda m: rf'$F_{m.group(1)}$'), - (r'^liutex_axis(\d+)$', lambda m: rf'$\lambda_{m.group(1)}$'), - (r'^rho(\d+)$', lambda m: rf'$\rho_{m.group(1)}$'), - (r'^Y_(.+)$', lambda m: rf'$Y_{{\mathrm{{{m.group(1)}}}}}$'), - (r'^nR(\d+)$', lambda m: rf'$nR_{{{m.group(1)}}}$'), - (r'^nV(\d+)$', lambda m: rf'$nV_{{{m.group(1)}}}$'), - (r'^nP(\d+)$', lambda m: rf'$nP_{{{m.group(1)}}}$'), - (r'^nM(\d+)$', lambda m: rf'$nM_{{{m.group(1)}}}$'), - (r'^color_function(\d+)$', lambda m: rf'color $f_{m.group(1)}$'), + (r"^vel(\d+)$", lambda m: [r"$u$", r"$v$", r"$w$"][int(m.group(1)) - 1] if int(m.group(1)) <= 3 else rf"$v_{m.group(1)}$"), + (r"^mom(\d+)$", lambda m: rf"$\rho {['u', 'v', 'w'][int(m.group(1)) - 1]}$" if int(m.group(1)) <= 3 else rf"$m_{m.group(1)}$"), + (r"^alpha(\d+)$", lambda m: rf"$\alpha_{m.group(1)}$"), + (r"^alpha_rho(\d+)$", lambda m: rf"$\alpha_{m.group(1)}\rho_{m.group(1)}$"), + (r"^alpha_rho_e(\d+)$", lambda m: rf"$\alpha_{m.group(1)}\rho_{m.group(1)}e_{m.group(1)}$"), + (r"^omega(\d+)$", lambda m: rf"$\omega_{m.group(1)}$"), + (r"^tau(\d+)$", lambda m: rf"$\tau_{m.group(1)}$"), + (r"^xi(\d+)$", lambda m: rf"$\xi_{m.group(1)}$"), + (r"^flux(\d+)$", lambda m: rf"$F_{m.group(1)}$"), + (r"^liutex_axis(\d+)$", lambda m: rf"$\lambda_{m.group(1)}$"), + (r"^rho(\d+)$", lambda m: rf"$\rho_{m.group(1)}$"), + (r"^Y_(.+)$", lambda m: rf"$Y_{{\mathrm{{{m.group(1)}}}}}$"), + (r"^nR(\d+)$", lambda m: rf"$nR_{{{m.group(1)}}}$"), + (r"^nV(\d+)$", lambda m: rf"$nV_{{{m.group(1)}}}$"), + (r"^nP(\d+)$", lambda m: rf"$nP_{{{m.group(1)}}}$"), + (r"^nM(\d+)$", lambda m: rf"$nM_{{{m.group(1)}}}$"), + (r"^color_function(\d+)$", lambda m: rf"color $f_{m.group(1)}$"), ] @@ -83,11 +84,11 @@ def _overlay_bubbles(ax, bubbles, scale: float = 1.0) -> None: """ if bubbles is None or len(bubbles) == 0: return - from matplotlib.patches import Circle # pylint: disable=import-outside-toplevel - from matplotlib.collections import PatchCollection # pylint: disable=import-outside-toplevel + from matplotlib.collections import PatchCollection + from matplotlib.patches import Circle + circles = [Circle((b[0], b[1]), b[3] * scale) for b in bubbles] - pc = PatchCollection(circles, facecolors='none', edgecolors='white', - linewidths=0.5, alpha=0.8) + pc = PatchCollection(circles, facecolors="none", edgecolors="white", linewidths=0.5, alpha=0.8) ax.add_collection(pc) @@ -102,31 +103,31 @@ def pretty_label(varname): return varname -def render_1d(x_cc, data, varname, step, output, **opts): # pylint: disable=too-many-arguments,too-many-positional-arguments +def render_1d(x_cc, data, varname, step, output, **opts): """Render a 1D line plot and save as PNG.""" - fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 6))) + fig, ax = plt.subplots(figsize=opts.get("figsize", (10, 6))) label = pretty_label(varname) ax.plot(x_cc, data, linewidth=1.5) - ax.set_xlabel(r'$x$') + ax.set_xlabel(r"$x$") ax.set_ylabel(label) - ax.set_title(f'{label} (step {step})') + ax.set_title(f"{label} (step {step})") ax.grid(True, alpha=0.3) - ax.ticklabel_format(axis='y', style='sci', scilimits=(-3, 4), useMathText=True) + ax.ticklabel_format(axis="y", style="sci", scilimits=(-3, 4), useMathText=True) - log_scale = opts.get('log_scale', False) + log_scale = opts.get("log_scale", False) if log_scale: - ax.set_yscale('log') - vmin = opts.get('vmin') - vmax = opts.get('vmax') + ax.set_yscale("log") + vmin = opts.get("vmin") + vmax = opts.get("vmax") if vmin is not None or vmax is not None: ax.set_ylim(vmin, vmax) fig.tight_layout() - fig.savefig(output, dpi=opts.get('dpi', 150)) + fig.savefig(output, dpi=opts.get("dpi", 150)) plt.close(fig) -def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=too-many-locals +def render_1d_tiled(x_cc, variables, step, output, **opts): """Render all 1D variables in a tiled subplot grid and save as PNG.""" varnames = sorted(variables.keys()) n = len(varnames) @@ -140,11 +141,9 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t nrows = math.ceil(n / ncols) fig_w = 5 * ncols fig_h = 2.8 * nrows - fig, axes = plt.subplots(nrows, ncols, - figsize=opts.get('figsize', (fig_w, fig_h)), - sharex=True, squeeze=False) + fig, axes = plt.subplots(nrows, ncols, figsize=opts.get("figsize", (fig_w, fig_h)), sharex=True, squeeze=False) - log_scale = opts.get('log_scale', False) + log_scale = opts.get("log_scale", False) for idx, vn in enumerate(varnames): row, col = divmod(idx, ncols) ax = axes[row][col] @@ -153,7 +152,7 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t ax.tick_params(labelsize=8) ax.grid(True, alpha=0.3) if log_scale: - ax.set_yscale('log') + ax.set_yscale("log") # Hide unused subplots for idx in range(n, nrows * ncols): @@ -163,11 +162,11 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t # X-label only on bottom row for col in range(ncols): bottom_row = min(nrows - 1, (n - 1) // ncols) if col < (n % ncols or ncols) else nrows - 2 - axes[bottom_row][col].set_xlabel(r'$x$', fontsize=9) + axes[bottom_row][col].set_xlabel(r"$x$", fontsize=9) - fig.suptitle(f'step {step}', fontsize=11, y=0.99) + fig.suptitle(f"step {step}", fontsize=11, y=0.99) fig.tight_layout(rect=[0, 0, 1, 0.97]) - fig.savefig(output, dpi=opts.get('dpi', 150)) + fig.savefig(output, dpi=opts.get("dpi", 150)) plt.close(fig) @@ -184,15 +183,15 @@ def _figsize_for_domain(x_cc, y_cc, base=10): return (fig_w, fig_h) -def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def render_2d(x_cc, y_cc, data, varname, step, output, **opts): """Render a 2D colormap via pcolormesh and save as PNG.""" default_size = _figsize_for_domain(x_cc, y_cc) - fig, ax = plt.subplots(figsize=opts.get('figsize', default_size)) + fig, ax = plt.subplots(figsize=opts.get("figsize", default_size)) - cmap = opts.get('cmap', 'viridis') - vmin = opts.get('vmin') - vmax = opts.get('vmax') - log_scale = opts.get('log_scale', False) + cmap = opts.get("cmap", "viridis") + vmin = opts.get("vmin") + vmax = opts.get("vmax") + log_scale = opts.get("log_scale", False) norm = None if log_scale: @@ -207,49 +206,45 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab vmax = None # data shape is (nx, ny), pcolormesh expects (ny, nx) when using x_cc, y_cc - pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, - norm=norm, shading='auto') + pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading="auto") label = pretty_label(varname) fig.colorbar(pcm, ax=ax, label=label) - ax.set_xlabel(r'$x$') - ax.set_ylabel(r'$y$') - ax.set_title(f'{label} (step {step})') - ax.set_aspect('equal', adjustable='box') + ax.set_xlabel(r"$x$") + ax.set_ylabel(r"$y$") + ax.set_title(f"{label} (step {step})") + ax.set_aspect("equal", adjustable="box") - _overlay_bubbles(ax, opts.get('bubbles'), scale=opts.get('bubble_scale', 1.0)) + _overlay_bubbles(ax, opts.get("bubbles"), scale=opts.get("bubble_scale", 1.0)) fig.tight_layout() - fig.savefig(output, dpi=opts.get('dpi', 150)) + fig.savefig(output, dpi=opts.get("dpi", 150)) plt.close(fig) -def render_2d_tiled(assembled, step, output, **opts): # pylint: disable=too-many-locals +def render_2d_tiled(assembled, step, output, **opts): """Render all 2D variables in a tiled subplot grid and save as PNG.""" varnames = sorted(assembled.variables.keys()) n = len(varnames) if n == 0: return if n == 1: - render_2d(assembled.x_cc, assembled.y_cc, - assembled.variables[varnames[0]], varnames[0], step, output, **opts) + render_2d(assembled.x_cc, assembled.y_cc, assembled.variables[varnames[0]], varnames[0], step, output, **opts) return ncols = min(n, 3) nrows = math.ceil(n / ncols) cell_w, cell_h = _figsize_for_domain(assembled.x_cc, assembled.y_cc, base=4) - fig, axes = plt.subplots(nrows, ncols, - figsize=opts.get('figsize', (cell_w * ncols, cell_h * nrows)), - squeeze=False) + fig, axes = plt.subplots(nrows, ncols, figsize=opts.get("figsize", (cell_w * ncols, cell_h * nrows)), squeeze=False) - cmap = opts.get('cmap', 'viridis') - log_scale = opts.get('log_scale', False) + cmap = opts.get("cmap", "viridis") + log_scale = opts.get("log_scale", False) for idx, vn in enumerate(varnames): row, col = divmod(idx, ncols) ax = axes[row][col] data = assembled.variables[vn] norm = None - vmin = opts.get('vmin') - vmax = opts.get('vmax') + vmin = opts.get("vmin") + vmax = opts.get("vmax") if log_scale and np.any(data > 0): lo = float(np.nanmin(data[data > 0])) hi = float(np.nanmax(data)) @@ -257,36 +252,31 @@ def render_2d_tiled(assembled, step, output, **opts): # pylint: disable=too-man norm = LogNorm(vmin=lo, vmax=hi) vmin = None vmax = None - pcm = ax.pcolormesh(assembled.x_cc, assembled.y_cc, data.T, - cmap=cmap, vmin=vmin, vmax=vmax, - norm=norm, shading='auto') + pcm = ax.pcolormesh(assembled.x_cc, assembled.y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading="auto") label = pretty_label(vn) fig.colorbar(pcm, ax=ax, label=label) ax.set_title(label, fontsize=9) - ax.set_aspect('equal', adjustable='box') + ax.set_aspect("equal", adjustable="box") ax.tick_params(labelsize=7) - _overlay_bubbles(ax, opts.get('bubbles'), scale=opts.get('bubble_scale', 1.0)) + _overlay_bubbles(ax, opts.get("bubbles"), scale=opts.get("bubble_scale", 1.0)) for idx in range(n, nrows * ncols): row, col = divmod(idx, ncols) axes[row][col].set_visible(False) - fig.suptitle(f'step {step}', fontsize=11, y=1.01) + fig.suptitle(f"step {step}", fontsize=11, y=1.01) fig.tight_layout() - fig.savefig(output, dpi=opts.get('dpi', 150), bbox_inches='tight') + fig.savefig(output, dpi=opts.get("dpi", 150), bbox_inches="tight") plt.close(fig) -def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches - slice_index=None, slice_value=None, **opts): +def render_3d_slice(assembled, varname, step, output, slice_axis="z", slice_index=None, slice_value=None, **opts): """Extract a 2D slice from 3D data and render as a colormap.""" data_3d = assembled.variables[varname] - axis_map = {'x': 0, 'y': 1, 'z': 2} + axis_map = {"x": 0, "y": 1, "z": 2} if slice_axis not in axis_map: - raise ValueError( - f"Invalid slice_axis '{slice_axis}'. Must be one of: 'x', 'y', 'z'." - ) + raise ValueError(f"Invalid slice_axis '{slice_axis}'. Must be one of: 'x', 'y', 'z'.") axis_idx = axis_map[slice_axis] coords = [assembled.x_cc, assembled.y_cc, assembled.z_cc] @@ -304,23 +294,23 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: if axis_idx == 0: sliced = data_3d[idx, :, :] x_plot, y_plot = assembled.y_cc, assembled.z_cc - xlabel, ylabel = r'$y$', r'$z$' + xlabel, ylabel = r"$y$", r"$z$" elif axis_idx == 1: sliced = data_3d[:, idx, :] x_plot, y_plot = assembled.x_cc, assembled.z_cc - xlabel, ylabel = r'$x$', r'$z$' + xlabel, ylabel = r"$x$", r"$z$" else: sliced = data_3d[:, :, idx] x_plot, y_plot = assembled.x_cc, assembled.y_cc - xlabel, ylabel = r'$x$', r'$y$' + xlabel, ylabel = r"$x$", r"$y$" default_size = _figsize_for_domain(x_plot, y_plot) - fig, ax = plt.subplots(figsize=opts.get('figsize', default_size)) + fig, ax = plt.subplots(figsize=opts.get("figsize", default_size)) - cmap = opts.get('cmap', 'viridis') - vmin = opts.get('vmin') - vmax = opts.get('vmax') - log_scale = opts.get('log_scale', False) + cmap = opts.get("cmap", "viridis") + vmin = opts.get("vmin") + vmax = opts.get("vmax") + log_scale = opts.get("log_scale", False) norm = None if log_scale: @@ -336,34 +326,29 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: vmax = None # sliced shape depends on axis: need to transpose appropriately - pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, - vmax=vmax, norm=norm, shading='auto') + pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading="auto") label = pretty_label(varname) fig.colorbar(pcm, ax=ax, label=label) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) slice_coord = coord_along[idx] - ax.set_title(f'{label} (step {step}, {slice_axis}={slice_coord:.4g})') - ax.set_aspect('equal', adjustable='box') + ax.set_title(f"{label} (step {step}, {slice_axis}={slice_coord:.4g})") + ax.set_aspect("equal", adjustable="box") # Overlay bubbles that lie within one radius of the slice plane - bubbles = opts.get('bubbles') + bubbles = opts.get("bubbles") if bubbles is not None and len(bubbles) > 0: - slice_col = {'x': 0, 'y': 1, 'z': 2}[slice_axis] + slice_col = {"x": 0, "y": 1, "z": 2}[slice_axis] plot_cols = [c for c in (0, 1, 2) if c != slice_col] near = np.abs(bubbles[:, slice_col] - slice_coord) <= bubbles[:, 3] - _overlay_bubbles(ax, - bubbles[near][:, [plot_cols[0], plot_cols[1], slice_col, 3]] - if near.any() else None, - scale=opts.get('bubble_scale', 1.0)) + _overlay_bubbles(ax, bubbles[near][:, [plot_cols[0], plot_cols[1], slice_col, 3]] if near.any() else None, scale=opts.get("bubble_scale", 1.0)) fig.tight_layout() - fig.savefig(output, dpi=opts.get('dpi', 150)) + fig.savefig(output, dpi=opts.get("dpi", 150)) plt.close(fig) -def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches - read_func=None, tiled=False, bubble_func=None, **opts): +def render_mp4(varname, steps, output, fps=10, read_func=None, tiled=False, bubble_func=None, **opts): """ Generate an MP4 video by iterating over timesteps. @@ -393,8 +378,8 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum # Pre-compute vmin/vmax from first, middle, and last frames if not provided # (not needed for tiled mode — each subplot auto-scales independently) - auto_vmin = opts.get('vmin') - auto_vmax = opts.get('vmax') + auto_vmin = opts.get("vmin") + auto_vmax = opts.get("vmax") if not tiled and (auto_vmin is None or auto_vmax is None): sample_steps = [steps[0]] @@ -404,7 +389,7 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum sample_steps.append(steps[len(steps) // 2]) all_mins, all_maxs = [], [] - log_scale = opts.get('log_scale', False) + log_scale = opts.get("log_scale", False) for s in sample_steps: ad = read_func(s) d = ad.variables.get(varname) @@ -419,17 +404,17 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum all_maxs.append(np.nanmax(d)) if auto_vmin is None and all_mins: - opts['vmin'] = min(all_mins) + opts["vmin"] = min(all_mins) if auto_vmax is None and all_maxs: - opts['vmax'] = max(all_maxs) + opts["vmax"] = max(all_maxs) # Write frames to a unique temp directory to avoid concurrent-run conflicts output_dir = os.path.dirname(os.path.abspath(output)) os.makedirs(output_dir, exist_ok=True) - viz_dir = tempfile.mkdtemp(dir=output_dir, prefix='_frames_') + viz_dir = tempfile.mkdtemp(dir=output_dir, prefix="_frames_") def _cleanup(): - for fname in sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')): + for fname in sorted(f for f in os.listdir(viz_dir) if f.endswith(".png")): try: os.remove(os.path.join(viz_dir, fname)) except OSError: @@ -440,15 +425,16 @@ def _cleanup(): pass try: - from tqdm import tqdm # pylint: disable=import-outside-toplevel - step_iter = tqdm(steps, desc='Rendering frames') + from tqdm import tqdm + + step_iter = tqdm(steps, desc="Rendering frames") except ImportError: step_iter = steps try: for i, step in enumerate(step_iter): assembled = read_func(step) - frame_path = os.path.join(viz_dir, f'{i:06d}.png') + frame_path = os.path.join(viz_dir, f"{i:06d}.png") # Inject per-step bubble positions into opts if bubble_func provided frame_opts = opts @@ -456,50 +442,41 @@ def _cleanup(): try: frame_opts = dict(opts, bubbles=bubble_func(step)) except (OSError, ValueError) as exc: - import warnings # pylint: disable=import-outside-toplevel + import warnings + warnings.warn(f"Skipping bubble overlay for step {step}: {exc}", stacklevel=2) if tiled and assembled.ndim == 1: - render_1d_tiled(assembled.x_cc, assembled.variables, - step, frame_path, **frame_opts) + render_1d_tiled(assembled.x_cc, assembled.variables, step, frame_path, **frame_opts) elif tiled and assembled.ndim == 2: render_2d_tiled(assembled, step, frame_path, **frame_opts) elif assembled.ndim == 1: var_data = assembled.variables.get(varname) if var_data is None: continue - render_1d(assembled.x_cc, var_data, - varname, step, frame_path, **frame_opts) + render_1d(assembled.x_cc, var_data, varname, step, frame_path, **frame_opts) elif assembled.ndim == 2: var_data = assembled.variables.get(varname) if var_data is None: continue - render_2d(assembled.x_cc, assembled.y_cc, - var_data, - varname, step, frame_path, **frame_opts) + render_2d(assembled.x_cc, assembled.y_cc, var_data, varname, step, frame_path, **frame_opts) elif assembled.ndim == 3: var_data = assembled.variables.get(varname) if var_data is None: continue render_3d_slice(assembled, varname, step, frame_path, **frame_opts) else: - raise ValueError( - f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " - "Expected 1, 2, or 3." - ) + raise ValueError(f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. Expected 1, 2, or 3.") except BaseException: _cleanup() raise # Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg) - frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) + frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith(".png")) if not frame_files: _cleanup() - raise ValueError( - f"No frames were rendered for '{varname}'. " - "The variable may not exist in the loaded timesteps." - ) + raise ValueError(f"No frames were rendered for '{varname}'. The variable may not exist in the loaded timesteps.") success = False try: @@ -510,9 +487,9 @@ def _to_rgb(arr): imageio may return RGBA (4-ch) or even grayscale depending on the PNG source. libx264/yuv420p requires consistent 3-channel input. """ - if arr.ndim == 2: # grayscale → RGB + if arr.ndim == 2: # grayscale → RGB arr = np.stack([arr, arr, arr], axis=-1) - elif arr.shape[2] == 4: # RGBA → RGB (drop alpha) + elif arr.shape[2] == 4: # RGBA → RGB (drop alpha) arr = arr[:, :, :3] return arr.astype(np.uint8) @@ -539,16 +516,19 @@ def _uniform_frame(arr): # Second pass: encode. macro_block_size=1 disables imageio's own resize # since we already ensured even dimensions above. with imageio.get_writer( - output, fps=fps, codec='libx264', pixelformat='yuv420p', - macro_block_size=1, ffmpeg_log_level='error', + output, + fps=fps, + codec="libx264", + pixelformat="yuv420p", + macro_block_size=1, + ffmpeg_log_level="error", ) as writer: for fname in frame_files: - writer.append_data(_uniform_frame( - imageio.imread(os.path.join(viz_dir, fname)) - )) + writer.append_data(_uniform_frame(imageio.imread(os.path.join(viz_dir, fname)))) success = True - except Exception as exc: # pylint: disable=broad-except - import warnings # pylint: disable=import-outside-toplevel + except Exception as exc: + import warnings + warnings.warn(f"MP4 encoding error: {exc}", stacklevel=2) finally: _cleanup() diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 287a14c197..e27a1f28a4 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -41,6 +41,7 @@ # File-structure cache # --------------------------------------------------------------------------- + @dataclass class _SiloStructure: """Cached HDF5 layout for one processor's silo files. @@ -48,12 +49,13 @@ class _SiloStructure: All timestep files in the same rank directory have the same internal path assignments, so we only need to parse this once. """ + ndims: int - coord_paths: List[bytes] # HDF5 paths for x, y[, z] node coords + coord_paths: List[bytes] # HDF5 paths for x, y[, z] node coords var_paths: Dict[str, bytes] = field(default_factory=dict) # varname → data path -_struct_cache: Dict[str, _SiloStructure] = {} # key = rank directory path +_struct_cache: Dict[str, _SiloStructure] = {} # key = rank directory path _struct_lock = threading.Lock() @@ -129,13 +131,14 @@ def clear_structure_cache() -> None: # File reader # --------------------------------------------------------------------------- + def _resolve_path(h5file, path_bytes): """Resolve a silo internal path (e.g. b'/.silo/#000003') to a numpy array.""" path = path_bytes.decode() if isinstance(path_bytes, (bytes, np.bytes_)) else str(path_bytes) return np.array(h5file[path]) -def read_silo_file( # pylint: disable=too-many-locals +def read_silo_file( path: str, var_filter: Optional[str] = None, rank_dir: Optional[str] = None, @@ -181,12 +184,10 @@ def read_silo_file( # pylint: disable=too-many-locals # buffer. HDF5 stores it row-major. Reinterpret in Fortran order # so data[i,j,k] = value at (x_i, y_j, z_k). if data.ndim >= 2: - data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F') + data = np.ascontiguousarray(data).ravel().reshape(data.shape, order="F") variables[var_name] = data - return ProcessorData( - m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables - ) + return ProcessorData(m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables) # --------------------------------------------------------------------------- @@ -199,12 +200,10 @@ def read_silo_file( # pylint: disable=too-many-locals def _get_pool() -> ThreadPoolExecutor: """Return a module-level thread pool, creating it on first use.""" - global _READ_POOL # pylint: disable=global-statement + global _READ_POOL # noqa: PLW0603 with _POOL_LOCK: if _READ_POOL is None: - _READ_POOL = ThreadPoolExecutor( - max_workers=32, thread_name_prefix="mfc_silo" - ) + _READ_POOL = ThreadPoolExecutor(max_workers=32, thread_name_prefix="mfc_silo") atexit.register(_READ_POOL.shutdown, wait=False) return _READ_POOL @@ -213,6 +212,7 @@ def _get_pool() -> ThreadPoolExecutor: # Assembly # --------------------------------------------------------------------------- + def assemble_silo( case_dir: str, step: int, @@ -241,10 +241,7 @@ def assemble_silo( rank_dir = os.path.join(base, f"p{rank}") silo_file = os.path.join(rank_dir, f"{step}.silo") if not os.path.isfile(silo_file): - raise FileNotFoundError( - f"Processor file not found: {silo_file}. " - "Incomplete output (missing rank) would produce incorrect data." - ) + raise FileNotFoundError(f"Processor file not found: {silo_file}. Incomplete output (missing rank) would produce incorrect data.") rank_paths.append((rank, rank_dir, silo_file)) def _read_one(args): diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 7fe72096a4..594db84f2a 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -5,40 +5,45 @@ data assembly (binary + silo, 1D/2D/3D), and 1D rendering. Uses checked-in fixture data generated from minimal MFC runs. """ -# pylint: disable=import-outside-toplevel,protected-access import os import tempfile import unittest +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") -FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures') +# Rendering tests import matplotlib and are slow (~8s). Skip them during +# pre-commit (MFC_SKIP_RENDER_TESTS=1) and run the full suite in CI only. +_SKIP_RENDER = os.environ.get("MFC_SKIP_RENDER_TESTS", "") == "1" +_SKIP_RENDER_MSG = "MFC_SKIP_RENDER_TESTS=1 — skipping rendering tests" # Fixture paths for each dimension + format -FIX_1D_BIN = os.path.join(FIXTURES, '1d_binary') -FIX_1D_SILO = os.path.join(FIXTURES, '1d_silo') -FIX_2D_BIN = os.path.join(FIXTURES, '2d_binary') -FIX_2D_SILO = os.path.join(FIXTURES, '2d_silo') -FIX_3D_BIN = os.path.join(FIXTURES, '3d_binary') -FIX_3D_SILO = os.path.join(FIXTURES, '3d_silo') -FIX_1D_BIN_2RANK = os.path.join(FIXTURES, '1d_binary_2rank') +FIX_1D_BIN = os.path.join(FIXTURES, "1d_binary") +FIX_1D_SILO = os.path.join(FIXTURES, "1d_silo") +FIX_2D_BIN = os.path.join(FIXTURES, "2d_binary") +FIX_2D_SILO = os.path.join(FIXTURES, "2d_silo") +FIX_3D_BIN = os.path.join(FIXTURES, "3d_binary") +FIX_3D_SILO = os.path.join(FIXTURES, "3d_silo") +FIX_1D_BIN_2RANK = os.path.join(FIXTURES, "1d_binary_2rank") # --------------------------------------------------------------------------- # Tests: _parse_steps # --------------------------------------------------------------------------- + class TestParseSteps(unittest.TestCase): """Test _parse_steps() step-argument parsing.""" def _parse(self, arg, available): from .viz import _parse_steps + matched, _ = _parse_steps(arg, available) return matched def test_all_keyword(self): """'all' returns every available step.""" - self.assertEqual(self._parse('all', [0, 100, 200]), [0, 100, 200]) + self.assertEqual(self._parse("all", [0, 100, 200]), [0, 100, 200]) def test_none_returns_all(self): """None returns every available step.""" @@ -46,137 +51,148 @@ def test_none_returns_all(self): def test_last_keyword(self): """'last' returns only the final step.""" - self.assertEqual(self._parse('last', [0, 100, 200]), [200]) + self.assertEqual(self._parse("last", [0, 100, 200]), [200]) def test_last_empty(self): """'last' with no available steps returns empty list.""" - self.assertEqual(self._parse('last', []), []) + self.assertEqual(self._parse("last", []), []) def test_single_int(self): """Single integer selects that step.""" - self.assertEqual(self._parse('100', [0, 100, 200]), [100]) + self.assertEqual(self._parse("100", [0, 100, 200]), [100]) def test_single_int_missing(self): """Single integer not in available returns empty list.""" - self.assertEqual(self._parse('999', [0, 100, 200]), []) + self.assertEqual(self._parse("999", [0, 100, 200]), []) def test_range(self): """Range format 'start:end:stride' selects matching steps.""" - result = self._parse('0:500:200', [0, 100, 200, 300, 400, 500]) + result = self._parse("0:500:200", [0, 100, 200, 300, 400, 500]) self.assertEqual(result, [0, 200, 400]) def test_range_no_stride(self): """Range format 'start:end' defaults to stride 1.""" - result = self._parse('0:2', [0, 1, 2, 3]) + result = self._parse("0:2", [0, 1, 2, 3]) self.assertEqual(result, [0, 1, 2]) def test_comma_list(self): """Comma-separated list selects the intersection with available steps.""" - result = self._parse('0,100,200,1000', [0, 100, 200, 300, 1000]) + result = self._parse("0,100,200,1000", [0, 100, 200, 300, 1000]) self.assertEqual(result, [0, 100, 200, 1000]) def test_comma_list_filters_unavailable(self): """Comma list silently drops steps not in available.""" - result = self._parse('0,999', [0, 100, 200]) + result = self._parse("0,999", [0, 100, 200]) self.assertEqual(result, [0]) def test_ellipsis_expansion(self): """Ellipsis infers stride and expands the range.""" - result = self._parse('0,100,200,...,1000', - list(range(0, 1001, 100))) + result = self._parse("0,100,200,...,1000", list(range(0, 1001, 100))) self.assertEqual(result, list(range(0, 1001, 100))) def test_ellipsis_partial_available(self): """Ellipsis expansion filters to only available steps.""" # only even-numbered hundreds available avail = [0, 200, 400, 600, 800, 1000] - result = self._parse('0,100,...,1000', avail) + result = self._parse("0,100,...,1000", avail) self.assertEqual(result, [0, 200, 400, 600, 800, 1000]) def test_ellipsis_requires_two_prefix_values(self): """Ellipsis with only one prefix value raises MFCException.""" from mfc.common import MFCException + with self.assertRaises(MFCException): - self._parse('0,...,1000', [0, 100, 1000]) + self._parse("0,...,1000", [0, 100, 1000]) def test_ellipsis_must_be_second_to_last(self): """Ellipsis not in second-to-last position raises MFCException.""" from mfc.common import MFCException + with self.assertRaises(MFCException): - self._parse('0,100,...,500,1000', [0, 100, 500, 1000]) + self._parse("0,100,...,500,1000", [0, 100, 500, 1000]) def test_ellipsis_n_requested_is_expanded_range(self): """Ellipsis n_requested reflects the expanded range, not the matched count.""" from .viz import _parse_steps + # Range 0,100,...,1000 expands to 11 steps; only 3 are available. - matched, n_req = _parse_steps('0,100,...,1000', [0, 200, 1000]) + matched, n_req = _parse_steps("0,100,...,1000", [0, 200, 1000]) self.assertEqual(n_req, 11) self.assertEqual(matched, [0, 200, 1000]) def test_invalid_value(self): """Non-numeric, non-keyword input raises MFCException.""" from mfc.common import MFCException + with self.assertRaises(MFCException): - self._parse('bogus', [0, 100]) + self._parse("bogus", [0, 100]) def test_hyphen_range_raises_clean_error(self): """'0-100' (hyphen instead of colon) raises MFCException, not raw ValueError.""" from mfc.common import MFCException + with self.assertRaises(MFCException): - self._parse('0-100', [0, 100]) + self._parse("0-100", [0, 100]) # --------------------------------------------------------------------------- # Tests: pretty_label # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestPrettyLabel(unittest.TestCase): """Test pretty_label() LaTeX label generation.""" def _label(self, varname): from .renderer import pretty_label + return pretty_label(varname) def test_known_scalar(self): """Known scalars map to LaTeX.""" - self.assertEqual(self._label('pres'), r'$p$') - self.assertEqual(self._label('rho'), r'$\rho$') + self.assertEqual(self._label("pres"), r"$p$") + self.assertEqual(self._label("rho"), r"$\rho$") def test_vel_indexed(self): """vel1/vel2/vel3 map to u/v/w.""" - self.assertEqual(self._label('vel1'), r'$u$') - self.assertEqual(self._label('vel2'), r'$v$') - self.assertEqual(self._label('vel3'), r'$w$') + self.assertEqual(self._label("vel1"), r"$u$") + self.assertEqual(self._label("vel2"), r"$v$") + self.assertEqual(self._label("vel3"), r"$w$") def test_alpha_indexed(self): """alpha maps to LaTeX subscript.""" - self.assertIn('2', self._label('alpha2')) + self.assertIn("2", self._label("alpha2")) def test_unknown_passthrough(self): """Unknown variable names pass through unchanged.""" - self.assertEqual(self._label('my_custom_var'), 'my_custom_var') + self.assertEqual(self._label("my_custom_var"), "my_custom_var") # --------------------------------------------------------------------------- # Tests: discover_format # --------------------------------------------------------------------------- + class TestDiscoverFormat(unittest.TestCase): """Test discover_format() binary/silo detection.""" def test_binary_detection(self): """Detects binary format from binary/ directory.""" from .reader import discover_format - self.assertEqual(discover_format(FIX_1D_BIN), 'binary') + + self.assertEqual(discover_format(FIX_1D_BIN), "binary") def test_silo_detection(self): """Detects silo format from silo_hdf5/ directory.""" from .reader import discover_format - self.assertEqual(discover_format(FIX_1D_SILO), 'silo') + + self.assertEqual(discover_format(FIX_1D_SILO), "silo") def test_missing_dir_raises(self): """Missing directories raise FileNotFoundError.""" from .reader import discover_format + d = tempfile.mkdtemp() try: with self.assertRaises(FileNotFoundError): @@ -189,13 +205,15 @@ def test_missing_dir_raises(self): # Tests: discover_timesteps # --------------------------------------------------------------------------- + class TestDiscoverTimesteps(unittest.TestCase): """Test discover_timesteps() against fixture data.""" def test_binary_1d(self): """Finds sorted timesteps from 1D binary fixture.""" from .reader import discover_timesteps - steps = discover_timesteps(FIX_1D_BIN, 'binary') + + steps = discover_timesteps(FIX_1D_BIN, "binary") self.assertEqual(steps, sorted(steps)) self.assertIn(0, steps) self.assertGreater(len(steps), 1) @@ -203,7 +221,8 @@ def test_binary_1d(self): def test_silo_1d(self): """Finds sorted timesteps from 1D silo fixture.""" from .reader import discover_timesteps - steps = discover_timesteps(FIX_1D_SILO, 'silo') + + steps = discover_timesteps(FIX_1D_SILO, "silo") self.assertEqual(steps, sorted(steps)) self.assertIn(0, steps) self.assertGreater(len(steps), 1) @@ -213,30 +232,34 @@ def test_silo_1d(self): # Tests: binary read + assemble (1D, 2D, 3D) # --------------------------------------------------------------------------- + class TestAssembleBinary1D(unittest.TestCase): """Test binary reader with 1D fixture data.""" def test_ndim(self): """1D fixture assembles with ndim=1.""" from .reader import assemble - data = assemble(FIX_1D_BIN, 0, 'binary') + + data = assemble(FIX_1D_BIN, 0, "binary") self.assertEqual(data.ndim, 1) def test_grid_and_vars(self): """1D fixture has non-empty grid and expected variables.""" from .reader import assemble - data = assemble(FIX_1D_BIN, 0, 'binary') + + data = assemble(FIX_1D_BIN, 0, "binary") self.assertGreater(len(data.x_cc), 0) - self.assertIn('pres', data.variables) - self.assertIn('vel1', data.variables) - self.assertEqual(data.variables['pres'].shape, data.x_cc.shape) + self.assertIn("pres", data.variables) + self.assertIn("vel1", data.variables) + self.assertEqual(data.variables["pres"].shape, data.x_cc.shape) def test_var_filter(self): """Passing var= loads only that variable.""" from .reader import assemble - data = assemble(FIX_1D_BIN, 0, 'binary', var='pres') - self.assertIn('pres', data.variables) - self.assertNotIn('vel1', data.variables) + + data = assemble(FIX_1D_BIN, 0, "binary", var="pres") + self.assertIn("pres", data.variables) + self.assertNotIn("vel1", data.variables) class TestAssembleBinary1DMultiRank(unittest.TestCase): @@ -245,36 +268,43 @@ class TestAssembleBinary1DMultiRank(unittest.TestCase): def test_ndim(self): """2-rank 1D fixture assembles with ndim=1.""" from .reader import assemble - data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + + data = assemble(FIX_1D_BIN_2RANK, 0, "binary") self.assertEqual(data.ndim, 1) def test_cell_count_after_dedup(self): """Ghost cell overlap is deduplicated: 16 unique cells from two overlapping ranks.""" from .reader import assemble - data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + + data = assemble(FIX_1D_BIN_2RANK, 0, "binary") self.assertEqual(len(data.x_cc), 16) def test_grid_is_sorted_and_unique(self): """Assembled global grid is strictly increasing with no duplicates.""" import numpy as np + from .reader import assemble - data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + + data = assemble(FIX_1D_BIN_2RANK, 0, "binary") diffs = np.diff(data.x_cc) self.assertTrue(bool(np.all(diffs > 0)), "x_cc is not strictly increasing") def test_variable_values_match_position(self): """pres values (== x_cc position) are placed at the correct global cells.""" import numpy as np + from .reader import assemble - data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') - np.testing.assert_allclose(data.variables['pres'], data.x_cc, atol=1e-10) + + data = assemble(FIX_1D_BIN_2RANK, 0, "binary") + np.testing.assert_allclose(data.variables["pres"], data.x_cc, atol=1e-10) def test_all_vars_present(self): """Both variables written by both ranks appear in the assembled output.""" from .reader import assemble - data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') - self.assertIn('pres', data.variables) - self.assertIn('rho', data.variables) + + data = assemble(FIX_1D_BIN_2RANK, 0, "binary") + self.assertIn("pres", data.variables) + self.assertIn("rho", data.variables) class TestAssembleBinary2D(unittest.TestCase): @@ -283,16 +313,18 @@ class TestAssembleBinary2D(unittest.TestCase): def test_ndim(self): """2D fixture assembles with ndim=2.""" from .reader import assemble - data = assemble(FIX_2D_BIN, 0, 'binary') + + data = assemble(FIX_2D_BIN, 0, "binary") self.assertEqual(data.ndim, 2) def test_grid_shape(self): """2D fixture has 2D variable arrays matching grid.""" from .reader import assemble - data = assemble(FIX_2D_BIN, 0, 'binary') + + data = assemble(FIX_2D_BIN, 0, "binary") self.assertGreater(len(data.x_cc), 0) self.assertGreater(len(data.y_cc), 0) - pres = data.variables['pres'] + pres = data.variables["pres"] self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc))) @@ -302,41 +334,45 @@ class TestAssembleBinary3D(unittest.TestCase): def test_ndim(self): """3D fixture assembles with ndim=3.""" from .reader import assemble - data = assemble(FIX_3D_BIN, 0, 'binary') + + data = assemble(FIX_3D_BIN, 0, "binary") self.assertEqual(data.ndim, 3) def test_grid_shape(self): """3D fixture has 3D variable arrays matching grid.""" from .reader import assemble - data = assemble(FIX_3D_BIN, 0, 'binary') + + data = assemble(FIX_3D_BIN, 0, "binary") self.assertGreater(len(data.x_cc), 0) self.assertGreater(len(data.y_cc), 0) self.assertGreater(len(data.z_cc), 0) - pres = data.variables['pres'] - self.assertEqual(pres.shape, - (len(data.x_cc), len(data.y_cc), len(data.z_cc))) + pres = data.variables["pres"] + self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc), len(data.z_cc))) # --------------------------------------------------------------------------- # Tests: silo read + assemble (1D, 2D, 3D) # --------------------------------------------------------------------------- + class TestAssembleSilo1D(unittest.TestCase): """Test silo reader with 1D fixture data.""" def test_ndim(self): """1D silo fixture assembles with ndim=1.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_1D_SILO, 0) self.assertEqual(data.ndim, 1) def test_grid_and_vars(self): """1D silo fixture has non-empty grid and expected variables.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_1D_SILO, 0) self.assertGreater(len(data.x_cc), 0) - self.assertIn('pres', data.variables) - self.assertEqual(data.variables['pres'].shape, data.x_cc.shape) + self.assertIn("pres", data.variables) + self.assertEqual(data.variables["pres"].shape, data.x_cc.shape) class TestAssembleSilo2D(unittest.TestCase): @@ -345,14 +381,16 @@ class TestAssembleSilo2D(unittest.TestCase): def test_ndim(self): """2D silo fixture assembles with ndim=2.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_2D_SILO, 0) self.assertEqual(data.ndim, 2) def test_grid_shape(self): """2D silo fixture has 2D variable arrays matching grid.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_2D_SILO, 0) - pres = data.variables['pres'] + pres = data.variables["pres"] self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc))) @@ -362,31 +400,35 @@ class TestAssembleSilo3D(unittest.TestCase): def test_ndim(self): """3D silo fixture assembles with ndim=3.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_3D_SILO, 0) self.assertEqual(data.ndim, 3) def test_grid_shape(self): """3D silo fixture has 3D variable arrays matching grid.""" from .silo_reader import assemble_silo + data = assemble_silo(FIX_3D_SILO, 0) - pres = data.variables['pres'] - self.assertEqual(pres.shape, - (len(data.x_cc), len(data.y_cc), len(data.z_cc))) + pres = data.variables["pres"] + self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc), len(data.z_cc))) # --------------------------------------------------------------------------- # Tests: binary vs silo consistency # --------------------------------------------------------------------------- + class TestBinarySiloConsistency(unittest.TestCase): """Verify binary and silo readers produce consistent results.""" def test_1d_same_grid(self): """Binary and silo 1D fixtures have the same grid.""" + import numpy as np + from .reader import assemble from .silo_reader import assemble_silo - import numpy as np - bin_data = assemble(FIX_1D_BIN, 0, 'binary') + + bin_data = assemble(FIX_1D_BIN, 0, "binary") silo_data = assemble_silo(FIX_1D_SILO, 0) np.testing.assert_allclose(bin_data.x_cc, silo_data.x_cc, atol=1e-10) @@ -394,17 +436,19 @@ def test_1d_same_vars(self): """Binary and silo 1D fixtures have the same variable names.""" from .reader import assemble from .silo_reader import assemble_silo - bin_data = assemble(FIX_1D_BIN, 0, 'binary') + + bin_data = assemble(FIX_1D_BIN, 0, "binary") silo_data = assemble_silo(FIX_1D_SILO, 0) - self.assertEqual(sorted(bin_data.variables.keys()), - sorted(silo_data.variables.keys())) + self.assertEqual(sorted(bin_data.variables.keys()), sorted(silo_data.variables.keys())) def test_1d_same_values(self): """Binary and silo 1D fixtures have the same variable values.""" import numpy as np + from .reader import assemble from .silo_reader import assemble_silo - bin_data = assemble(FIX_1D_BIN, 0, 'binary') + + bin_data = assemble(FIX_1D_BIN, 0, "binary") silo_data = assemble_silo(FIX_1D_SILO, 0) common = sorted(set(bin_data.variables) & set(silo_data.variables)) self.assertGreater(len(common), 0, "No common variables to compare") @@ -412,7 +456,8 @@ def test_1d_same_values(self): np.testing.assert_allclose( bin_data.variables[vname], silo_data.variables[vname], - rtol=1e-5, atol=1e-10, + rtol=1e-5, + atol=1e-10, err_msg=f"Variable '{vname}' differs between binary and silo", ) @@ -421,6 +466,8 @@ def test_1d_same_values(self): # Tests: 1D rendering (requires matplotlib/imageio) # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRender1D(unittest.TestCase): """Smoke test: render 1D plots from fixture data.""" @@ -428,11 +475,12 @@ def test_render_png(self): """Renders a single-variable PNG that is non-empty.""" from .reader import assemble from .renderer import render_1d - data = assemble(FIX_1D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_1D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_1d(data.x_cc, data.variables['pres'], 'pres', 0, out) + render_1d(data.x_cc, data.variables["pres"], "pres", 0, out) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: @@ -442,8 +490,9 @@ def test_render_tiled_png(self): """Tiled render of all variables produces a non-empty PNG.""" from .reader import assemble from .renderer import render_1d_tiled - data = assemble(FIX_1D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_1D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: render_1d_tiled(data.x_cc, data.variables, 0, out) @@ -453,6 +502,7 @@ def test_render_tiled_png(self): os.unlink(out) +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRender2D(unittest.TestCase): """Smoke test: render a 2D PNG from fixture data.""" @@ -460,18 +510,19 @@ def test_render_2d_png(self): """Renders a 2D colormap PNG that is non-empty.""" from .reader import assemble from .renderer import render_2d - data = assemble(FIX_2D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_2D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_2d(data.x_cc, data.y_cc, data.variables['pres'], - 'pres', 0, out) + render_2d(data.x_cc, data.y_cc, data.variables["pres"], "pres", 0, out) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: os.unlink(out) +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRender3DSlice(unittest.TestCase): """Smoke test: render a 3D slice PNG from fixture data.""" @@ -479,11 +530,12 @@ def test_render_3d_slice_png(self): """Renders a 3D midplane-slice PNG that is non-empty.""" from .reader import assemble from .renderer import render_3d_slice - data = assemble(FIX_3D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_3D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_3d_slice(data, 'pres', 0, out) + render_3d_slice(data, "pres", 0, out) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: @@ -494,11 +546,13 @@ def test_render_3d_slice_png(self): # Tests: _steps_hint # --------------------------------------------------------------------------- + class TestStepsHint(unittest.TestCase): """Test _steps_hint() step preview for error messages.""" def _hint(self, steps, n=8): from .viz import _steps_hint + return _steps_hint(steps, n) def test_empty(self): @@ -508,60 +562,66 @@ def test_empty(self): def test_short_list_shows_all(self): """Short list shows all steps without truncation.""" result = self._hint([0, 100, 200]) - self.assertIn('0', result) - self.assertIn('200', result) - self.assertNotIn('...', result) + self.assertIn("0", result) + self.assertIn("200", result) + self.assertNotIn("...", result) def test_long_list_truncated(self): """Long list includes count and truncation marker.""" - steps = list(range(0, 2000, 100)) # 20 steps + steps = list(range(0, 2000, 100)) # 20 steps result = self._hint(steps, n=8) - self.assertIn('...', result) - self.assertIn('[20 total]', result) - self.assertIn('0', result) # head present - self.assertIn('1900', result) # tail present + self.assertIn("...", result) + self.assertIn("[20 total]", result) + self.assertIn("0", result) # head present + self.assertIn("1900", result) # tail present # --------------------------------------------------------------------------- # Tests: _validate_cmap # --------------------------------------------------------------------------- + class TestValidateCmap(unittest.TestCase): """Test _validate_cmap() colormap validation.""" def _validate(self, name): from .viz import _validate_cmap + _validate_cmap(name) def test_known_cmaps_pass(self): """Known colormaps do not raise.""" - for name in ('viridis', 'plasma', 'coolwarm', 'gray'): + for name in ("viridis", "plasma", "coolwarm", "gray"): with self.subTest(name=name): self._validate(name) def test_unknown_cmap_raises(self): """Unknown colormap raises MFCException.""" from mfc.common import MFCException + with self.assertRaises(MFCException): - self._validate('notacolormap_xyz_1234') + self._validate("notacolormap_xyz_1234") def test_typo_suggests_correct(self): """Typo in colormap name raises MFCException suggesting the correct spelling.""" from mfc.common import MFCException + with self.assertRaises(MFCException) as ctx: - self._validate('virids') # typo of viridis - self.assertIn('viridis', str(ctx.exception)) + self._validate("virids") # typo of viridis + self.assertIn("viridis", str(ctx.exception)) # --------------------------------------------------------------------------- # Tests: bounded TUI cache # --------------------------------------------------------------------------- + class TestTuiCache(unittest.TestCase): """Test that the shared step cache respects CACHE_MAX.""" def setUp(self): import mfc.viz._step_cache as cache_mod + self._mod = cache_mod cache_mod.clear() @@ -579,9 +639,11 @@ def test_cache_stores_entry(self): def test_cache_hit_avoids_reload(self): """Second load of same step does not call read_func again.""" calls = [0] + def counting(step): calls[0] += 1 return step + self._mod.load(5, counting) self._mod.load(5, counting) self.assertEqual(calls[0], 1) @@ -592,12 +654,12 @@ def test_cache_evicts_oldest_at_cap(self): for i in range(cap + 3): self._mod.load(i, self._read) self.assertLessEqual(len(self._mod._cache), cap) - self.assertNotIn(0, self._mod._cache) # first evicted - self.assertIn(cap + 2, self._mod._cache) # most recent kept + self.assertNotIn(0, self._mod._cache) # first evicted + self.assertIn(cap + 2, self._mod._cache) # most recent kept def test_seed_clears_and_populates(self): """seed() clears existing cache and pre-loads one entry.""" - self._mod.load(99, self._read) # put something in first + self._mod.load(99, self._read) # put something in first self._mod.seed(0, "preloaded") self.assertEqual(len(self._mod._cache), 1) self.assertEqual(self._mod._cache[0], "preloaded") @@ -607,6 +669,8 @@ def test_seed_clears_and_populates(self): # Tests: log scale rendering (new feature smoke tests) # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRenderLogScale(unittest.TestCase): """Smoke test: log scale option produces valid PNG output.""" @@ -614,12 +678,12 @@ def test_render_1d_log_scale(self): """render_1d with log_scale=True produces a non-empty PNG.""" from .reader import assemble from .renderer import render_1d - data = assemble(FIX_1D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_1D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_1d(data.x_cc, data.variables['pres'], 'pres', 0, out, - log_scale=True) + render_1d(data.x_cc, data.variables["pres"], "pres", 0, out, log_scale=True) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: @@ -629,12 +693,12 @@ def test_render_2d_log_scale(self): """render_2d with log_scale=True produces a non-empty PNG.""" from .reader import assemble from .renderer import render_2d - data = assemble(FIX_2D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_2D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_2d(data.x_cc, data.y_cc, data.variables['pres'], - 'pres', 0, out, log_scale=True) + render_2d(data.x_cc, data.y_cc, data.variables["pres"], "pres", 0, out, log_scale=True) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: @@ -645,13 +709,16 @@ def test_render_2d_log_scale(self): # Tests: multi-rank assembly (ghost-cell deduplication) # --------------------------------------------------------------------------- + class TestMultiRankAssembly(unittest.TestCase): """Test assemble_from_proc_data with synthetic multi-processor data.""" def _make_proc(self, x_cb, pres): """Build a minimal 1D ProcessorData from boundary coordinates.""" import numpy as np + from .reader import ProcessorData + return ProcessorData( m=len(x_cb) - 1, n=0, @@ -659,32 +726,34 @@ def _make_proc(self, x_cb, pres): x_cb=np.array(x_cb, dtype=np.float64), y_cb=np.array([0.0]), z_cb=np.array([0.0]), - variables={'pres': np.array(pres, dtype=np.float64)}, + variables={"pres": np.array(pres, dtype=np.float64)}, ) def test_two_rank_1d_dedup(self): """Two processors with one overlapping ghost cell assemble correctly.""" import numpy as np + from .reader import assemble_from_proc_data + # Domain: 4 cells with centers at 0.125, 0.375, 0.625, 0.875 # Proc 0 sees cells 0-2 (center 0.625 is ghost from proc 1) # Proc 1 sees cells 1-3 (center 0.375 is ghost from proc 0) - p0 = self._make_proc([0.00, 0.25, 0.50, 0.75], - [1.0, 2.0, 3.0]) # centers: 0.125, 0.375, 0.625 - p1 = self._make_proc([0.25, 0.50, 0.75, 1.00], - [2.0, 3.0, 4.0]) # centers: 0.375, 0.625, 0.875 + p0 = self._make_proc([0.00, 0.25, 0.50, 0.75], [1.0, 2.0, 3.0]) # centers: 0.125, 0.375, 0.625 + p1 = self._make_proc([0.25, 0.50, 0.75, 1.00], [2.0, 3.0, 4.0]) # centers: 0.375, 0.625, 0.875 result = assemble_from_proc_data([(0, p0), (1, p1)]) self.assertEqual(result.ndim, 1) self.assertEqual(len(result.x_cc), 4) np.testing.assert_allclose(result.x_cc, [0.125, 0.375, 0.625, 0.875]) - np.testing.assert_allclose(result.variables['pres'], [1.0, 2.0, 3.0, 4.0]) + np.testing.assert_allclose(result.variables["pres"], [1.0, 2.0, 3.0, 4.0]) def test_large_extent_dedup(self): """Deduplication works correctly for large-extent domains (>1e6).""" import numpy as np + from .reader import assemble_from_proc_data + # Scale up by 1e7: extent=1e7, decimals = ceil(-log10(1e7)) + 12 = 5 scale = 1e7 p0 = self._make_proc( @@ -697,9 +766,7 @@ def test_large_extent_dedup(self): ) result = assemble_from_proc_data([(0, p0), (1, p1)]) self.assertEqual(len(result.x_cc), 4) - np.testing.assert_allclose( - result.variables['pres'], [1.0, 2.0, 3.0, 4.0] - ) + np.testing.assert_allclose(result.variables["pres"], [1.0, 2.0, 3.0, 4.0]) def test_very_large_extent_dedup_negative_decimals(self): """Deduplication works for extent ~1e13 where decimals becomes negative. @@ -710,7 +777,9 @@ def test_very_large_extent_dedup_negative_decimals(self): are >> 10, so distinct cell-centers must not be collapsed. """ import numpy as np + from .reader import assemble_from_proc_data + scale = 1e13 p0 = self._make_proc( [0.00 * scale, 0.25 * scale, 0.50 * scale, 0.75 * scale], @@ -722,15 +791,15 @@ def test_very_large_extent_dedup_negative_decimals(self): ) result = assemble_from_proc_data([(0, p0), (1, p1)]) self.assertEqual(len(result.x_cc), 4) - np.testing.assert_allclose( - result.variables['pres'], [1.0, 2.0, 3.0, 4.0] - ) + np.testing.assert_allclose(result.variables["pres"], [1.0, 2.0, 3.0, 4.0]) # --------------------------------------------------------------------------- # Tests: render_2d_tiled # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRender2DTiled(unittest.TestCase): """Smoke test: render_2d_tiled produces a valid PNG from 2D fixture data.""" @@ -738,8 +807,9 @@ def test_render_2d_tiled_png(self): """Tiled render of all 2D variables produces a non-empty PNG.""" from .reader import assemble from .renderer import render_2d_tiled - data = assemble(FIX_2D_BIN, 0, 'binary') - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + data = assemble(FIX_2D_BIN, 0, "binary") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: render_2d_tiled(data, 0, out) @@ -753,19 +823,23 @@ def test_render_2d_tiled_png(self): # Tests: render_3d_slice non-default axes and selectors # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRender3DSliceAxes(unittest.TestCase): """Test render_3d_slice with non-default slice axes and selectors.""" def setUp(self): from .reader import assemble - self._data = assemble(FIX_3D_BIN, 0, 'binary') + + self._data = assemble(FIX_3D_BIN, 0, "binary") def _render(self, **kwargs): from .renderer import render_3d_slice - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: out = f.name try: - render_3d_slice(self._data, 'pres', 0, out, **kwargs) + render_3d_slice(self._data, "pres", 0, out, **kwargs) self.assertTrue(os.path.isfile(out)) self.assertGreater(os.path.getsize(out), 0) finally: @@ -773,11 +847,11 @@ def _render(self, **kwargs): def test_x_axis_slice(self): """X-axis midplane slice produces a non-empty PNG.""" - self._render(slice_axis='x') + self._render(slice_axis="x") def test_y_axis_slice(self): """Y-axis midplane slice produces a non-empty PNG.""" - self._render(slice_axis='y') + self._render(slice_axis="y") def test_slice_by_index(self): """slice_index=0 selects first plane along default z axis.""" @@ -793,73 +867,82 @@ def test_slice_by_value(self): # Tests: render_mp4 # --------------------------------------------------------------------------- + +@unittest.skipIf(_SKIP_RENDER, _SKIP_RENDER_MSG) class TestRenderMp4(unittest.TestCase): """Smoke test: render_mp4 exercises frame rendering and returns a bool.""" def _make_read_func(self, case_dir, fmt): from .reader import assemble + def _read(step): return assemble(case_dir, step, fmt) + return _read def test_mp4_1d_returns_bool(self): """render_mp4 with 1D data returns True or False without raising.""" from .reader import discover_timesteps from .renderer import render_mp4 - steps = discover_timesteps(FIX_1D_BIN, 'binary')[:2] - read_func = self._make_read_func(FIX_1D_BIN, 'binary') + + steps = discover_timesteps(FIX_1D_BIN, "binary")[:2] + read_func = self._make_read_func(FIX_1D_BIN, "binary") with tempfile.TemporaryDirectory() as tmpdir: - out = os.path.join(tmpdir, 'test.mp4') - result = render_mp4('pres', steps, out, fps=2, read_func=read_func) + out = os.path.join(tmpdir, "test.mp4") + result = render_mp4("pres", steps, out, fps=2, read_func=read_func) self.assertIsInstance(result, bool) def test_mp4_tiled_1d_returns_bool(self): """render_mp4 with tiled=True returns True or False without raising.""" from .reader import discover_timesteps from .renderer import render_mp4 - steps = discover_timesteps(FIX_1D_BIN, 'binary')[:2] - read_func = self._make_read_func(FIX_1D_BIN, 'binary') + + steps = discover_timesteps(FIX_1D_BIN, "binary")[:2] + read_func = self._make_read_func(FIX_1D_BIN, "binary") with tempfile.TemporaryDirectory() as tmpdir: - out = os.path.join(tmpdir, 'test_tiled.mp4') - result = render_mp4('pres', steps, out, fps=2, - read_func=read_func, tiled=True) + out = os.path.join(tmpdir, "test_tiled.mp4") + result = render_mp4("pres", steps, out, fps=2, read_func=read_func, tiled=True) self.assertIsInstance(result, bool) def test_mp4_no_read_func_raises(self): """render_mp4 with read_func=None raises ValueError.""" from .renderer import render_mp4 + with self.assertRaises(ValueError): - render_mp4('pres', [0], '/tmp/unused.mp4', read_func=None) + render_mp4("pres", [0], "/tmp/unused.mp4", read_func=None) def test_mp4_empty_steps_raises(self): """render_mp4 with empty steps raises ValueError.""" from .renderer import render_mp4 + with self.assertRaises(ValueError): - render_mp4('pres', [], '/tmp/unused.mp4', - read_func=lambda s: None) + render_mp4("pres", [], "/tmp/unused.mp4", read_func=lambda s: None) # --------------------------------------------------------------------------- # Tests: silo assemble_silo var_filter # --------------------------------------------------------------------------- + class TestAssembleSiloVarFilter(unittest.TestCase): """Test assemble_silo with var= filter to cover the silo var_filter path.""" def test_1d_var_filter_includes_only_requested(self): """Silo 1D: var='pres' loads pres and excludes vel1.""" from .silo_reader import assemble_silo - data = assemble_silo(FIX_1D_SILO, 0, var='pres') - self.assertIn('pres', data.variables) - self.assertNotIn('vel1', data.variables) + + data = assemble_silo(FIX_1D_SILO, 0, var="pres") + self.assertIn("pres", data.variables) + self.assertNotIn("vel1", data.variables) def test_2d_var_filter_includes_only_requested(self): """Silo 2D: var='pres' loads pres and excludes other variables.""" from .silo_reader import assemble_silo - filtered = assemble_silo(FIX_2D_SILO, 0, var='pres') + + filtered = assemble_silo(FIX_2D_SILO, 0, var="pres") all_data = assemble_silo(FIX_2D_SILO, 0) - self.assertIn('pres', filtered.variables) - other_vars = [v for v in all_data.variables if v != 'pres'] + self.assertIn("pres", filtered.variables) + other_vars = [v for v in all_data.variables if v != "pres"] if other_vars: self.assertNotIn(other_vars[0], filtered.variables) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 3efd34b053..14912b8d90 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -9,17 +9,16 @@ Requires: textual>=0.43, textual-plotext, plotext """ + from __future__ import annotations from typing import Callable, List, Optional, Tuple import numpy as np - from rich.color import Color as RichColor from rich.console import Group as RichGroup from rich.style import Style from rich.text import Text as RichText - from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding @@ -27,20 +26,33 @@ from textual.message import Message from textual.reactive import reactive from textual.widgets import ( - Digits, Footer, Header, Label, ListItem, ListView, Static, + Digits, + Footer, + Header, + Label, + ListItem, + ListView, + Static, ) from textual.worker import get_current_worker - from textual_plotext import PlotextPlot from mfc.common import MFCException from mfc.printer import cons + from . import _step_cache # Colormaps available via [c] cycling _CMAPS: List[str] = [ - 'viridis', 'plasma', 'inferno', 'magma', 'cividis', - 'coolwarm', 'RdBu_r', 'seismic', 'gray', + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + "coolwarm", + "RdBu_r", + "seismic", + "gray", ] _load = _step_cache.load @@ -64,7 +76,8 @@ # Plot widget # --------------------------------------------------------------------------- -class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too-few-public-methods + +class MFCPlot(PlotextPlot): """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim / ._varname / ._step before calling .refresh().""" @@ -74,6 +87,7 @@ class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too- class Clicked(Message): """Posted when the user clicks a heatmap cell (Feature 5).""" + def __init__(self, x_val: float, y_val: float, val: float) -> None: self.x_val = x_val self.y_val = y_val @@ -120,9 +134,7 @@ def reset_zoom(self) -> None: self._zoom = (0.0, 1.0, 0.0, 1.0) self.refresh() - def _zoom_around( # pylint: disable=too-many-locals - self, cx_frac: float, cy_frac: float, factor: float - ) -> None: + def _zoom_around(self, cx_frac: float, cy_frac: float, factor: float) -> None: """Zoom by *factor* centred at *(cx_frac, cy_frac)* in [0,1]² of current view.""" x0, x1, y0, y1 = self._zoom x_span = x1 - x0 @@ -169,7 +181,7 @@ def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] self._scroll_zoom(event, factor=1.0 / 0.75) - def on_mouse_up(self, event) -> None: # pylint: disable=too-many-locals + def on_mouse_up(self, event) -> None: """Feature 5 — post Clicked message with the data value at the heatmap cell.""" if event.button != 1: return @@ -188,17 +200,17 @@ def on_mouse_up(self, event) -> None: # pylint: disable=too-many-locals ix_pos = int(np.round(col * (n_ix - 1) / max(self._last_w_map - 1, 1))) # Display is y-flipped: row 0 = top = y_max. iy_pos = n_iy - 1 - int(np.round(row * (n_iy - 1) / max(self._last_h_plot - 1, 1))) - xi = int(self._last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object - yi = int(self._last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object + xi = int(self._last_ix[np.clip(ix_pos, 0, n_ix - 1)]) + yi = int(self._last_iy[np.clip(iy_pos, 0, n_iy - 1)]) x_cc = self._x_cc y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) data = self._data - x_val = float(x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + x_val = float(x_cc[xi]) # type: ignore[index] y_val = float(y_cc[yi]) - val = float(data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + val = float(data[xi, yi]) # type: ignore[index] self.post_message(MFCPlot.Clicked(x_val, y_val, val)) - def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements + def render(self): data = self._data x_cc = self._x_cc self.plt.clear_figure() @@ -232,8 +244,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- return super().render() # 2D: pure-Rich heatmap with vertical colorbar. - import matplotlib # pylint: disable=import-outside-toplevel - import matplotlib.colors as mcolors # pylint: disable=import-outside-toplevel + import matplotlib + import matplotlib.colors as mcolors # Content area = widget size minus 1-char border on each side. # Reserve 1 row each for header and footer → h_plot rows for the image. @@ -247,18 +259,18 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- # Preserve the physical x/y aspect ratio. y_cc_2d = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) - x_extent = max(abs(float(x_cc[-1]) - float(x_cc[0])), 1e-30) # pylint: disable=unsubscriptable-object + x_extent = max(abs(float(x_cc[-1]) - float(x_cc[0])), 1e-30) y_extent = max(abs(float(y_cc_2d[-1]) - float(y_cc_2d[0])), 1e-30) domain_ratio = float(np.clip(x_extent / y_extent, _ASPECT_MIN, _ASPECT_MAX)) char_ratio = domain_ratio * _CELL_RATIO w_ideal = int(round(h_plot_avail * char_ratio)) if w_ideal <= w_map_avail: - w_map = max(w_ideal, 4) + w_map = max(w_ideal, 4) h_plot = h_plot_avail else: h_plot = max(int(round(w_map_avail / char_ratio)), 4) - w_map = w_map_avail + w_map = w_map_avail # Apply zoom window to data index ranges (Feature 6). x0_f, x1_f, y0_f, y1_f = self._zoom @@ -275,19 +287,19 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- self._last_ix = ix self._last_iy = iy - ds = data[np.ix_(ix, iy)] # pylint: disable=unsubscriptable-object + ds = data[np.ix_(ix, iy)] # Compute which screen cells to stamp with an open-circle glyph. bubble_cells: set = set() bubbles = self._bubbles if bubbles is not None and len(bubbles) > 0: - x_phys = x_cc[ix] # type: ignore[index] # pylint: disable=unsubscriptable-object + x_phys = x_cc[ix] # type: ignore[index] y_phys = y_cc_2d[iy] - x_min, x_max = float(x_phys[0]), float(x_phys[-1]) - y_min, y_max = float(y_phys[0]), float(y_phys[-1]) + x_min, x_max = float(x_phys[0]), float(x_phys[-1]) + y_min, y_max = float(y_phys[0]), float(y_phys[-1]) x_range = max(abs(x_max - x_min), 1e-30) y_range = max(abs(y_max - y_min), 1e-30) - for b in bubbles: # pylint: disable=not-an-iterable + for b in bubbles: bx, by, br = float(b[0]), float(b[1]), float(b[3]) if bx < x_min - br or bx > x_max + br: continue @@ -368,20 +380,12 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- log_tag = " [log]" if log_active else (" [log n/a]" if self._log_scale else "") frozen_tag = " [frozen]" if self._vmin is not None else "" zoomed_tag = " [zoom]" if self._zoom != (0.0, 1.0, 0.0, 1.0) else "" - header = RichText( - f" {self._varname} (step {self._step})" - f" [{vmin:.3g}, {vmax:.3g}]{log_tag}{frozen_tag}{zoomed_tag}", - style="bold" - ) + header = RichText(f" {self._varname} (step {self._step}) [{vmin:.3g}, {vmax:.3g}]{log_tag}{frozen_tag}{zoomed_tag}", style="bold") # Show the visible coordinate range (reflects zoom when active). - x_lo = float(x_cc[ix[0]]) # type: ignore[index] # pylint: disable=unsubscriptable-object - x_hi = float(x_cc[ix[-1]]) # type: ignore[index] # pylint: disable=unsubscriptable-object + x_lo = float(x_cc[ix[0]]) # type: ignore[index] + x_hi = float(x_cc[ix[-1]]) # type: ignore[index] y_vis = y_cc_2d[iy] - footer = RichText( - f" x: [{x_lo:.3f} \u2026 {x_hi:.3f}]" - f" y: [{float(y_vis[0]):.3f} \u2026 {float(y_vis[-1]):.3f}]", - style="dim" - ) + footer = RichText(f" x: [{x_lo:.3f} \u2026 {x_hi:.3f}] y: [{float(y_vis[0]):.3f} \u2026 {float(y_vis[-1]):.3f}]", style="dim") return RichGroup(header, *lines, footer) @@ -389,7 +393,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- # Main TUI app # --------------------------------------------------------------------------- -class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes + +class MFCTuiApp(App): """Textual TUI for MFC post-processed data.""" CSS = """ @@ -451,7 +456,7 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes log_scale: reactive[bool] = reactive(False, always_update=True) playing: reactive[bool] = reactive(False, always_update=True) - def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__( self, steps: List[int], varnames: List[str], @@ -515,10 +520,9 @@ def watch_log_scale(self, _old: bool, _new: bool) -> None: def watch_playing(self, _old: bool, new: bool) -> None: if new: self._play_timer = self.set_interval(0.5, self._auto_advance) - else: - if self._play_timer is not None: - self._play_timer.stop() - self._play_timer = None + elif self._play_timer is not None: + self._play_timer.stop() + self._play_timer = None # ------------------------------------------------------------------ # MFCPlot.Clicked handler — update status bar (Feature 5) @@ -526,9 +530,7 @@ def watch_playing(self, _old: bool, new: bool) -> None: def on_mfcplot_clicked(self, event: MFCPlot.Clicked) -> None: """Receive the heatmap click message and update the status bar.""" - self._click_info = ( - f" │ x={event.x_val:.4f} y={event.y_val:.4f} val={event.val:.6g}" - ) + self._click_info = f" │ x={event.x_val:.4f} y={event.y_val:.4f} val={event.val:.6g}" self.query_one("#status", Static).update(self._status_text()) # ------------------------------------------------------------------ @@ -570,10 +572,18 @@ def _push_data(self) -> None: pass # bubble overlay is best-effort; skip on read errors self.call_from_thread( - self._apply_data, assembled, data, step, var, cmap, log, frozen, bubbles, + self._apply_data, + assembled, + data, + step, + var, + cmap, + log, + frozen, + bubbles, ) - def _apply_data( # pylint: disable=too-many-arguments,too-many-positional-arguments + def _apply_data( self, assembled, data: Optional[np.ndarray], @@ -586,20 +596,20 @@ def _apply_data( # pylint: disable=too-many-arguments,too-many-positional-argum ) -> None: """Apply loaded data to the plot widget. Runs on the main thread.""" plot = self.query_one("#plot", MFCPlot) - plot._x_cc = assembled.x_cc # pylint: disable=protected-access - plot._y_cc = assembled.y_cc # pylint: disable=protected-access - plot._data = data # pylint: disable=protected-access - plot._ndim = self._ndim # pylint: disable=protected-access - plot._varname = var # pylint: disable=protected-access - plot._step = step # pylint: disable=protected-access - plot._cmap_name = cmap # pylint: disable=protected-access - plot._log_scale = log # pylint: disable=protected-access - plot._bubbles = bubbles # pylint: disable=protected-access + plot._x_cc = assembled.x_cc + plot._y_cc = assembled.y_cc + plot._data = data + plot._ndim = self._ndim + plot._varname = var + plot._step = step + plot._cmap_name = cmap + plot._log_scale = log + plot._bubbles = bubbles if frozen is not None: - plot._vmin, plot._vmax = frozen # pylint: disable=protected-access + plot._vmin, plot._vmax = frozen else: - plot._vmin = None # pylint: disable=protected-access - plot._vmax = None # pylint: disable=protected-access + plot._vmin = None + plot._vmax = None plot.refresh() # Update step counter (Feature 2). @@ -622,13 +632,7 @@ def _status_text(self) -> str: if self.playing: flags.append("▶") flag_str = (" " + " ".join(flags)) if flags else "" - return ( - f" step {step} [{self.step_idx + 1}/{total}]" - f" var: {self.var_name}" - f" cmap: {self.cmap_name}" - f"{flag_str}" - f"{self._click_info}" - ) + return f" step {step} [{self.step_idx + 1}/{total}] var: {self.var_name} cmap: {self.cmap_name}{flag_str}{self._click_info}" # ------------------------------------------------------------------ # Actions @@ -660,7 +664,7 @@ def action_toggle_freeze(self) -> None: self._frozen_range = None else: plot = self.query_one("#plot", MFCPlot) - self._frozen_range = (plot._last_vmin, plot._last_vmax) # pylint: disable=protected-access + self._frozen_range = (plot._last_vmin, plot._last_vmax) self._push_data() def action_toggle_play(self) -> None: @@ -678,6 +682,7 @@ def _auto_advance(self) -> None: # Public entry point # --------------------------------------------------------------------------- + def run_tui( init_var: Optional[str], steps: List[int], @@ -687,10 +692,7 @@ def run_tui( ) -> None: """Launch the Textual TUI for MFC visualization (1D/2D only).""" if ndim not in (1, 2): - raise MFCException( - f"Terminal UI only supports 1D and 2D data (got ndim={ndim}). " - "Use --interactive for 3D data." - ) + raise MFCException(f"Terminal UI only supports 1D and 2D data (got ndim={ndim}). Use --interactive for 3D data.") # Preload first step to discover variables first = _load(steps[0], read_func) @@ -700,14 +702,8 @@ def run_tui( if init_var not in varnames: init_var = varnames[0] - cons.print( - f"[bold]Launching TUI[/bold] — {len(steps)} step(s), " - f"{len(varnames)} variable(s)" - ) - cons.print( - "[dim] ,/. or ←/→ step • space play • l log • f freeze" - " • c cmap • ↑↓ var • scroll zoom • r reset zoom • click value • q quit[/dim]" - ) + cons.print(f"[bold]Launching TUI[/bold] — {len(steps)} step(s), {len(varnames)} variable(s)") + cons.print("[dim] ,/. or ←/→ step • space play • l log • f freeze • c cmap • ↑↓ var • scroll zoom • r reset zoom • click value • q quit[/dim]") _step_cache.seed(steps[0], first) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 5b935c44a6..6bbf7d7b6e 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -7,27 +7,24 @@ import os import warnings -from mfc.state import ARG from mfc.common import MFCException from mfc.printer import cons +from mfc.state import ARG - - -_CMAP_POPULAR = ( - 'viridis, plasma, inferno, magma, turbo, ' - 'coolwarm, RdBu_r, bwr, hot, jet, gray, seismic' -) +_CMAP_POPULAR = "viridis, plasma, inferno, magma, turbo, coolwarm, RdBu_r, bwr, hot, jet, gray, seismic" def _validate_cmap(name): """Raise a helpful MFCException if *name* is not a known matplotlib colormap.""" - import matplotlib # pylint: disable=import-outside-toplevel + import matplotlib + if name in matplotlib.colormaps: return try: - from rapidfuzz import process # pylint: disable=import-outside-toplevel + from rapidfuzz import process + matches = process.extract(name, list(matplotlib.colormaps), limit=3) - suggestions = ', '.join(m[0] for m in matches) + suggestions = ", ".join(m[0] for m in matches) hint = f" Did you mean: {suggestions}?" except ImportError: hint = f" Popular choices: {_CMAP_POPULAR}." @@ -39,10 +36,10 @@ def _steps_hint(steps, n=8): if not steps: return "none found" if len(steps) <= n: - return ', '.join(str(s) for s in steps) + return ", ".join(str(s) for s in steps) half = n // 2 - head = ', '.join(str(s) for s in steps[:half]) - tail = ', '.join(str(s) for s in steps[-half:]) + head = ", ".join(str(s) for s in steps[:half]) + tail = ", ".join(str(s) for s in steps[-half:]) return f"{head}, ... [{len(steps)} total] ..., {tail}" @@ -54,45 +51,30 @@ def _parse_step_list(s, available_steps): "0,100,200,1000" -> [0, 100, 200, 1000] (intersection with available) "0,100,200,...,1000" -> range(0, 1001, 100) (infers stride=100 from last pair) """ - parts = [p.strip() for p in s.split(',')] + parts = [p.strip() for p in s.split(",")] avail_set = set(available_steps) - if '...' in parts: - idx = parts.index('...') + if "..." in parts: + idx = parts.index("...") if idx < 2: - raise MFCException( - f"Invalid --step value '{s}'. " - "Ellipsis '...' requires at least two values before it, " - "e.g. '0,100,...,1000'." - ) + raise MFCException(f"Invalid --step value '{s}'. Ellipsis '...' requires at least two values before it, e.g. '0,100,...,1000'.") if idx != len(parts) - 2: - raise MFCException( - f"Invalid --step value '{s}'. " - "Ellipsis '...' must be the second-to-last item, " - "e.g. '0,100,...,1000'." - ) + raise MFCException(f"Invalid --step value '{s}'. Ellipsis '...' must be the second-to-last item, e.g. '0,100,...,1000'.") try: prefix = [int(p) for p in parts[:idx]] end = int(parts[idx + 1]) except ValueError as exc: - raise MFCException( - f"Invalid --step value '{s}': all values must be integers." - ) from exc + raise MFCException(f"Invalid --step value '{s}': all values must be integers.") from exc stride = prefix[-1] - prefix[-2] if stride <= 0: - raise MFCException( - f"Invalid --step value '{s}': " - f"ellipsis stride must be positive (got {stride})." - ) + raise MFCException(f"Invalid --step value '{s}': ellipsis stride must be positive (got {stride}).") requested = list(range(prefix[0], end + 1, stride)) else: try: requested = [int(p) for p in parts] except ValueError as exc: - raise MFCException( - f"Invalid --step value '{s}': all values must be integers." - ) from exc + raise MFCException(f"Invalid --step value '{s}': all values must be integers.") from exc return [t for t in requested if t in avail_set] @@ -113,22 +95,22 @@ def _parse_steps(step_arg, available_steps): - "last": last available timestep - "all": all available timesteps """ - if step_arg is None or step_arg == 'all': + if step_arg is None or step_arg == "all": return available_steps, 0 - if step_arg == 'last': + if step_arg == "last": return ([available_steps[-1]] if available_steps else []), 0 s = str(step_arg) - if ',' in s: + if "," in s: matched = _parse_step_list(s, available_steps) # n_requested: count of explicit values (ellipsis form expands to a range) - parts = [p.strip() for p in s.split(',')] - if '...' in parts: + parts = [p.strip() for p in s.split(",")] + if "..." in parts: # Compute the expanded range length independently of filtering. try: - idx = parts.index('...') + idx = parts.index("...") prefix = [int(p) for p in parts[:idx]] end_val = int(parts[-1]) stride = prefix[-1] - prefix[-2] @@ -140,12 +122,10 @@ def _parse_steps(step_arg, available_steps): return matched, n_req try: - if ':' in s: - parts = s.split(':') + if ":" in s: + parts = s.split(":") if len(parts) > 3: - raise MFCException( - f"Invalid range '{step_arg}': expected start:end or start:end:stride." - ) + raise MFCException(f"Invalid range '{step_arg}': expected start:end or start:end:stride.") start = int(parts[0]) end = int(parts[1]) stride = int(parts[2]) if len(parts) > 2 else 1 @@ -155,10 +135,7 @@ def _parse_steps(step_arg, available_steps): single = int(s) except ValueError as exc: raise MFCException( - f"Invalid --step value '{step_arg}'. " - "Expected an integer, a range (start:end:stride), " - "a comma list (0,100,200), an ellipsis list (0,100,...,1000), " - "'last', or 'all'." + f"Invalid --step value '{step_arg}'. Expected an integer, a range (start:end:stride), a comma list (0,100,200), an ellipsis list (0,100,...,1000), 'last', or 'all'." ) from exc if available_steps and single not in set(available_steps): @@ -166,13 +143,13 @@ def _parse_steps(step_arg, available_steps): return [single], 1 -def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches +def viz(): """Main viz command dispatcher.""" - from .reader import discover_format, discover_timesteps, assemble, has_lag_bubble_evol, read_lag_bubbles_at_step # pylint: disable=import-outside-toplevel - from .renderer import render_1d, render_1d_tiled, render_2d, render_2d_tiled, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel + from .reader import assemble, discover_format, discover_timesteps, has_lag_bubble_evol, read_lag_bubbles_at_step + from .renderer import render_1d, render_1d_tiled, render_2d, render_2d_tiled, render_3d_slice, render_mp4 - case_dir = ARG('input') + case_dir = ARG("input") if case_dir is None: raise MFCException("Please specify a case directory.") @@ -181,11 +158,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc raise MFCException(f"Directory not found: {case_dir}") # Auto-detect or use specified format - fmt_arg = ARG('format') + fmt_arg = ARG("format") if fmt_arg: - if fmt_arg not in ('binary', 'silo'): - raise MFCException(f"Unknown format '{fmt_arg}'. " - "Supported formats: 'binary', 'silo'.") + if fmt_arg not in ("binary", "silo"): + raise MFCException(f"Unknown format '{fmt_arg}'. Supported formats: 'binary', 'silo'.") fmt = fmt_arg else: try: @@ -196,15 +172,14 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f"[yellow]Warning:[/yellow] {_warning.message}") except FileNotFoundError as exc: msg = str(exc) - if os.path.isfile(os.path.join(case_dir, 'case.py')): - msg += (" This looks like an MFC case directory. " - "Did you forget to run post_process first?") + if os.path.isfile(os.path.join(case_dir, "case.py")): + msg += " This looks like an MFC case directory. Did you forget to run post_process first?" raise MFCException(msg) from exc cons.print(f"[bold]Format:[/bold] {fmt}") # Handle --list-steps - if ARG('list_steps'): + if ARG("list_steps"): steps = discover_timesteps(case_dir, fmt) if not steps: cons.print("[yellow]No timesteps found.[/yellow]") @@ -222,33 +197,29 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc return # Handle --list-vars (requires --step) - if ARG('list_vars'): - step_arg = ARG('step') + if ARG("list_vars"): + step_arg = ARG("step") steps = discover_timesteps(case_dir, fmt) if not steps: - raise MFCException( - f"No timesteps found in '{case_dir}' ({fmt} format). " - "Ensure post_process has been run and produced output files.") + raise MFCException(f"No timesteps found in '{case_dir}' ({fmt} format). Ensure post_process has been run and produced output files.") - if step_arg is None or step_arg == 'all': + if step_arg is None or step_arg == "all": step = steps[0] cons.print(f"[dim]Using first available timestep: {step}[/dim]") - elif step_arg == 'last': + elif step_arg == "last": step = steps[-1] cons.print(f"[dim]Using last available timestep: {step}[/dim]") else: try: step = int(step_arg) except ValueError as exc: - raise MFCException(f"Invalid --step value '{step_arg}'. " - "Expected an integer, 'last', or 'all'.") from exc + raise MFCException(f"Invalid --step value '{step_arg}'. Expected an integer, 'last', or 'all'.") from exc if step not in steps: - raise MFCException( - f"Timestep {step} not found. " - f"Available steps: {_steps_hint(steps)}") + raise MFCException(f"Timestep {step} not found. Available steps: {_steps_hint(steps)}") + + if fmt == "silo": + from .silo_reader import assemble_silo - if fmt == 'silo': - from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel assembled = assemble_silo(case_dir, step) else: assembled = assemble(case_dir, step, fmt) @@ -261,33 +232,29 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc return # For rendering, --step is required; --var is optional for 1D/2D (shows all in tiled layout) - varname = ARG('var') - step_arg = ARG('step') - tiled = varname is None or varname == 'all' + varname = ARG("var") + step_arg = ARG("step") + tiled = varname is None or varname == "all" # TUI is the default mode; --interactive, --png, and --mp4 are explicit. - use_tui = not ARG('interactive') and not ARG('png') and not ARG('mp4') + use_tui = not ARG("interactive") and not ARG("png") and not ARG("mp4") - if ARG('interactive') or use_tui or ARG('mp4'): + if ARG("interactive") or use_tui or ARG("mp4"): # Load all steps by default; honour an explicit --step so users can # reduce the set for large 3D cases before hitting the step limit. - if step_arg == 'last': - step_arg = 'all' + if step_arg == "last": + step_arg = "all" steps = discover_timesteps(case_dir, fmt) if not steps: - raise MFCException( - f"No timesteps found in '{case_dir}' ({fmt} format). " - "Ensure post_process has been run and produced output files.") + raise MFCException(f"No timesteps found in '{case_dir}' ({fmt} format). Ensure post_process has been run and produced output files.") requested_steps, n_requested = _parse_steps(step_arg, steps) if not requested_steps: - detail = (f" ({n_requested} requested, 0 matched)" if n_requested > 1 else "") - raise MFCException( - f"No matching timesteps for --step {step_arg!r}{detail}. " - f"Available steps: {_steps_hint(steps)}") + detail = f" ({n_requested} requested, 0 matched)" if n_requested > 1 else "" + raise MFCException(f"No matching timesteps for --step {step_arg!r}{detail}. Available steps: {_steps_hint(steps)}") - interactive = ARG('interactive') + interactive = ARG("interactive") # Lagrange bubble overlay: auto-detect D/lag_bubble_evol_*.dat files bubble_func = None @@ -300,15 +267,17 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc load_all = tiled or interactive or use_tui def read_step(step): - if fmt == 'silo': - from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + if fmt == "silo": + from .silo_reader import assemble_silo + return assemble_silo(case_dir, step, var=None if load_all else varname) return assemble(case_dir, step, fmt, var=None if load_all else varname) def read_step_one_var(step, var): """Read a single variable for a step — used by interactive mode.""" - if fmt == 'silo': - from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + if fmt == "silo": + from .silo_reader import assemble_silo + return assemble_silo(case_dir, step, var=var) return assemble(case_dir, step, fmt, var=var) @@ -320,9 +289,7 @@ def read_step_one_var(step, var): # Interactive mode caches all steps simultaneously, so use a tighter limit. _3d_limit = 50 if interactive else 500 if test_assembled.ndim == 3 and len(requested_steps) > _3d_limit: - raise MFCException( - f"Refusing to load {len(requested_steps)} timesteps for 3D data " - f"(limit is {_3d_limit}). Use --step with a range or stride to reduce.") + raise MFCException(f"Refusing to load {len(requested_steps)} timesteps for 3D data (limit is {_3d_limit}). Use --step with a range or stride to reduce.") # Tiled mode works for 1D and 2D. For 3D, auto-select the first variable. if tiled and not interactive and not use_tui: @@ -331,127 +298,110 @@ def read_step_one_var(step, var): if varname is None: raise MFCException("No variables found in output.") tiled = False - cons.print(f"[dim]Auto-selected variable: [bold]{varname}[/bold]" - " (use --var to specify)[/dim]") + cons.print(f"[dim]Auto-selected variable: [bold]{varname}[/bold] (use --var to specify)[/dim]") if not tiled and not interactive and not use_tui and varname not in test_assembled.variables: # test_assembled was loaded with var_filter=varname so its variables dict # may be empty. Re-read without filter (errors only, so extra I/O is fine) # to build a useful "available variables" list for the error message. if not avail: - if fmt == 'silo': - from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + if fmt == "silo": + from .silo_reader import assemble_silo + _full = assemble_silo(case_dir, requested_steps[0]) else: _full = assemble(case_dir, requested_steps[0], fmt) avail = sorted(_full.variables.keys()) - avail_str = ', '.join(avail) if avail else '(none — check post_process output)' - raise MFCException( - f"Variable '{varname}' not found. " - f"Available: {avail_str}. " - f"Use --list-vars to see variables at a given step." - ) + avail_str = ", ".join(avail) if avail else "(none — check post_process output)" + raise MFCException(f"Variable '{varname}' not found. Available: {avail_str}. Use --list-vars to see variables at a given step.") # TUI mode — launch Textual terminal UI (1D/2D only) if use_tui: if test_assembled.ndim == 3: - raise MFCException( - "Terminal UI only supports 1D and 2D data. " - "Use --interactive for 3D data." - ) - from .tui import run_tui # pylint: disable=import-outside-toplevel + raise MFCException("Terminal UI only supports 1D and 2D data. Use --interactive for 3D data.") + from .tui import run_tui + init_var = varname if varname in avail else (avail[0] if avail else None) - run_tui(init_var, requested_steps, read_step, ndim=test_assembled.ndim, - bubble_func=bubble_func) + run_tui(init_var, requested_steps, read_step, ndim=test_assembled.ndim, bubble_func=bubble_func) return # Interactive mode — launch Dash web server if interactive: - ignored = [f for f in ('slice_index', 'slice_value') - if ARG(f) is not None] - if ARG('slice_axis') != 'z': - ignored.insert(0, 'slice_axis') + ignored = [f for f in ("slice_index", "slice_value") if ARG(f) is not None] + if ARG("slice_axis") != "z": + ignored.insert(0, "slice_axis") if ignored: - cons.print(f"[yellow]Warning:[/yellow] {', '.join('--' + f.replace('_', '-') for f in ignored)} " - "ignored in --interactive mode (use the UI controls instead).") - from .interactive import run_interactive # pylint: disable=import-outside-toplevel - port = ARG('port') - host = ARG('host') + cons.print(f"[yellow]Warning:[/yellow] {', '.join('--' + f.replace('_', '-') for f in ignored)} ignored in --interactive mode (use the UI controls instead).") + from .interactive import run_interactive + + port = ARG("port") + host = ARG("host") # Default to first available variable if --var was not specified init_var = varname if varname in avail else (avail[0] if avail else None) - run_interactive(init_var, requested_steps, read_step, - port=int(port), host=str(host), - bubble_func=bubble_func, - read_one_var_func=read_step_one_var) + run_interactive(init_var, requested_steps, read_step, port=int(port), host=str(host), bubble_func=bubble_func, read_one_var_func=read_step_one_var) return # --- PNG / MP4 rendering options (not used by TUI or interactive) --- render_opts = { - 'cmap': ARG('cmap'), - 'dpi': ARG('dpi'), - 'slice_axis': ARG('slice_axis'), + "cmap": ARG("cmap"), + "dpi": ARG("dpi"), + "slice_axis": ARG("slice_axis"), } - if ARG('vmin') is not None: - render_opts['vmin'] = float(ARG('vmin')) - if ARG('vmax') is not None: - render_opts['vmax'] = float(ARG('vmax')) - if ARG('log_scale'): - render_opts['log_scale'] = True - if ARG('slice_index') is not None and ARG('slice_value') is not None: + if ARG("vmin") is not None: + render_opts["vmin"] = float(ARG("vmin")) + if ARG("vmax") is not None: + render_opts["vmax"] = float(ARG("vmax")) + if ARG("log_scale"): + render_opts["log_scale"] = True + if ARG("slice_index") is not None and ARG("slice_value") is not None: raise MFCException("--slice-index and --slice-value are mutually exclusive.") - if ARG('slice_index') is not None: - render_opts['slice_index'] = int(ARG('slice_index')) - if ARG('slice_value') is not None: - render_opts['slice_value'] = float(ARG('slice_value')) + if ARG("slice_index") is not None: + render_opts["slice_index"] = int(ARG("slice_index")) + if ARG("slice_value") is not None: + render_opts["slice_value"] = float(ARG("slice_value")) - cmap_name = ARG('cmap') + cmap_name = ARG("cmap") if cmap_name: _validate_cmap(cmap_name) # Create output directory - output_base = ARG('output') + output_base = ARG("output") if output_base is None: - output_base = os.path.join(case_dir, 'viz') + output_base = os.path.join(case_dir, "viz") os.makedirs(output_base, exist_ok=True) # MP4 mode - if ARG('mp4'): - fps = float(ARG('fps')) + if ARG("mp4"): + fps = float(ARG("fps")) _FPS_DEFAULT = 10.0 _MIN_DURATION = 5.0 # seconds n_frames = len(requested_steps) if fps == _FPS_DEFAULT and n_frames / fps < _MIN_DURATION: fps = max(1.0, n_frames / _MIN_DURATION) - cons.print( - f"[dim]Auto-adjusted fps to {fps:.2g} " - f"so video is at least {_MIN_DURATION:.0f}s " - f"(use --fps to override).[/dim]" - ) - label = 'all' if tiled else varname - mp4_path = os.path.join(output_base, f'{label}.mp4') - cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({n_frames} frames @ {fps:.2g} fps = {n_frames/fps:.1f}s)") + cons.print(f"[dim]Auto-adjusted fps to {fps:.2g} so video is at least {_MIN_DURATION:.0f}s (use --fps to override).[/dim]") + label = "all" if tiled else varname + mp4_path = os.path.join(output_base, f"{label}.mp4") + cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({n_frames} frames @ {fps:.2g} fps = {n_frames / fps:.1f}s)") try: - success = render_mp4(varname, requested_steps, mp4_path, - fps=fps, read_func=read_step, - tiled=tiled, bubble_func=bubble_func, **render_opts) + success = render_mp4(varname, requested_steps, mp4_path, fps=fps, read_func=read_step, tiled=tiled, bubble_func=bubble_func, **render_opts) except ValueError as exc: raise MFCException(str(exc)) from exc if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") else: - raise MFCException(f"Failed to generate {mp4_path}. " - "Ensure imageio and imageio-ffmpeg are installed.") + raise MFCException(f"Failed to generate {mp4_path}. Ensure imageio and imageio-ffmpeg are installed.") return # Single or multiple PNG frames try: - from tqdm import tqdm # pylint: disable=import-outside-toplevel - step_iter = tqdm(requested_steps, desc='Rendering') + from tqdm import tqdm + + step_iter = tqdm(requested_steps, desc="Rendering") except ImportError: step_iter = requested_steps failures = [] - label = 'all' if tiled else varname + label = "all" if tiled else varname for step in step_iter: try: # Reuse the already-loaded probe data for the first step @@ -461,7 +411,7 @@ def read_step_one_var(step, var): failures.append(step) continue - output_path = os.path.join(output_base, f'{label}_{step}.png') + output_path = os.path.join(output_base, f"{label}_{step}.png") # Inject bubble positions for this step step_opts = render_opts @@ -472,22 +422,17 @@ def read_step_one_var(step, var): cons.print(f"[yellow]Warning:[/yellow] Skipping bubble overlay for step {step}: {exc}") if tiled and assembled.ndim == 1: - render_1d_tiled(assembled.x_cc, assembled.variables, - step, output_path, **step_opts) + render_1d_tiled(assembled.x_cc, assembled.variables, step, output_path, **step_opts) elif tiled and assembled.ndim == 2: render_2d_tiled(assembled, step, output_path, **step_opts) elif assembled.ndim == 1: - render_1d(assembled.x_cc, assembled.variables[varname], - varname, step, output_path, **step_opts) + render_1d(assembled.x_cc, assembled.variables[varname], varname, step, output_path, **step_opts) elif assembled.ndim == 2: - render_2d(assembled.x_cc, assembled.y_cc, - assembled.variables[varname], - varname, step, output_path, **step_opts) + render_2d(assembled.x_cc, assembled.y_cc, assembled.variables[varname], varname, step, output_path, **step_opts) elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, output_path, **step_opts) else: - cons.print(f"[yellow]Warning:[/yellow] Unsupported ndim={assembled.ndim} " - f"for step {step}, skipping.") + cons.print(f"[yellow]Warning:[/yellow] Unsupported ndim={assembled.ndim} for step {step}, skipping.") failures.append(step) continue @@ -496,7 +441,6 @@ def read_step_one_var(step, var): rendered = len(requested_steps) - len(failures) if failures: - cons.print(f"[yellow]Warning:[/yellow] {len(failures)} step(s) failed: " - f"{failures[:10]}{'...' if len(failures) > 10 else ''}") + cons.print(f"[yellow]Warning:[/yellow] {len(failures)} step(s) failed: {failures[:10]}{'...' if len(failures) > 10 else ''}") if rendered > 1: cons.print(f"[bold green]Saved {rendered} frames to:[/bold green] {output_base}/") diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 5492baa286..74611d3442 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -23,9 +23,8 @@ dependencies = [ # Code Health "typos", - "pylint", + "ruff", "fprettify", - "autopep8", # Python formatter (black has issues with Python 3.12.5) "ansi2txt", # Profiling