diff --git a/case/plume/plume_cylinder_sweep/config.ini b/case/plume/plume_cylinder_sweep/config.ini new file mode 100644 index 0000000..49b20bc --- /dev/null +++ b/case/plume/plume_cylinder_sweep/config.ini @@ -0,0 +1,76 @@ +# Cylinder-target SWEEP smoke case: the plate-sweep setup re-aimed at a +# CYLINDER target (data/stl/cylinder_transformed.stl -- radius 2 m, axis along +# X in [-7, 0], centroid (-3.5, 0, 0)). The VV (single argon thruster, D = 1 m, +# S0 = 2.0, Simplified kinetics + Maxwellian wall) is swept over 19 approach +# angles x 5 orbit radii (95 firings; jfh_cylinder_sweep.A) about the cylinder +# centroid, writing per-firing strikes to results/strikes/firing-.vtu. This +# case exists to confirm the strike pipeline RUNS on a curved/closed target; +# the strike results are not physically meaningful. Geometry: the target is +# the shared high-res mesh data/stl/cylinder.stl (14036 faces; resolved from +# the shared data/ dir, not copied into the case per the repo's de-dup +# convention); the sweep JFH is generated by +# jfh/generate_cylinder_sweep_jfh.py (its centroid/radii are unchanged -- +# cylinder.stl and cylinder_transformed.stl share the same geometry). + +# Visiting Vehicle for RPOD analysis +[vv] +stl_lm = cylinder.stl +stl_thruster = mold_funnel_transformed.stl + +# Target Vehicle for RPOD analysis +[tv] +stl = cylinder.stl + +# surface wall temperature (Kelvin): Tw/T0 = 1.5 (paper Figs. 17-21) +surface_temp = 300 + +# proportion of diffuse particle reflections [0, 1] +sigma = 1 + +# check plume constraints? 0 or 1 +check_constraints = 0 + +# max heat flux integral (J/m^2) +heat_flux_load = inf +heat_flux_window_size = 1 + +# max heat flux rate (W/m^2) +heat_flux = inf + +# max pressure load +normal_pressure_load = inf +normal_pressure_window_size = inf + +# max normal pressure (N/m^2) +normal_pressure = inf + +# max shear pressure (N/m^2) +shear_pressure = inf + +# Plume kinetics and interactions models +[pm] +# Gas kinetics model +kinetics = Simplified + +# Gas-surface interaction model +surface_interaction = Maxwellian + +# Jet Firing History +[jfh] +jfh = jfh_cylinder_sweep.A + +# Thruster configuration data. +[tcd] +# Thruster Configuration File - single head-on thruster, no cant. +tcf = tcf_1_argon.txt + +# Thruster Definition File - argon at the paper conditions. +tdf = tdf.csv + +# Parameters for scaling plume geometry. +[plume] +radius = 25 +# ~89 deg: the default 0.436 (25 deg) would clip the plate -- its lower +# corners sit ~70 deg off-axis at the paper distance. 1.55 rad keeps the +# gating wedge clear of the whole plate at every sweep pose. +wedge_theta = 1.55 diff --git a/case/plume/plume_cylinder_sweep/jfh/generate_cylinder_sweep_jfh.py b/case/plume/plume_cylinder_sweep/jfh/generate_cylinder_sweep_jfh.py new file mode 100644 index 0000000..28bef6f --- /dev/null +++ b/case/plume/plume_cylinder_sweep/jfh/generate_cylinder_sweep_jfh.py @@ -0,0 +1,75 @@ +"""Generate the sweep JFH for the cylinder-target smoke case. + +Same angle x distance sweep idea as the plate cases, but aimed at a CYLINDER +target (data/stl/cylinder_transformed.stl: radius 2 m, axis along X spanning +x in [-7, 0], centroid C = (-3.5, 0, 0), bounding-sphere radius ~4.03 m). + +The plate is replaced by the cylinder; the visiting vehicle (single thruster +at the VV origin, no cant) is still swept. For each firing the VV is placed on +the arc of radius L about the cylinder centroid C, with the thruster axis aimed +at C and the JFH DCM set so the pipeline's plume normal (first column of the +DCM, with the identity TCF) points along that axis: + + d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * t_hat + VV position = C + L * d_hat(alpha), thruster axis = -d_hat(alpha) + +with n_hat = (0, 0, 1) and t_hat = (1, 0, 0), so the sweep orbits the cylinder +in the global X-Z plane (alpha = 0 fires straight down -Z onto the curved +surface; alpha = +/-90 fires along -/+X at the end caps). + +The orbit radii all exceed the cylinder's bounding sphere so the VV never sits +inside the target (this case is a pipeline smoke test -- the strike results are +not physically meaningful). Sweep: alpha from -90 to +90 deg in 10 deg steps +(19 angles) x L in {6, 8, 10, 12, 14} m, enumerated distance-major: +firing index = i_L * 19 + i_alpha + 1. 95 firings total, 1.0 s each. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_cylinder_sweep_jfh.py +""" + +from pathlib import Path + +import numpy as np + +from generate_jfh_inclined_plate import write_jfh + +OUT_NAME = 'jfh_cylinder_sweep.A' + +CYLINDER_CENTER = np.array([-3.5, 0.0, 0.0]) +ALPHA0_DEG = 0.0 # global-frame orbit-plane tilt +ALPHAS_DEG = np.arange(-90.0, 90.0 + 1e-9, 10.0) +RADII = [6.0, 8.0, 10.0, 12.0, 14.0] # arc radii (m); all > ~4.03 bounding sphere + + +def pose_for(alpha_deg, L): + """(vv_position, dcm) for one sweep firing; dcm's first column is the + thruster axis (see the pipeline's plume-normal convention).""" + a0 = np.deg2rad(ALPHA0_DEG) + n_hat = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + t_hat = np.array([np.cos(a0), 0.0, np.sin(a0)]) + alpha = np.deg2rad(alpha_deg) + d_hat = np.cos(alpha) * n_hat + np.sin(alpha) * t_hat + position = CYLINDER_CENTER + L * d_hat + axis = -d_hat # aimed at the cylinder centroid + # right-handed triad; the axis always lies in the X-Z plane, so the + # global Y axis is a valid second column + c1 = np.array([0.0, 1.0, 0.0]) + c2 = np.cross(axis, c1) + dcm = np.column_stack([axis, c1, c2]) + return position, dcm + + +def dcm_string(dcm): + return ' '.join(f'{v:.6e}' for v in np.asarray(dcm).ravel()) + + +if __name__ == '__main__': + firings = [] + for L in RADII: + for alpha_deg in ALPHAS_DEG: + position, dcm = pose_for(alpha_deg, L) + firings.append((0.0, 1.0, dcm_string(dcm), tuple(position), [1])) + out_path = Path(__file__).resolve().parent / OUT_NAME + write_jfh(out_path, firings) + print(f'saved {out_path} ({len(firings)} firings: ' + f'{len(ALPHAS_DEG)} angles x {len(RADII)} radii)') diff --git a/case/plume/plume_cylinder_sweep/jfh/generate_jfh_inclined_plate.py b/case/plume/plume_cylinder_sweep/jfh/generate_jfh_inclined_plate.py new file mode 100644 index 0000000..249f71d --- /dev/null +++ b/case/plume/plume_cylinder_sweep/jfh/generate_jfh_inclined_plate.py @@ -0,0 +1,37 @@ +"""Generate the single-firing JFH for the Cai 2016 inclined-plate case. + +One firing at the paper geometry (Aerospace 2016, 3(4):43, Section 4): the +visiting vehicle sits at the origin with an identity DCM, so its single +thruster (exit at the VV origin, no cant) fires along +X toward the plate +centered at (4, 0, 0) m. Firing time 1.0 s makes heat_flux_load equal the +heat-flux rate. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_jfh_inclined_plate.py +""" + +from pathlib import Path + +OUT_NAME = 'jfh_plume_inclined_plate.A' + +DCM_IDENTITY = ('1.000000e+00 0.000000e+00 0.000000e+00 ' + '0.000000e+00 1.000000e+00 0.000000e+00 ' + '0.000000e+00 0.000000e+00 1.000000e+00') + + +def write_jfh(path, firings): + """firings: list of (dt, t, dcm_str, xyz, thrusters).""" + lines = [f'offseted {len(firings)} 0', ' 0.000 0.000 0.000'] + for n, (dt, t, dcm, xyz, thrusters) in enumerate(firings, start=1): + xyz_str = ' '.join(f'{v:.9g}' for v in xyz) + thr_str = ' '.join(str(i) for i in thrusters) + lines.append(f' {n} {dt:g} {t:g} 0 {dcm} {xyz_str} 1 ' + f'{len(thrusters)} {thr_str}') + with open(path, 'w', newline='\n') as fh: + fh.write('\n'.join(lines) + '\n') + + +if __name__ == '__main__': + out_path = Path(__file__).resolve().parent / OUT_NAME + write_jfh(out_path, [(0.0, 1.0, DCM_IDENTITY, (0.0, 0.0, 0.0), [1])]) + print(f'saved {out_path}') diff --git a/case/plume/plume_cylinder_sweep/jfh/jfh_cylinder_sweep.A b/case/plume/plume_cylinder_sweep/jfh/jfh_cylinder_sweep.A new file mode 100644 index 0000000..f60a586 --- /dev/null +++ b/case/plume/plume_cylinder_sweep/jfh/jfh_cylinder_sweep.A @@ -0,0 +1,97 @@ +offseted 95 0 + 0.000 0.000 0.000 + 1 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -9.5 0 3.6739404e-16 1 1 1 + 2 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -9.40884652 0 1.04188907 1 1 1 + 3 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -9.13815572 0 2.05212086 1 1 1 + 4 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -8.69615242 0 3 1 1 1 + 5 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -8.09626666 0 3.85672566 1 1 1 + 6 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -7.35672566 0 4.59626666 1 1 1 + 7 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -6.5 0 5.19615242 1 1 1 + 8 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -5.55212086 0 5.63815572 1 1 1 + 9 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -4.54188907 0 5.90884652 1 1 1 + 10 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 -3.5 0 6 1 1 1 + 11 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 -2.45811093 0 5.90884652 1 1 1 + 12 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 -1.44787914 0 5.63815572 1 1 1 + 13 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 -0.5 0 5.19615242 1 1 1 + 14 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 0.356725658 0 4.59626666 1 1 1 + 15 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 1.09626666 0 3.85672566 1 1 1 + 16 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 1.69615242 0 3 1 1 1 + 17 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 2.13815572 0 2.05212086 1 1 1 + 18 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 2.40884652 0 1.04188907 1 1 1 + 19 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 2.5 0 3.6739404e-16 1 1 1 + 20 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -11.5 0 4.8985872e-16 1 1 1 + 21 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -11.378462 0 1.38918542 1 1 1 + 22 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -11.017541 0 2.73616115 1 1 1 + 23 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -10.4282032 0 4 1 1 1 + 24 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -9.62835554 0 5.14230088 1 1 1 + 25 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -8.64230088 0 6.12835554 1 1 1 + 26 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -7.5 0 6.92820323 1 1 1 + 27 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -6.23616115 0 7.51754097 1 1 1 + 28 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -4.88918542 0 7.87846202 1 1 1 + 29 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 -3.5 0 8 1 1 1 + 30 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 -2.11081458 0 7.87846202 1 1 1 + 31 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 -0.763838853 0 7.51754097 1 1 1 + 32 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 0.5 0 6.92820323 1 1 1 + 33 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 1.64230088 0 6.12835554 1 1 1 + 34 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 2.62835554 0 5.14230088 1 1 1 + 35 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 3.42820323 0 4 1 1 1 + 36 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 4.01754097 0 2.73616115 1 1 1 + 37 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 4.37846202 0 1.38918542 1 1 1 + 38 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 4.5 0 4.8985872e-16 1 1 1 + 39 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -13.5 0 6.123234e-16 1 1 1 + 40 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -13.3480775 0 1.73648178 1 1 1 + 41 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -12.8969262 0 3.42020143 1 1 1 + 42 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -12.160254 0 5 1 1 1 + 43 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -11.1604444 0 6.4278761 1 1 1 + 44 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -9.9278761 0 7.66044443 1 1 1 + 45 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -8.5 0 8.66025404 1 1 1 + 46 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -6.92020143 0 9.39692621 1 1 1 + 47 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -5.23648178 0 9.84807753 1 1 1 + 48 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 -3.5 0 10 1 1 1 + 49 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 -1.76351822 0 9.84807753 1 1 1 + 50 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 -0.0797985667 0 9.39692621 1 1 1 + 51 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 1.5 0 8.66025404 1 1 1 + 52 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 2.9278761 0 7.66044443 1 1 1 + 53 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 4.16044443 0 6.4278761 1 1 1 + 54 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 5.16025404 0 5 1 1 1 + 55 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 5.89692621 0 3.42020143 1 1 1 + 56 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 6.34807753 0 1.73648178 1 1 1 + 57 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 6.5 0 6.123234e-16 1 1 1 + 58 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -15.5 0 7.34788079e-16 1 1 1 + 59 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -15.317693 0 2.08377813 1 1 1 + 60 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -14.7763114 0 4.10424172 1 1 1 + 61 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -13.8923048 0 6 1 1 1 + 62 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -12.6925333 0 7.71345132 1 1 1 + 63 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -11.2134513 0 9.19253332 1 1 1 + 64 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -9.5 0 10.3923048 1 1 1 + 65 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -7.60424172 0 11.2763114 1 1 1 + 66 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -5.58377813 0 11.817693 1 1 1 + 67 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 -3.5 0 12 1 1 1 + 68 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 -1.41622187 0 11.817693 1 1 1 + 69 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 0.60424172 0 11.2763114 1 1 1 + 70 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 2.5 0 10.3923048 1 1 1 + 71 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 4.21345132 0 9.19253332 1 1 1 + 72 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 5.69253332 0 7.71345132 1 1 1 + 73 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 6.89230485 0 6 1 1 1 + 74 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 7.77631145 0 4.10424172 1 1 1 + 75 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 8.31769304 0 2.08377813 1 1 1 + 76 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 8.5 0 7.34788079e-16 1 1 1 + 77 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -17.5 0 8.57252759e-16 1 1 1 + 78 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -17.2873085 0 2.43107449 1 1 1 + 79 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -16.6556967 0 4.78828201 1 1 1 + 80 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -15.6243557 0 7 1 1 1 + 81 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -14.2246222 0 8.99902654 1 1 1 + 82 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -12.4990265 0 10.7246222 1 1 1 + 83 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -10.5 0 12.1243557 1 1 1 + 84 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -8.28828201 0 13.1556967 1 1 1 + 85 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -5.93107449 0 13.7873085 1 1 1 + 86 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 -3.5 0 14 1 1 1 + 87 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 -1.06892551 0 13.7873085 1 1 1 + 88 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 1.28828201 0 13.1556967 1 1 1 + 89 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 3.5 0 12.1243557 1 1 1 + 90 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 5.49902654 0 10.7246222 1 1 1 + 91 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 7.2246222 0 8.99902654 1 1 1 + 92 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 8.62435565 0 7 1 1 1 + 93 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 9.65569669 0 4.78828201 1 1 1 + 94 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 10.2873085 0 2.43107449 1 1 1 + 95 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 10.5 0 8.57252759e-16 1 1 1 diff --git a/case/plume/plume_cylinder_sweep/tcd/tcf_1_argon.txt b/case/plume/plume_cylinder_sweep/tcd/tcf_1_argon.txt new file mode 100644 index 0000000..53e6e0e --- /dev/null +++ b/case/plume/plume_cylinder_sweep/tcd/tcf_1_argon.txt @@ -0,0 +1,6 @@ +1 +m +0.000000 0.000000 0.000000 +0.000000 0.000000 0.000000 +T1 ARG 0 0 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 +0 diff --git a/case/plume/plume_cylinder_sweep/tcd/tdf.csv b/case/plume/plume_cylinder_sweep/tcd/tdf.csv new file mode 100644 index 0000000..dc39035 --- /dev/null +++ b/case/plume/plume_cylinder_sweep/tcd/tdf.csv @@ -0,0 +1,2 @@ +#,name,prop,F,isp,MIB,m,mdot,ve,d,R,gamma,Te,rhoe,n +ARG,CAI2016,argon,1,1,1,1,0.001,577.0684534784414,1.0,208.13,1.6666666666666667,200,6.6329E-06,1.0E+20 diff --git a/case/plume/plume_flat_plate_sweep/config.ini b/case/plume/plume_flat_plate_sweep/config.ini new file mode 100644 index 0000000..60238f8 --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/config.ini @@ -0,0 +1,73 @@ +# Cai 2016 FLAT-plate SWEEP case (Aerospace 3(4):43, Section 4): a pure +# global-frame reframing of the paper geometry to alpha0 = 0 (flat 8 m x 8 m +# plate in the X-Y plane, center at the origin, argon round jet D = 1 m, +# S0 = 2.0, T0 = 200 K, Tw = 300 K, fully diffuse), swept over 19 approach +# angles x 5 stand-off distances (95 firings; jfh_flat_plate_sweep.A). The +# reframing is physics-invariant, so this reproduces the same coefficients as +# the sibling plume_inclined_plate_sweep but with strikes that read cleanly in +# ParaView (results/strikes/firing-.vtu). Driven by +# tests/rpod/rpod_verification_test_06.py. Geometry assets are generated by +# the scripts in stl/ and jfh/ (--alpha0-deg 0 --distance 0; see docstrings). + +# Visiting Vehicle for RPOD analysis +[vv] +stl_lm = cylinder_transformed.stl +stl_thruster = mold_funnel_transformed.stl + +# Target Vehicle for RPOD analysis +[tv] +stl = flat_plate_transformed.stl + +# surface wall temperature (Kelvin): Tw/T0 = 1.5 (paper Figs. 17-21) +surface_temp = 300 + +# proportion of diffuse particle reflections [0, 1] +sigma = 1 + +# check plume constraints? 0 or 1 +check_constraints = 0 + +# max heat flux integral (J/m^2) +heat_flux_load = inf +heat_flux_window_size = 1 + +# max heat flux rate (W/m^2) +heat_flux = inf + +# max pressure load +normal_pressure_load = inf +normal_pressure_window_size = inf + +# max normal pressure (N/m^2) +normal_pressure = inf + +# max shear pressure (N/m^2) +shear_pressure = inf + +# Plume kinetics and interactions models +[pm] +# Gas kinetics model +kinetics = Simplified + +# Gas-surface interaction model +surface_interaction = Maxwellian + +# Jet Firing History +[jfh] +jfh = jfh_flat_plate_sweep.A + +# Thruster configuration data. +[tcd] +# Thruster Configuration File - single head-on thruster, no cant. +tcf = tcf_1_argon.txt + +# Thruster Definition File - argon at the paper conditions. +tdf = tdf.csv + +# Parameters for scaling plume geometry. +[plume] +radius = 25 +# ~89 deg: the default 0.436 (25 deg) would clip the plate -- its lower +# corners sit ~70 deg off-axis at the paper distance. 1.55 rad keeps the +# gating wedge clear of the whole plate at every sweep pose. +wedge_theta = 1.55 diff --git a/case/plume/plume_flat_plate_sweep/jfh/generate_jfh_inclined_plate.py b/case/plume/plume_flat_plate_sweep/jfh/generate_jfh_inclined_plate.py new file mode 100644 index 0000000..249f71d --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/jfh/generate_jfh_inclined_plate.py @@ -0,0 +1,37 @@ +"""Generate the single-firing JFH for the Cai 2016 inclined-plate case. + +One firing at the paper geometry (Aerospace 2016, 3(4):43, Section 4): the +visiting vehicle sits at the origin with an identity DCM, so its single +thruster (exit at the VV origin, no cant) fires along +X toward the plate +centered at (4, 0, 0) m. Firing time 1.0 s makes heat_flux_load equal the +heat-flux rate. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_jfh_inclined_plate.py +""" + +from pathlib import Path + +OUT_NAME = 'jfh_plume_inclined_plate.A' + +DCM_IDENTITY = ('1.000000e+00 0.000000e+00 0.000000e+00 ' + '0.000000e+00 1.000000e+00 0.000000e+00 ' + '0.000000e+00 0.000000e+00 1.000000e+00') + + +def write_jfh(path, firings): + """firings: list of (dt, t, dcm_str, xyz, thrusters).""" + lines = [f'offseted {len(firings)} 0', ' 0.000 0.000 0.000'] + for n, (dt, t, dcm, xyz, thrusters) in enumerate(firings, start=1): + xyz_str = ' '.join(f'{v:.9g}' for v in xyz) + thr_str = ' '.join(str(i) for i in thrusters) + lines.append(f' {n} {dt:g} {t:g} 0 {dcm} {xyz_str} 1 ' + f'{len(thrusters)} {thr_str}') + with open(path, 'w', newline='\n') as fh: + fh.write('\n'.join(lines) + '\n') + + +if __name__ == '__main__': + out_path = Path(__file__).resolve().parent / OUT_NAME + write_jfh(out_path, [(0.0, 1.0, DCM_IDENTITY, (0.0, 0.0, 0.0), [1])]) + print(f'saved {out_path}') diff --git a/case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py b/case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py new file mode 100644 index 0000000..a8e574f --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py @@ -0,0 +1,101 @@ +"""Generate the ONE sweep JFH for the Phase-3 angle x distance study. + +The plate is STATIONARY (target vehicles do not move): the visiting vehicle +is swept instead. For each firing the VV (single thruster at the VV origin, +no cant) is placed on the arc of radius L about the plate center +C = (4, 0, 0), with the thruster axis aimed at C and the JFH DCM set so the +pipeline's plume normal (first column of the DCM, with the identity TCF) +points along that axis. + +Pose parametrization, all in the plate's tilt plane (global X-Z): + + d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * t_hat + VV position = C + L * d_hat(alpha), thruster axis = -d_hat(alpha) + +where n_hat = (-sin 60, 0, cos 60) is the plate normal facing the nozzle +side and t_hat = (cos 60, 0, sin 60) the plate's inclined tangent. +alpha = 0 is HEAD-ON (thruster axis along the plate normal; the paper's +alpha0 = 90 deg); the sign is the tilt direction, mapping +alpha_paper = 90 deg - |alpha|. Results must be mirror-symmetric in ++/-alpha (the plate is square); +/-90 deg is edge-on/degenerate and is +kept to confirm ~zero struck faces. + +Sweep: alpha from -90 to +90 deg in 10 deg steps (19 angles) x +L/D in {2, 4, 6, 8, 10} (D = 1 m), enumerated distance-major: +firing index = i_L * 19 + i_alpha + 1. 95 firings total, firing time +1.0 s each. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_sweep_jfh.py +""" + +from pathlib import Path + +import numpy as np + +from generate_jfh_inclined_plate import write_jfh + +OUT_NAME = 'jfh_inclined_plate_sweep.A' + +PLATE_CENTER = np.array([4.0, 0.0, 0.0]) +ALPHA0_DEG = 60.0 +ALPHAS_DEG = np.arange(-90.0, 90.0 + 1e-9, 10.0) +L_OVER_D = [2.0, 4.0, 6.0, 8.0, 10.0] + + +def pose_for(alpha_deg, L, plate_center=PLATE_CENTER, alpha0_deg=ALPHA0_DEG): + """(vv_position, dcm) for one sweep firing; dcm's first column is the + thruster axis (see the pipeline's plume-normal convention). + + plate_center / alpha0_deg place the whole {plate + swept-arc} rig in the + global frame. They are physics-invariant (every relative incidence, and + thus every coefficient, is preserved under this rigid rotation + + translation) -- a flat alpha0 = 0 variant is a pure visualization + reframing of the same case. They must match the target STL's own + center/tilt (stl/transform_inclined_plate.py) and the analysis constants + (tests/rpod/rpod_verification_test_06.py).""" + a0 = np.deg2rad(alpha0_deg) + n_hat = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + t_hat = np.array([np.cos(a0), 0.0, np.sin(a0)]) + alpha = np.deg2rad(alpha_deg) + d_hat = np.cos(alpha) * n_hat + np.sin(alpha) * t_hat + position = np.asarray(plate_center, dtype=float) + L * d_hat + axis = -d_hat # aimed at the plate center + # right-handed triad; the axis always lies in the X-Z plane, so the + # global Y axis is a valid second column + c1 = np.array([0.0, 1.0, 0.0]) + c2 = np.cross(axis, c1) + dcm = np.column_stack([axis, c1, c2]) + return position, dcm + + +def dcm_string(dcm): + return ' '.join(f'{v:.6e}' for v in np.asarray(dcm).ravel()) + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--alpha0-deg', type=float, default=ALPHA0_DEG, + help='global-frame plate tilt (deg); physics-' + 'invariant, must match the target STL') + parser.add_argument('--distance', type=float, default=PLATE_CENTER[0], + help='plate-center X in the global frame (m); the ' + 'center is (distance, 0, 0)') + parser.add_argument('--out', type=str, default=OUT_NAME, + help='output JFH filename in this jfh/ folder') + args = parser.parse_args() + + plate_center = np.array([args.distance, 0.0, 0.0]) + firings = [] + for L in L_OVER_D: + for alpha_deg in ALPHAS_DEG: + position, dcm = pose_for(alpha_deg, L, plate_center=plate_center, + alpha0_deg=args.alpha0_deg) + firings.append((0.0, 1.0, dcm_string(dcm), tuple(position), [1])) + out_path = Path(__file__).resolve().parent / args.out + write_jfh(out_path, firings) + print(f'saved {out_path} ({len(firings)} firings: ' + f'{len(ALPHAS_DEG)} angles x {len(L_OVER_D)} distances, ' + f'alpha0 = {args.alpha0_deg} deg, center X = {args.distance} m)') diff --git a/case/plume/plume_flat_plate_sweep/jfh/jfh_flat_plate_sweep.A b/case/plume/plume_flat_plate_sweep/jfh/jfh_flat_plate_sweep.A new file mode 100644 index 0000000..b32d99d --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/jfh/jfh_flat_plate_sweep.A @@ -0,0 +1,97 @@ +offseted 95 0 + 0.000 0.000 0.000 + 1 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -2 0 1.2246468e-16 1 1 1 + 2 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -1.96961551 0 0.347296355 1 1 1 + 3 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -1.87938524 0 0.684040287 1 1 1 + 4 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -1.73205081 0 1 1 1 1 + 5 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -1.53208889 0 1.28557522 1 1 1 + 6 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -1.28557522 0 1.53208889 1 1 1 + 7 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -1 0 1.73205081 1 1 1 + 8 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -0.684040287 0 1.87938524 1 1 1 + 9 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -0.347296355 0 1.96961551 1 1 1 + 10 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 0 0 2 1 1 1 + 11 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 0.347296355 0 1.96961551 1 1 1 + 12 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 0.684040287 0 1.87938524 1 1 1 + 13 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 1 0 1.73205081 1 1 1 + 14 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 1.28557522 0 1.53208889 1 1 1 + 15 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 1.53208889 0 1.28557522 1 1 1 + 16 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 1.73205081 0 1 1 1 1 + 17 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 1.87938524 0 0.684040287 1 1 1 + 18 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 1.96961551 0 0.347296355 1 1 1 + 19 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 2 0 1.2246468e-16 1 1 1 + 20 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -4 0 2.4492936e-16 1 1 1 + 21 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -3.93923101 0 0.694592711 1 1 1 + 22 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -3.75877048 0 1.36808057 1 1 1 + 23 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -3.46410162 0 2 1 1 1 + 24 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -3.06417777 0 2.57115044 1 1 1 + 25 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -2.57115044 0 3.06417777 1 1 1 + 26 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -2 0 3.46410162 1 1 1 + 27 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -1.36808057 0 3.75877048 1 1 1 + 28 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -0.694592711 0 3.93923101 1 1 1 + 29 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 0 0 4 1 1 1 + 30 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 0.694592711 0 3.93923101 1 1 1 + 31 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 1.36808057 0 3.75877048 1 1 1 + 32 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 2 0 3.46410162 1 1 1 + 33 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 2.57115044 0 3.06417777 1 1 1 + 34 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 3.06417777 0 2.57115044 1 1 1 + 35 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 3.46410162 0 2 1 1 1 + 36 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 3.75877048 0 1.36808057 1 1 1 + 37 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 3.93923101 0 0.694592711 1 1 1 + 38 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 4 0 2.4492936e-16 1 1 1 + 39 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -6 0 3.6739404e-16 1 1 1 + 40 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -5.90884652 0 1.04188907 1 1 1 + 41 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -5.63815572 0 2.05212086 1 1 1 + 42 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -5.19615242 0 3 1 1 1 + 43 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -4.59626666 0 3.85672566 1 1 1 + 44 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -3.85672566 0 4.59626666 1 1 1 + 45 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -3 0 5.19615242 1 1 1 + 46 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -2.05212086 0 5.63815572 1 1 1 + 47 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -1.04188907 0 5.90884652 1 1 1 + 48 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 0 0 6 1 1 1 + 49 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 1.04188907 0 5.90884652 1 1 1 + 50 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 2.05212086 0 5.63815572 1 1 1 + 51 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 3 0 5.19615242 1 1 1 + 52 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 3.85672566 0 4.59626666 1 1 1 + 53 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 4.59626666 0 3.85672566 1 1 1 + 54 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 5.19615242 0 3 1 1 1 + 55 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 5.63815572 0 2.05212086 1 1 1 + 56 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 5.90884652 0 1.04188907 1 1 1 + 57 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 6 0 3.6739404e-16 1 1 1 + 58 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -8 0 4.8985872e-16 1 1 1 + 59 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -7.87846202 0 1.38918542 1 1 1 + 60 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -7.51754097 0 2.73616115 1 1 1 + 61 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -6.92820323 0 4 1 1 1 + 62 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -6.12835554 0 5.14230088 1 1 1 + 63 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -5.14230088 0 6.12835554 1 1 1 + 64 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -4 0 6.92820323 1 1 1 + 65 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -2.73616115 0 7.51754097 1 1 1 + 66 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -1.38918542 0 7.87846202 1 1 1 + 67 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 0 0 8 1 1 1 + 68 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 1.38918542 0 7.87846202 1 1 1 + 69 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 2.73616115 0 7.51754097 1 1 1 + 70 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 4 0 6.92820323 1 1 1 + 71 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 5.14230088 0 6.12835554 1 1 1 + 72 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 6.12835554 0 5.14230088 1 1 1 + 73 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 6.92820323 0 4 1 1 1 + 74 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 7.51754097 0 2.73616115 1 1 1 + 75 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 7.87846202 0 1.38918542 1 1 1 + 76 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 8 0 4.8985872e-16 1 1 1 + 77 0 1 0 1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.123234e-17 0.000000e+00 1.000000e+00 -10 0 6.123234e-16 1 1 1 + 78 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -9.84807753 0 1.73648178 1 1 1 + 79 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -9.39692621 0 3.42020143 1 1 1 + 80 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -8.66025404 0 5 1 1 1 + 81 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -7.66044443 0 6.4278761 1 1 1 + 82 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -6.4278761 0 7.66044443 1 1 1 + 83 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -5 0 8.66025404 1 1 1 + 84 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 -3.42020143 0 9.39692621 1 1 1 + 85 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 -1.73648178 0 9.84807753 1 1 1 + 86 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 0 0 10 1 1 1 + 87 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 1.73648178 0 9.84807753 1 1 1 + 88 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 3.42020143 0 9.39692621 1 1 1 + 89 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 5 0 8.66025404 1 1 1 + 90 0 1 0 -6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -7.660444e-01 0.000000e+00 -6.427876e-01 6.4278761 0 7.66044443 1 1 1 + 91 0 1 0 -7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -6.427876e-01 0.000000e+00 -7.660444e-01 7.66044443 0 6.4278761 1 1 1 + 92 0 1 0 -8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -5.000000e-01 0.000000e+00 -8.660254e-01 8.66025404 0 5 1 1 1 + 93 0 1 0 -9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -3.420201e-01 0.000000e+00 -9.396926e-01 9.39692621 0 3.42020143 1 1 1 + 94 0 1 0 -9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -1.736482e-01 0.000000e+00 -9.848078e-01 9.84807753 0 1.73648178 1 1 1 + 95 0 1 0 -1.000000e+00 0.000000e+00 6.123234e-17 -0.000000e+00 1.000000e+00 0.000000e+00 -6.123234e-17 0.000000e+00 -1.000000e+00 10 0 6.123234e-16 1 1 1 diff --git a/case/plume/plume_flat_plate_sweep/stl/flat_plate_transformed.stl b/case/plume/plume_flat_plate_sweep/stl/flat_plate_transformed.stl new file mode 100644 index 0000000..fe62d0f Binary files /dev/null and b/case/plume/plume_flat_plate_sweep/stl/flat_plate_transformed.stl differ diff --git a/case/plume/plume_flat_plate_sweep/stl/transform_inclined_plate.py b/case/plume/plume_flat_plate_sweep/stl/transform_inclined_plate.py new file mode 100644 index 0000000..dc6d9be --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/stl/transform_inclined_plate.py @@ -0,0 +1,101 @@ +"""Generate the inclined-plate target mesh for the Cai 2016 verification case. + +Builds a single-sided, uniformly triangulated rectangular plate matching the +paper's Section-4 geometry (Aerospace 2016, 3(4):43): an 8 m x 8 m plate whose +center sits at (L, 0, 0) = (4, 0, 0) m from the nozzle exit, inclined by +alpha0 = 60 deg. The thruster fires along +X from a head-on JFH pose (VV at +the origin, identity DCM), so the global frame coincides with the paper's +nozzle frame and with pyrpod/plume/CaiImpingement2016.py: + + plate point(s, tau) = center + s * (0, 1, 0) + tau * (cos a0, 0, sin a0) + +Every face normal points toward the thruster, (-sin a0, 0, cos a0), which the +strike pipeline's facing test (surface_dot_plume < 0) requires; the script +asserts this before saving. + +Angle, distance, plate size, and mesh resolution are parametrized for the +Phase-3 sweep reuse. Defaults reproduce the paper case with 2 * 72^2 = 10368 +faces (~0.11 m elements): contour-quality resolution at a per-face kinetics +cost that keeps a single firing in the tens of seconds. + +Run from this directory: python transform_inclined_plate.py +""" + +import argparse +from pathlib import Path + +import numpy as np +from stl import mesh + + +def build_plate_mesh(alpha0_deg=60.0, center=(4.0, 0.0, 0.0), + half_width=4.0, half_height=4.0, n_div=72): + """Return a numpy-stl Mesh of the inclined plate. + + Parameters + ---------- + alpha0_deg : float + plate inclination angle alpha0 (deg); 90 = normal to the jet axis + center : sequence of float + plate center in the global/nozzle frame (m) + half_width : float + semi-width W0 along the horizontal s direction (m) + half_height : float + semi-length H0 along the inclined tau direction (m) + n_div : int + quad divisions per axis (faces = 2 * n_div^2) + """ + a0 = np.deg2rad(alpha0_deg) + center = np.asarray(center, dtype=float) + t_s = np.array([0.0, 1.0, 0.0]) + t_tau = np.array([np.cos(a0), 0.0, np.sin(a0)]) + normal = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + + s_edges = np.linspace(-half_width, half_width, n_div + 1) + tau_edges = np.linspace(-half_height, half_height, n_div + 1) + + def point(s, tau): + return center + s * t_s + tau * t_tau + + data = np.zeros(2 * n_div * n_div, dtype=mesh.Mesh.dtype) + k = 0 + for i in range(n_div): + for j in range(n_div): + p00 = point(s_edges[i], tau_edges[j]) + p10 = point(s_edges[i + 1], tau_edges[j]) + p01 = point(s_edges[i], tau_edges[j + 1]) + p11 = point(s_edges[i + 1], tau_edges[j + 1]) + # wound so cross(v1-v0, v2-v0) points along `normal` + data['vectors'][k] = np.array([p00, p01, p11]) + data['vectors'][k + 1] = np.array([p00, p11, p10]) + k += 2 + + plate = mesh.Mesh(data) + plate.update_normals() + unit_normals = plate.get_unit_normals() + assert np.allclose(unit_normals, normal, atol=1e-9), ( + 'face normals do not all point toward the thruster') + return plate + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--alpha0-deg', type=float, default=60.0) + parser.add_argument('--distance', type=float, default=4.0, + help='nozzle-to-plate-center distance L (m)') + parser.add_argument('--half-width', type=float, default=4.0) + parser.add_argument('--half-height', type=float, default=4.0) + parser.add_argument('--n-div', type=int, default=72) + parser.add_argument('--out', type=str, + default='inclined_plate_transformed.stl') + args = parser.parse_args() + + plate = build_plate_mesh(alpha0_deg=args.alpha0_deg, + center=(args.distance, 0.0, 0.0), + half_width=args.half_width, + half_height=args.half_height, + n_div=args.n_div) + out_path = Path(__file__).resolve().parent / args.out + plate.save(str(out_path)) + print(f'saved {out_path} ({len(plate.vectors)} faces, ' + f'alpha0 = {args.alpha0_deg} deg, L = {args.distance} m)') diff --git a/case/plume/plume_flat_plate_sweep/tcd/tcf_1_argon.txt b/case/plume/plume_flat_plate_sweep/tcd/tcf_1_argon.txt new file mode 100644 index 0000000..53e6e0e --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/tcd/tcf_1_argon.txt @@ -0,0 +1,6 @@ +1 +m +0.000000 0.000000 0.000000 +0.000000 0.000000 0.000000 +T1 ARG 0 0 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 +0 diff --git a/case/plume/plume_flat_plate_sweep/tcd/tdf.csv b/case/plume/plume_flat_plate_sweep/tcd/tdf.csv new file mode 100644 index 0000000..dc39035 --- /dev/null +++ b/case/plume/plume_flat_plate_sweep/tcd/tdf.csv @@ -0,0 +1,2 @@ +#,name,prop,F,isp,MIB,m,mdot,ve,d,R,gamma,Te,rhoe,n +ARG,CAI2016,argon,1,1,1,1,0.001,577.0684534784414,1.0,208.13,1.6666666666666667,200,6.6329E-06,1.0E+20 diff --git a/case/plume/plume_inclined_plate/config.ini b/case/plume/plume_inclined_plate/config.ini new file mode 100644 index 0000000..e8192e1 --- /dev/null +++ b/case/plume/plume_inclined_plate/config.ini @@ -0,0 +1,68 @@ +# Cai 2016 inclined-plate impingement verification case (Aerospace 3(4):43, +# Section 4): argon round jet, D = 1 m, S0 = 2.0 (ve = S0*sqrt(2*R*T0)), +# T0 = 200 K, plate 8 m x 8 m at L = 4D inclined alpha0 = 60 deg, Tw = 300 K, +# fully diffuse (sigma = 1). Geometry assets are generated by the scripts in +# stl/ and jfh/ (see their docstrings). + +# Visiting Vehicle for RPOD analysis +[vv] +stl_lm = cylinder_transformed.stl +stl_thruster = mold_funnel_transformed.stl + +# Target Vehicle for RPOD analysis +[tv] +stl = inclined_plate_transformed.stl + +# surface wall temperature (Kelvin): Tw/T0 = 1.5 (paper Figs. 17-21) +surface_temp = 300 + +# proportion of diffuse particle reflections [0, 1] +sigma = 1 + +# check plume constraints? 0 or 1 +check_constraints = 0 + +# max heat flux integral (J/m^2) +heat_flux_load = inf +heat_flux_window_size = 1 + +# max heat flux rate (W/m^2) +heat_flux = inf + +# max pressure load +normal_pressure_load = inf +normal_pressure_window_size = inf + +# max normal pressure (N/m^2) +normal_pressure = inf + +# max shear pressure (N/m^2) +shear_pressure = inf + +# Plume kinetics and interactions models +[pm] +# Gas kinetics model +kinetics = Simplified + +# Gas-surface interaction model +surface_interaction = Maxwellian + +# Jet Firing History +[jfh] +jfh = jfh_plume_inclined_plate.A + +# Thruster configuration data. +[tcd] +# Thruster Configuration File - single head-on thruster, no cant. +tcf = tcf_1_argon.txt + +# Thruster Definition File - argon at the paper conditions. +tdf = tdf.csv + +# Parameters for scaling plume geometry. +[plume] +radius = 25 +# ~89 deg: the default 0.436 (25 deg) would clip the plate -- its lower +# corners sit ~70 deg off-axis at the paper distance. 1.55 rad keeps the +# gating wedge clear of the whole plate at every sweep pose. +wedge_theta = 1.55 diff --git a/case/plume/plume_inclined_plate/jfh/generate_jfh_inclined_plate.py b/case/plume/plume_inclined_plate/jfh/generate_jfh_inclined_plate.py new file mode 100644 index 0000000..249f71d --- /dev/null +++ b/case/plume/plume_inclined_plate/jfh/generate_jfh_inclined_plate.py @@ -0,0 +1,37 @@ +"""Generate the single-firing JFH for the Cai 2016 inclined-plate case. + +One firing at the paper geometry (Aerospace 2016, 3(4):43, Section 4): the +visiting vehicle sits at the origin with an identity DCM, so its single +thruster (exit at the VV origin, no cant) fires along +X toward the plate +centered at (4, 0, 0) m. Firing time 1.0 s makes heat_flux_load equal the +heat-flux rate. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_jfh_inclined_plate.py +""" + +from pathlib import Path + +OUT_NAME = 'jfh_plume_inclined_plate.A' + +DCM_IDENTITY = ('1.000000e+00 0.000000e+00 0.000000e+00 ' + '0.000000e+00 1.000000e+00 0.000000e+00 ' + '0.000000e+00 0.000000e+00 1.000000e+00') + + +def write_jfh(path, firings): + """firings: list of (dt, t, dcm_str, xyz, thrusters).""" + lines = [f'offseted {len(firings)} 0', ' 0.000 0.000 0.000'] + for n, (dt, t, dcm, xyz, thrusters) in enumerate(firings, start=1): + xyz_str = ' '.join(f'{v:.9g}' for v in xyz) + thr_str = ' '.join(str(i) for i in thrusters) + lines.append(f' {n} {dt:g} {t:g} 0 {dcm} {xyz_str} 1 ' + f'{len(thrusters)} {thr_str}') + with open(path, 'w', newline='\n') as fh: + fh.write('\n'.join(lines) + '\n') + + +if __name__ == '__main__': + out_path = Path(__file__).resolve().parent / OUT_NAME + write_jfh(out_path, [(0.0, 1.0, DCM_IDENTITY, (0.0, 0.0, 0.0), [1])]) + print(f'saved {out_path}') diff --git a/case/plume/plume_inclined_plate/jfh/jfh_plume_inclined_plate.A b/case/plume/plume_inclined_plate/jfh/jfh_plume_inclined_plate.A new file mode 100644 index 0000000..610bf30 --- /dev/null +++ b/case/plume/plume_inclined_plate/jfh/jfh_plume_inclined_plate.A @@ -0,0 +1,3 @@ +offseted 1 0 + 0.000 0.000 0.000 + 1 0 1 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0 0 0 1 1 1 diff --git a/case/plume/plume_inclined_plate/stl/inclined_plate_transformed.stl b/case/plume/plume_inclined_plate/stl/inclined_plate_transformed.stl new file mode 100644 index 0000000..6debcc4 Binary files /dev/null and b/case/plume/plume_inclined_plate/stl/inclined_plate_transformed.stl differ diff --git a/case/plume/plume_inclined_plate/stl/transform_inclined_plate.py b/case/plume/plume_inclined_plate/stl/transform_inclined_plate.py new file mode 100644 index 0000000..dc6d9be --- /dev/null +++ b/case/plume/plume_inclined_plate/stl/transform_inclined_plate.py @@ -0,0 +1,101 @@ +"""Generate the inclined-plate target mesh for the Cai 2016 verification case. + +Builds a single-sided, uniformly triangulated rectangular plate matching the +paper's Section-4 geometry (Aerospace 2016, 3(4):43): an 8 m x 8 m plate whose +center sits at (L, 0, 0) = (4, 0, 0) m from the nozzle exit, inclined by +alpha0 = 60 deg. The thruster fires along +X from a head-on JFH pose (VV at +the origin, identity DCM), so the global frame coincides with the paper's +nozzle frame and with pyrpod/plume/CaiImpingement2016.py: + + plate point(s, tau) = center + s * (0, 1, 0) + tau * (cos a0, 0, sin a0) + +Every face normal points toward the thruster, (-sin a0, 0, cos a0), which the +strike pipeline's facing test (surface_dot_plume < 0) requires; the script +asserts this before saving. + +Angle, distance, plate size, and mesh resolution are parametrized for the +Phase-3 sweep reuse. Defaults reproduce the paper case with 2 * 72^2 = 10368 +faces (~0.11 m elements): contour-quality resolution at a per-face kinetics +cost that keeps a single firing in the tens of seconds. + +Run from this directory: python transform_inclined_plate.py +""" + +import argparse +from pathlib import Path + +import numpy as np +from stl import mesh + + +def build_plate_mesh(alpha0_deg=60.0, center=(4.0, 0.0, 0.0), + half_width=4.0, half_height=4.0, n_div=72): + """Return a numpy-stl Mesh of the inclined plate. + + Parameters + ---------- + alpha0_deg : float + plate inclination angle alpha0 (deg); 90 = normal to the jet axis + center : sequence of float + plate center in the global/nozzle frame (m) + half_width : float + semi-width W0 along the horizontal s direction (m) + half_height : float + semi-length H0 along the inclined tau direction (m) + n_div : int + quad divisions per axis (faces = 2 * n_div^2) + """ + a0 = np.deg2rad(alpha0_deg) + center = np.asarray(center, dtype=float) + t_s = np.array([0.0, 1.0, 0.0]) + t_tau = np.array([np.cos(a0), 0.0, np.sin(a0)]) + normal = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + + s_edges = np.linspace(-half_width, half_width, n_div + 1) + tau_edges = np.linspace(-half_height, half_height, n_div + 1) + + def point(s, tau): + return center + s * t_s + tau * t_tau + + data = np.zeros(2 * n_div * n_div, dtype=mesh.Mesh.dtype) + k = 0 + for i in range(n_div): + for j in range(n_div): + p00 = point(s_edges[i], tau_edges[j]) + p10 = point(s_edges[i + 1], tau_edges[j]) + p01 = point(s_edges[i], tau_edges[j + 1]) + p11 = point(s_edges[i + 1], tau_edges[j + 1]) + # wound so cross(v1-v0, v2-v0) points along `normal` + data['vectors'][k] = np.array([p00, p01, p11]) + data['vectors'][k + 1] = np.array([p00, p11, p10]) + k += 2 + + plate = mesh.Mesh(data) + plate.update_normals() + unit_normals = plate.get_unit_normals() + assert np.allclose(unit_normals, normal, atol=1e-9), ( + 'face normals do not all point toward the thruster') + return plate + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--alpha0-deg', type=float, default=60.0) + parser.add_argument('--distance', type=float, default=4.0, + help='nozzle-to-plate-center distance L (m)') + parser.add_argument('--half-width', type=float, default=4.0) + parser.add_argument('--half-height', type=float, default=4.0) + parser.add_argument('--n-div', type=int, default=72) + parser.add_argument('--out', type=str, + default='inclined_plate_transformed.stl') + args = parser.parse_args() + + plate = build_plate_mesh(alpha0_deg=args.alpha0_deg, + center=(args.distance, 0.0, 0.0), + half_width=args.half_width, + half_height=args.half_height, + n_div=args.n_div) + out_path = Path(__file__).resolve().parent / args.out + plate.save(str(out_path)) + print(f'saved {out_path} ({len(plate.vectors)} faces, ' + f'alpha0 = {args.alpha0_deg} deg, L = {args.distance} m)') diff --git a/case/plume/plume_inclined_plate/tcd/tcf_1_argon.txt b/case/plume/plume_inclined_plate/tcd/tcf_1_argon.txt new file mode 100644 index 0000000..53e6e0e --- /dev/null +++ b/case/plume/plume_inclined_plate/tcd/tcf_1_argon.txt @@ -0,0 +1,6 @@ +1 +m +0.000000 0.000000 0.000000 +0.000000 0.000000 0.000000 +T1 ARG 0 0 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 +0 diff --git a/case/plume/plume_inclined_plate/tcd/tdf.csv b/case/plume/plume_inclined_plate/tcd/tdf.csv new file mode 100644 index 0000000..dc39035 --- /dev/null +++ b/case/plume/plume_inclined_plate/tcd/tdf.csv @@ -0,0 +1,2 @@ +#,name,prop,F,isp,MIB,m,mdot,ve,d,R,gamma,Te,rhoe,n +ARG,CAI2016,argon,1,1,1,1,0.001,577.0684534784414,1.0,208.13,1.6666666666666667,200,6.6329E-06,1.0E+20 diff --git a/case/plume/plume_inclined_plate_sweep/config.ini b/case/plume/plume_inclined_plate_sweep/config.ini new file mode 100644 index 0000000..edd5020 --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/config.ini @@ -0,0 +1,72 @@ +# Cai 2016 inclined-plate SWEEP case (Aerospace 3(4):43, Section 4): the +# paper's 60 deg 8 m x 8 m plate (argon round jet, D = 1 m, S0 = 2.0, T0 = +# 200 K, Tw = 300 K, fully diffuse), swept over 19 approach angles x 5 +# stand-off distances (95 firings; jfh_inclined_plate_sweep.A). Driven by +# scripts/inclined_plate_sweep_study.py, which writes per-pose strikes to +# results/strikes/firing-.vtu. Geometry assets are generated by the +# scripts in stl/ and jfh/ (see their docstrings). The single paper pose +# lives in the sibling case plume_inclined_plate (rpod_integration_test_07); +# the flat-plate reframing in plume_flat_plate_sweep (rpod_verification_test_06). + +# Visiting Vehicle for RPOD analysis +[vv] +stl_lm = cylinder_transformed.stl +stl_thruster = mold_funnel_transformed.stl + +# Target Vehicle for RPOD analysis +[tv] +stl = inclined_plate_transformed.stl + +# surface wall temperature (Kelvin): Tw/T0 = 1.5 (paper Figs. 17-21) +surface_temp = 300 + +# proportion of diffuse particle reflections [0, 1] +sigma = 1 + +# check plume constraints? 0 or 1 +check_constraints = 0 + +# max heat flux integral (J/m^2) +heat_flux_load = inf +heat_flux_window_size = 1 + +# max heat flux rate (W/m^2) +heat_flux = inf + +# max pressure load +normal_pressure_load = inf +normal_pressure_window_size = inf + +# max normal pressure (N/m^2) +normal_pressure = inf + +# max shear pressure (N/m^2) +shear_pressure = inf + +# Plume kinetics and interactions models +[pm] +# Gas kinetics model +kinetics = Simplified + +# Gas-surface interaction model +surface_interaction = Maxwellian + +# Jet Firing History +[jfh] +jfh = jfh_inclined_plate_sweep.A + +# Thruster configuration data. +[tcd] +# Thruster Configuration File - single head-on thruster, no cant. +tcf = tcf_1_argon.txt + +# Thruster Definition File - argon at the paper conditions. +tdf = tdf.csv + +# Parameters for scaling plume geometry. +[plume] +radius = 25 +# ~89 deg: the default 0.436 (25 deg) would clip the plate -- its lower +# corners sit ~70 deg off-axis at the paper distance. 1.55 rad keeps the +# gating wedge clear of the whole plate at every sweep pose. +wedge_theta = 1.55 diff --git a/case/plume/plume_inclined_plate_sweep/jfh/generate_jfh_inclined_plate.py b/case/plume/plume_inclined_plate_sweep/jfh/generate_jfh_inclined_plate.py new file mode 100644 index 0000000..249f71d --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/jfh/generate_jfh_inclined_plate.py @@ -0,0 +1,37 @@ +"""Generate the single-firing JFH for the Cai 2016 inclined-plate case. + +One firing at the paper geometry (Aerospace 2016, 3(4):43, Section 4): the +visiting vehicle sits at the origin with an identity DCM, so its single +thruster (exit at the VV origin, no cant) fires along +X toward the plate +centered at (4, 0, 0) m. Firing time 1.0 s makes heat_flux_load equal the +heat-flux rate. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_jfh_inclined_plate.py +""" + +from pathlib import Path + +OUT_NAME = 'jfh_plume_inclined_plate.A' + +DCM_IDENTITY = ('1.000000e+00 0.000000e+00 0.000000e+00 ' + '0.000000e+00 1.000000e+00 0.000000e+00 ' + '0.000000e+00 0.000000e+00 1.000000e+00') + + +def write_jfh(path, firings): + """firings: list of (dt, t, dcm_str, xyz, thrusters).""" + lines = [f'offseted {len(firings)} 0', ' 0.000 0.000 0.000'] + for n, (dt, t, dcm, xyz, thrusters) in enumerate(firings, start=1): + xyz_str = ' '.join(f'{v:.9g}' for v in xyz) + thr_str = ' '.join(str(i) for i in thrusters) + lines.append(f' {n} {dt:g} {t:g} 0 {dcm} {xyz_str} 1 ' + f'{len(thrusters)} {thr_str}') + with open(path, 'w', newline='\n') as fh: + fh.write('\n'.join(lines) + '\n') + + +if __name__ == '__main__': + out_path = Path(__file__).resolve().parent / OUT_NAME + write_jfh(out_path, [(0.0, 1.0, DCM_IDENTITY, (0.0, 0.0, 0.0), [1])]) + print(f'saved {out_path}') diff --git a/case/plume/plume_inclined_plate_sweep/jfh/generate_sweep_jfh.py b/case/plume/plume_inclined_plate_sweep/jfh/generate_sweep_jfh.py new file mode 100644 index 0000000..a8e574f --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/jfh/generate_sweep_jfh.py @@ -0,0 +1,101 @@ +"""Generate the ONE sweep JFH for the Phase-3 angle x distance study. + +The plate is STATIONARY (target vehicles do not move): the visiting vehicle +is swept instead. For each firing the VV (single thruster at the VV origin, +no cant) is placed on the arc of radius L about the plate center +C = (4, 0, 0), with the thruster axis aimed at C and the JFH DCM set so the +pipeline's plume normal (first column of the DCM, with the identity TCF) +points along that axis. + +Pose parametrization, all in the plate's tilt plane (global X-Z): + + d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * t_hat + VV position = C + L * d_hat(alpha), thruster axis = -d_hat(alpha) + +where n_hat = (-sin 60, 0, cos 60) is the plate normal facing the nozzle +side and t_hat = (cos 60, 0, sin 60) the plate's inclined tangent. +alpha = 0 is HEAD-ON (thruster axis along the plate normal; the paper's +alpha0 = 90 deg); the sign is the tilt direction, mapping +alpha_paper = 90 deg - |alpha|. Results must be mirror-symmetric in ++/-alpha (the plate is square); +/-90 deg is edge-on/degenerate and is +kept to confirm ~zero struck faces. + +Sweep: alpha from -90 to +90 deg in 10 deg steps (19 angles) x +L/D in {2, 4, 6, 8, 10} (D = 1 m), enumerated distance-major: +firing index = i_L * 19 + i_alpha + 1. 95 firings total, firing time +1.0 s each. + +JFH files are always generated by script, never hand-edited. Run from this +directory: python generate_sweep_jfh.py +""" + +from pathlib import Path + +import numpy as np + +from generate_jfh_inclined_plate import write_jfh + +OUT_NAME = 'jfh_inclined_plate_sweep.A' + +PLATE_CENTER = np.array([4.0, 0.0, 0.0]) +ALPHA0_DEG = 60.0 +ALPHAS_DEG = np.arange(-90.0, 90.0 + 1e-9, 10.0) +L_OVER_D = [2.0, 4.0, 6.0, 8.0, 10.0] + + +def pose_for(alpha_deg, L, plate_center=PLATE_CENTER, alpha0_deg=ALPHA0_DEG): + """(vv_position, dcm) for one sweep firing; dcm's first column is the + thruster axis (see the pipeline's plume-normal convention). + + plate_center / alpha0_deg place the whole {plate + swept-arc} rig in the + global frame. They are physics-invariant (every relative incidence, and + thus every coefficient, is preserved under this rigid rotation + + translation) -- a flat alpha0 = 0 variant is a pure visualization + reframing of the same case. They must match the target STL's own + center/tilt (stl/transform_inclined_plate.py) and the analysis constants + (tests/rpod/rpod_verification_test_06.py).""" + a0 = np.deg2rad(alpha0_deg) + n_hat = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + t_hat = np.array([np.cos(a0), 0.0, np.sin(a0)]) + alpha = np.deg2rad(alpha_deg) + d_hat = np.cos(alpha) * n_hat + np.sin(alpha) * t_hat + position = np.asarray(plate_center, dtype=float) + L * d_hat + axis = -d_hat # aimed at the plate center + # right-handed triad; the axis always lies in the X-Z plane, so the + # global Y axis is a valid second column + c1 = np.array([0.0, 1.0, 0.0]) + c2 = np.cross(axis, c1) + dcm = np.column_stack([axis, c1, c2]) + return position, dcm + + +def dcm_string(dcm): + return ' '.join(f'{v:.6e}' for v in np.asarray(dcm).ravel()) + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--alpha0-deg', type=float, default=ALPHA0_DEG, + help='global-frame plate tilt (deg); physics-' + 'invariant, must match the target STL') + parser.add_argument('--distance', type=float, default=PLATE_CENTER[0], + help='plate-center X in the global frame (m); the ' + 'center is (distance, 0, 0)') + parser.add_argument('--out', type=str, default=OUT_NAME, + help='output JFH filename in this jfh/ folder') + args = parser.parse_args() + + plate_center = np.array([args.distance, 0.0, 0.0]) + firings = [] + for L in L_OVER_D: + for alpha_deg in ALPHAS_DEG: + position, dcm = pose_for(alpha_deg, L, plate_center=plate_center, + alpha0_deg=args.alpha0_deg) + firings.append((0.0, 1.0, dcm_string(dcm), tuple(position), [1])) + out_path = Path(__file__).resolve().parent / args.out + write_jfh(out_path, firings) + print(f'saved {out_path} ({len(firings)} firings: ' + f'{len(ALPHAS_DEG)} angles x {len(L_OVER_D)} distances, ' + f'alpha0 = {args.alpha0_deg} deg, center X = {args.distance} m)') diff --git a/case/plume/plume_inclined_plate_sweep/jfh/jfh_inclined_plate_sweep.A b/case/plume/plume_inclined_plate_sweep/jfh/jfh_inclined_plate_sweep.A new file mode 100644 index 0000000..3889cef --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/jfh/jfh_inclined_plate_sweep.A @@ -0,0 +1,97 @@ +offseted 95 0 + 0.000 0.000 0.000 + 1 0 1 0 5.000000e-01 0.000000e+00 -8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 8.660254e-01 0.000000e+00 5.000000e-01 3 0 -1.73205081 1 1 1 + 2 0 1 0 6.427876e-01 0.000000e+00 -7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 7.660444e-01 0.000000e+00 6.427876e-01 2.71442478 0 -1.53208889 1 1 1 + 3 0 1 0 7.660444e-01 0.000000e+00 -6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 6.427876e-01 0.000000e+00 7.660444e-01 2.46791111 0 -1.28557522 1 1 1 + 4 0 1 0 8.660254e-01 0.000000e+00 -5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 5.000000e-01 0.000000e+00 8.660254e-01 2.26794919 0 -1 1 1 1 + 5 0 1 0 9.396926e-01 0.000000e+00 -3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 3.420201e-01 0.000000e+00 9.396926e-01 2.12061476 0 -0.684040287 1 1 1 + 6 0 1 0 9.848078e-01 0.000000e+00 -1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 1.736482e-01 0.000000e+00 9.848078e-01 2.03038449 0 -0.347296355 1 1 1 + 7 0 1 0 1.000000e+00 0.000000e+00 2.220446e-16 -0.000000e+00 1.000000e+00 -0.000000e+00 -2.220446e-16 0.000000e+00 1.000000e+00 2 0 4.4408921e-16 1 1 1 + 8 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 2.03038449 0 0.347296355 1 1 1 + 9 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 2.12061476 0 0.684040287 1 1 1 + 10 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 2.26794919 0 1 1 1 1 + 11 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 2.46791111 0 1.28557522 1 1 1 + 12 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 2.71442478 0 1.53208889 1 1 1 + 13 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 3 0 1.73205081 1 1 1 + 14 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 3.31595971 0 1.87938524 1 1 1 + 15 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 3.65270364 0 1.96961551 1 1 1 + 16 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 4 0 2 1 1 1 + 17 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 4.34729636 0 1.96961551 1 1 1 + 18 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 4.68404029 0 1.87938524 1 1 1 + 19 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 5 0 1.73205081 1 1 1 + 20 0 1 0 5.000000e-01 0.000000e+00 -8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 8.660254e-01 0.000000e+00 5.000000e-01 2 0 -3.46410162 1 1 1 + 21 0 1 0 6.427876e-01 0.000000e+00 -7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 7.660444e-01 0.000000e+00 6.427876e-01 1.42884956 0 -3.06417777 1 1 1 + 22 0 1 0 7.660444e-01 0.000000e+00 -6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 6.427876e-01 0.000000e+00 7.660444e-01 0.935822228 0 -2.57115044 1 1 1 + 23 0 1 0 8.660254e-01 0.000000e+00 -5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 5.000000e-01 0.000000e+00 8.660254e-01 0.535898385 0 -2 1 1 1 + 24 0 1 0 9.396926e-01 0.000000e+00 -3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 3.420201e-01 0.000000e+00 9.396926e-01 0.241229517 0 -1.36808057 1 1 1 + 25 0 1 0 9.848078e-01 0.000000e+00 -1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 1.736482e-01 0.000000e+00 9.848078e-01 0.060768988 0 -0.694592711 1 1 1 + 26 0 1 0 1.000000e+00 0.000000e+00 2.220446e-16 -0.000000e+00 1.000000e+00 -0.000000e+00 -2.220446e-16 0.000000e+00 1.000000e+00 0 0 8.8817842e-16 1 1 1 + 27 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 0.060768988 0 0.694592711 1 1 1 + 28 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 0.241229517 0 1.36808057 1 1 1 + 29 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 0.535898385 0 2 1 1 1 + 30 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 0.935822228 0 2.57115044 1 1 1 + 31 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 1.42884956 0 3.06417777 1 1 1 + 32 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 2 0 3.46410162 1 1 1 + 33 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 2.63191943 0 3.75877048 1 1 1 + 34 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 3.30540729 0 3.93923101 1 1 1 + 35 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 4 0 4 1 1 1 + 36 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 4.69459271 0 3.93923101 1 1 1 + 37 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 5.36808057 0 3.75877048 1 1 1 + 38 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 6 0 3.46410162 1 1 1 + 39 0 1 0 5.000000e-01 0.000000e+00 -8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 8.660254e-01 0.000000e+00 5.000000e-01 1 0 -5.19615242 1 1 1 + 40 0 1 0 6.427876e-01 0.000000e+00 -7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 7.660444e-01 0.000000e+00 6.427876e-01 0.143274342 0 -4.59626666 1 1 1 + 41 0 1 0 7.660444e-01 0.000000e+00 -6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 6.427876e-01 0.000000e+00 7.660444e-01 -0.596266659 0 -3.85672566 1 1 1 + 42 0 1 0 8.660254e-01 0.000000e+00 -5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 5.000000e-01 0.000000e+00 8.660254e-01 -1.19615242 0 -3 1 1 1 + 43 0 1 0 9.396926e-01 0.000000e+00 -3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 3.420201e-01 0.000000e+00 9.396926e-01 -1.63815572 0 -2.05212086 1 1 1 + 44 0 1 0 9.848078e-01 0.000000e+00 -1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 1.736482e-01 0.000000e+00 9.848078e-01 -1.90884652 0 -1.04188907 1 1 1 + 45 0 1 0 1.000000e+00 0.000000e+00 2.220446e-16 -0.000000e+00 1.000000e+00 -0.000000e+00 -2.220446e-16 0.000000e+00 1.000000e+00 -2 0 1.33226763e-15 1 1 1 + 46 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -1.90884652 0 1.04188907 1 1 1 + 47 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -1.63815572 0 2.05212086 1 1 1 + 48 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -1.19615242 0 3 1 1 1 + 49 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -0.596266659 0 3.85672566 1 1 1 + 50 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 0.143274342 0 4.59626666 1 1 1 + 51 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 1 0 5.19615242 1 1 1 + 52 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 1.94787914 0 5.63815572 1 1 1 + 53 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 2.95811093 0 5.90884652 1 1 1 + 54 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 4 0 6 1 1 1 + 55 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 5.04188907 0 5.90884652 1 1 1 + 56 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 6.05212086 0 5.63815572 1 1 1 + 57 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 7 0 5.19615242 1 1 1 + 58 0 1 0 5.000000e-01 0.000000e+00 -8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 8.660254e-01 0.000000e+00 5.000000e-01 -8.8817842e-16 0 -6.92820323 1 1 1 + 59 0 1 0 6.427876e-01 0.000000e+00 -7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 7.660444e-01 0.000000e+00 6.427876e-01 -1.14230088 0 -6.12835554 1 1 1 + 60 0 1 0 7.660444e-01 0.000000e+00 -6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 6.427876e-01 0.000000e+00 7.660444e-01 -2.12835554 0 -5.14230088 1 1 1 + 61 0 1 0 8.660254e-01 0.000000e+00 -5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 5.000000e-01 0.000000e+00 8.660254e-01 -2.92820323 0 -4 1 1 1 + 62 0 1 0 9.396926e-01 0.000000e+00 -3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 3.420201e-01 0.000000e+00 9.396926e-01 -3.51754097 0 -2.73616115 1 1 1 + 63 0 1 0 9.848078e-01 0.000000e+00 -1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 1.736482e-01 0.000000e+00 9.848078e-01 -3.87846202 0 -1.38918542 1 1 1 + 64 0 1 0 1.000000e+00 0.000000e+00 2.220446e-16 -0.000000e+00 1.000000e+00 -0.000000e+00 -2.220446e-16 0.000000e+00 1.000000e+00 -4 0 1.77635684e-15 1 1 1 + 65 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -3.87846202 0 1.38918542 1 1 1 + 66 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -3.51754097 0 2.73616115 1 1 1 + 67 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -2.92820323 0 4 1 1 1 + 68 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -2.12835554 0 5.14230088 1 1 1 + 69 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -1.14230088 0 6.12835554 1 1 1 + 70 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 0 0 6.92820323 1 1 1 + 71 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 1.26383885 0 7.51754097 1 1 1 + 72 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 2.61081458 0 7.87846202 1 1 1 + 73 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 4 0 8 1 1 1 + 74 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 5.38918542 0 7.87846202 1 1 1 + 75 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 6.73616115 0 7.51754097 1 1 1 + 76 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 8 0 6.92820323 1 1 1 + 77 0 1 0 5.000000e-01 0.000000e+00 -8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 8.660254e-01 0.000000e+00 5.000000e-01 -1 0 -8.66025404 1 1 1 + 78 0 1 0 6.427876e-01 0.000000e+00 -7.660444e-01 -0.000000e+00 1.000000e+00 0.000000e+00 7.660444e-01 0.000000e+00 6.427876e-01 -2.4278761 0 -7.66044443 1 1 1 + 79 0 1 0 7.660444e-01 0.000000e+00 -6.427876e-01 -0.000000e+00 1.000000e+00 0.000000e+00 6.427876e-01 0.000000e+00 7.660444e-01 -3.66044443 0 -6.4278761 1 1 1 + 80 0 1 0 8.660254e-01 0.000000e+00 -5.000000e-01 -0.000000e+00 1.000000e+00 0.000000e+00 5.000000e-01 0.000000e+00 8.660254e-01 -4.66025404 0 -5 1 1 1 + 81 0 1 0 9.396926e-01 0.000000e+00 -3.420201e-01 -0.000000e+00 1.000000e+00 0.000000e+00 3.420201e-01 0.000000e+00 9.396926e-01 -5.39692621 0 -3.42020143 1 1 1 + 82 0 1 0 9.848078e-01 0.000000e+00 -1.736482e-01 -0.000000e+00 1.000000e+00 0.000000e+00 1.736482e-01 0.000000e+00 9.848078e-01 -5.84807753 0 -1.73648178 1 1 1 + 83 0 1 0 1.000000e+00 0.000000e+00 2.220446e-16 -0.000000e+00 1.000000e+00 -0.000000e+00 -2.220446e-16 0.000000e+00 1.000000e+00 -6 0 2.22044605e-15 1 1 1 + 84 0 1 0 9.848078e-01 0.000000e+00 1.736482e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -1.736482e-01 0.000000e+00 9.848078e-01 -5.84807753 0 1.73648178 1 1 1 + 85 0 1 0 9.396926e-01 0.000000e+00 3.420201e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -3.420201e-01 0.000000e+00 9.396926e-01 -5.39692621 0 3.42020143 1 1 1 + 86 0 1 0 8.660254e-01 0.000000e+00 5.000000e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -5.000000e-01 0.000000e+00 8.660254e-01 -4.66025404 0 5 1 1 1 + 87 0 1 0 7.660444e-01 0.000000e+00 6.427876e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -6.427876e-01 0.000000e+00 7.660444e-01 -3.66044443 0 6.4278761 1 1 1 + 88 0 1 0 6.427876e-01 0.000000e+00 7.660444e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -7.660444e-01 0.000000e+00 6.427876e-01 -2.4278761 0 7.66044443 1 1 1 + 89 0 1 0 5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -8.660254e-01 0.000000e+00 5.000000e-01 -1 0 8.66025404 1 1 1 + 90 0 1 0 3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.396926e-01 0.000000e+00 3.420201e-01 0.579798567 0 9.39692621 1 1 1 + 91 0 1 0 1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 -0.000000e+00 -9.848078e-01 0.000000e+00 1.736482e-01 2.26351822 0 9.84807753 1 1 1 + 92 0 1 0 -0.000000e+00 0.000000e+00 1.000000e+00 -0.000000e+00 1.000000e+00 0.000000e+00 -1.000000e+00 0.000000e+00 0.000000e+00 4 0 10 1 1 1 + 93 0 1 0 -1.736482e-01 0.000000e+00 9.848078e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.848078e-01 0.000000e+00 -1.736482e-01 5.73648178 0 9.84807753 1 1 1 + 94 0 1 0 -3.420201e-01 0.000000e+00 9.396926e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -9.396926e-01 0.000000e+00 -3.420201e-01 7.42020143 0 9.39692621 1 1 1 + 95 0 1 0 -5.000000e-01 0.000000e+00 8.660254e-01 -0.000000e+00 1.000000e+00 0.000000e+00 -8.660254e-01 0.000000e+00 -5.000000e-01 9 0 8.66025404 1 1 1 diff --git a/case/plume/plume_inclined_plate_sweep/stl/inclined_plate_transformed.stl b/case/plume/plume_inclined_plate_sweep/stl/inclined_plate_transformed.stl new file mode 100644 index 0000000..6debcc4 Binary files /dev/null and b/case/plume/plume_inclined_plate_sweep/stl/inclined_plate_transformed.stl differ diff --git a/case/plume/plume_inclined_plate_sweep/stl/transform_inclined_plate.py b/case/plume/plume_inclined_plate_sweep/stl/transform_inclined_plate.py new file mode 100644 index 0000000..dc6d9be --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/stl/transform_inclined_plate.py @@ -0,0 +1,101 @@ +"""Generate the inclined-plate target mesh for the Cai 2016 verification case. + +Builds a single-sided, uniformly triangulated rectangular plate matching the +paper's Section-4 geometry (Aerospace 2016, 3(4):43): an 8 m x 8 m plate whose +center sits at (L, 0, 0) = (4, 0, 0) m from the nozzle exit, inclined by +alpha0 = 60 deg. The thruster fires along +X from a head-on JFH pose (VV at +the origin, identity DCM), so the global frame coincides with the paper's +nozzle frame and with pyrpod/plume/CaiImpingement2016.py: + + plate point(s, tau) = center + s * (0, 1, 0) + tau * (cos a0, 0, sin a0) + +Every face normal points toward the thruster, (-sin a0, 0, cos a0), which the +strike pipeline's facing test (surface_dot_plume < 0) requires; the script +asserts this before saving. + +Angle, distance, plate size, and mesh resolution are parametrized for the +Phase-3 sweep reuse. Defaults reproduce the paper case with 2 * 72^2 = 10368 +faces (~0.11 m elements): contour-quality resolution at a per-face kinetics +cost that keeps a single firing in the tens of seconds. + +Run from this directory: python transform_inclined_plate.py +""" + +import argparse +from pathlib import Path + +import numpy as np +from stl import mesh + + +def build_plate_mesh(alpha0_deg=60.0, center=(4.0, 0.0, 0.0), + half_width=4.0, half_height=4.0, n_div=72): + """Return a numpy-stl Mesh of the inclined plate. + + Parameters + ---------- + alpha0_deg : float + plate inclination angle alpha0 (deg); 90 = normal to the jet axis + center : sequence of float + plate center in the global/nozzle frame (m) + half_width : float + semi-width W0 along the horizontal s direction (m) + half_height : float + semi-length H0 along the inclined tau direction (m) + n_div : int + quad divisions per axis (faces = 2 * n_div^2) + """ + a0 = np.deg2rad(alpha0_deg) + center = np.asarray(center, dtype=float) + t_s = np.array([0.0, 1.0, 0.0]) + t_tau = np.array([np.cos(a0), 0.0, np.sin(a0)]) + normal = np.array([-np.sin(a0), 0.0, np.cos(a0)]) + + s_edges = np.linspace(-half_width, half_width, n_div + 1) + tau_edges = np.linspace(-half_height, half_height, n_div + 1) + + def point(s, tau): + return center + s * t_s + tau * t_tau + + data = np.zeros(2 * n_div * n_div, dtype=mesh.Mesh.dtype) + k = 0 + for i in range(n_div): + for j in range(n_div): + p00 = point(s_edges[i], tau_edges[j]) + p10 = point(s_edges[i + 1], tau_edges[j]) + p01 = point(s_edges[i], tau_edges[j + 1]) + p11 = point(s_edges[i + 1], tau_edges[j + 1]) + # wound so cross(v1-v0, v2-v0) points along `normal` + data['vectors'][k] = np.array([p00, p01, p11]) + data['vectors'][k + 1] = np.array([p00, p11, p10]) + k += 2 + + plate = mesh.Mesh(data) + plate.update_normals() + unit_normals = plate.get_unit_normals() + assert np.allclose(unit_normals, normal, atol=1e-9), ( + 'face normals do not all point toward the thruster') + return plate + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--alpha0-deg', type=float, default=60.0) + parser.add_argument('--distance', type=float, default=4.0, + help='nozzle-to-plate-center distance L (m)') + parser.add_argument('--half-width', type=float, default=4.0) + parser.add_argument('--half-height', type=float, default=4.0) + parser.add_argument('--n-div', type=int, default=72) + parser.add_argument('--out', type=str, + default='inclined_plate_transformed.stl') + args = parser.parse_args() + + plate = build_plate_mesh(alpha0_deg=args.alpha0_deg, + center=(args.distance, 0.0, 0.0), + half_width=args.half_width, + half_height=args.half_height, + n_div=args.n_div) + out_path = Path(__file__).resolve().parent / args.out + plate.save(str(out_path)) + print(f'saved {out_path} ({len(plate.vectors)} faces, ' + f'alpha0 = {args.alpha0_deg} deg, L = {args.distance} m)') diff --git a/case/plume/plume_inclined_plate_sweep/tcd/tcf_1_argon.txt b/case/plume/plume_inclined_plate_sweep/tcd/tcf_1_argon.txt new file mode 100644 index 0000000..53e6e0e --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/tcd/tcf_1_argon.txt @@ -0,0 +1,6 @@ +1 +m +0.000000 0.000000 0.000000 +0.000000 0.000000 0.000000 +T1 ARG 0 0 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 +0 diff --git a/case/plume/plume_inclined_plate_sweep/tcd/tdf.csv b/case/plume/plume_inclined_plate_sweep/tcd/tdf.csv new file mode 100644 index 0000000..dc39035 --- /dev/null +++ b/case/plume/plume_inclined_plate_sweep/tcd/tdf.csv @@ -0,0 +1,2 @@ +#,name,prop,F,isp,MIB,m,mdot,ve,d,R,gamma,Te,rhoe,n +ARG,CAI2016,argon,1,1,1,1,0.001,577.0684534784414,1.0,208.13,1.6666666666666667,200,6.6329E-06,1.0E+20 diff --git a/pyrpod/plume/CaiImpingement2016.py b/pyrpod/plume/CaiImpingement2016.py new file mode 100644 index 0000000..3a6a6dc --- /dev/null +++ b/pyrpod/plume/CaiImpingement2016.py @@ -0,0 +1,1070 @@ +""" +Verification reference for Cai 2016 (inclined-plate plume impingement). + +Cai, C., "Gaskinetic Modeling on Dilute Gaseous Plume Impingement Flows," +Aerospace 2016, 3(4), 43, doi:10.3390/aerospace3040043. + +This module is a PLAIN-FUNCTION reference implementation of the paper's exact +solutions -- the single source of truth for every Cai-2016 verification +figure in tests/plume. It covers: + +* Section 4 surface properties: diffuse-plate Cp/Cf1/Cf2/Cq (Eqs. 9-13), + specular Cp (Eq. 14), Eq. 15 plate averages (Figs. 17-21); +* Section 3, the 2D slot jet on an inclined planar plate: surface + coefficients Cp,d/Cf,d/Cq,d and Cp,s (Eqs. 2-4, 8; Figs. 7-10) and the + combined jet + plate flowfield temperature (Figs. 5-6); +* Section 4 flowfield pressure in the vertical Y = 0 plane with the plate + contribution, diffuse and specular (Figs. 15-16). + +The free-jet field factors of the 3D solutions are imported from +pyrpod/plume/RarefiedPlumeGasKinetics.py (the Cai & Wang 2012 plume model +of record) rather than re-derived, so the two implementations can never +drift apart. This module exists to verify PyRPOD's plume modeling against +the paper -- it is NOT a new plume model class and must not be wired into +the strike pipeline. + +Section-3 validation geometry (interpretation note): this paper does not +print the 2D case's numbers (they come from its ref. [39], Cai & He 2016); +they are read off Figs. 1 and 5-10: slot height 2H, center-to-center +distance L = 4*(2H), plate semi-width W = 5*(2H), Tw/T0 = 1.5, with +(S0, alpha0) per figure legend. The plotted axes are X/(2H) and s/(2H). + +Flowfield figures (5-6, 15-16) evaluate the analytic solutions on the +whole X > 0 half-plane with no plate shadowing, exactly as the paper's +contours do (Fig. 6's specular temperature field is symmetric about the +plate line only under this convention); the diffuse plate emits from its +front face only, so behind-plate points carry the jet-only continuation. +Specular-plate effects use the paper's virtual-nozzle construction; the +virtual exit is built by literal mirror reflection of the real exit about +the plate plane, which reproduces Eq. 7 (2D) and the printed 3D virtual +position (L(1 - cos 2a0), 0, -L sin 2a0). The virtual DRIFT is the mirror +image U0*(cos 2a0, 0, sin 2a0); the paper's prose sign +(-U0 cos 2a0, ..., -U0 sin 2a0) is the velocity-space DOMAIN VERTEX -U0' +of Fig. 14, not the drift (same resolution style as the 2012 Eq. 7 typo +note in RarefiedPlumeGasKinetics). + +Geometry and symbol conventions (paper Fig. 11) +----------------------------------------------- +* Nozzle exit: disk of radius R_0 centered at the origin in the Y-Z plane; + bulk flow along +X with speed ratio S_0 = U_0 / sqrt(2*R*T_0). +* Plate: center at (L, 0, 0), inclination angle alpha_0. Local plate + coordinates (s, tau) map to global coordinates as + X = L + tau*cos(alpha_0), Y = s, Z = tau*sin(alpha_0), + i.e. tau runs along the inclined direction in the X-Z plane + (|tau| <= H_0) and s along the horizontal Y direction (|s| <= W_0). + The plate unit normal facing the nozzle is (-sin(alpha_0), 0, cos(alpha_0)); + alpha_0 = 90 deg is a plate normal to the jet axis. +* Exit-disk integration point: E = (0, r*cos(theta), r*sin(theta)), so that + Eq. 10 reads Q^2 = |P - E|^2 / X^2 >= 1 (NOTE: unlike the 2012 paper's + special factor Q_2012 = X^2/|P-E|^2 <= 1, the 2016 paper's Q is the + reciprocal square root: a = S_0/Q = S_0*sqrt(Q_2012), the speed ratio + projected on the ray from E to the plate point). +* B1 = (Z - r*sin(theta))/X, B2 = (Y - r*cos(theta))/X, and + mu = sin(alpha_0) - B1*cos(alpha_0) = -(ray direction . outward normal)*Q, + so mu > 0 exactly when the ray from E strikes the FRONT face of the plate. + +Overflow safety: every Appendix-A factor A_k(a) carries a e^(a^2) term while +the solutions carry e^(-S_0^2); since a = S_0/Q <= S_0, the two are combined +into e^(a^2 - S_0^2) <= 1 (the same scaling style used by get_K_factor in +RarefiedPlumeGasKinetics.py), so no term can overflow for large S_0. + +Diffuse-wall re-emission density n_w (interpretation note) +---------------------------------------------------------- +The paper states n_w(s, tau) is set by non-penetration at the plate but does +not print the 3D expression (its refs. [35, 37] give the 2D principle). The +3D analog is derived here from first principles: the incoming number flux +from the jet at plate point P, + + Phi_in = n_0 * e^(-S_0^2) / (pi^(3/2) * beta_0 * X^2) + * Int_disk (A_1/Q^4) * mu * r dr dtheta, + +must equal the effusion flux of the wall Maxwellian at T_w, +Phi_out = n_w * sqrt(R*T_w/(2*pi)) = n_w / (2*sqrt(pi)*beta_w), giving + + n_w/n_0 = (2/sqrt(eps)) * e^(-S_0^2)/(pi * X^2) + * Int_disk (A_1/Q^4) * mu * r dr dtheta, eps = T_w/T_0. + +This is exactly "the A_1-analog exit-disk integral": the same reduction that +produces Eq. 9 (pressure ~ A_2/Q^5 * mu^2) and Eq. 13 (energy flux ~ +A_3/Q^4 * mu) yields number flux ~ A_1/Q^4 * mu. Validation against the +paper's figures: with this n_w the center-region magnitudes reproduce +Figs. 17-18 (diffuse Cp peaks just above the 0.2 innermost contour and +specular Cp above 0.3, specular slightly higher at the impingement center, +as the paper notes), and the wall energy-emission term of Eq. 13, +-eps^(3/2) * (n_w/n_0) / (sqrt(pi)*S_0^3), is the effusion energy flux +(2*k*T_w per emitted molecule) of the SAME n_w -- an independent +consistency check between Eqs. 9 and 13. + +Front-face visibility clamp +--------------------------- +The printed Eqs. 9-13 integrate over the whole exit disk. For the paper's +validation geometry (alpha_0 = 60 deg, L = 4D) every disk point sees the +front face (mu > 0 everywhere), so clamping is a no-op there. For grazing +geometries (small alpha_0 and/or small L, reached in the sweep study) part +of the disk falls behind the plate plane; those rays cannot deposit flux on +the front face, so integrand contributions with mu <= 0 are dropped. This +extends the formulas continuously to the grazing limit instead of letting +unphysical negative-flux contributions enter. + +All returned coefficients use the paper's normalization: pressure and shear +by n_0*m*U_0^2/2, heat flux by n_0*m*U_0^3/2 (m = molecular mass); they are +dimensionless and independent of n_0. +""" + +import numpy as np +from scipy.special import erf + +from pyrpod.plume.RarefiedPlumeGasKinetics import ( + get_K_factor, + get_M_factor, + get_N_factor, + get_Q_full, +) + +#: Gauss-Legendre order per axis for the exit-disk integrals. The integrand +#: is analytic on the compact disk (denominators bounded below by X^2 > 0), +#: so convergence is geometric; 48 nodes reach ~1e-12 for every geometry in +#: the study (verified by the order-doubling test in run_sanity_checks). +DEFAULT_ORDER = 48 + + +def _scaled_A_factors(a, S_0): + ''' + Appendix-A factors A_1, A_2, A_3 evaluated at a and pre-multiplied + by the solutions' e^(-S_0^2) prefactor, combined overflow-safely + into e^(a^2 - S_0^2) <= 1 (a = S_0/Q <= S_0). + + Parameters + ---------- + a : ndarray + projected speed ratio S_0/Q along each disk-to-plate ray + S_0 : float + molecular speed ratio at the nozzle exit + + Returns + ------- + tuple of ndarray + (e^(-S_0^2)*A_1(a), e^(-S_0^2)*A_2(a), e^(-S_0^2)*A_3(a)) + ''' + erf_term = 0.25 * np.sqrt(np.pi) * (1 + erf(a)) * np.exp(a ** 2 - S_0 ** 2) + exp_term = np.exp(-S_0 ** 2) + A1 = erf_term * (3 * a + 2 * a ** 3) + exp_term * (0.5 + 0.5 * a ** 2) + A2 = (erf_term * (1.5 + 6 * a ** 2 + 2 * a ** 4) + + exp_term * (1.25 * a + 0.5 * a ** 3)) + A3 = (erf_term * (2 * a ** 5 + 10 * a ** 3 + 7.5 * a) + + exp_term * (0.5 * a ** 4 + 2.25 * a ** 2 + 1.0)) + return A1, A2, A3 + + +def plate_point_coords(s, tau, alpha_0, L): + ''' + Global coordinates of plate points from local plate coordinates + (see module docstring for the convention). + + Parameters + ---------- + s, tau : float or ndarray + local plate coordinates (m); broadcast together + alpha_0 : float + plate inclination angle (rad) + L : float + center-to-center nozzle-to-plate distance (m) + + Returns + ------- + tuple of ndarray + (X, Y, Z) global coordinates (m) + ''' + s = np.asarray(s, dtype=float) + tau = np.asarray(tau, dtype=float) + X = L + tau * np.cos(alpha_0) + Y = np.broadcast_to(s, np.broadcast(s, tau).shape).copy() + Z = tau * np.sin(alpha_0) + return X, Y, Z + + +def surface_coefficients(X, Y, Z, S_0, alpha_0, eps, R_0, + order=DEFAULT_ORDER, chunk=256): + ''' + Exact surface coefficients of Cai 2016 Eqs. 9-14 at global plate + points (X, Y, Z), vectorized with tensor-product Gauss-Legendre + quadrature over the exit disk (r in [0, R_0], theta in [0, 2*pi]). + + Points with X <= 0 are behind the exit plane: the collisionless jet + carries no molecules there, so every coefficient is 0. + + Parameters + ---------- + X, Y, Z : ndarray + global plate-point coordinates (m); flattened internally, + outputs keep the input shape + S_0 : float + exit speed ratio + alpha_0 : float + plate inclination angle (rad) + eps : float + wall-to-exit temperature ratio T_w/T_0 + R_0 : float + nozzle exit radius (m) + order : int + Gauss-Legendre nodes per axis (see DEFAULT_ORDER) + chunk : int + plate points evaluated per vectorized block (memory control) + + Returns + ------- + dict of ndarray + 'Cp_d' : diffuse-plate pressure coefficient [Eq. 9] + 'Cf1_d' : diffuse shear along the inclined direction [Eq. 11] + 'Cf2_d' : diffuse shear along the horizontal (Y) [Eq. 12] + 'Cq_d' : diffuse heat-flux coefficient [Eq. 13] + 'Cp_s' : specular-plate pressure coefficient [Eq. 14] + 'nw' : wall re-emission density ratio n_w/n_0 (see docstring) + ''' + X = np.asarray(X, dtype=float) + shape = X.shape + Xf = X.ravel() + Yf = np.asarray(Y, dtype=float).ravel() + Zf = np.asarray(Z, dtype=float).ravel() + + nodes, weights = np.polynomial.legendre.leggauss(order) + r = 0.5 * R_0 * (nodes + 1) + w_r = 0.5 * R_0 * weights + th = np.pi * (nodes + 1) + w_th = np.pi * weights + Rn, Tn = np.meshgrid(r, th, indexing='ij') + WR = np.outer(w_r, w_th) * Rn # quadrature weight * r (Jacobian) + Ey = Rn * np.cos(Tn) # exit-disk point (0, Ey, Ez) + Ez = Rn * np.sin(Tn) + + sin_a, cos_a = np.sin(alpha_0), np.cos(alpha_0) + sin_2a, cos_2a = np.sin(2 * alpha_0), np.cos(2 * alpha_0) + + n_pts = Xf.size + I_p = np.zeros(n_pts) + I_f1 = np.zeros(n_pts) + I_f2 = np.zeros(n_pts) + I_q = np.zeros(n_pts) + I_n = np.zeros(n_pts) + ahead = Xf > 0.0 + + idx_all = np.nonzero(ahead)[0] + for start in range(0, idx_all.size, chunk): + idx = idx_all[start:start + chunk] + Xc = Xf[idx][:, None, None] + Yc = Yf[idx][:, None, None] + Zc = Zf[idx][:, None, None] + + dy = Yc - Ey + dz = Zc - Ez + Q2 = (Xc ** 2 + dy ** 2 + dz ** 2) / Xc ** 2 # Eq. 10 + Q = np.sqrt(Q2) + a = S_0 / Q + B1 = dz / Xc + B2 = dy / Xc + mu = sin_a - B1 * cos_a + front = mu > 0.0 # front-face visibility clamp + mu_p = np.where(front, mu, 0.0) + + A1, A2, A3 = _scaled_A_factors(a, S_0) + inv_Q4 = 1.0 / (Q2 * Q2) + inv_Q5 = inv_Q4 / Q + + I_p[idx] = np.sum(WR * A2 * inv_Q5 * mu_p ** 2, axis=(1, 2)) + I_f1[idx] = np.sum( + WR * A2 * inv_Q5 + * (0.5 * (1 - B1 ** 2) * sin_2a - B1 * cos_2a) + * front, axis=(1, 2)) + I_f2[idx] = np.sum(WR * A2 * B2 * inv_Q5 * mu_p, axis=(1, 2)) + I_q[idx] = np.sum(WR * A3 * inv_Q4 * mu_p, axis=(1, 2)) + I_n[idx] = np.sum(WR * A1 * inv_Q4 * mu_p, axis=(1, 2)) + + with np.errstate(divide='ignore', invalid='ignore'): + pref = np.where(ahead, 1.0 / (np.pi ** 1.5 * Xf ** 2), 0.0) + pref_pi = np.where(ahead, 1.0 / (np.pi * Xf ** 2), 0.0) + + Cp_jet = (2.0 / S_0 ** 2) * pref * I_p + Cf1 = (2.0 / S_0 ** 2) * pref * I_f1 + Cf2 = (2.0 / S_0 ** 2) * pref * I_f2 + nw = (2.0 / np.sqrt(eps)) * pref_pi * I_n + Cq = (pref_pi * I_q - eps ** 1.5 * nw) / (np.sqrt(np.pi) * S_0 ** 3) + Cp_d = Cp_jet + eps / (2.0 * S_0 ** 2) * nw + Cp_s = 2.0 * Cp_jet + + return {'Cp_d': Cp_d.reshape(shape), 'Cf1_d': Cf1.reshape(shape), + 'Cf2_d': Cf2.reshape(shape), 'Cq_d': Cq.reshape(shape), + 'Cp_s': Cp_s.reshape(shape), 'nw': nw.reshape(shape)} + + +def surface_coefficients_plate(s, tau, S_0, alpha_0, eps, R_0, L, + order=DEFAULT_ORDER): + ''' + Convenience wrapper of surface_coefficients over local plate + coordinates (s, tau); see plate_point_coords for the mapping. + ''' + s = np.asarray(s, dtype=float) + tau = np.asarray(tau, dtype=float) + shape = np.broadcast(s, tau).shape + sB = np.broadcast_to(s, shape) + tB = np.broadcast_to(tau, shape) + X, Y, Z = plate_point_coords(sB, tB, alpha_0, L) + return surface_coefficients(X, Y, Z, S_0, alpha_0, eps, R_0, order=order) + + +def averaged_coefficients(S_0, alpha_0, eps, R_0, L, W_0, H_0, + n_gl=64, order=DEFAULT_ORDER): + ''' + Plate-averaged properties of Eq. 15 by Gauss-Legendre quadrature + over the plate: CP, CF1, CF2, CQ, the moment coefficient + CM = 1/(2*H_0*S) * Int tau*Cp,d ds dtau, and s_cc = CM/CP. + + Parameters + ---------- + S_0, alpha_0, eps, R_0, L : as in surface_coefficients + W_0 : float + plate semi-width along s (m) + H_0 : float + plate semi-length along the inclined direction tau (m) + n_gl : int + Gauss-Legendre nodes per plate axis + order : int + exit-disk quadrature order per axis + + Returns + ------- + dict of float + keys 'CP', 'CF1', 'CF2', 'CQ', 'CM', 's_cc' + ''' + nodes, weights = np.polynomial.legendre.leggauss(n_gl) + s = W_0 * nodes + w_s = W_0 * weights + tau = H_0 * nodes + w_tau = H_0 * weights + Sg, Tg = np.meshgrid(s, tau, indexing='ij') + W2D = np.outer(w_s, w_tau) + area = 4.0 * W_0 * H_0 + + c = surface_coefficients_plate(Sg, Tg, S_0, alpha_0, eps, R_0, L, + order=order) + CP = np.sum(W2D * c['Cp_d']) / area + CF1 = np.sum(W2D * c['Cf1_d']) / area + CF2 = np.sum(W2D * c['Cf2_d']) / area + CQ = np.sum(W2D * c['Cq_d']) / area + CM = np.sum(W2D * Tg * c['Cp_d']) / (2.0 * H_0 * area) + s_cc = CM / CP if CP != 0.0 else np.nan + return {'CP': CP, 'CF1': CF1, 'CF2': CF2, 'CQ': CQ, 'CM': CM, + 's_cc': s_cc} + + +# --------------------------------------------------------------------------- +# Section 3: 2D slot jet impinging on an inclined planar plate (Eqs. 1-8) +# --------------------------------------------------------------------------- + +def _scaled_planar_factors(a, S): + ''' + e^(-S^2)-scaled polar-velocity wedge moments e^(a^2) * I_k(a) of a + drifting Maxwellian in 2D, k = 1, 2, 3, where + I_k(a) = Int_0^inf t^k e^(-(t-a)^2) dt; k = 2 and 3 reproduce the + Appendix A_0 and A_1. Here a = S*cos(theta - drift angle) may be + NEGATIVE (rays opposed to the drift); a^2 <= S^2 keeps every + exponential bounded and (1 + erf(a)) -> 0 kills opposed rays. + + Returns (E1, A0, A1), each pre-multiplied by e^(-S^2). + ''' + erf_term = (1 + erf(a)) * np.exp(a ** 2 - S ** 2) + exp_term = np.exp(-S ** 2) + sq = np.sqrt(np.pi) + E1 = 0.5 * exp_term + 0.5 * sq * a * erf_term + A0 = 0.25 * sq * (1 + 2 * a ** 2) * erf_term + 0.5 * a * exp_term + A1 = (0.25 * sq * (3 * a + 2 * a ** 3) * erf_term + + (0.5 + 0.5 * a ** 2) * exp_term) + return E1, A0, A1 + + +def _scaled_G_factor(a, S): + ''' + e^(-S^2)-scaled energy-flux integrand of Eq. 4, + G(a) = (sqrt(pi)/2)(2 + 7a^2 + 2a^4) e^(a^2) [1 + erf(a)] + 3a + a^3. + Verified to equal 2 e^(a^2) (I_4 + I_2/2): the planar c^3 moment + plus the out-of-plane w^2 energy carried by the number flux. + ''' + erf_term = (1 + erf(a)) * np.exp(a ** 2 - S ** 2) + return (0.5 * np.sqrt(np.pi) * (2 + 7 * a ** 2 + 2 * a ** 4) * erf_term + + (3 * a + a ** 3) * np.exp(-S ** 2)) + + +def planar_plate_point_coords(s, alpha_0, L): + '''2D plate point (X, Y) = (L + s cos(alpha_0), s sin(alpha_0)).''' + s = np.asarray(s, dtype=float) + return L + s * np.cos(alpha_0), s * np.sin(alpha_0) + + +def planar_surface_coefficients(s, S_0, alpha_0, eps, H, L, + order=DEFAULT_ORDER): + ''' + Exact 2D surface coefficients of Cai 2016 Eqs. 2-4 and 8 at plate + positions s (distance from the plate center along the plate). + + The wedge subtended by the slot exit (x = 0, y in [-H, H]) at the + plate point (X, Y) spans theta in [atan2(Y - H, X), atan2(Y + H, X)] + (Eq. 1); a = S_0 cos(theta). The diffuse-wall density n_w(s) follows + from non-penetration with the A_0 number-flux integral, + n_w/n_0 = 2/sqrt(pi*eps) * e^(-S_0^2) * + Int A_0(a) sin(alpha_0 - theta) dtheta, + the 2D analog of the 3D A_1-integral (module docstring). Front-face + visibility clamps sin(alpha_0 - theta) <= 0 contributions, and + plate points behind the exit plane (X <= 0) carry zero load. + + Returns dict with 'Cp_d', 'Cf_d', 'Cq_d', 'Cp_s', 'nw'. + ''' + s = np.asarray(s, dtype=float) + shape = s.shape + X, Y = planar_plate_point_coords(s.ravel(), alpha_0, L) + ahead = X > 0.0 + + nodes, wts = np.polynomial.legendre.leggauss(order) + Xa, Ya = X[ahead][:, None], Y[ahead][:, None] + th1 = np.arctan2(Ya - H, Xa) + th2 = np.arctan2(Ya + H, Xa) + mid, half = 0.5 * (th1 + th2), 0.5 * (th2 - th1) + theta = mid + half * nodes[None, :] + w = half * wts[None, :] + + a = S_0 * np.cos(theta) + _, A0s, A1s = _scaled_planar_factors(a, S_0) + Gs = _scaled_G_factor(a, S_0) + mu = np.sin(alpha_0 - theta) + front = mu > 0.0 + mu_p = np.where(front, mu, 0.0) + + I_p = np.sum(w * A1s * mu_p ** 2, axis=1) + I_f = np.sum(w * A1s * np.sin(2 * alpha_0 - 2 * theta) * front, axis=1) + I_q = np.sum(w * Gs * mu_p, axis=1) + I_n = np.sum(w * A0s * mu_p, axis=1) + + def expand(vals): + full = np.zeros(X.size) + full[ahead] = vals + return full.reshape(shape) + + nw = expand(2.0 / np.sqrt(np.pi * eps) * I_n) + Cp_jet = expand(2.0 / (np.pi * S_0 ** 2) * I_p) + Cf_d = expand(1.0 / (np.pi * S_0 ** 2) * I_f) + Cq_d = (expand(I_q / (2.0 * np.pi * S_0 ** 3)) + - eps ** 1.5 * nw / (np.sqrt(np.pi) * S_0 ** 3)) + return {'Cp_d': Cp_jet + eps / (2.0 * S_0 ** 2) * nw, + 'Cf_d': Cf_d, 'Cq_d': Cq_d, 'Cp_s': 2.0 * Cp_jet, 'nw': nw} + + +def _mirror_about_plate_2d(p, alpha_0, L): + '''Mirror 2D point(s) about the plate line through (L, 0) at alpha_0.''' + n_hat = np.array([-np.sin(alpha_0), np.cos(alpha_0)]) + p = np.asarray(p, dtype=float) + d = (p[..., 0] - L) * n_hat[0] + p[..., 1] * n_hat[1] + return p - 2.0 * d[..., None] * n_hat + + +def planar_flowfield(X, Y, S_0, alpha_0, eps, H, L, W, plate='diffuse', + order=DEFAULT_ORDER, nw_grid=1024): + ''' + Combined 2D flowfield moments at points (X, Y) for the Section-3 + problem: the free slot jet plus either the diffuse-plate wall + emission (Fig. 5) or the specular virtual nozzle (Fig. 6); + plate=None gives the free jet alone (used by the sanity checks). + + Populations are combined by raw moments (number, momentum, energy + including the out-of-plane thermal energy at each population's own + temperature); T/T0 = (2/3) beta_0^2 (M2/n - V^2) + 0 (the 1/3 + out-of-plane share is inside M2). Diffuse emission integrates over + arrival directions theta with the wall density n_w(s(theta)) at the + ray-plate intersection, interpolated from a dense s grid, so finite + plate edges are handled naturally (rays missing the plate carry + nothing) and only front-face emission counts. The specular virtual + exit is the literal mirror of the real exit with mirrored drift + (module docstring). No shadowing is modeled (matching the paper's + contours); points with X <= 0 return NaN. + + Returns dict 'n' (n/n_0), 'Ux', 'Uy' (times sqrt(beta_0)), + 'T' (T/T_0). + ''' + X = np.asarray(X, dtype=float) + shape = X.shape + Xf = X.ravel() + Yf = np.asarray(Y, dtype=float).ravel() + valid = Xf > 0.0 + Xa, Ya = Xf[valid][:, None], Yf[valid][:, None] + + nodes, wts = np.polynomial.legendre.leggauss(order) + N = np.zeros(Xa.size) + MX = np.zeros(Xa.size) + MY = np.zeros(Xa.size) + M2 = np.zeros(Xa.size) + + def add_population(th_lo, span, drift_angle, S, beta_ratio, + n_ref=None): + '''Wedge population moments; n_ref = None means constant n_0.''' + nonlocal N, MX, MY, M2 + theta = th_lo + span * 0.5 * (nodes[None, :] + 1.0) + w = span * 0.5 * wts[None, :] + a = S * np.cos(theta - drift_angle) + E1, A0s, _A1s = _scaled_planar_factors(a, S) + dens = 1.0 if n_ref is None else n_ref + dN = np.sum(w * dens * E1, axis=1) / np.pi + N += dN + MX += np.sum(w * dens * A0s * np.cos(theta), axis=1) \ + / np.pi * beta_ratio + MY += np.sum(w * dens * A0s * np.sin(theta), axis=1) \ + / np.pi * beta_ratio + M2 += np.sum(w * dens * _A1s, axis=1) / np.pi * beta_ratio ** 2 \ + + dN * beta_ratio ** 2 / 2.0 + + # free jet: wedge subtended by the slot exit, drift along +x + th1 = np.arctan2(Ya - H, Xa) + th2 = np.arctan2(Ya + H, Xa) + add_population(th1, th2 - th1, 0.0, S_0, 1.0) + + if plate == 'diffuse': + s_grid = np.linspace(-W, W, nw_grid) + nw_vals = planar_surface_coefficients(s_grid, S_0, alpha_0, eps, + H, L, order=order)['nw'] + t_hat = np.array([np.cos(alpha_0), np.sin(alpha_0)]) + n_hat = np.array([-np.sin(alpha_0), np.cos(alpha_0)]) + ends = np.array([planar_plate_point_coords(sgn * W, alpha_0, L) + for sgn in (-1.0, 1.0)]) + ang = np.arctan2(Ya - ends[:, 1], Xa - ends[:, 0]) + span = np.mod(ang[:, 1:2] - ang[:, 0:1] + np.pi, 2 * np.pi) - np.pi + lo = np.where(span >= 0, ang[:, 0:1], ang[:, 1:2]) + span = np.abs(span) + theta = lo + span * 0.5 * (nodes[None, :] + 1.0) + w = span * 0.5 * wts[None, :] + ct, st = np.cos(theta), np.sin(theta) + # ray-plate intersection: P = plate(s) + u * theta_hat, u > 0 + denom = t_hat[0] * st - t_hat[1] * ct + rx, ry = Xa - L, Ya + with np.errstate(divide='ignore', invalid='ignore'): + s_hit = (rx * st - ry * ct) / denom + u_hit = (t_hat[0] * ry - t_hat[1] * rx) / denom + emitting = (u_hit > 0.0) & (ct * n_hat[0] + st * n_hat[1] > 0.0) + nw_at = np.interp(s_hit, s_grid, nw_vals, left=0.0, right=0.0) + nw_at = np.where(emitting & np.isfinite(s_hit), nw_at, 0.0) + # resting wall Maxwellian: E1 = 1/2, A0 = sqrt(pi)/4, A1 = 1/2 + sqe = np.sqrt(eps) + dN = np.sum(w * nw_at, axis=1) * 0.5 / np.pi + N += dN + MX += np.sum(w * nw_at * ct, axis=1) \ + * (np.sqrt(np.pi) / 4.0) / np.pi * sqe + MY += np.sum(w * nw_at * st, axis=1) \ + * (np.sqrt(np.pi) / 4.0) / np.pi * sqe + M2 += np.sum(w * nw_at, axis=1) * 0.5 / np.pi * eps \ + + dN * eps / 2.0 + elif plate == 'specular': + exit_pts = np.array([[0.0, -H], [0.0, H]]) + mirrored = _mirror_about_plate_2d(exit_pts, alpha_0, L) + ang = np.arctan2(Ya - mirrored[:, 1], Xa - mirrored[:, 0]) + span = np.mod(ang[:, 1:2] - ang[:, 0:1] + np.pi, 2 * np.pi) - np.pi + lo = np.where(span >= 0, ang[:, 0:1], ang[:, 1:2]) + add_population(lo, np.abs(span), 2.0 * alpha_0, S_0, 1.0) + elif plate is not None: + raise ValueError(f'unknown plate treatment {plate!r}') + + with np.errstate(divide='ignore', invalid='ignore'): + Ux = MX / N + Uy = MY / N + T = (2.0 / 3.0) * (M2 / N - Ux ** 2 - Uy ** 2) + + def expand(vals): + full = np.full(Xf.size, np.nan) + full[valid] = vals + return full.reshape(shape) + + return {'n': expand(N), 'Ux': expand(Ux), 'Uy': expand(Uy), + 'T': expand(T)} + + +# --------------------------------------------------------------------------- +# Section 4 flowfield pressure in the Y = 0 plane (Figs. 15-16) +# --------------------------------------------------------------------------- + +def _plate_emission_moments(Xa, Za, Px, Py, Pz, nw, wA, alpha_0, eps, + chunk=128): + ''' + Raw-moment contributions of the diffuse-wall emission at field + points (Xa, 0, Za): solid-angle integrals over plate nodes + (Px, Py, Pz) carrying weights wA and wall densities nw, emitting + half-space Maxwellians at T_w from the front face only + (cos(xi) > 0). Per solid angle: dn = n_w/(4 pi) domega, + d(nV) = n_w sqrt(eps)/(2 pi^(3/2)) domega along the ray, and + dM2 = 3 n_w eps/(8 pi) domega (the resting-Maxwellian I_2, I_3, + I_4 moments; full-sphere limits n_w and 3 n_w R T_w check out). + + Returns (dN, dMx, dMy, dMz, dM2) normalized like the jet moments. + ''' + n_hat = np.array([-np.sin(alpha_0), 0.0, np.cos(alpha_0)]) + npts = Xa.size + dN = np.zeros(npts) + dMx = np.zeros(npts) + dMy = np.zeros(npts) + dMz = np.zeros(npts) + dM2 = np.zeros(npts) + mom = np.sqrt(eps) / (2.0 * np.pi ** 1.5) + for start in range(0, npts, chunk): + sl = slice(start, min(start + chunk, npts)) + dx = Xa[sl][:, None] - Px[None, :] + dy = -Py[None, :] * np.ones((Xa[sl].size, 1)) + dz = Za[sl][:, None] - Pz[None, :] + d2 = dx ** 2 + dy ** 2 + dz ** 2 + d = np.sqrt(d2) + cos_xi = (dx * n_hat[0] + dy * n_hat[1] + dz * n_hat[2]) / d + cos_xi = np.clip(cos_xi, 0.0, None) # front-face emission only + dodd = nw[None, :] * cos_xi / d2 * wA[None, :] + dN[sl] = np.sum(dodd, axis=1) / (4.0 * np.pi) + dMx[sl] = mom * np.sum(dodd * dx / d, axis=1) + dMy[sl] = mom * np.sum(dodd * dy / d, axis=1) + dMz[sl] = mom * np.sum(dodd * dz / d, axis=1) + dM2[sl] = 3.0 * eps / (8.0 * np.pi) * np.sum(dodd, axis=1) + return dN, dMx, dMy, dMz, dM2 + + +def _jet_moments_3d(x_loc, rho_loc, S_0, R_0, order=DEFAULT_ORDER, + chunk=256): + ''' + Raw moments (n/n_0, U*sqrt(beta_0), W*sqrt(beta_0), + M2 = n beta_0^2 / n_0) of the free round jet at local + coordinates (x_loc downstream of the exit, rho_loc off-axis), + evaluated with the Cai & Wang 2012 exit-disk integrals via the + K/M/N factors imported from RarefiedPlumeGasKinetics (single + source of truth). Zero for x_loc <= 0 (no backflow in the model). + ''' + x_loc = np.asarray(x_loc, dtype=float).ravel() + rho_loc = np.asarray(rho_loc, dtype=float).ravel() + + nodes, weights = np.polynomial.legendre.leggauss(order) + r = 0.5 * R_0 * (nodes + 1) + w_r = 0.5 * R_0 * weights + epsn = 0.5 * np.pi * nodes + w_eps = 0.5 * np.pi * weights + Rn, En = np.meshgrid(r, epsn, indexing='ij') + W2D = np.outer(w_r, w_eps) + sinE = np.sin(En) + + n = np.zeros(x_loc.size) + U = np.zeros(x_loc.size) + W = np.zeros(x_loc.size) + M2 = np.zeros(x_loc.size) + idx_all = np.nonzero(x_loc > 0.0)[0] + for start in range(0, idx_all.size, chunk): + idx = idx_all[start:start + chunk] + Xc = x_loc[idx][:, None, None] + Zc = rho_loc[idx][:, None, None] + Q = get_Q_full(Rn, En, Xc, Zc) + Kf = get_K_factor(Q, S_0) + Mf = get_M_factor(Q, S_0) + Nf = get_N_factor(Q, S_0) + I_K = np.sum(W2D * Rn * Kf, axis=(1, 2)) + I_M = np.sum(W2D * Rn * Mf, axis=(1, 2)) + I_W = np.sum(W2D * (Zc - Rn * sinE) * Rn * Mf, axis=(1, 2)) + I_N = np.sum(W2D * Rn * Nf, axis=(1, 2)) + n[idx] = I_K / (np.pi ** 1.5 * x_loc[idx] ** 2) + U[idx] = I_M / I_K + W[idx] = I_W / (x_loc[idx] * I_K) + T = (-(2.0 / 3.0) * (U[idx] ** 2 + W[idx] ** 2) + + (4.0 / 3.0) * I_N / I_K) + M2[idx] = n[idx] * (1.5 * T + U[idx] ** 2 + W[idx] ** 2) + return n, U, W, M2 + + +def flowfield_pressure_plane(X, Z, S_0, alpha_0, eps, R_0, L, W_0, H_0, + plate='diffuse', order=DEFAULT_ORDER, + plate_order=48, chunk=128): + ''' + Static-pressure field p/p_0 = (n/n_0)(T/T_0) in the vertical Y = 0 + plane for the Section-4 3D impingement problem (Figs. 15-16), + normalized by the exit static pressure p_0 = n_0 k T_0. The local + temperature enters explicitly (the 2012 paper warns n*k*T_0 is + invalid), assembled from the raw moments of the populations: + + * the free round jet (2012 exit-disk integrals, see + _jet_moments_3d); + * plate='diffuse': the wall re-emission -- a solid-angle integral + over the plate of half-space Maxwellians with the Eq.-9 n_w(s, + tau) at the plate's own Gauss-Legendre nodes (front-face + emission only, cos(xi) > 0); + * plate='specular': the virtual nozzle mirrored about the plate + plane with mirrored drift (module docstring), evaluated with the + same jet integrals in the virtual frame; + * plate=None: free jet alone (sanity checks). + + No shadowing is modeled, matching the paper's contour convention; + X <= 0 returns NaN. + + Returns dict 'n', 'T', 'p' (all normalized), shaped like X. + ''' + X = np.asarray(X, dtype=float) + shape = X.shape + Xf = X.ravel() + Zf = np.asarray(Z, dtype=float).ravel() + valid = Xf > 0.0 + Xa, Za = Xf[valid], Zf[valid] + npts = Xa.size + + # free jet in the global frame (axis +x, exit at the origin) + n1, U1, W1, M21 = _jet_moments_3d(Xa, np.abs(Za), S_0, R_0, order=order) + N = n1.copy() + MXv = n1 * U1 + MYv = np.zeros(npts) + MZv = n1 * W1 * np.sign(Za) + M2 = M21.copy() + + if plate == 'diffuse': + nodes, wts = np.polynomial.legendre.leggauss(plate_order) + s_n = W_0 * nodes + w_s = W_0 * wts + t_n = H_0 * nodes + w_t = H_0 * wts + Sg, Tg = np.meshgrid(s_n, t_n, indexing='ij') + wA = np.outer(w_s, w_t).ravel() + Px, Py, Pz = plate_point_coords(Sg.ravel(), Tg.ravel(), alpha_0, L) + nw = surface_coefficients(Px, Py, Pz, S_0, alpha_0, eps, R_0, + order=order)['nw'] + dN, dMx, dMy, dMz, dM2 = _plate_emission_moments( + Xa, Za, Px, Py, Pz, nw, wA, alpha_0, eps, chunk=chunk) + N += dN + MXv += dMx + MYv += dMy + MZv += dMz + M2 += dM2 + elif plate == 'specular': + origin_v = np.array([L * (1.0 - np.cos(2 * alpha_0)), 0.0, + -L * np.sin(2 * alpha_0)]) + axis_v = np.array([np.cos(2 * alpha_0), 0.0, np.sin(2 * alpha_0)]) + rx = Xa - origin_v[0] + rz = Za - origin_v[2] + x_loc = rx * axis_v[0] + rz * axis_v[2] + px = rx - x_loc * axis_v[0] + pz = rz - x_loc * axis_v[2] + rho = np.hypot(px, pz) + n2, U2, W2, M22 = _jet_moments_3d(x_loc, rho, S_0, R_0, order=order) + safe = rho > 1e-12 + rhx = np.where(safe, px / np.where(safe, rho, 1.0), 0.0) + rhz = np.where(safe, pz / np.where(safe, rho, 1.0), 0.0) + N += n2 + MXv += n2 * (U2 * axis_v[0] + W2 * rhx) + MZv += n2 * (U2 * axis_v[2] + W2 * rhz) + M2 += M22 + elif plate is not None: + raise ValueError(f'unknown plate treatment {plate!r}') + + with np.errstate(divide='ignore', invalid='ignore'): + Vx = MXv / N + Vy = MYv / N + Vz = MZv / N + T = (2.0 / 3.0) * (M2 / N - Vx ** 2 - Vy ** 2 - Vz ** 2) + p = N * T + + def expand(vals): + full = np.full(Xf.size, np.nan) + full[valid] = vals + return full.reshape(shape) + + return {'n': expand(N), 'T': expand(T), 'p': expand(p)} + + +def run_sanity_checks(S_0=2.0, alpha_0=np.deg2rad(60.0), eps=1.5, + R_0=0.5, L=4.0, verbose=True): + ''' + Built-in verification of the implementation: + + 1. s-symmetry: Cp_d, Cf1_d, Cq_d even in s; Cf2_d odd in s + (Figs. 17-21 are left-right symmetric / antisymmetric). + 2. Cp_s = 2 x jet-only part of Cp_d (Eq. 14 vs Eq. 9), i.e. + Cp_s = 2*(Cp_d - eps/(2*S_0^2)*n_w/n_0). + 3. Wall-term flux balance: the incoming number-flux integral + recomputed independently with scipy.integrate.dblquad matches + the Gauss-Legendre n_w to ~1e-10 (validates both the quadrature + and the n_w reduction). + 4. Quadrature convergence: order 48 vs 96 agree to ~1e-12. + + Raises AssertionError on failure; returns a dict of the measured + deviations. + ''' + from scipy import integrate + + s = np.linspace(-3.5, 3.5, 15) + tau = np.linspace(-3.5, 3.5, 15) + Sg, Tg = np.meshgrid(s, tau, indexing='ij') + c = surface_coefficients_plate(Sg, Tg, S_0, alpha_0, eps, R_0, L) + c_mirror = surface_coefficients_plate(-Sg, Tg, S_0, alpha_0, eps, R_0, L) + + results = {} + for key, parity in [('Cp_d', 1), ('Cf1_d', 1), ('Cq_d', 1), + ('Cf2_d', -1)]: + dev = np.max(np.abs(c[key] - parity * c_mirror[key])) + scale = np.max(np.abs(c[key])) + results[f'symmetry_{key}'] = dev / scale + assert dev <= 1e-12 * scale, f'{key} s-parity violated: {dev}' + + dev = np.max(np.abs( + c['Cp_s'] - 2.0 * (c['Cp_d'] - eps / (2 * S_0 ** 2) * c['nw']))) + results['specular_vs_jet'] = dev + assert dev <= 1e-14, f'Cp_s != 2 x jet-only Cp_d: {dev}' + + # independent dblquad check of the n_w flux integral at spot points + sin_a, cos_a = np.sin(alpha_0), np.cos(alpha_0) + for s0, t0 in [(0.0, 0.0), (1.5, -2.0), (-2.5, 3.0)]: + X, Y, Z = plate_point_coords(s0, t0, alpha_0, L) + X, Y, Z = float(X), float(Y), float(Z) + + def flux_integrand(r, th): + dy = Y - r * np.cos(th) + dz = Z - r * np.sin(th) + Q = np.sqrt((X ** 2 + dy ** 2 + dz ** 2) / X ** 2) + a = S_0 / Q + A1, _, _ = _scaled_A_factors(np.asarray(a), S_0) + mu = sin_a - (dz / X) * cos_a + return float(A1) / Q ** 4 * max(mu, 0.0) * r + + I_n_ref, _ = integrate.dblquad( + flux_integrand, 0.0, 2 * np.pi, 0.0, R_0, + epsabs=1e-13, epsrel=1e-12) + nw_ref = (2.0 / np.sqrt(eps)) * I_n_ref / (np.pi * X ** 2) + nw_gl = float(surface_coefficients( + np.array([X]), np.array([Y]), np.array([Z]), + S_0, alpha_0, eps, R_0)['nw'][0]) + dev = abs(nw_gl - nw_ref) / nw_ref + results[f'nw_dblquad_s{s0}_tau{t0}'] = dev + assert dev <= 1e-9, f'n_w flux balance failed at ({s0},{t0}): {dev}' + + c_fine = surface_coefficients_plate(Sg, Tg, S_0, alpha_0, eps, R_0, L, + order=2 * DEFAULT_ORDER) + for key in ('Cp_d', 'Cf1_d', 'Cf2_d', 'Cq_d'): + scale = np.max(np.abs(c_fine[key])) + dev = np.max(np.abs(c[key] - c_fine[key])) / scale + results[f'convergence_{key}'] = dev + assert dev <= 1e-10, f'quadrature not converged for {key}: {dev}' + + if verbose: + for name, value in results.items(): + print(f' {name}: {value:.3e}') + return results + + +def run_planar_sanity_checks(S_0=2.0, alpha_0=np.deg2rad(60.0), eps=1.5, + H=0.5, L=4.0, W=5.0, verbose=True): + ''' + Verification of the Section-3 (2D planar) implementation: + + 1. alpha_0 = 90 deg parity: Cp,d/Cq,d/n_w even and Cf,d odd in s + (the normal-plate configuration is mirror-symmetric). + 2. Cp,s = 2 x jet-only part of Cp,d (Eq. 8 vs Eq. 2). + 3. n_w flux balance against an independent scipy.integrate.quad + evaluation of the A_0 number-flux integral. + 4. Diffuse half-space closure: just in front of the plate center + the wall-emission population fills a half-space, so its density + tends to n_w(0)/2 (checked via flowfield(diffuse) - flowfield + (jet only)). + 5. Specular mirror symmetry: the virtual-nozzle field satisfies + T(P) = T(P') and n(P) = n(P') for P' mirrored about the plate + line -- the defining symmetry of Fig. 6. + + Raises AssertionError on failure; returns measured deviations. + ''' + from scipy import integrate + + results = {} + s = np.linspace(-4.0, 4.0, 17) + c90 = planar_surface_coefficients(s, S_0, np.pi / 2, eps, H, L) + for key, parity in [('Cp_d', 1), ('Cq_d', 1), ('nw', 1), ('Cf_d', -1)]: + dev = np.max(np.abs(c90[key] - parity * c90[key][::-1])) + scale = np.max(np.abs(c90[key])) + results[f'planar_parity_{key}'] = dev / scale + assert dev <= 1e-12 * scale, f'2D {key} parity violated: {dev}' + + c = planar_surface_coefficients(s, S_0, alpha_0, eps, H, L) + dev = np.max(np.abs( + c['Cp_s'] - 2.0 * (c['Cp_d'] - eps / (2 * S_0 ** 2) * c['nw']))) + results['planar_specular_vs_jet'] = dev + assert dev <= 1e-14, f'2D Cp_s != 2 x jet-only Cp_d: {dev}' + + for s0 in (0.0, -2.0, 3.0): + X, Y = planar_plate_point_coords(s0, alpha_0, L) + + def flux_integrand(theta): + a = S_0 * np.cos(theta) + _, A0s, _ = _scaled_planar_factors(np.asarray(a), S_0) + return float(A0s) * max(np.sin(alpha_0 - theta), 0.0) + + I_ref, _ = integrate.quad(flux_integrand, + np.arctan2(Y - H, X), + np.arctan2(Y + H, X), + epsabs=1e-13, epsrel=1e-12) + nw_ref = 2.0 / np.sqrt(np.pi * eps) * I_ref + nw_gl = float(planar_surface_coefficients( + np.array([s0]), S_0, alpha_0, eps, H, L)['nw'][0]) + dev = abs(nw_gl - nw_ref) / nw_ref + results[f'planar_nw_quad_s{s0}'] = dev + assert dev <= 1e-9, f'2D n_w flux balance failed at s={s0}: {dev}' + + h = 1e-3 + n_hat = np.array([-np.sin(alpha_0), np.cos(alpha_0)]) + P = np.array([L, 0.0]) + h * n_hat + both = planar_flowfield(P[0:1], P[1:2], S_0, alpha_0, eps, H, L, W, + plate='diffuse') + jet = planar_flowfield(P[0:1], P[1:2], S_0, alpha_0, eps, H, L, W, + plate=None) + nw0 = float(planar_surface_coefficients( + np.array([0.0]), S_0, alpha_0, eps, H, L)['nw'][0]) + emission = float(both['n'][0] - jet['n'][0]) + dev = abs(emission - nw0 / 2.0) / (nw0 / 2.0) + results['planar_halfspace_closure'] = dev + assert dev <= 1e-2, f'2D wall-emission half-space closure: {dev}' + + pts = np.array([[1.0, 0.5], [2.5, -1.0], [3.0, 2.0], [1.5, -2.5]]) + mirrored = _mirror_about_plate_2d(pts, alpha_0, L) + f1 = planar_flowfield(pts[:, 0], pts[:, 1], S_0, alpha_0, eps, H, L, + W, plate='specular') + f2 = planar_flowfield(mirrored[:, 0], mirrored[:, 1], S_0, alpha_0, + eps, H, L, W, plate='specular') + for key in ('n', 'T'): + dev = np.max(np.abs(f1[key] - f2[key]) / np.abs(f1[key])) + results[f'planar_specular_mirror_{key}'] = dev + assert dev <= 1e-9, f'2D specular mirror symmetry ({key}): {dev}' + + if verbose: + for name, value in results.items(): + print(f' {name}: {value:.3e}') + return results + + +def run_flowfield3d_sanity_checks(S_0=2.0, alpha_0=np.deg2rad(60.0), + eps=1.5, R_0=0.5, L=4.0, W_0=4.0, + H_0=4.0, verbose=True): + ''' + Verification of the Section-4 flowfield-pressure implementation + (Figs. 15-16): + + 1. Solid-angle sum rule for the wall-emission kernel: a uniformly + emitting square plate (half-side a, on-axis height h) subtends + the closed-form solid angle 4*atan(a^2/(h*sqrt(2a^2 + h^2))), + so the emission density must equal n_w * Omega/(4 pi) exactly + (-> n_w/2 in the half-space limit). Validates the kernel + constant and geometry independent of n_w. + 2. GL-vs-dblquad of the real n_w-weighted emission-density + integral at a field point (validates the assembly with the + Eq.-9 wall density). + 3. Specular mirror symmetry in the Y = 0 plane: n, T, p match at + points mirrored about the plate line (the virtual-nozzle + construction's defining property). + + Raises AssertionError on failure; returns measured deviations. + ''' + from scipy import integrate + + results = {} + kw = dict(S_0=S_0, alpha_0=alpha_0, eps=eps, R_0=R_0, L=L, + W_0=W_0, H_0=H_0) + + # 1. uniform-emitter sum rule (synthetic plate, alpha_0 = 90 deg so + # the plate normal is -x and "on-axis height" is along x) + a_test, h_test, n_gl = 40.0, 5.0, 96 + nodes, wts = np.polynomial.legendre.leggauss(n_gl) + Sg, Tg = np.meshgrid(a_test * nodes, a_test * nodes, indexing='ij') + wA = np.outer(a_test * wts, a_test * wts).ravel() + Px, Py, Pz = plate_point_coords(Sg.ravel(), Tg.ravel(), np.pi / 2, 0.0) + dN, _, _, _, _ = _plate_emission_moments( + np.array([-h_test]), np.array([0.0]), Px, Py, Pz, + np.ones(Px.size), wA, np.pi / 2, eps) + omega = 4.0 * np.arctan(a_test ** 2 / ( + h_test * np.sqrt(2 * a_test ** 2 + h_test ** 2))) + dev = abs(float(dN[0]) - omega / (4 * np.pi)) / (omega / (4 * np.pi)) + results['3d_solid_angle_sum_rule'] = dev + assert dev <= 1e-6, f'3D emission solid-angle sum rule: {dev}' + + # 2. GL vs dblquad with the real Eq.-9 n_w at a resolvable standoff + n_hat2 = np.array([-np.sin(alpha_0), np.cos(alpha_0)]) + P = np.array([L, 0.0]) + 1.0 * n_hat2 + np.array([0.3, 0.0]) + both = flowfield_pressure_plane(P[0:1], P[1:2], plate='diffuse', **kw) + jet = flowfield_pressure_plane(P[0:1], P[1:2], plate=None, **kw) + emission_gl = float(both['n'][0] - jet['n'][0]) + n_hat3 = np.array([-np.sin(alpha_0), 0.0, np.cos(alpha_0)]) + + def integrand(tau, s): + px, py, pz = plate_point_coords(s, tau, alpha_0, L) + nw = float(surface_coefficients( + np.array([px]), np.array([py]), np.array([pz]), + S_0, alpha_0, eps, R_0)['nw'][0]) + dvec = np.array([P[0] - px, -py, P[1] - pz]) + d2 = float(dvec @ dvec) + cos_xi = max(float(dvec @ n_hat3) / np.sqrt(d2), 0.0) + return nw * cos_xi / d2 / (4.0 * np.pi) + + emission_ref, _ = integrate.dblquad(integrand, -W_0, W_0, -H_0, H_0, + epsabs=1e-10, epsrel=1e-8) + dev = abs(emission_gl - emission_ref) / emission_ref + results['3d_emission_gl_vs_dblquad'] = dev + assert dev <= 1e-6, f'3D emission GL vs dblquad: {dev}' + + pts = np.array([[1.0, 0.5], [2.5, -1.0], [3.0, 2.0], [1.5, -2.5]]) + mirrored = _mirror_about_plate_2d(pts, alpha_0, L) + f1 = flowfield_pressure_plane(pts[:, 0], pts[:, 1], + plate='specular', **kw) + f2 = flowfield_pressure_plane(mirrored[:, 0], mirrored[:, 1], + plate='specular', **kw) + for key in ('n', 'T', 'p'): + dev = np.max(np.abs(f1[key] - f2[key]) / np.abs(f1[key])) + results[f'3d_specular_mirror_{key}'] = dev + assert dev <= 1e-9, f'3D specular mirror symmetry ({key}): {dev}' + + if verbose: + for name, value in results.items(): + print(f' {name}: {value:.3e}') + return results + + +if __name__ == '__main__': + print('Cai 2016 reference implementation sanity checks ' + '(S0=2, alpha0=60 deg, eps=1.5, L=4D):') + run_sanity_checks() + print('Section-3 (2D planar) checks:') + run_planar_sanity_checks() + print('Section-4 flowfield-pressure checks:') + run_flowfield3d_sanity_checks() + print('all checks passed') + avg = averaged_coefficients(2.0, np.deg2rad(60.0), 1.5, 0.5, 4.0, + 4.0, 4.0) + print('Eq. 15 averaged coefficients at the paper conditions:') + for key, value in avg.items(): + print(f' {key} = {value:.6g}') + + # paper-figure anchors (visual validation targets) + s = np.linspace(-5.0, 5.0, 401) + c2d = planar_surface_coefficients(s, 2.0, np.deg2rad(60.0), 1.5, + 0.5, 4.0) + print('2D anchors (S0=2, 60 deg): ' + f"peak Cp_d {c2d['Cp_d'].max():.3f} (Fig. 7 ~0.93), " + f"peak Cp_s {c2d['Cp_s'].max():.3f} (Fig. 8 ~1.23), " + f"peak Cf_d {c2d['Cf_d'].max():.3f} (Fig. 9 ~0.33), " + f"peak Cq_d {c2d['Cq_d'].max():.3f} (Fig. 10 ~0.27)") + sign_change = s[np.nonzero(np.diff(np.sign(c2d['Cf_d'])))[0]] + print(f' Cf_d zero crossings at s/(2H) = {np.round(sign_change, 2)} ' + '(Fig. 9 separation point ~ -2)') + x = np.linspace(0.2, 7.0, 60) + y = np.linspace(-3.5, 3.5, 50) + Xg, Yg = np.meshgrid(x, y) + Td = planar_flowfield(Xg, Yg, 2.0, np.deg2rad(60.0), 1.5, 0.5, 4.0, + 5.0, plate='diffuse')['T'] + Ts = planar_flowfield(Xg, Yg, 2.0, np.deg2rad(60.0), 1.5, 0.5, 4.0, + 5.0, plate='specular')['T'] + print(f'2D flowfield peaks: diffuse T/T0 {np.nanmax(Td):.2f} ' + f'(Fig. 5 top contour 2.4), specular {np.nanmax(Ts):.2f} ' + '(Fig. 6 top contour 3.5)') + pd = flowfield_pressure_plane(Xg, Yg, 2.0, np.deg2rad(60.0), 1.5, + 0.5, 4.0, 4.0, 4.0, plate='diffuse')['p'] + ps = flowfield_pressure_plane(Xg, Yg, 2.0, np.deg2rad(60.0), 1.5, + 0.5, 4.0, 4.0, 4.0, + plate='specular')['p'] + # near the exit p/p0 -> 1 for any plate; the figure anchor is the + # impingement-center accumulation region (X > 2.5) + imp = Xg > 2.5 + print('3D flowfield impingement-region peaks: diffuse p/p0 ' + f'{np.nanmax(pd[imp]):.2f} (Fig. 15 top contour 0.5), specular ' + f'{np.nanmax(ps[imp]):.2f} (Fig. 16 top contour 0.4)') diff --git a/pyrpod/plume/PlumeStrikeCalculator.py b/pyrpod/plume/PlumeStrikeCalculator.py index 755f8c9..4ac9463 100644 --- a/pyrpod/plume/PlumeStrikeCalculator.py +++ b/pyrpod/plume/PlumeStrikeCalculator.py @@ -11,9 +11,19 @@ Implementation notes: - compute_plume_strikes() runs a NumPy-vectorized strike-detection path by default. _compute_plume_strikes_scalar() preserves the original per-face - loop verbatim as a reference implementation for tests and benchmarking. + loop as a reference implementation for tests and benchmarking; the two + must produce identical strike arrays, struck-face IDs, and load values. - The vectorized core operates on plain serializable inputs (arrays, dicts, floats) so it can also run inside process-based workers. +- Surface loads use the TRUE incidence angle: the positional off-axis angle + theta locates a face in the plume field (SimplifiedGasKinetics evaluates + n, U, T there, and the legacy 3.14-based theta still gates the wedge hit + test, bit-for-bit unchanged), but the Shen/Maxwellian wall formulas + receive the angle between the local flow direction (face centroid minus + thruster exit -- the collisionless flow is radial) and the face unit + normal. Previously the positional theta was fed to the wall formulas as + the incidence angle, so plate orientation never affected load magnitudes; + _surface_loads_with_incidence() is the shared fix for both paths. Future work (no new dependencies planned): - Vectorize the SimplifiedGasKinetics evaluations for struck faces. @@ -26,7 +36,53 @@ from typing import Any, Dict, List, Optional, Sequence import numpy as np -from pyrpod.plume.RarefiedPlumeGasKinetics import SimplifiedGasKinetics +from pyrpod.plume.RarefiedPlumeGasKinetics import ( + AVOGADROS_NUMBER, + SimplifiedGasKinetics, + get_maxwellian_heat_transfer, + get_maxwellian_pressure, + get_maxwellian_shear_pressure, +) + + +def _surface_loads_with_incidence(simple_plume: SimplifiedGasKinetics, + incidence: float): + """Maxwellian surface loads for a struck face at the true incidence angle. + + simple_plume carries the plume-field state at the face's position (its + constructor theta is the positional off-axis angle -- a plume-field + coordinate); this helper extracts the same local field values the class's + own get_pressure/get_shear_pressure/get_heat_flux use (including their + exact-centerline branch at theta == 0) and feeds them to the Shen wall + formulas with `incidence`, the angle between the local flow direction + (radial from the thruster exit) and the face unit normal. + + Returns (pressure, shear, heat_flux) in SI units; shear is signed as + returned by get_maxwellian_shear_pressure (callers take abs, matching + the legacy accumulation). + """ + if simple_plume.theta != 0: # not on plume centerline + n_inf = simple_plume.n_0 * simple_plume.get_num_density_ratio() + T = simple_plume.T_0 * simple_plume.get_temp_ratio() + u = simple_plume.get_U_normalized() / simple_plume.beta_0 + w = simple_plume.get_W_normalized() / simple_plume.beta_0 + U = np.sqrt(u ** 2 + w ** 2) + else: # on plume centerline: exact closed forms + n_inf = simple_plume.n_0 * simple_plume.get_num_density_centerline() + T = simple_plume.T_0 * simple_plume.get_temp_centerline() + U = simple_plume.get_velocity_centerline() / simple_plume.beta_0 + rho_inf = n_inf * simple_plume.molar_mass / AVOGADROS_NUMBER + S = U * simple_plume.get_beta(T) + + sigma = simple_plume.sigma + T_w = simple_plume.T_w + pressure = get_maxwellian_pressure(rho_inf, U, S, sigma, incidence, + T, T_w) + shear = get_maxwellian_shear_pressure(rho_inf, U, S, sigma, incidence) + heat_flux = get_maxwellian_heat_transfer(rho_inf, S, sigma, incidence, + T, T_w, simple_plume.R, + simple_plume.gamma) + return pressure, shear, heat_flux def compute_face_centroids(vectors: np.ndarray) -> np.ndarray: @@ -159,14 +215,19 @@ def _compute_plume_strikes_core( sigma = plume_params['sigma'] t_type = thruster_data[thruster_id]['type'][0] metrics = thruster_metrics[t_type] + # True incidence angle between the local (radial) flow direction + # -unit_distance and the face unit normal; fed to the wall + # formulas in place of the positional theta (see module header). + incidence = np.arccos(np.clip( + (unit_distance * normals).sum(axis=1), -1.0, 1.0)) for idx in np.nonzero(hit)[0]: simple_plume = SimplifiedGasKinetics( norm_distance[idx], theta[idx], metrics, T_w, sigma ) - pressures[idx] += simple_plume.get_pressure() - shear = simple_plume.get_shear_pressure() + p, shear, hf = _surface_loads_with_incidence( + simple_plume, incidence[idx]) + pressures[idx] += p shear_stresses[idx] += abs(shear) - hf = simple_plume.get_heat_flux() heat_flux[idx] += hf heat_flux_load[idx] += hf * firing_time @@ -229,9 +290,12 @@ def _compute_plume_strikes_scalar( ) -> Dict[str, np.ndarray]: """Scalar reference implementation of compute_plume_strikes(). - Preserved verbatim from the original per-face loop. Kept for regression - tests, debugging, and benchmarking against the vectorized path; the two - must produce identical strike arrays and struck-face IDs. + Preserves the original per-face loop structure (the hit test is + bit-for-bit the legacy computation). Kept for regression tests, + debugging, and benchmarking against the vectorized path; the two must + produce identical strike arrays, struck-face IDs, and load values. + Surface loads use the true incidence angle via the shared + _surface_loads_with_incidence(), exactly as the vectorized core does. """ num_faces = len(target_mesh.vectors) strikes = np.zeros(num_faces) @@ -298,10 +362,14 @@ def _compute_plume_strikes_scalar( t_type = vv.thruster_data[thruster_id]['type'][0] thruster_metrics = vv.thruster_metrics[t_type] simple_plume = SimplifiedGasKinetics(norm_distance, theta, thruster_metrics, T_w, sigma) - pressures[idx] += simple_plume.get_pressure() - shear = simple_plume.get_shear_pressure() + # True incidence angle (see module header): local flow + # direction -unit_distance vs the face unit normal. + incidence = np.arccos(np.clip( + np.dot(np.squeeze(unit_distance), n), -1.0, 1.0)) + p, shear, hf = _surface_loads_with_incidence( + simple_plume, incidence) + pressures[idx] += p shear_stresses[idx] += abs(shear) - hf = simple_plume.get_heat_flux() heat_flux[idx] += hf heat_flux_load[idx] += hf * firing_time diff --git a/scripts/inclined_plate_sweep_study.py b/scripts/inclined_plate_sweep_study.py new file mode 100644 index 0000000..f27a9f4 --- /dev/null +++ b/scripts/inclined_plate_sweep_study.py @@ -0,0 +1,414 @@ +"""Phase-3 driver: angle x distance sweep of the Cai 2016 inclined plate. + +Runs the 95-firing sweep JFH of case/plume/plume_inclined_plate_sweep +(generated by that case's jfh/generate_sweep_jfh.py: VV on arcs of radius L +about the stationary plate center, thruster aimed at the center; alpha = 0 is +head-on = paper +alpha0 = 90 deg, alpha_paper = 90 - |alpha| deg; L/D in {2, 4, 6, 8, 10}) +through the strike pipeline's per-firing core, compute_plume_strikes -- the +same function PlumeStrikeEstimationStudy calls per firing -- keeping each +firing's per-face arrays separate so the pipeline's cumulative accumulation +never pollutes the per-pose analysis. This is a standalone study script: +nothing in pyrpod/mdao is used and nothing is optimized. + +Outputs, under case/plume/plume_inclined_plate_sweep/results/ (gitignored): + - sweep/sweep_coefficients.csv: per firing, the Eq.-15 plate-averaged + coefficients CP/CF1/CF2/CQ/CM/s_cc from the pipeline AND from the exact + reference (pyrpod/plume/CaiImpingement2016.py), plus the peak per-face + Cp / |Cf| / Cq, struck-face counts, and per-firing wall time; + - sweep/coeff_.png: each averaged coefficient vs alpha, one curve + per L/D, pipeline (solid + markers) and reference (dashed) together; + - sweep/peaks.png: peak per-face pressure/shear/heat-flux coefficients vs + alpha per L/D; + - strikes/firing-.vtu: one VTK unstructured grid per firing, numbered + strictly by JFH index (the standard RPOD strike convention; same writer + as the other RPOD cases) carrying that pose's own per-face strikes, + dimensional loads (pressure_Pa, shear_Pa, heat_flux_W_m2), and + coefficients (Cp, Cshear, Cf1_case, Cf2_case, Cq). The Cf components + here are in the mesh's CASE frame, un-flipped -- so the shear vectors + point the way they physically do on that pose's mesh, unlike the + paper-convention aggregate CF1/CM in the CSV. Written per firing + (independent poses; no cumulative accumulation, unlike + PlumeStrikeEstimationStudy.jfh_plume_strikes), so each file is exactly + one pose. strikes/sweep_LoD.pvd ParaView collections group the + fixed-distance poses with alpha as the time coordinate, so an angle + sweep can be scrubbed in ParaView. Disable the VTK export with + --no-vtk (CSV + plots only). + +Conventions and sanity checks: + - Averaged pipeline coefficients follow Eq. 15: area-weighted sums over + the whole plate area S (unstruck faces carry zero load), with + CM = sum(tau * Cp * A) / (2 * H0 * S) and s_cc = CM / CP. + - The pipeline's per-face |shear| is decomposed onto the plate's + (tau, s) axes along the tangential projection of the radial flow + direction (the same decomposition as the Phase-1 figure overlays) to + obtain signed Cf1/Cf2 comparable to the paper's components. + - Poses with alpha < 0 map directly onto the paper geometry + (tau_paper = tau_case); alpha > 0 is its mirror image, so CF1 and CM + are sign-flipped into the paper convention. Mirror symmetry of the + results in +/-alpha is then asserted (the plate is square). + - +/-90 deg is edge-on/degenerate: the firings are kept (CSV rows and a + printed report) but excluded from the plots and the mirror check. At + exactly edge-on the facing test's surface_dot_plume is 0 in exact + arithmetic, so the mesh's float32 normals make strike membership + epsilon-arbitrary (a third to two thirds of the faces pass, not + mirror-symmetrically). The plate-AVERAGED load there is ~zero as + expected (a few percent of head-on), but individual epsilon-passing + faces next to the in-plane nozzle carry large grazing near-field + values at close range (peak Cp ~ 4 at L/D = 2), which would dominate + the peak plots meaninglessly. (The reference at alpha_paper = 0 + likewise keeps only the small plume-spread load.) + +Run from the repo root: python scripts/inclined_plate_sweep_study.py +(add --no-vtk to skip the per-pose VTK export). +""" + +import argparse +import csv +import sys +import time +from pathlib import Path + +import numpy as np + +_REPO_ROOT = Path(__file__).resolve().parents[1] +for extra in (_REPO_ROOT, _REPO_ROOT / 'tests' / 'plume'): + if str(extra) not in sys.path: + sys.path.insert(0, str(extra)) + +import matplotlib # noqa: E402 +matplotlib.use('Agg') +import matplotlib.pyplot as plt # noqa: E402 + +import plume_impingement_utils as piu # noqa: E402 (paper constants) +from pyrpod.mission import MissionEnvironment # noqa: E402 +from pyrpod.plume import CaiImpingement2016 as cai # noqa: E402 +from pyrpod.plume.PlumeStrikeCalculator import ( # noqa: E402 + compute_face_centroids, + compute_plume_strikes, +) +from pyrpod.rpod import JetFiringHistory # noqa: E402 +from pyrpod.util.stl.stl import convert_stl_to_vtk # noqa: E402 +from pyrpod.vehicle import TargetVehicle, VisitingVehicle # noqa: E402 + +CASE_DIR = str(_REPO_ROOT / 'case' / 'plume' / 'plume_inclined_plate_sweep') + '/' +SWEEP_JFH = 'jfh_inclined_plate_sweep.A' +RESULTS_DIR = Path(CASE_DIR) / 'results' / 'sweep' +# Per-pose strikes follow the standard RPOD convention: one .vtu per firing, +# numbered by JFH index in results/strikes/ (firing-.vtu). The CSV and the +# coefficient/peak plots stay in results/sweep/. +STRIKES_DIR = Path(CASE_DIR) / 'results' / 'strikes' + +PLATE_CENTER = np.array([4.0, 0.0, 0.0]) +ALPHA0_DEG = 60.0 # the mesh's fixed global tilt +H_0 = W_0 = 4.0 # plate semi-lengths (m) +ALPHAS_DEG = np.arange(-90.0, 90.0 + 1e-9, 10.0) +L_OVER_D = [2.0, 4.0, 6.0, 8.0, 10.0] + +# dataviz categorical palette, fixed slot order (validated CVD-safe) +PALETTE = ['#2a78d6', '#008300', '#e87ba4', '#eda100', '#1baf7a'] + +_A0 = np.deg2rad(ALPHA0_DEG) +NORMAL_OUT = np.array([-np.sin(_A0), 0.0, np.cos(_A0)]) # toward the VV side +TANGENT_TAU = np.array([np.cos(_A0), 0.0, np.sin(_A0)]) +TANGENT_S = np.array([0.0, 1.0, 0.0]) + +COEFF_NAMES = ['CP', 'CF1', 'CF2', 'CQ', 'CM', 's_cc'] + + +def load_case(): + jfh = JetFiringHistory.JetFiringHistory(CASE_DIR) + jfh.config.set('jfh', 'jfh', SWEEP_JFH) # sweep JFH, same case assets + jfh.read_jfh() + + tv = TargetVehicle.TargetVehicle(CASE_DIR) + tv.set_stl() + + vv = VisitingVehicle.VisitingVehicle(CASE_DIR) + vv.set_thruster_config() + vv.set_thruster_metrics() + + me = MissionEnvironment.MissionEnvironment(CASE_DIR) + return jfh, tv, vv, me + + +def face_areas(vectors): + v0, v1, v2 = vectors[:, 0], vectors[:, 1], vectors[:, 2] + return 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis=1) + + +def pipeline_row(result, centroids, normals, areas, tau_face, thruster_pos, + alpha_deg): + """Eq.-15 averaged coefficients, peaks, and per-face field arrays from + one firing. Returns (coeffs, peaks, face_fields); face_fields holds + the case-frame per-face arrays for VTK (Cf1_case/Cf2_case un-flipped, + matching the actual mesh, unlike the paper-convention aggregates).""" + S_tot = float(np.sum(areas)) + Cp = result['pressures'] / piu.Q_DYN + Csh = result['shear_stress'] / piu.Q_DYN + Cq = result['heat_flux_rate'] / piu.Q_DYN_HEAT + + # signed shear components along the plate axes (tangential projection + # of the radial flow direction, as in the Phase-1 overlays) + rel = centroids - thruster_pos + dist = np.linalg.norm(rel, axis=1) + u_hat = rel / dist[:, None] + n_in = -NORMAL_OUT + cos_inc = u_hat @ n_in + t_vec = u_hat - cos_inc[:, None] * n_in + t_norm = np.linalg.norm(t_vec, axis=1) + safe = t_norm > 1e-12 + t_hat = np.zeros_like(t_vec) + t_hat[safe] = t_vec[safe] / t_norm[safe, None] + Cf1 = Csh * (t_hat @ TANGENT_TAU) + Cf2 = Csh * (t_hat @ TANGENT_S) + + flip = -1.0 if alpha_deg > 0 else 1.0 # into the paper convention + CP = float(np.sum(Cp * areas) / S_tot) + CF1 = flip * float(np.sum(Cf1 * areas) / S_tot) + CF2 = float(np.sum(Cf2 * areas) / S_tot) + CQ = float(np.sum(Cq * areas) / S_tot) + CM = flip * float(np.sum(tau_face * Cp * areas) / (2.0 * H_0 * S_tot)) + s_cc = CM / CP if CP != 0.0 else float('nan') + + face_fields = { + 'strikes': result['strikes'], + 'pressure_Pa': result['pressures'], + 'shear_Pa': result['shear_stress'], + 'heat_flux_W_m2': result['heat_flux_rate'], + 'Cp': Cp, + 'Cshear': Csh, + 'Cf1_case': Cf1, + 'Cf2_case': Cf2, + 'Cq': Cq, + } + return ({'CP': CP, 'CF1': CF1, 'CF2': CF2, 'CQ': CQ, 'CM': CM, + 's_cc': s_cc}, + {'peak_Cp': float(np.max(Cp)), 'peak_Cshear': float(np.max(Csh)), + 'peak_Cq': float(np.max(Cq)), + 'n_struck': int(np.count_nonzero(result['strikes']))}, + face_fields) + + +def write_pose_vtk(target, face_fields, base_name): + """Write one pose's per-face fields to STRIKES_DIR/.vtu via the + shared convert_stl_to_vtk writer (pyevtk needs C-contiguous float64).""" + cell_data = {k: np.ascontiguousarray(v, dtype=np.float64) + for k, v in face_fields.items()} + convert_stl_to_vtk(target, STRIKES_DIR, filename=base_name, + cellData=cell_data) + + +def write_pvd(path, entries): + """ParaView collection grouping (timestep, relative_vtu_path) entries.""" + lines = ['', + '', ' '] + for timestep, rel_file in sorted(entries): + lines.append(f' ') + lines += [' ', ''] + path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + + +def reference_rows(): + """Exact Eq.-15 coefficients per (|alpha|, L), cached (mirror-invariant).""" + cache = {} + for L in L_OVER_D: + for abs_alpha in sorted({abs(a) for a in ALPHAS_DEG}): + alpha_paper = np.deg2rad(90.0 - abs_alpha) + cache[(abs_alpha, L)] = cai.averaged_coefficients( + piu.S_0, alpha_paper, piu.EPS, piu.R_0, L, W_0, H_0) + return cache + + +def check_mirror_symmetry(rows): + """Results must be mirror-symmetric in +/-alpha (paper convention). + + The +/-90 deg poses are excluded: their strike membership comes from + float32-epsilon signs in the facing test, which are not mirrored. + CF2 is checked separately -- it vanishes identically by symmetry, so + normalizing its asymmetry by its own (noise) scale is meaningless. + """ + worst = 0.0 + for L in L_OVER_D: + by_alpha = {r['alpha_deg']: r for r in rows if r['L_over_D'] == L + and abs(r['alpha_deg']) < 90.0} + cp_scale = max(abs(r['CP_pipe']) for r in by_alpha.values()) + # tolerance covers the triangulation's O(h^2) s-asymmetry (the + # cell-diagonal split is not mirror-invariant) + assert max(abs(r['CF2_pipe']) for r in by_alpha.values()) \ + < 1e-4 * cp_scale, f'CF2 not ~0 at L/D={L}' + for a in ALPHAS_DEG[(ALPHAS_DEG > 0) & (ALPHAS_DEG < 90.0)]: + for name in ('CP', 'CF1', 'CQ', 'CM'): + lo, hi = by_alpha[-a][f'{name}_pipe'], by_alpha[a][f'{name}_pipe'] + scale = max(abs(v) for r in by_alpha.values() + for v in [r[f'{name}_pipe']]) + scale = max(scale, 1e-6 * cp_scale) + worst = max(worst, abs(hi - lo) / scale) + # tolerance: the JFH file stores DCMs to 6 significant digits (and + # positions to 9), so mirrored poses reproduce to ~1e-6 relative; + # a convention error would show up as O(1). + assert worst < 1e-5, f'mirror symmetry violated: {worst:.3g}' + return worst + + +def plot_coefficients(rows, out_dir): + style_note = 'solid + markers: pipeline dashed: Cai 2016 reference' + labels = {'CP': r'$C_P$', 'CF1': r'$C_{F1}$', 'CF2': r'$C_{F2}$', + 'CQ': r'$C_Q$', 'CM': r'$C_M$', 's_cc': r'$s_{cc}$'} + for name in COEFF_NAMES: + fig, ax = plt.subplots(figsize=(7, 4.5)) + for color, L in zip(PALETTE, L_OVER_D): + sub = sorted((r for r in rows if r['L_over_D'] == L + and abs(r['alpha_deg']) < 90.0), + key=lambda r: r['alpha_deg']) + a = [r['alpha_deg'] for r in sub] + ax.plot(a, [r[f'{name}_pipe'] for r in sub], '-o', color=color, + lw=2, ms=4, label=f'L/D = {L:g}') + ax.plot(a, [r[f'{name}_ref'] for r in sub], '--', color=color, + lw=2) + ax.annotate(f'{L:g}', (a[-1], sub[-1][f'{name}_pipe']), + textcoords='offset points', xytext=(6, 0), + color=color, fontsize=8, va='center') + ax.axhline(0.0, color='0.85', lw=0.8, zorder=0) + ax.set_xlabel(r'$\alpha$ (deg), 0 = head-on') + ax.set_ylabel(labels[name]) + ax.set_title(f'Plate-averaged {labels[name]} vs approach angle\n' + f'{style_note}', fontsize=10) + ax.set_xticks(np.arange(-90, 91, 30)) + ax.legend(fontsize=8, title='distance', title_fontsize=8) + fig.savefig(out_dir / f'coeff_{name}.png', dpi=200, + bbox_inches='tight') + plt.close(fig) + + +def plot_peaks(rows, out_dir): + quantities = [('peak_Cp', r'peak $C_p$'), + ('peak_Cshear', r'peak $|C_f|$'), + ('peak_Cq', r'peak $C_q$')] + fig, axes = plt.subplots(3, 1, figsize=(7, 9), sharex=True) + for ax, (key, label) in zip(axes, quantities): + for color, L in zip(PALETTE, L_OVER_D): + sub = sorted((r for r in rows if r['L_over_D'] == L + and abs(r['alpha_deg']) < 90.0), + key=lambda r: r['alpha_deg']) + ax.plot([r['alpha_deg'] for r in sub], [r[key] for r in sub], + '-o', color=color, lw=2, ms=4, label=f'L/D = {L:g}') + ax.set_ylabel(label) + ax.set_yscale('log') + axes[0].legend(fontsize=8, title='distance', title_fontsize=8) + axes[0].set_title('Peak per-face coefficients vs approach angle ' + '(pipeline)', fontsize=10) + axes[-1].set_xlabel(r'$\alpha$ (deg), 0 = head-on') + axes[-1].set_xticks(np.arange(-90, 91, 30)) + fig.savefig(out_dir / 'peaks.png', dpi=200, bbox_inches='tight') + plt.close(fig) + + +def main(write_vtk=True): + t_start = time.perf_counter() + jfh, tv, vv, me = load_case() + n_firings = len(jfh.JFH) + assert n_firings == len(ALPHAS_DEG) * len(L_OVER_D), ( + f'unexpected sweep JFH length {n_firings}; regenerate with ' + 'case/plume/plume_inclined_plate/jfh/generate_sweep_jfh.py') + + target = tv.mesh + normals = target.get_unit_normals() + centroids = compute_face_centroids(target.vectors) + areas = face_areas(target.vectors) + tau_face = (centroids - PLATE_CENTER) @ TANGENT_TAU + + if write_vtk: + STRIKES_DIR.mkdir(parents=True, exist_ok=True) + pvd_entries = {} # L/D -> [(alpha_deg, relative vtu path)] + + print(f'reference curves: {len(set(abs(ALPHAS_DEG)))} angles x ' + f'{len(L_OVER_D)} distances (Eq. 15 quadrature) ...') + t0 = time.perf_counter() + ref_cache = reference_rows() + t_ref = time.perf_counter() - t0 + print(f' done in {t_ref:.1f} s') + + rows = [] + t0 = time.perf_counter() + for i in range(n_firings): + L = L_OVER_D[i // len(ALPHAS_DEG)] + alpha_deg = float(ALPHAS_DEG[i % len(ALPHAS_DEG)]) + step = {'thrusters': jfh.JFH[i]['thrusters'], + 'xyz': np.array(jfh.JFH[i]['xyz']), + 'dcm': np.array(jfh.JFH[i]['dcm']), + 't': float(jfh.JFH[i]['t'])} + t_f = time.perf_counter() + result = compute_plume_strikes(target, normals, vv, step, me, + face_centroids=centroids) + dt = time.perf_counter() - t_f + + coeffs, peaks, face_fields = pipeline_row( + result, centroids, normals, areas, tau_face, step['xyz'], + alpha_deg) + ref = ref_cache[(abs(alpha_deg), L)] + row = {'alpha_deg': alpha_deg, 'L_over_D': L, + 'alpha_paper_deg': 90.0 - abs(alpha_deg), + 'wall_time_s': dt, **peaks} + for name in COEFF_NAMES: + row[f'{name}_pipe'] = coeffs[name] + row[f'{name}_ref'] = float(ref[name]) + rows.append(row) + + if write_vtk: + base = f'firing-{i}' # strict JFH-index numbering + write_pose_vtk(target, face_fields, base) + pvd_entries.setdefault(L, []).append( + (alpha_deg, f'{base}.vtu')) + t_sweep = time.perf_counter() - t0 + + # sanity: report the edge-on poses (strike membership is + # epsilon-arbitrary there, see the module docstring); mirror symmetry + head_on = {r['L_over_D']: r['CP_pipe'] for r in rows + if r['alpha_deg'] == 0.0} + for r in rows: + if abs(r['alpha_deg']) == 90.0: + print(f"edge-on alpha={r['alpha_deg']:+.0f}, " + f"L/D={r['L_over_D']:g}: {r['n_struck']} faces pass the " + f"epsilon-degenerate facing test; CP=" + f"{r['CP_pipe']:.3e} (head-on " + f"{head_on[r['L_over_D']]:.3e}), peak Cp=" + f"{r['peak_Cp']:.3e}, peak |Cf|={r['peak_Cshear']:.3e}") + worst = check_mirror_symmetry(rows) + print(f'mirror-symmetry check: worst normalized asymmetry {worst:.2e}') + + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + columns = list(rows[0].keys()) + csv_path = RESULTS_DIR / 'sweep_coefficients.csv' + with open(csv_path, 'w', encoding='utf-8', newline='') as fh: + writer = csv.DictWriter(fh, fieldnames=columns) + writer.writeheader() + writer.writerows(rows) + print(f'wrote {csv_path}') + + plot_coefficients(rows, RESULTS_DIR) + plot_peaks(rows, RESULTS_DIR) + print(f'wrote coefficient and peak plots to {RESULTS_DIR}') + + if write_vtk: + for L, entries in pvd_entries.items(): + write_pvd(STRIKES_DIR / f'sweep_LoD{int(L):02d}.pvd', entries) + print(f'wrote {n_firings} per-pose strike VTK files to {STRIKES_DIR} ' + f'(+ {len(pvd_entries)} sweep_LoD*.pvd collections)') + + n_faces = len(target.vectors) + print(f'timings: {n_firings} firings x {n_faces} faces -- sweep ' + f'{t_sweep:.1f} s ({t_sweep / n_firings * 1e3:.0f} ms/firing), ' + f'reference {t_ref:.1f} s, total ' + f'{time.perf_counter() - t_start:.1f} s') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--no-vtk', action='store_true', + help='skip the per-pose VTK export (CSV + plots ' + 'only)') + args = parser.parse_args() + main(write_vtk=not args.no_vtk) diff --git a/tests/README.md b/tests/README.md index 7d007bc..ffcecf1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,6 +41,20 @@ This dashboard provides an overview of all tests in the PyRPOD project, categori | `plume_integration_test_01.py` | Integration | Tests plume modeling in integrated systems. | ❌ | | `plume_unit_test_01.py` | Unit | Verifies individual plume calculation methods.| ❌ | | `plume_verification_test_01.py` | Verification | Validates plume outputs against benchmarks. | ❌ | +| `plume_verification_test_28.py` | Verification | Cai 2016 Fig. 17: diffuse-plate Cp contours (manual-run figure). | ✅ | +| `plume_verification_test_29.py` | Verification | Cai 2016 Fig. 18: specular-plate Cp contours (manual-run figure). | ✅ | +| `plume_verification_test_30.py` | Verification | Cai 2016 Fig. 19: diffuse-plate Cf1 contours (manual-run figure). | ✅ | +| `plume_verification_test_31.py` | Verification | Cai 2016 Fig. 20: diffuse-plate Cf2 contours (manual-run figure). | ✅ | +| `plume_verification_test_32.py` | Verification | Cai 2016 Fig. 21: diffuse-plate Cq contours (manual-run figure). | ✅ | +| `plume_verification_test_33.py` | Verification | Cai 2016 Fig. 5: 2D diffuse-plate flowfield T contours (manual-run figure). | ✅ | +| `plume_verification_test_34.py` | Verification | Cai 2016 Fig. 6: 2D specular-plate flowfield T contours (manual-run figure). | ✅ | +| `plume_verification_test_35.py` | Verification | Cai 2016 Fig. 7: 2D diffuse-plate Cp profiles (manual-run figure). | ✅ | +| `plume_verification_test_36.py` | Verification | Cai 2016 Fig. 8: 2D specular-plate Cp profiles (manual-run figure). | ✅ | +| `plume_verification_test_37.py` | Verification | Cai 2016 Fig. 9: 2D diffuse-plate Cf profiles (manual-run figure). | ✅ | +| `plume_verification_test_38.py` | Verification | Cai 2016 Fig. 10: 2D diffuse-plate Cq profiles (manual-run figure). | ✅ | +| `plume_verification_test_39.py` | Verification | Cai 2016 Fig. 15: 3D diffuse-plate flowfield p contours (manual-run figure). | ✅ | +| `plume_verification_test_40.py` | Verification | Cai 2016 Fig. 16: 3D specular-plate flowfield p contours (manual-run figure). | ✅ | +| `plume_impingement_error_summary.py` | Verification | Cai 2016 reference vs PyRPOD-chain max/mean error table (manual-run generator). | ✅ | --- @@ -51,6 +65,7 @@ This dashboard provides an overview of all tests in the PyRPOD project, categori | `rpod_integration_test_02.py` | Integration | Asserts plume strikes for notional 1D approach. | ✅ | | `rpod_integration_test_03.py` | Integration | Asserts plume strikes using KOZ geometry. | ✅ | | `rpod_integration_test_04.py` | Integration | Asserts plume strikes using hollow cube geometry. | ✅ | +| `rpod_integration_test_07.py` | Integration | Cai 2016 inclined-plate case: pipeline loads vs exact reference. | ✅ | | `rpod_unit_test_01.py` | Unit | Verifies STL to VTK data conversion. | ✅ | | `rpod_unit_test_02.py` | Unit | Verifiy behavior of JFH reader. | ✅ | | `rpod_unit_test_03.py` | Unit | Produces JFH data according to produced equation. | ⏳ | diff --git a/tests/plume/data/digitized/README.md b/tests/plume/data/digitized/README.md index 9f43dd1..29c2d9a 100644 --- a/tests/plume/data/digitized/README.md +++ b/tests/plume/data/digitized/README.md @@ -5,6 +5,18 @@ Drop digitized curves from Cai & Wang 2012 (JSR 49(1), DOI (`tests/plume/plume_verification_test_04` ... `_27`) will overlay them automatically on their next run. No code changes are needed. +The same convention serves the Cai 2016 impingement figures +(Aerospace 3(4):43, DOI 10.3390/aerospace3040043) reproduced by +`plume_verification_test_28` ... `_40`, with a `cai16_` stem prefix so +the 2016 paper's figure numbers never collide with the 2012 slots +above: `cai16_fig05_*.csv` ... `cai16_fig10_*.csv` (Section 3, 2D +planar plate), `cai16_fig15_*.csv`, `cai16_fig16_*.csv` (flowfield +pressure contours), and `cai16_fig17_*.csv` ... `cai16_fig21_*.csv` +(3D plate surface coefficients). One CSV per digitized DSMC/analytic +curve or contour polyline in the figure's plotted units, e.g. +`cai16_fig17_dsmc_0p2.csv` for the Cp = 0.2 line of the 2016 Fig. 17, +or `cai16_fig07_dsmc.csv` for the DSMC Cp profile of the 2016 Fig. 7. + ## File convention - Name: `_.csv`, where `` is the paper figure diff --git a/tests/plume/plume_figure_utils.py b/tests/plume/plume_figure_utils.py index 194772e..0ac8a75 100644 --- a/tests/plume/plume_figure_utils.py +++ b/tests/plume/plume_figure_utils.py @@ -77,6 +77,11 @@ BOLTZMANN = 1.380649e-23 # J / K OUTPUT_DIR = _THIS_DIR / 'output' +# Cai & Wang 2012 figures live in their own output subfolder, parallel to +# the Cai 2016 impingement set in output/Cai2016 (see +# plume_impingement_utils.OUTPUT_DIR). OUTPUT_DIR itself stays the shared +# output root so that derived path is unaffected. +CAI2012_DIR = OUTPUT_DIR / 'Cai2012' DIGITIZED_DIR = _THIS_DIR / 'data' / 'digitized' X_MIN_OVER_D = 0.05 # models require X > 0 @@ -381,9 +386,9 @@ def annotate_error(ax, text, loc='lower left'): def save_figure(fig, name): - """Save PNG to tests/plume/output and return the path.""" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - path = OUTPUT_DIR / f'{name}.png' + """Save PNG to tests/plume/output/Cai2012 and return the path.""" + CAI2012_DIR.mkdir(parents=True, exist_ok=True) + path = CAI2012_DIR / f'{name}.png' fig.savefig(path, dpi=200, bbox_inches='tight') plt.close(fig) return path diff --git a/tests/plume/plume_impingement_error_summary.py b/tests/plume/plume_impingement_error_summary.py new file mode 100644 index 0000000..04fbb40 --- /dev/null +++ b/tests/plume/plume_impingement_error_summary.py @@ -0,0 +1,108 @@ +# ======================== +# PyRPOD: tests/plume/plume_impingement_error_summary.py +# ======================== +# Extends the model-vs-model error-summary pattern of +# plume_verification_error_summary.py to the Cai 2016 inclined-plate +# impingement study (Figs. 17-21 conditions): maximum and mean relative +# differences between the exact reference surface coefficients +# (pyrpod/plume/CaiImpingement2016.py, Eqs. 9-14) and the current +# PyRPOD approximation (SimplifiedGasKinetics field + Shen/Maxwellian +# wall formulas at the true incidence angle -- the Phase-2-fixed strike +# pipeline chain), over the plate contour grid. +# +# As in the 2012 summary, each row restricts the comparison to the +# region where the reference magnitude is at least 5% of its peak: the +# largest relative differences otherwise occur near the plate corners +# and sign-change lines where the reference value is near zero. +# DSMC reference columns are pending digitized data +# (tests/plume/data/digitized/fig17_*.csv ... fig21_*.csv). +# +# The filename intentionally avoids pytest's collection patterns +# (test_*.py / *_test_*.py): this is a manual-run generator -- +# python tests/plume/plume_impingement_error_summary.py +# It writes tests/plume/output/Cai2016/cai2016_error_summary.csv and .md. + +import numpy as np + +import plume_impingement_utils as u +from pyrpod.plume import CaiImpingement2016 as cai + + +def build_rows(): + # module self-verification first: quadrature convergence, symmetry, + # specular identity, and the n_w flux balance (raises on failure) + cai.run_sanity_checks(u.S_0, u.ALPHA_0, u.EPS, u.R_0, u.L_PLATE, + verbose=False) + + ref = u.reference_grid() + chain_d = u.chain_grid() # diffuse chain (sigma = 1) + chain_s = u.chain_grid(sigma=0.0) # specular chain for Cp,s + + ref_shear = np.hypot(ref['Cf1_d'], ref['Cf2_d']) + comparisons = [ + ('Cp,d (Fig. 17)', chain_d['Cp'], ref['Cp_d']), + ('Cp,s (Fig. 18)', chain_s['Cp'], ref['Cp_s']), + ('Cf1,d (Fig. 19)', chain_d['Cf1'], ref['Cf1_d']), + ('Cf2,d (Fig. 20)', chain_d['Cf2'], ref['Cf2_d']), + ('|Cf,d| (shear magnitude)', chain_d['Cshear'], ref_shear), + ('Cq,d (Fig. 21)', chain_d['Cq'], ref['Cq_d']), + ] + + rows = [] + for quantity, chain, reference in comparisons: + mask = u.significant_mask(reference) + rows.append({ + 'quantity': quantity, + 'comparison': 'PyRPOD chain vs Cai 2016 exact', + 'max_rel_diff': u.base.max_rel_diff(chain[mask], + reference[mask]), + 'mean_rel_diff': u.mean_rel_diff(chain, reference, mask), + 'restriction': '|ref| >= 5% of peak', + 'vs_DSMC': 'pending digitized data', + }) + return rows + + +def write_summary(): + rows = build_rows() + u.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + columns = ['quantity', 'comparison', 'max_rel_diff', 'mean_rel_diff', + 'restriction', 'vs_DSMC'] + + csv_path = u.OUTPUT_DIR / 'cai2016_error_summary.csv' + with open(csv_path, 'w', encoding='utf-8', newline='') as fh: + fh.write(','.join(columns) + '\n') + for row in rows: + fh.write(','.join( + f'{row[c]:.4g}' if isinstance(row[c], float) else str(row[c]) + for c in columns) + '\n') + + md_path = u.OUTPUT_DIR / 'cai2016_error_summary.md' + with open(md_path, 'w', encoding='utf-8', newline='') as fh: + fh.write('# Cai 2016 impingement error summary ' + '(reference vs PyRPOD chain)\n\n') + fh.write('Section-4 conditions: argon, D = 1 m, S0 = 2.0, ' + 'L = 4D, alpha0 = 60 deg, Tw/T0 = 1.5, 8 m x 8 m ' + 'plate, contour grid ' + f'{u.GRID_N}x{u.GRID_N}. DSMC reference columns are ' + 'pending digitized data.\n\n') + fh.write('| ' + ' | '.join(columns) + ' |\n') + fh.write('|' + '---|' * len(columns) + '\n') + for row in rows: + fh.write('| ' + ' | '.join( + f'{row[c]:.4g}' if isinstance(row[c], float) else str(row[c]) + for c in columns) + ' |\n') + fh.write('\nNotes: the PyRPOD chain is the strike-pipeline ' + 'computation (SimplifiedGasKinetics local field + ' + 'Shen/Maxwellian wall model at the true incidence ' + 'angle); Cp,s uses sigma = 0. Rows are restricted to ' + '|ref| >= 5% of the peak magnitude -- unrestricted ' + 'maxima occur where the reference is near zero (plate ' + 'corners, stagnation/sign-change lines), the same ' + 'caveat as the 2012 summary.\n') + return csv_path, md_path + + +if __name__ == '__main__': + for path in write_summary(): + print(f'wrote {path}') diff --git a/tests/plume/plume_impingement_utils.py b/tests/plume/plume_impingement_utils.py new file mode 100644 index 0000000..37cf6be --- /dev/null +++ b/tests/plume/plume_impingement_utils.py @@ -0,0 +1,374 @@ +# ======================== +# PyRPOD: tests/plume/plume_impingement_utils.py +# ======================== +# Shared helpers for the manual-run verification figure scripts +# plume_verification_test_28 ... _32, which reproduce Figs. 17-21 of +# Cai, C., "Gaskinetic Modeling on Dilute Gaseous Plume Impingement +# Flows," Aerospace 2016, 3(4), 43, doi:10.3390/aerospace3040043 +# (Section 4: round jet impinging on an inclined rectangular plate). +# +# Design notes +# ------------ +# * Paper validation conditions (Sec. 4): argon, D = 1.0 m, L = 4D, +# plate 8 m x 8 m (W0 = H0 = L), T0 = 200 K, S0 = 2.0, Tw = 300 K +# (eps = 1.5), alpha0 = 60 deg, fully diffuse plate. All plotted +# quantities are the paper's dimensionless coefficients, so n_0 is +# inert; 1e20 m^-3 matches plume_figure_utils. +# * The exact reference curves delegate to +# pyrpod/plume/CaiImpingement2016.py (Eqs. 9-14); that module's +# docstring records the n_w interpretation and its validation. +# * Every figure also overlays the CURRENT PYRPOD APPROXIMATION: +# SimplifiedGasKinetics local field values (evaluated at the plume +# coordinates distance / off-axis angle of each plate point) fed +# through the Shen/Maxwellian wall formulas at the TRUE incidence +# angle between the local flow direction (radial from the exit) and +# the inclined plate normal. This replicates exactly what the +# Phase-2-fixed strike pipeline computes per struck face, so the +# pipeline comparison in tests/rpod is apples-to-apples with these +# figures. The Shen shear magnitude is decomposed onto the plate's +# (s, tau) axes along the tangential projection of the flow +# direction to compare with the paper's Cf1/Cf2 components. +# * Specular figure (Fig. 18): the comparable Maxwellian setting is +# sigma = 0 (fully specular reflection) -- Shen's formulas then drop +# the wall-temperature term, matching the paper's remark that the +# plate temperature has no effect on Cp,s. +# * Scripts _33.._40 reproduce the paper's remaining figures from the +# same single source of truth (pyrpod/plume/CaiImpingement2016.py): +# Figs. 5-6 (Section-3 2D flowfield temperature with a diffuse / +# specular plate), Figs. 7-10 (2D plate surface Cp/Cf/Cq profiles), +# and Figs. 15-16 (Section-4 flowfield pressure in the Y = 0 plane). +# The Section-3 validation geometry (slot 2H, L = 4*(2H), plate +# semi-width W = 5*(2H)) is read off the paper's figures -- see the +# reference module docstring. These figures have no PyRPOD-chain +# overlay: SimplifiedGasKinetics is a round-nozzle model (not +# comparable to the 2D slot jet) and the strike pipeline computes no +# flowfields-with-plates, so they are reference + digitized-DSMC +# reproductions only. +# * Digitized-overlay slots follow tests/plume/data/digitized/README.md +# with the cai16_ stem prefix (the 2016 paper's figure numbers would +# otherwise collide with the 2012 slots): cai16_fig05_*.csv ... +# cai16_fig21_*.csv (absent files silently skipped). +# * Generic figure plumbing (run_script, overlay_digitized, +# annotate_error, max_rel_diff) is reused from plume_figure_utils; +# only the paper-specific constants live here. Figures are saved to +# the study's own subfolder, tests/plume/output/Cai2016, to keep the +# Cai 2016 verification set separate from the 2012 figure outputs. + +import sys +from pathlib import Path + +import numpy as np + +_THIS_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _THIS_DIR.parents[1] +if str(_REPO_ROOT) not in sys.path: # direct `python tests/plume/...py` runs + sys.path.insert(0, str(_REPO_ROOT)) + +import plume_figure_utils as base # noqa: E402 +from plume_figure_utils import plt # noqa: E402 +from pyrpod.plume import CaiImpingement2016 as cai # noqa: E402 +from pyrpod.plume.RarefiedPlumeGasKinetics import ( # noqa: E402 + AVOGADROS_NUMBER, + GAS_CONSTANT, + SimplifiedGasKinetics, + get_maxwellian_heat_transfer, + get_maxwellian_pressure, + get_maxwellian_shear_pressure, +) + +# Cai 2016 Section-4 validation conditions (argon) +R_SPECIFIC = 208.13 # J / (kg K) +GAMMA = 5.0 / 3.0 +T_0 = 200.0 # K +N_0 = 1.0e20 # m^-3 (inert for the coefficients) +D_NOZZLE = 1.0 # m +R_0 = D_NOZZLE / 2.0 # m +S_0 = 2.0 +U_0 = S_0 * np.sqrt(2.0 * R_SPECIFIC * T_0) # ~577.07 m/s +T_W = 300.0 # K +EPS = T_W / T_0 # 1.5 +ALPHA_0 = np.deg2rad(60.0) +L_PLATE = 4.0 * D_NOZZLE # m +W_0 = H_0 = L_PLATE # plate semi-width/semi-length (8 m x 8 m) +SIGMA = 1.0 + +M_PARTICLE = (GAS_CONSTANT / R_SPECIFIC) / AVOGADROS_NUMBER # kg +Q_DYN = 0.5 * N_0 * M_PARTICLE * U_0 ** 2 # n0*m*U0^2/2 (Pa) +Q_DYN_HEAT = 0.5 * N_0 * M_PARTICLE * U_0 ** 3 # n0*m*U0^3/2 (W/m^2) + +GRID_N = 81 # nodes per plate axis for contour grids + +OUTPUT_DIR = base.OUTPUT_DIR / 'Cai2016' # figure output subfolder + +# Section-3 (2D slot jet) validation geometry in units of the slot +# height D2 = 2H (read off the paper's figures; see the reference +# module docstring): L = 4*(2H), plate semi-width W = 5*(2H). +D2_SLOT = 1.0 +H_SLOT = D2_SLOT / 2.0 +L_2D = 4.0 * D2_SLOT +W_2D = 5.0 * D2_SLOT +ALPHA_2D = np.deg2rad(60.0) # Figs. 5-6 inclination + +#: paper-legend line styles for the four-parameter profile figures +PROFILE_STYLES = ['-', '--', (0, (6, 2)), '-.'] + +THRUSTER_CHARACTERISTICS = {'d': D_NOZZLE, 've': U_0, 'R': R_SPECIFIC, + 'gamma': GAMMA, 'Te': T_0, 'n': N_0} + +_SIN_A, _COS_A = np.sin(ALPHA_0), np.cos(ALPHA_0) +#: plate inward normal (flow side -> plate) and in-plane axes in global coords +NORMAL_IN = np.array([_SIN_A, 0.0, -_COS_A]) +TANGENT_TAU = np.array([_COS_A, 0.0, _SIN_A]) +TANGENT_S = np.array([0.0, 1.0, 0.0]) + + +def plate_axes(n=GRID_N): + """(s, tau) axes in meters (= diameters, D = 1 m) spanning the plate.""" + return np.linspace(-W_0, W_0, n), np.linspace(-H_0, H_0, n) + + +def reference_grid(n=GRID_N): + """Exact Cai 2016 coefficients on the (s, tau) tensor grid; returns + dict of arrays shaped (len(tau), len(s)) for plt.contour.""" + s, tau = plate_axes(n) + Sg, Tg = np.meshgrid(s, tau) + return cai.surface_coefficients_plate(Sg, Tg, S_0, ALPHA_0, EPS, R_0, + L_PLATE) + + +def chain_point_loads(X, Y, Z, sigma=SIGMA): + """Current-PyRPOD approximation at one global plate point (X, Y, Z): + SimplifiedGasKinetics field state at the point's plume coordinates, + Shen/Maxwellian wall loads at the true incidence angle. + + Returns (pressure, shear_magnitude, heat_flux) in SI units. This is + the per-face computation of the Phase-2-fixed strike pipeline + (PlumeStrikeCalculator), kept in one helper so the figures and the + pipeline comparison test share a single definition. + """ + dist = float(np.sqrt(X * X + Y * Y + Z * Z)) + theta_pos = float(np.arctan2(np.hypot(Y, Z), X)) + plume = SimplifiedGasKinetics(dist, theta_pos, THRUSTER_CHARACTERISTICS, + T_W, sigma) + if theta_pos != 0.0: + n_ratio = plume.get_num_density_ratio() + T = T_0 * plume.get_temp_ratio() + u = plume.get_U_normalized() / plume.beta_0 + w = plume.get_W_normalized() / plume.beta_0 + U = float(np.hypot(u, w)) + else: + n_ratio = plume.get_num_density_centerline() + T = T_0 * plume.get_temp_centerline() + U = plume.get_velocity_centerline() / plume.beta_0 + rho_inf = N_0 * n_ratio * M_PARTICLE + S = U * plume.get_beta(T) + + cos_inc = (X * _SIN_A - Z * _COS_A) / dist # flow dir . inward normal + incidence = float(np.arccos(np.clip(cos_inc, -1.0, 1.0))) + + pressure = get_maxwellian_pressure(rho_inf, U, S, sigma, incidence, + T, T_W) + shear = abs(get_maxwellian_shear_pressure(rho_inf, U, S, sigma, + incidence)) + heat = get_maxwellian_heat_transfer(rho_inf, S, sigma, incidence, + T, T_W, R_SPECIFIC, GAMMA) + return pressure, shear, heat + + +def chain_grid(n=GRID_N, sigma=SIGMA): + """Current-PyRPOD approximation coefficients on the (s, tau) grid. + + Returns dict with 'Cp', 'Cf1', 'Cf2', 'Cshear', 'Cq' shaped like + reference_grid arrays. The shear magnitude from Shen's formula acts + along the tangential projection of the (radial) flow direction; its + components on the plate axes give Cf1 (inclined direction) and Cf2 + (horizontal), signed like the paper's Figs. 19-20. + """ + s, tau = plate_axes(n) + Sg, Tg = np.meshgrid(s, tau) + X, Y, Z = cai.plate_point_coords(Sg, Tg, ALPHA_0, L_PLATE) + + Cp = np.empty_like(X) + Cf1 = np.empty_like(X) + Cf2 = np.empty_like(X) + Csh = np.empty_like(X) + Cq = np.empty_like(X) + it = np.nditer(X, flags=['multi_index']) + for _ in it: + i = it.multi_index + x, y, z = float(X[i]), float(Y[i]), float(Z[i]) + p, sh, q = chain_point_loads(x, y, z, sigma=sigma) + dist = np.sqrt(x * x + y * y + z * z) + u_hat = np.array([x, y, z]) / dist + cos_inc = float(u_hat @ NORMAL_IN) + t_vec = u_hat - cos_inc * NORMAL_IN + t_norm = np.linalg.norm(t_vec) + t_hat = t_vec / t_norm if t_norm > 1e-12 else np.zeros(3) + Cp[i] = p / Q_DYN + Csh[i] = sh / Q_DYN + Cf1[i] = sh * float(t_hat @ TANGENT_TAU) / Q_DYN + Cf2[i] = sh * float(t_hat @ TANGENT_S) / Q_DYN + Cq[i] = q / Q_DYN_HEAT + return {'Cp': Cp, 'Cf1': Cf1, 'Cf2': Cf2, 'Cshear': Csh, 'Cq': Cq} + + +def save_figure(fig, name): + """Save PNG to tests/plume/output/Cai2016 and return the path.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + path = OUTPUT_DIR / f'{name}.png' + fig.savefig(path, dpi=200, bbox_inches='tight') + plt.close(fig) + return path + + +def mean_rel_diff(candidate, reference, mask): + """mean |candidate - reference| / |reference| over the masked entries.""" + candidate = np.asarray(candidate, dtype=float) + reference = np.asarray(reference, dtype=float) + m = mask & np.isfinite(candidate) & np.isfinite(reference) + return float(np.mean(np.abs(candidate[m] - reference[m]) + / np.abs(reference[m]))) + + +def significant_mask(reference, fraction=0.05): + """Entries where |reference| >= fraction * max|reference| -- the + region where relative errors are meaningful (the largest relative + differences otherwise sit in the near-zero plate corners, the same + caveat the 2012 error summary records).""" + reference = np.asarray(reference, dtype=float) + return np.abs(reference) >= fraction * np.max(np.abs(reference)) + + +def impingement_contour_figure(fig_stem, ref_field, chain_field, levels, + title, file_name, chain_label='PyRPOD chain', + fmt='%g'): + """Shared Figs. 17-21 builder: reference contours (solid black), + current-PyRPOD-approximation contours (dashed red, same levels), + digitized slots, and a max/mean relative-error annotation over the + significant region.""" + s, tau = plate_axes() + + fig, ax = plt.subplots(figsize=(6, 6)) + cs = ax.contour(s, tau, ref_field, levels=levels, colors='k', + linewidths=1.2) + ax.clabel(cs, fmt=fmt, fontsize=7) + ax.contour(s, tau, chain_field, levels=levels, colors='r', + linewidths=0.9, linestyles='dashed') + n_dig = base.overlay_digitized(ax, fig_stem, style='line') + + ax.set_xlim(-W_0, W_0) + ax.set_ylim(-H_0, H_0) + ax.set_xlabel('s') + ax.set_ylabel(r'$\tau$') + ax.set_title(title) + handles = [plt.Line2D([], [], color='k', lw=1.2, + label='Cai 2016 (exact)'), + plt.Line2D([], [], color='r', lw=0.9, ls='--', + label=chain_label)] + if n_dig: + handles.append(plt.Line2D([], [], color='k', lw=1.0, + label='DSMC (digitized)')) + ax.legend(handles=handles, fontsize=7, loc='upper right') + + mask = significant_mask(ref_field) + base.annotate_error( + ax, + f'max rel diff = {base.max_rel_diff(chain_field[mask], ref_field[mask]):.2g}\n' + f'mean rel diff = {mean_rel_diff(chain_field, ref_field, mask):.2g}\n' + r'(where $|C_{ref}| \geq$ 5% of peak)') + return save_figure(fig, file_name) + + +def _draw_plate_trace(ax, alpha_0, L, semi_length): + """Plate trace in the plotted plane (2D plate line or the 3D plate's + Y = 0 section), drawn like the paper's contour figures.""" + tau = np.array([-semi_length, semi_length]) + ax.plot(L + tau * np.cos(alpha_0), tau * np.sin(alpha_0), 'k-', lw=1.6) + + +def planar_temperature_contour_figure(plate, levels, fig_stem, file_name, + title, S_0=2.0, grid_n=161): + """Figs. 5-6 builder: 2D flowfield temperature contours T/T0 for the + slot jet with a diffuse or specular plate (delegates to + cai.planar_flowfield), solid black with labels, plate line drawn, + digitized slots overlaid.""" + x = np.linspace(0.05, 8.0, grid_n) + y = np.linspace(-4.0, 4.0, grid_n) + Xg, Yg = np.meshgrid(x * D2_SLOT, y * D2_SLOT) + T = cai.planar_flowfield(Xg, Yg, S_0, ALPHA_2D, EPS, H_SLOT, L_2D, + W_2D, plate=plate)['T'] + + fig, ax = plt.subplots(figsize=(6, 6)) + cs = ax.contour(x, y, T, levels=levels, colors='k', linewidths=1.1) + ax.clabel(cs, fmt='%g', fontsize=7) + _draw_plate_trace(ax, ALPHA_2D, L_2D, W_2D) + n_dig = base.overlay_digitized(ax, fig_stem, style='line') + + ax.set_xlim(0, 8) + ax.set_ylim(-4, 4) + ax.set_xlabel('X/(2H)') + ax.set_ylabel('Y/(2H)') + ax.set_title(title) + handles = [plt.Line2D([], [], color='k', lw=1.1, label='Analytical')] + if n_dig: + handles.append(plt.Line2D([], [], color='k', lw=1.0, + label='DSMC (digitized)')) + ax.legend(handles=handles, fontsize=7, loc='lower right') + return save_figure(fig, file_name) + + +def planar_profile_figure(quantity, params, fig_stem, file_name, title, + ylabel, ylim, n_s=401): + """Figs. 7-10 builder: 2D plate surface coefficient profiles vs + s/(2H) for (S_0, alpha_0 deg) parameter combinations (delegates to + cai.planar_surface_coefficients), paper-style black line styles, + digitized slots overlaid.""" + s = np.linspace(-5.0, 5.0, n_s) * D2_SLOT + + fig, ax = plt.subplots(figsize=(6, 4.5)) + for (S_0, alpha_deg), ls in zip(params, PROFILE_STYLES): + coeffs = cai.planar_surface_coefficients( + s, S_0, np.deg2rad(alpha_deg), EPS, H_SLOT, L_2D) + ax.plot(s / D2_SLOT, coeffs[quantity], color='k', ls=ls, lw=1.1, + label=f'$S_0$={S_0:g}, {alpha_deg:g}$^\\circ$') + base.overlay_digitized(ax, fig_stem) + + ax.set_xlim(-5, 6) + ax.set_ylim(*ylim) + ax.set_xlabel('s/(2H)') + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.legend(fontsize=8) + return save_figure(fig, file_name) + + +def pressure_contour_figure_3d(plate, levels, fig_stem, file_name, title, + S_0=2.0, grid_n=141): + """Figs. 15-16 builder: Section-4 flowfield static-pressure contours + p/p0 in the vertical Y = 0 plane with a diffuse or specular plate + (delegates to cai.flowfield_pressure_plane), plate section drawn, + digitized slots overlaid.""" + x = np.linspace(0.05, 8.0, grid_n) + z = np.linspace(-4.0, 4.0, grid_n) + Xg, Zg = np.meshgrid(x * D_NOZZLE, z * D_NOZZLE) + p = cai.flowfield_pressure_plane( + Xg, Zg, S_0, ALPHA_0, EPS, R_0, L_PLATE, W_0, H_0, + plate=plate)['p'] + + fig, ax = plt.subplots(figsize=(6, 6)) + cs = ax.contour(x, z, p, levels=levels, colors='k', linewidths=1.1) + ax.clabel(cs, fmt='%g', fontsize=7) + _draw_plate_trace(ax, ALPHA_0, L_PLATE, H_0) + n_dig = base.overlay_digitized(ax, fig_stem, style='line') + + ax.set_xlim(0, 8) + ax.set_ylim(-4, 4) + ax.set_xlabel('$X/(2R_0)$') + ax.set_ylabel('$W/(2R_0)$') + ax.set_title(title) + handles = [plt.Line2D([], [], color='k', lw=1.1, label='Analytical')] + if n_dig: + handles.append(plt.Line2D([], [], color='k', lw=1.0, + label='DSMC (digitized)')) + ax.legend(handles=handles, fontsize=7, loc='lower right') + return save_figure(fig, file_name) diff --git a/tests/plume/plume_verification_error_summary.py b/tests/plume/plume_verification_error_summary.py index b99b6d7..a3963e1 100644 --- a/tests/plume/plume_verification_error_summary.py +++ b/tests/plume/plume_verification_error_summary.py @@ -16,7 +16,7 @@ # The filename intentionally avoids pytest's collection patterns # (test_*.py / *_test_*.py): this is a manual-run generator -- # python tests/plume/plume_verification_error_summary.py -# It writes tests/plume/output/model_error_summary.csv and .md. +# It writes tests/plume/output/Cai2012/model_error_summary.csv and .md. import numpy as np @@ -77,11 +77,11 @@ def build_rows(): def write_summary(): rows = build_rows() - u.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + u.CAI2012_DIR.mkdir(parents=True, exist_ok=True) columns = ['curve', 'quantity', 'comparison', 'max_rel_diff', 'max_rel_diff_restricted', 'restriction', 'vs_DSMC'] - csv_path = u.OUTPUT_DIR / 'model_error_summary.csv' + csv_path = u.CAI2012_DIR / 'model_error_summary.csv' with open(csv_path, 'w', encoding='utf-8', newline='') as fh: fh.write(','.join(columns) + '\n') for row in rows: @@ -89,7 +89,7 @@ def write_summary(): f'{row[c]:.4g}' if isinstance(row[c], float) else str(row[c]) for c in columns) + '\n') - md_path = u.OUTPUT_DIR / 'model_error_summary.md' + md_path = u.CAI2012_DIR / 'model_error_summary.md' with open(md_path, 'w', encoding='utf-8', newline='') as fh: fh.write('# Model-vs-model error summary (Table 1 analog)\n\n') fh.write(f'Cai & Wang 2012 conditions, S0 = {S_0}. DSMC reference ' diff --git a/tests/plume/plume_verification_test_04.py b/tests/plume/plume_verification_test_04.py index 5978a31..0429d24 100644 --- a/tests/plume/plume_verification_test_04.py +++ b/tests/plume/plume_verification_test_04.py @@ -8,7 +8,7 @@ # Manual-run verification script (design decision D5): it defines no # pytest tests and is executed directly -- # python tests/plume/plume_verification_test_04.py -# The figure is saved to tests/plume/output/. +# The figure is saved to tests/plume/output/Cai2012/. # Digitized overlays looked for: fig02_*.csv (none published -- Fig. 2 # has no DSMC data in the paper). diff --git a/tests/plume/plume_verification_test_28.py b/tests/plume/plume_verification_test_28.py new file mode 100644 index 0000000..7e3a818 --- /dev/null +++ b/tests/plume/plume_verification_test_28.py @@ -0,0 +1,34 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_28.py +# ======================== +# Reproduces Cai 2016 Fig. 17: diffuse-plate surface pressure contours +# Cp,d(s, tau) for a round argon jet (D = 1 m, S0 = 2.0, T0 = 200 K) on +# the 8 m x 8 m plate at L = 4D, alpha0 = 60 deg, Tw/T0 = 1.5 (Eq. 9, +# solid black). Overlaid dashed red: the current PyRPOD approximation +# (SimplifiedGasKinetics field + Shen/Maxwellian wall pressure at the +# true incidence angle, sigma = 1) -- exactly what the fixed strike +# pipeline computes per struck face. +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_28.py +# Digitized overlays looked for: cai16_fig17_*.csv (e.g. +# cai16_fig17_dsmc_0p2.csv, one CSV per digitized contour polyline; the +# cai16_ prefix keeps the 2016 paper's slots distinct from the Cai & +# Wang 2012 fig17 slot). + +import plume_impingement_utils as u + + +def generate_figure(): + ref = u.reference_grid() + chain = u.chain_grid() + levels = [0.005, 0.01, 0.05, 0.1, 0.2] + return u.impingement_contour_figure( + 'cai16_fig17', ref['Cp_d'], chain['Cp'], levels, + 'Diffuse plate $C_{p,d}(s,\\tau)$, $S_0$=2.0, ' + '$T_w/T_0$=1.5, $\\alpha_0$=60$^\\circ$ (Fig. 17)', + 'fig17_diffuse_pressure') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_29.py b/tests/plume/plume_verification_test_29.py new file mode 100644 index 0000000..858221e --- /dev/null +++ b/tests/plume/plume_verification_test_29.py @@ -0,0 +1,32 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_29.py +# ======================== +# Reproduces Cai 2016 Fig. 18: specular-plate surface pressure contours +# Cp,s(s, tau) (Eq. 14, solid black) at the Section-4 conditions +# (argon, D = 1 m, S0 = 2.0, L = 4D, alpha0 = 60 deg). Overlaid dashed +# red: the current PyRPOD approximation with sigma = 0 -- Shen's +# Maxwellian pressure reduces to the fully specular reflection, and, +# consistent with the paper, the wall temperature drops out. +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_29.py +# Digitized overlays looked for: cai16_fig18_*.csv (one CSV per +# digitized contour polyline; cai16_ prefix avoids the 2012 fig18 slot). + +import plume_impingement_utils as u + + +def generate_figure(): + ref = u.reference_grid() + chain = u.chain_grid(sigma=0.0) + levels = [0.005, 0.01, 0.05, 0.1, 0.2, 0.3] + return u.impingement_contour_figure( + 'cai16_fig18', ref['Cp_s'], chain['Cp'], levels, + 'Specular plate $C_{p,s}(s,\\tau)$, $S_0$=2.0, ' + '$\\alpha_0$=60$^\\circ$ (Fig. 18)', + 'fig18_specular_pressure', + chain_label='PyRPOD chain ($\\sigma$=0)') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_30.py b/tests/plume/plume_verification_test_30.py new file mode 100644 index 0000000..964be20 --- /dev/null +++ b/tests/plume/plume_verification_test_30.py @@ -0,0 +1,33 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_30.py +# ======================== +# Reproduces Cai 2016 Fig. 19: diffuse-plate friction coefficient +# Cf1,d(s, tau) along the inclined direction (Eq. 11, solid black) at +# the Section-4 conditions. The zero contour is the stagnation line +# (tau ~ -2): gas flows up-plate above it (positive Cf1) and down-plate +# below. Overlaid dashed red: the current PyRPOD approximation -- the +# Shen/Maxwellian shear magnitude at the true incidence angle, +# decomposed onto the inclined plate axis along the tangential +# projection of the radial flow direction. +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_30.py +# Digitized overlays looked for: cai16_fig19_*.csv (one CSV per +# digitized contour polyline; cai16_ prefix avoids the 2012 fig19 slot). + +import plume_impingement_utils as u + + +def generate_figure(): + ref = u.reference_grid() + chain = u.chain_grid() + levels = [-0.001, 0.0, 0.001, 0.01, 0.05] + return u.impingement_contour_figure( + 'cai16_fig19', ref['Cf1_d'], chain['Cf1'], levels, + 'Diffuse plate $C_{f_1,d}(s,\\tau)$, $S_0$=2.0, ' + '$T_w/T_0$=1.5, $\\alpha_0$=60$^\\circ$ (Fig. 19)', + 'fig19_diffuse_friction_tau') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_31.py b/tests/plume/plume_verification_test_31.py new file mode 100644 index 0000000..2801257 --- /dev/null +++ b/tests/plume/plume_verification_test_31.py @@ -0,0 +1,32 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_31.py +# ======================== +# Reproduces Cai 2016 Fig. 20: diffuse-plate friction coefficient +# Cf2,d(s, tau) along the horizontal direction (Eq. 12, solid black) at +# the Section-4 conditions -- antisymmetric in s (gas flows outward to +# both sides of the vertical symmetry plane). Overlaid dashed red: the +# current PyRPOD approximation, decomposed onto the horizontal plate +# axis (see plume_verification_test_30 header). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_31.py +# Digitized overlays looked for: cai16_fig20_*.csv (one CSV per +# digitized contour polyline; cai16_ prefix avoids the 2012 fig20 slot). + +import plume_impingement_utils as u + + +def generate_figure(): + ref = u.reference_grid() + chain = u.chain_grid() + levels = [-0.02, -0.01, -0.005, -0.001, + 0.001, 0.005, 0.01, 0.02] + return u.impingement_contour_figure( + 'cai16_fig20', ref['Cf2_d'], chain['Cf2'], levels, + 'Diffuse plate $C_{f_2,d}(s,\\tau)$, $S_0$=2.0, ' + '$T_w/T_0$=1.5, $\\alpha_0$=60$^\\circ$ (Fig. 20)', + 'fig20_diffuse_friction_s') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_32.py b/tests/plume/plume_verification_test_32.py new file mode 100644 index 0000000..d4115af --- /dev/null +++ b/tests/plume/plume_verification_test_32.py @@ -0,0 +1,30 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_32.py +# ======================== +# Reproduces Cai 2016 Fig. 21: diffuse-plate heat-flux coefficient +# Cq,d(s, tau) (Eq. 13, solid black) at the Section-4 conditions -- +# peak just beneath the plate center, as the paper notes. Overlaid +# dashed red: the current PyRPOD approximation (SimplifiedGasKinetics +# field + Shen/Maxwellian heat transfer at the true incidence angle). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_32.py +# Digitized overlays looked for: cai16_fig21_*.csv (one CSV per +# digitized contour polyline; cai16_ prefix avoids the 2012 fig21 slot). + +import plume_impingement_utils as u + + +def generate_figure(): + ref = u.reference_grid() + chain = u.chain_grid() + levels = [0.001, 0.01, 0.05] + return u.impingement_contour_figure( + 'cai16_fig21', ref['Cq_d'], chain['Cq'], levels, + 'Diffuse plate $C_{q,d}(s,\\tau)$, $S_0$=2.0, ' + '$T_w/T_0$=1.5, $\\alpha_0$=60$^\\circ$ (Fig. 21)', + 'fig21_diffuse_heat_flux') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_33.py b/tests/plume/plume_verification_test_33.py new file mode 100644 index 0000000..acebd38 --- /dev/null +++ b/tests/plume/plume_verification_test_33.py @@ -0,0 +1,30 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_33.py +# ======================== +# Reproduces Cai 2016 Fig. 5: temperature contours T/T0 for the 2D slot +# jet (S0 = 2.0) impinging on the inclined DIFFUSE planar plate +# (alpha0 = 60 deg, Tw/T0 = 1.5, L = 4*(2H), W = 5*(2H)) -- the combined +# free-jet + wall-emission field of Section 3, with the stagnation +# heating peaking at T/T0 ~ 2.4 in front of the plate. Delegates to +# pyrpod/plume/CaiImpingement2016.planar_flowfield (single source of +# truth); the analytic field is evaluated on the whole X > 0 plane with +# no shadowing, exactly as the paper's contours are drawn. +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_33.py +# Digitized overlays looked for: cai16_fig05_*.csv (one CSV per +# digitized contour polyline). + +import plume_impingement_utils as u + + +def generate_figure(): + return u.planar_temperature_contour_figure( + 'diffuse', [1.0, 1.4, 1.8, 2.0, 2.2, 2.4], 'cai16_fig05', + 'cai16_fig05_planar_diffuse_temperature', + '2D jet on diffuse plate, $T/T_0$, $S_0$=2.0, ' + '$\\alpha_0$=60$^\\circ$, $T_w/T_0$=1.5 (Fig. 5)') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_34.py b/tests/plume/plume_verification_test_34.py new file mode 100644 index 0000000..787d1da --- /dev/null +++ b/tests/plume/plume_verification_test_34.py @@ -0,0 +1,31 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_34.py +# ======================== +# Reproduces Cai 2016 Fig. 6: temperature contours T/T0 for the 2D slot +# jet (S0 = 2.0) impinging on the inclined SPECULAR planar plate +# (alpha0 = 60 deg, L = 4*(2H)) -- the free jet plus the paper's +# virtual-nozzle construction (mirrored exit and drift, Eq. 7), whose +# defining property is the contour symmetry about the plate line that +# the module's sanity checks assert to machine precision; counterflow +# stagnation heating peaks at T/T0 ~ 3.5. Delegates to +# pyrpod/plume/CaiImpingement2016.planar_flowfield (single source of +# truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_34.py +# Digitized overlays looked for: cai16_fig06_*.csv (one CSV per +# digitized contour polyline). + +import plume_impingement_utils as u + + +def generate_figure(): + return u.planar_temperature_contour_figure( + 'specular', [1.0, 1.5, 2.0, 2.5, 3.0, 3.5], 'cai16_fig06', + 'cai16_fig06_planar_specular_temperature', + '2D jet on specular plate, $T/T_0$, $S_0$=2.0, ' + '$\\alpha_0$=60$^\\circ$ (Fig. 6)') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_35.py b/tests/plume/plume_verification_test_35.py new file mode 100644 index 0000000..79dff66 --- /dev/null +++ b/tests/plume/plume_verification_test_35.py @@ -0,0 +1,31 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_35.py +# ======================== +# Reproduces Cai 2016 Fig. 7: 2D diffuse-plate surface pressure +# profiles Cp,d(s) (Eq. 2, with the A0-integral non-penetration wall +# density) for (S0, alpha0) = (2.0, 60), (2.0, 30), (1.5, 60), +# (1.5, 30) deg at Tw/T0 = 1.5 -- the profiles skew with smaller +# inclination angle, peaking at ~0.93 for the DSMC-validated +# (2.0, 60 deg) case. Delegates to +# pyrpod/plume/CaiImpingement2016.planar_surface_coefficients (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_35.py +# Digitized overlays looked for: cai16_fig07_*.csv (e.g. +# cai16_fig07_dsmc.csv, the paper's S0 = 2.0 / 60 deg DSMC symbols). + +import plume_impingement_utils as u + +PARAMS = [(2.0, 60.0), (2.0, 30.0), (1.5, 60.0), (1.5, 30.0)] + + +def generate_figure(): + return u.planar_profile_figure( + 'Cp_d', PARAMS, 'cai16_fig07', 'cai16_fig07_planar_cp_diffuse', + 'Diffuse planar plate $C_{p,d}(s)$, $T_w/T_0$=1.5 (Fig. 7)', + '$C_p$', (0.0, 1.0)) + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_36.py b/tests/plume/plume_verification_test_36.py new file mode 100644 index 0000000..79cca26 --- /dev/null +++ b/tests/plume/plume_verification_test_36.py @@ -0,0 +1,30 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_36.py +# ======================== +# Reproduces Cai 2016 Fig. 8: 2D specular-plate surface pressure +# profiles Cp,s(s) (Eq. 8 -- twice the jet-only normal momentum flux, +# wall temperature drops out) for (S0, alpha0) = (2.0, 60), (2.0, 30), +# (1.5, 60), (1.5, 30) deg, peaking at ~1.23 for the DSMC-validated +# (2.0, 60 deg) case. Delegates to +# pyrpod/plume/CaiImpingement2016.planar_surface_coefficients (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_36.py +# Digitized overlays looked for: cai16_fig08_*.csv (e.g. +# cai16_fig08_dsmc.csv). + +import plume_impingement_utils as u + +PARAMS = [(2.0, 60.0), (2.0, 30.0), (1.5, 60.0), (1.5, 30.0)] + + +def generate_figure(): + return u.planar_profile_figure( + 'Cp_s', PARAMS, 'cai16_fig08', 'cai16_fig08_planar_cp_specular', + 'Specular planar plate $C_{p,s}(s)$ (Fig. 8)', + '$C_p$', (0.0, 1.25)) + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_37.py b/tests/plume/plume_verification_test_37.py new file mode 100644 index 0000000..634e2bf --- /dev/null +++ b/tests/plume/plume_verification_test_37.py @@ -0,0 +1,31 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_37.py +# ======================== +# Reproduces Cai 2016 Fig. 9: 2D diffuse-plate surface friction +# profiles Cf,d(s) (Eq. 3) for (S0, alpha0) = (2.0, 60), (2.0, 30), +# (1.5, 60), (1.5, 30) deg at Tw/T0 = 1.5. Smaller inclination angles +# create larger friction; the (2.0, 60 deg) profile crosses zero near +# s/(2H) ~ -2 (the paper's possible flow-separation spot; the module +# anchor reproduces it at -2.12). Delegates to +# pyrpod/plume/CaiImpingement2016.planar_surface_coefficients (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_37.py +# Digitized overlays looked for: cai16_fig09_*.csv (e.g. +# cai16_fig09_dsmc.csv). + +import plume_impingement_utils as u + +PARAMS = [(2.0, 60.0), (2.0, 30.0), (1.5, 60.0), (1.5, 30.0)] + + +def generate_figure(): + return u.planar_profile_figure( + 'Cf_d', PARAMS, 'cai16_fig09', 'cai16_fig09_planar_cf_diffuse', + 'Diffuse planar plate $C_{f,d}(s)$, $T_w/T_0$=1.5 (Fig. 9)', + '$C_f$', (-0.1, 0.4)) + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_38.py b/tests/plume/plume_verification_test_38.py new file mode 100644 index 0000000..8952487 --- /dev/null +++ b/tests/plume/plume_verification_test_38.py @@ -0,0 +1,31 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_38.py +# ======================== +# Reproduces Cai 2016 Fig. 10: 2D diffuse-plate surface heat-flux +# profiles Cq,d(s) (Eq. 4, incoming energy flux minus the wall-Maxwellian +# effusion at Tw) for (S0, alpha0) = (2.0, 60), (2.0, 90), (1.5, 60), +# (1.5, 90) deg at Tw/T0 = 1.5 -- the larger S0, the larger the heat +# flux, peaking at ~0.27 for the DSMC-validated (2.0, 60 deg) case. +# Delegates to +# pyrpod/plume/CaiImpingement2016.planar_surface_coefficients (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_38.py +# Digitized overlays looked for: cai16_fig10_*.csv (e.g. +# cai16_fig10_dsmc.csv). + +import plume_impingement_utils as u + +PARAMS = [(2.0, 60.0), (2.0, 90.0), (1.5, 60.0), (1.5, 90.0)] + + +def generate_figure(): + return u.planar_profile_figure( + 'Cq_d', PARAMS, 'cai16_fig10', 'cai16_fig10_planar_cq_diffuse', + 'Diffuse planar plate $C_{q,d}(s)$, $T_w/T_0$=1.5 (Fig. 10)', + '$C_q$', (0.0, 0.3)) + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_39.py b/tests/plume/plume_verification_test_39.py new file mode 100644 index 0000000..72871f7 --- /dev/null +++ b/tests/plume/plume_verification_test_39.py @@ -0,0 +1,33 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_39.py +# ======================== +# Reproduces Cai 2016 Fig. 15: static-pressure contours p/p0 in the +# vertical Y = 0 plane for the Section-4 round jet (argon, D = 1 m, +# S0 = 2.0) impinging on the inclined DIFFUSE rectangular plate +# (alpha0 = 60 deg, L = 4D, Tw/T0 = 1.5) -- the free-jet field (2012 +# exit-disk integrals, imported from RarefiedPlumeGasKinetics) combined +# with the Eq.-9 wall-emission population, showing the paper's gas +# accumulation in the impingement center region (peak p/p0 ~ 0.58 just +# below the plate center vs the paper's 0.5 innermost contour). +# Delegates to +# pyrpod/plume/CaiImpingement2016.flowfield_pressure_plane (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_39.py +# Digitized overlays looked for: cai16_fig15_*.csv (one CSV per +# digitized contour polyline). + +import plume_impingement_utils as u + + +def generate_figure(): + return u.pressure_contour_figure_3d( + 'diffuse', [0.05, 0.1, 0.2, 0.4, 0.5], 'cai16_fig15', + 'cai16_fig15_pressure_diffuse', + 'Round jet on diffuse plate, $p/p_0$ in $Y$=0 plane, ' + '$S_0$=2.0, $\\alpha_0$=60$^\\circ$ (Fig. 15)') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/plume/plume_verification_test_40.py b/tests/plume/plume_verification_test_40.py new file mode 100644 index 0000000..cf9ad18 --- /dev/null +++ b/tests/plume/plume_verification_test_40.py @@ -0,0 +1,33 @@ +# ======================== +# PyRPOD: tests/plume/plume_verification_test_40.py +# ======================== +# Reproduces Cai 2016 Fig. 16: static-pressure contours p/p0 in the +# vertical Y = 0 plane for the Section-4 round jet (argon, D = 1 m, +# S0 = 2.0) impinging on the inclined SPECULAR rectangular plate +# (alpha0 = 60 deg, L = 4D) -- the free-jet field plus the paper's 3D +# virtual nozzle at (L(1 - cos 2a0), 0, -L sin 2a0) with mirrored drift +# (see the reference module docstring for the drift-sign resolution), +# whose mirror symmetry about the plate plane the module's sanity +# checks assert to machine precision; peak p/p0 ~ 0.50 vs the paper's +# 0.4 innermost contour. Delegates to +# pyrpod/plume/CaiImpingement2016.flowfield_pressure_plane (single +# source of truth). +# +# Manual-run verification script (design decision D5): no pytest tests; +# run directly -- python tests/plume/plume_verification_test_40.py +# Digitized overlays looked for: cai16_fig16_*.csv (one CSV per +# digitized contour polyline). + +import plume_impingement_utils as u + + +def generate_figure(): + return u.pressure_contour_figure_3d( + 'specular', [0.01, 0.05, 0.1, 0.2, 0.3, 0.4], 'cai16_fig16', + 'cai16_fig16_pressure_specular', + 'Round jet on specular plate, $p/p_0$ in $Y$=0 plane, ' + '$S_0$=2.0, $\\alpha_0$=60$^\\circ$ (Fig. 16)') + + +if __name__ == '__main__': + u.base.run_script(generate_figure) diff --git a/tests/rpod/rpod_integration_test_07.py b/tests/rpod/rpod_integration_test_07.py new file mode 100644 index 0000000..d47cc6d --- /dev/null +++ b/tests/rpod/rpod_integration_test_07.py @@ -0,0 +1,204 @@ +# ======================== +# PyRPOD: tests/rpod/rpod_integration_test_07.py +# ======================== +# Cai 2016 inclined-plate verification: runs case/plume/plume_inclined_plate +# (argon round jet, D = 1 m, S0 = 2.0, 8 m x 8 m plate at L = 4D inclined +# 60 deg, Tw/T0 = 1.5, sigma = 1) through PlumeStrikeEstimationStudy, +# converts the per-face dimensional loads to the paper's coefficient +# normalization (pressure/shear by n0*m*U0^2/2, heat flux by n0*m*U0^3/2), +# and compares face-by-face against the exact reference functions +# (pyrpod/plume/CaiImpingement2016.py, Eqs. 9-13) evaluated at the face +# centroids. +# +# Hard assertions cover what must be exact: +# - geometry: every plate face is struck exactly once (the whole plate +# sits inside the gating wedge/radius at this pose); +# - the vectorized and scalar strike paths agree (strike arrays exactly, +# kinetics arrays to 1e-12, per the plume_unit_test_04 convention); +# - internal consistency: the pipeline loads match the analytic +# PyRPOD-approximation chain of tests/plume/plume_impingement_utils +# (same SimplifiedGasKinetics field + Shen/Maxwellian wall model at the +# true incidence angle) to <2% -- the only difference is the hit test's +# legacy 3.14-based positional theta feeding the field evaluation. +# +# The pipeline-vs-reference gap itself is QUANTITATIVE DOCUMENTATION, not a +# hard tolerance gate: the Maxwellian chain is an engineering approximation +# of Cai's exact solution, and the deliverable is the measured gap. Max and +# mean relative errors (restricted to faces where the reference magnitude +# is >= 5% of its peak, as in the error summaries) are printed and appended +# to tests/plume/output/cai2016_pipeline_error_summary.md. +# +# Run: python -m pytest rpod/rpod_integration_test_07.py -s (from tests/) + +import sys +import time +import unittest +from pathlib import Path + +import numpy as np + +_TESTS_DIR = Path(__file__).resolve().parents[1] +if str(_TESTS_DIR / 'plume') not in sys.path: + sys.path.insert(0, str(_TESTS_DIR / 'plume')) +if str(_TESTS_DIR.parent) not in sys.path: # repo root for direct runs + sys.path.insert(0, str(_TESTS_DIR.parent)) + +import plume_impingement_utils as piu # noqa: E402 +from pyrpod.mission import MissionEnvironment # noqa: E402 +from pyrpod.plume import CaiImpingement2016 as cai # noqa: E402 +from pyrpod.plume.PlumeStrikeCalculator import ( # noqa: E402 + _compute_plume_strikes_scalar, + compute_face_centroids, + compute_plume_strikes, +) +from pyrpod.rpod import JetFiringHistory, PlumeStrikeEstimationStudy # noqa: E402 +from pyrpod.vehicle import TargetVehicle, VisitingVehicle # noqa: E402 + +CASE_DIR = '../case/plume/plume_inclined_plate/' +SUMMARY_PATH = _TESTS_DIR / 'plume' / 'output' / 'Cai2016' / \ + 'cai2016_pipeline_error_summary.md' + + +def face_areas(vectors): + v0, v1, v2 = vectors[:, 0], vectors[:, 1], vectors[:, 2] + return 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis=1) + + +class InclinedPlateVerificationChecks(unittest.TestCase): + + def test_pipeline_vs_cai2016_reference(self): + # 1. Set up (driver pattern of rpod_integration_test_05) + jfh = JetFiringHistory.JetFiringHistory(CASE_DIR) + jfh.read_jfh() + + tv = TargetVehicle.TargetVehicle(CASE_DIR) + tv.set_stl() + + vv = VisitingVehicle.VisitingVehicle(CASE_DIR) + vv.set_thruster_config() + vv.set_thruster_metrics() + + me = MissionEnvironment.MissionEnvironment(CASE_DIR) + + study = PlumeStrikeEstimationStudy.PlumeStrikeEstimationStudy(me) + study.study_init(jfh, tv, vv) + + # 2. Execute the full pipeline (writes VTK to the case results dir) + t0 = time.perf_counter() + firing_data = study.jfh_plume_strikes() + t_pipeline = time.perf_counter() - t0 + + self.assertEqual(list(firing_data.keys()), ['1']) + result = firing_data['1'] + + target = tv.mesh + n_faces = len(target.vectors) + centroids = compute_face_centroids(target.vectors) + normals = target.get_unit_normals() + + # 3. Geometry: the whole plate lies inside the gating wedge/radius + # at the paper pose, so every face must be struck exactly once. + np.testing.assert_array_equal(result['strikes'], np.ones(n_faces)) + + # 4. Vectorized and scalar paths must agree (shared incidence fix). + step = {'thrusters': jfh.JFH[0]['thrusters'], + 'xyz': np.array(jfh.JFH[0]['xyz']), + 'dcm': np.array(jfh.JFH[0]['dcm']), + 't': float(jfh.JFH[0]['t'])} + vec = compute_plume_strikes(target, normals, vv, step, me, + face_centroids=centroids) + scal = _compute_plume_strikes_scalar(target, normals, vv, step, me) + np.testing.assert_array_equal(vec['strikes'], scal['strikes']) + for key in ('pressures', 'shear_stress', 'heat_flux_rate'): + self.assertTrue(np.allclose(vec[key], scal[key], + rtol=1e-12, atol=1e-12), + msg=f'{key}: scalar vs vectorized differ') + + # 5. Convert to the paper's coefficients. Thruster at the origin + # firing +X, so centroid coordinates are the paper's (X, Y, Z). + Cp_pipe = result['pressures'] / piu.Q_DYN + Csh_pipe = result['shear_stress'] / piu.Q_DYN + Cq_pipe = result['heat_flux_rate'] / piu.Q_DYN_HEAT + # firing time is 1 s, so the load equals the rate + self.assertTrue(np.allclose(result['heat_flux_load'], + result['heat_flux_rate'])) + + # 6. Exact reference at the face centroids. + ref = cai.surface_coefficients( + centroids[:, 0], centroids[:, 1], centroids[:, 2], + piu.S_0, piu.ALPHA_0, piu.EPS, piu.R_0) + ref_shear = np.hypot(ref['Cf1_d'], ref['Cf2_d']) + + # 7. Internal consistency vs the Phase-1 analytic chain on a face + # subsample (every 97th face covers the plate quasi-uniformly). + # Denominators are floored at 5% of the subsample peak: the + # Maxwellian heat flux changes sign on the plate (adiabatic-wall + # crossover), so a purely relative test would divide by ~0 there + # while the absolute agreement stays tight. + sub = np.arange(0, n_faces, 97) + chain = np.array([piu.chain_point_loads(*centroids[i]) for i in sub]) + for pipe_vals, chain_vals, name in [ + (result['pressures'][sub], chain[:, 0], 'pressure'), + (result['shear_stress'][sub], chain[:, 1], 'shear'), + (result['heat_flux_rate'][sub], chain[:, 2], 'heat flux')]: + scale = np.maximum(np.abs(chain_vals), + 0.05 * np.max(np.abs(chain_vals))) + rel = np.abs(pipe_vals - chain_vals) / scale + self.assertLess(np.max(rel), 2e-2, + msg=f'pipeline diverged from analytic chain ' + f'({name}): max rel {np.max(rel):.3g}') + + # 8. Quantitative documentation of the pipeline-vs-reference gap. + areas = face_areas(target.vectors) + rows = [] + for name, pipe, reference in [ + ('Cp,d', Cp_pipe, ref['Cp_d']), + ('|Cf,d|', Csh_pipe, ref_shear), + ('Cq,d', Cq_pipe, ref['Cq_d'])]: + mask = piu.significant_mask(reference) + rel = np.abs(pipe[mask] - reference[mask]) / np.abs(reference[mask]) + rows.append((name, float(np.max(rel)), float(np.mean(rel)), + float(np.sum(pipe * areas) / np.sum(areas)), + float(np.sum(reference * areas) / np.sum(areas)))) + print(f'[cai2016] {name}: max rel err {np.max(rel):.3f}, ' + f'mean rel err {np.mean(rel):.3f} ' + f'(over {mask.sum()}/{n_faces} significant faces)') + print(f'[cai2016] pipeline wall time: {t_pipeline:.1f} s ' + f'({n_faces} faces, 1 firing)') + + SUMMARY_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SUMMARY_PATH, 'w', encoding='utf-8', newline='') as fh: + fh.write('# Cai 2016 pipeline error summary ' + '(strike pipeline vs exact reference)\n\n') + fh.write('Written by tests/rpod/rpod_integration_test_07.py: ' + 'face-by-face comparison of the ' + 'PlumeStrikeEstimationStudy loads (Simplified ' + 'kinetics + Maxwellian wall model at the true ' + 'incidence angle) against ' + 'pyrpod/plume/CaiImpingement2016.py at the paper ' + f'conditions; {n_faces} faces, pipeline wall time ' + f'{t_pipeline:.1f} s.\n\n') + fh.write('| quantity | max_rel_err | mean_rel_err | ' + 'plate avg (pipeline) | plate avg (reference) |\n') + fh.write('|---|---|---|---|---|\n') + for name, mx, mn, avg_p, avg_r in rows: + fh.write(f'| {name} | {mx:.4g} | {mn:.4g} | {avg_p:.4g} | ' + f'{avg_r:.4g} |\n') + fh.write('\nRelative errors restricted to faces with ' + '|reference| >= 5% of its peak (the same near-zero ' + 'caveat as the other summaries). The gap is the ' + 'documented accuracy of the Maxwellian engineering ' + 'chain vs the exact collisionless solution -- not a ' + 'regression gate.\n') + print(f'[cai2016] wrote {SUMMARY_PATH}') + + # Loose envelope so a future regression that breaks the chain + # entirely (e.g. reverting to positional theta) fails loudly: + # with the orientation-blind bug, mean Cp error was ~3x larger. + self.assertLess(rows[0][2], 0.15, + msg='mean Cp error vs reference far above the ' + 'documented Maxwellian-chain gap') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/rpod/rpod_verification_test_06.py b/tests/rpod/rpod_verification_test_06.py new file mode 100644 index 0000000..9693006 --- /dev/null +++ b/tests/rpod/rpod_verification_test_06.py @@ -0,0 +1,398 @@ +# ======================== +# PyRPOD: tests/rpod/rpod_verification_test_06.py +# ======================== +# Cai 2016 flat-plate SWEEP verification: the swept analog of +# rpod_integration_test_07. Runs the 95-firing sweep JFH of +# case/plume/plume_flat_plate_sweep (a flat alpha0 = 0 reframing of the +# paper geometry; VV on arcs of radius L about the stationary plate, +# thruster aimed at the plate center; 19 approach angles alpha = -90..90 deg +# x 5 stand-off distances L/D in {2,4,6,8,10}) through the strike pipeline's +# per-firing core (compute_plume_strikes -- the same function +# PlumeStrikeEstimationStudy calls per firing), reduces each pose to the +# Eq.-15 plate-averaged coefficients, and compares them against the exact +# reference (pyrpod/plume/CaiImpingement2016.py). The 60 deg inclined sweep +# lives in the sibling case plume_inclined_plate_sweep (driven by the study +# script); the reframing is physics-invariant, so both give identical +# coefficients -- flat just reads more cleanly in ParaView. +# +# This is the pytest promotion of the assertion-relevant core of +# scripts/inclined_plate_sweep_study.py; that script is kept intact as the +# full-artifact generator (CSV, coefficient/peak plots, per-pose VTK + +# ParaView collections). Only the invariants live here -- no plots/CSV. +# Per-pose strike VTK (one .vtu per pose + ParaView .pvd collections, for +# visual inspection) is OPT-IN: set the env var PYRPOD_SWEEP_VTK=1 to write +# them to the case's results/sweep/ (gitignored); the default run is lean. +# +# Hard gates (a paper-frame convention error breaks these as O(1)): +# - the sweep JFH has exactly len(ALPHAS) x len(L/D) = 95 firings; +# - mirror symmetry: results in +/-alpha match to <1e-5 relative (the +# JFH stores DCMs to 6 significant digits, so mirrored poses reproduce +# to ~1e-6); the edge-on +/-90 deg poses are excluded (their strike +# membership is float32-epsilon-arbitrary, see the script docstring); +# - CF2 vanishes identically by symmetry (~0 to 1e-4 of the CP scale). +# +# The pipeline-vs-reference gap is QUANTITATIVE DOCUMENTATION with a loose +# regression gate (as in rpod_integration_test_07): the Maxwellian chain +# is an engineering approximation of Cai's exact solution. Plate-averaged +# CP/CF1/CQ max/mean relative errors (non-edge-on poses) are printed and +# written to tests/plume/output/Cai2016/cai2016_sweep_error_summary.md. +# +# Run: python -m pytest rpod/rpod_verification_test_06.py -s (from tests/) +# Inspect: PYRPOD_SWEEP_VTK=1 python -m pytest rpod/rpod_verification_test_06.py -s +# (writes the per-pose strike .vtu files + sweep_LoD*.pvd) + +import os +import sys +import time +import unittest +from pathlib import Path + +import numpy as np + +_TESTS_DIR = Path(__file__).resolve().parents[1] +if str(_TESTS_DIR / 'plume') not in sys.path: + sys.path.insert(0, str(_TESTS_DIR / 'plume')) +if str(_TESTS_DIR.parent) not in sys.path: # repo root for direct runs + sys.path.insert(0, str(_TESTS_DIR.parent)) + +import plume_impingement_utils as piu # noqa: E402 +from pyrpod.mission import MissionEnvironment # noqa: E402 +from pyrpod.plume import CaiImpingement2016 as cai # noqa: E402 +from pyrpod.plume.PlumeStrikeCalculator import ( # noqa: E402 + compute_face_centroids, + compute_plume_strikes, +) +from pyrpod.rpod import JetFiringHistory # noqa: E402 +from pyrpod.util.stl.stl import convert_stl_to_vtk # noqa: E402 +from pyrpod.vehicle import TargetVehicle, VisitingVehicle # noqa: E402 + +# Dedicated flat-plate sweep case (alpha0 = 0, plate center at the origin): +# a pure global-frame reframing of the paper geometry so the swept strikes +# read cleanly in ParaView (flat plate in the X-Y plane, VV swept above it). +# Its config.ini selects the flat STL (flat_plate_transformed.stl) and the +# flat sweep JFH (jfh_flat_plate_sweep.A), so no runtime override is needed; +# the ALPHA0_DEG/PLATE_CENTER constants below match those assets (generated +# by stl/transform_inclined_plate.py --alpha0-deg 0 --distance 0 and +# jfh/generate_sweep_jfh.py --alpha0-deg 0 --distance 0). The paper single +# pose lives in plume_inclined_plate (rpod_integration_test_07); the 60 deg +# sweep in plume_inclined_plate_sweep (the study script). +CASE_DIR = '../case/plume/plume_flat_plate_sweep/' +SUMMARY_PATH = _TESTS_DIR / 'plume' / 'output' / 'Cai2016' / \ + 'cai2016_sweep_error_summary.md' + +# Per-pose strike VTK export (opt-in for inspection in ParaView; off by +# default so the normal pytest run stays lean). Enable by setting the +# environment variable PYRPOD_SWEEP_VTK=1. Strikes follow the standard RPOD +# pipeline convention -- one .vtu per firing, numbered by JFH index: +# results/strikes/firing-.vtu (i = 0 .. 94, one pose each) +# results/strikes/sweep_LoD.pvd (ParaView collections; alpha as time) +WRITE_VTK = bool(os.environ.get('PYRPOD_SWEEP_VTK')) +_CASE_ROOT = _TESTS_DIR.parent / 'case' / 'plume' / 'plume_flat_plate_sweep' +STRIKES_DIR = _CASE_ROOT / 'results' / 'strikes' + +# Sweep definition (mirrors scripts/inclined_plate_sweep_study.py). +PLATE_CENTER = np.array([0.0, 0.0, 0.0]) +ALPHA0_DEG = 0.0 # the mesh's fixed global tilt +ALPHAS_DEG = np.arange(-90.0, 90.0 + 1e-9, 10.0) +L_OVER_D = [2.0, 4.0, 6.0, 8.0, 10.0] +COEFF_NAMES = ['CP', 'CF1', 'CF2', 'CQ', 'CM', 's_cc'] + +_A0 = np.deg2rad(ALPHA0_DEG) +NORMAL_OUT = np.array([-np.sin(_A0), 0.0, np.cos(_A0)]) # toward the VV side +TANGENT_TAU = np.array([np.cos(_A0), 0.0, np.sin(_A0)]) +TANGENT_S = np.array([0.0, 1.0, 0.0]) + + +def face_areas(vectors): + v0, v1, v2 = vectors[:, 0], vectors[:, 1], vectors[:, 2] + return 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis=1) + + +def pipeline_coeffs(result, centroids, areas, tau_face, thruster_pos, + alpha_deg): + """Eq.-15 averaged coefficients, per-face peaks, and the case-frame + per-face field arrays (for VTK) for one firing. + + Faithful to scripts/inclined_plate_sweep_study.py's pipeline_row. + Signed shear components are decomposed onto the plate (tau, s) axes + along the tangential projection of the radial flow direction; alpha > 0 + poses are sign-flipped (CF1/CM) into the paper convention. The returned + face_fields carry the un-flipped case-frame components, matching the + actual mesh (as the script's VTK export does). + """ + S_tot = float(np.sum(areas)) + Cp = result['pressures'] / piu.Q_DYN + Csh = result['shear_stress'] / piu.Q_DYN + Cq = result['heat_flux_rate'] / piu.Q_DYN_HEAT + + rel = centroids - thruster_pos + dist = np.linalg.norm(rel, axis=1) + u_hat = rel / dist[:, None] + n_in = -NORMAL_OUT + cos_inc = u_hat @ n_in + t_vec = u_hat - cos_inc[:, None] * n_in + t_norm = np.linalg.norm(t_vec, axis=1) + safe = t_norm > 1e-12 + t_hat = np.zeros_like(t_vec) + t_hat[safe] = t_vec[safe] / t_norm[safe, None] + Cf1 = Csh * (t_hat @ TANGENT_TAU) + Cf2 = Csh * (t_hat @ TANGENT_S) + + flip = -1.0 if alpha_deg > 0 else 1.0 # into the paper convention + CP = float(np.sum(Cp * areas) / S_tot) + CF1 = flip * float(np.sum(Cf1 * areas) / S_tot) + CF2 = float(np.sum(Cf2 * areas) / S_tot) + CQ = float(np.sum(Cq * areas) / S_tot) + CM = flip * float(np.sum(tau_face * Cp * areas) / (2.0 * piu.H_0 * S_tot)) + s_cc = CM / CP if CP != 0.0 else float('nan') + + face_fields = { + 'strikes': result['strikes'], + 'pressure_Pa': result['pressures'], + 'shear_Pa': result['shear_stress'], + 'heat_flux_W_m2': result['heat_flux_rate'], + 'Cp': Cp, + 'Cshear': Csh, + 'Cf1_case': Cf1, + 'Cf2_case': Cf2, + 'Cq': Cq, + } + return ({'CP': CP, 'CF1': CF1, 'CF2': CF2, 'CQ': CQ, 'CM': CM, + 's_cc': s_cc}, + {'peak_Cp': float(np.max(Cp)), 'peak_Cshear': float(np.max(Csh)), + 'peak_Cq': float(np.max(Cq)), + 'n_struck': int(np.count_nonzero(result['strikes']))}, + face_fields) + + +def write_pose_vtk(target, face_fields, vtk_dir, base_name): + """Write one pose's per-face fields to vtk_dir/.vtu via the + shared convert_stl_to_vtk writer (pyevtk needs C-contiguous float64).""" + cell_data = {k: np.ascontiguousarray(v, dtype=np.float64) + for k, v in face_fields.items()} + convert_stl_to_vtk(target, vtk_dir, filename=base_name, + cellData=cell_data) + + +def write_pvd(path, entries): + """ParaView collection grouping (timestep, relative_vtu_path) entries.""" + lines = ['', + '', ' '] + for timestep, rel_file in sorted(entries): + lines.append(f' ') + lines += [' ', ''] + path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + + +def reference_rows(): + """Exact Eq.-15 coefficients per (|alpha|, L), cached (mirror-invariant).""" + cache = {} + for L in L_OVER_D: + for abs_alpha in sorted({abs(a) for a in ALPHAS_DEG}): + alpha_paper = np.deg2rad(90.0 - abs_alpha) + cache[(abs_alpha, L)] = cai.averaged_coefficients( + piu.S_0, alpha_paper, piu.EPS, piu.R_0, L, piu.W_0, piu.H_0) + return cache + + +def check_mirror_symmetry(rows): + """Results must be mirror-symmetric in +/-alpha (paper convention). + + The +/-90 deg poses are excluded: their strike membership comes from + float32-epsilon signs in the facing test, which are not mirrored. + CF2 is checked separately -- it vanishes identically by symmetry, so + normalizing its asymmetry by its own (noise) scale is meaningless. + """ + worst = 0.0 + for L in L_OVER_D: + by_alpha = {r['alpha_deg']: r for r in rows if r['L_over_D'] == L + and abs(r['alpha_deg']) < 90.0} + cp_scale = max(abs(r['CP_pipe']) for r in by_alpha.values()) + # tolerance covers the triangulation's O(h^2) s-asymmetry (the + # cell-diagonal split is not mirror-invariant) + assert max(abs(r['CF2_pipe']) for r in by_alpha.values()) \ + < 1e-4 * cp_scale, f'CF2 not ~0 at L/D={L}' + for a in ALPHAS_DEG[(ALPHAS_DEG > 0) & (ALPHAS_DEG < 90.0)]: + for name in ('CP', 'CF1', 'CQ', 'CM'): + lo, hi = by_alpha[-a][f'{name}_pipe'], by_alpha[a][f'{name}_pipe'] + scale = max(abs(v) for r in by_alpha.values() + for v in [r[f'{name}_pipe']]) + scale = max(scale, 1e-6 * cp_scale) + worst = max(worst, abs(hi - lo) / scale) + # tolerance: the JFH file stores DCMs to 6 significant digits (and + # positions to 9), so mirrored poses reproduce to ~1e-6 relative; + # a convention error would show up as O(1). + assert worst < 1e-5, f'mirror symmetry violated: {worst:.3g}' + return worst + + +def reference_envelope(rows): + """Plate-averaged pipeline-vs-reference relative error over the + non-edge-on poses (|alpha| < 90). Denominators are floored at 5% of + each coefficient's own reference peak so the near-zero angles do not + divide by ~0 (the significant_mask convention). Returns + {name: (max_rel, mean_rel, n_poses)} for CP/CF1/CQ.""" + sub = [r for r in rows if abs(r['alpha_deg']) < 90.0] + out = {} + for name in ('CP', 'CF1', 'CQ'): + pipe = np.array([r[f'{name}_pipe'] for r in sub]) + ref = np.array([r[f'{name}_ref'] for r in sub]) + scale = np.maximum(np.abs(ref), 0.05 * np.max(np.abs(ref))) + rel = np.abs(pipe - ref) / scale + out[name] = (float(np.max(rel)), float(np.mean(rel)), len(sub)) + return out + + +class InclinedPlateSweepVerification(unittest.TestCase): + + def test_sweep_vs_cai2016_reference(self): + # 1. Set up the flat-sweep case; its config.ini already selects the + # flat STL + 95-firing angle x distance JFH, so no override is needed. + jfh = JetFiringHistory.JetFiringHistory(CASE_DIR) + jfh.read_jfh() + + tv = TargetVehicle.TargetVehicle(CASE_DIR) + tv.set_stl() + + vv = VisitingVehicle.VisitingVehicle(CASE_DIR) + vv.set_thruster_config() + vv.set_thruster_metrics() + + me = MissionEnvironment.MissionEnvironment(CASE_DIR) + + n_firings = len(jfh.JFH) + self.assertEqual( + n_firings, len(ALPHAS_DEG) * len(L_OVER_D), + msg=f'unexpected sweep JFH length {n_firings}; regenerate with ' + 'case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py ' + '--alpha0-deg 0 --distance 0 --out jfh_flat_plate_sweep.A') + + # 2. Precompute the plate geometry once (poses share one mesh). + target = tv.mesh + n_faces = len(target.vectors) + normals = target.get_unit_normals() + centroids = compute_face_centroids(target.vectors) + areas = face_areas(target.vectors) + tau_face = (centroids - PLATE_CENTER) @ TANGENT_TAU + + # 3. Exact reference coefficients, cached per (|alpha|, L). + t0 = time.perf_counter() + ref_cache = reference_rows() + t_ref = time.perf_counter() - t0 + + # 4. Run every firing through the per-firing pipeline core. When + # PYRPOD_SWEEP_VTK is set, also write each pose's per-face strikes + # and loads to results/strikes/firing-.vtu (one file per firing, + # numbered by JFH index -- the standard RPOD strike convention; + # independent poses, no cumulative accumulation). + if WRITE_VTK: + STRIKES_DIR.mkdir(parents=True, exist_ok=True) + pvd_entries = {} # L/D -> [(alpha_deg, relative vtu path)] + + rows = [] + t0 = time.perf_counter() + for i in range(n_firings): + L = L_OVER_D[i // len(ALPHAS_DEG)] + alpha_deg = float(ALPHAS_DEG[i % len(ALPHAS_DEG)]) + step = {'thrusters': jfh.JFH[i]['thrusters'], + 'xyz': np.array(jfh.JFH[i]['xyz']), + 'dcm': np.array(jfh.JFH[i]['dcm']), + 't': float(jfh.JFH[i]['t'])} + result = compute_plume_strikes(target, normals, vv, step, me, + face_centroids=centroids) + coeffs, peaks, face_fields = pipeline_coeffs( + result, centroids, areas, tau_face, step['xyz'], alpha_deg) + ref = ref_cache[(abs(alpha_deg), L)] + row = {'alpha_deg': alpha_deg, 'L_over_D': L, **peaks} + for name in COEFF_NAMES: + row[f'{name}_pipe'] = coeffs[name] + row[f'{name}_ref'] = float(ref[name]) + rows.append(row) + + if WRITE_VTK: + base = f'firing-{i}' # strict JFH-index numbering + write_pose_vtk(target, face_fields, STRIKES_DIR, base) + pvd_entries.setdefault(L, []).append( + (alpha_deg, f'{base}.vtu')) + t_sweep = time.perf_counter() - t0 + + if WRITE_VTK: + for L, entries in pvd_entries.items(): + write_pvd(STRIKES_DIR / f'sweep_LoD{int(L):02d}.pvd', entries) + print(f'[cai2016-sweep] wrote {n_firings} per-pose strike VTK ' + f'files to {STRIKES_DIR} (+ {len(pvd_entries)} ' + f'sweep_LoD*.pvd collections)') + + # 5. Report the edge-on poses (excluded from the gates: strike + # membership is epsilon-degenerate there, see the script docstring). + head_on = {r['L_over_D']: r['CP_pipe'] for r in rows + if r['alpha_deg'] == 0.0} + for r in rows: + if abs(r['alpha_deg']) == 90.0: + print(f"[cai2016-sweep] edge-on alpha={r['alpha_deg']:+.0f}, " + f"L/D={r['L_over_D']:g}: {r['n_struck']} faces pass the " + f"epsilon-degenerate facing test; CP={r['CP_pipe']:.3e} " + f"(head-on {head_on[r['L_over_D']]:.3e})") + + # 6. Hard convention gates. + worst = check_mirror_symmetry(rows) + self.assertLess(worst, 1e-5, + msg=f'mirror symmetry violated: {worst:.3g}') + print(f'[cai2016-sweep] mirror-symmetry worst normalized asymmetry ' + f'{worst:.2e}') + + # 7. Quantitative documentation of the pipeline-vs-reference gap. + env = reference_envelope(rows) + for name in ('CP', 'CF1', 'CQ'): + mx, mn, n = env[name] + print(f'[cai2016-sweep] {name}: max rel err {mx:.3f}, ' + f'mean rel err {mn:.3f} (over {n} non-edge-on poses)') + print(f'[cai2016-sweep] timings: sweep {t_sweep:.1f} s ' + f'({t_sweep / n_firings * 1e3:.0f} ms/firing, {n_faces} faces), ' + f'reference {t_ref:.1f} s') + + SUMMARY_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SUMMARY_PATH, 'w', encoding='utf-8', newline='') as fh: + fh.write('# Cai 2016 inclined-plate sweep error summary ' + '(strike pipeline vs exact reference)\n\n') + fh.write('Written by tests/rpod/rpod_verification_test_06.py: ' + 'plate-averaged Eq.-15 coefficients from the ' + 'PlumeStrikeEstimationStudy per-firing core ' + '(compute_plume_strikes; Simplified kinetics + ' + 'Maxwellian wall model at the true incidence angle) ' + 'over the 95-firing angle x distance sweep, compared ' + 'against pyrpod/plume/CaiImpingement2016.py ' + f'(Eq. 15 quadrature). {len(ALPHAS_DEG)} approach ' + f'angles x {len(L_OVER_D)} distances, {n_faces} faces, ' + f'sweep wall time {t_sweep:.1f} s.\n\n') + fh.write(f'Mirror-symmetry worst normalized asymmetry: ' + f'{worst:.2e} (gate < 1e-5).\n\n') + fh.write('| quantity | max_rel_err | mean_rel_err | poses |\n') + fh.write('|---|---|---|---|\n') + for name in ('CP', 'CF1', 'CQ'): + mx, mn, n = env[name] + fh.write(f'| {name} | {mx:.4g} | {mn:.4g} | {n} |\n') + fh.write('\nPlate-averaged relative errors over the non-edge-on ' + 'poses (|alpha| < 90 deg), denominators floored at 5% ' + "of each coefficient's reference peak. The gap is the " + 'documented accuracy of the Maxwellian engineering ' + 'chain vs the exact collisionless solution -- not a ' + 'regression gate.\n') + print(f'[cai2016-sweep] wrote {SUMMARY_PATH}') + + # 8. Loose envelope so a convention regression (e.g. a dropped + # paper-frame sign flip) fails loudly while the normal + # Maxwellian-chain gap passes. Calibrated from the measured run + # (CP tracks reference ~1-3%, CF1 ~5%). + self.assertLess(env['CP'][1], 0.08, + msg='mean plate-averaged CP error vs reference far ' + 'above the documented Maxwellian-chain gap') + self.assertLess(env['CF1'][1], 0.15, + msg='mean plate-averaged CF1 error vs reference far ' + 'above the documented Maxwellian-chain gap') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/rpod/rpod_verification_test_07.py b/tests/rpod/rpod_verification_test_07.py new file mode 100644 index 0000000..57942ea --- /dev/null +++ b/tests/rpod/rpod_verification_test_07.py @@ -0,0 +1,112 @@ +# ======================== +# PyRPOD: tests/rpod/rpod_verification_test_07.py +# ======================== +# Cylinder-target sweep SMOKE test: exercises the strike pipeline on a +# CURVED, closed target (case/plume/plume_cylinder_sweep -- the shared +# high-res cylinder mesh data/stl/cylinder.stl: 14036 faces, radius 2 m, +# axis along X in [-7, 0], centroid (-3.5, 0, 0)) instead of a flat plate. The +# visiting vehicle (single argon thruster) is swept over 19 approach +# angles x 5 orbit radii (95 firings; jfh_cylinder_sweep.A) about the +# cylinder centroid, and the full PlumeStrikeEstimationStudy pipeline +# (jfh_plume_strikes) is run end-to-end. +# +# This confirms the case RUNS -- the pipeline loads the cylinder, sweeps +# every pose, and produces per-firing strikes -- NOT that the loads are +# physically meaningful (the orbit is a geometric smoke sweep, not a +# validated impingement scenario). Assertions therefore gate only on the +# pipeline completing with well-formed per-firing results and the sweep +# actually illuminating the cylinder. +# +# Run: python -m pytest rpod/rpod_verification_test_07.py -s (from tests/) + +import sys +import time +import unittest +from pathlib import Path + +import numpy as np + +_TESTS_DIR = Path(__file__).resolve().parents[1] +if str(_TESTS_DIR.parent) not in sys.path: # repo root for direct runs + sys.path.insert(0, str(_TESTS_DIR.parent)) + +from pyrpod.mission import MissionEnvironment # noqa: E402 +from pyrpod.rpod import ( # noqa: E402 + JetFiringHistory, + PlumeStrikeEstimationStudy, +) +from pyrpod.vehicle import TargetVehicle, VisitingVehicle # noqa: E402 + +CASE_DIR = '../case/plume/plume_cylinder_sweep/' +N_ANGLES = 19 +N_RADII = 5 +N_FACES = 14036 # data/stl/cylinder.stl (high-res target mesh) + + +class CylinderSweepSmoke(unittest.TestCase): + + def test_cylinder_sweep_runs(self): + # 1. Set up the case (minimal driver, as in rpod_integration_test_07); + # the case config already selects the cylinder STL + sweep JFH. + jfh = JetFiringHistory.JetFiringHistory(CASE_DIR) + jfh.read_jfh() + + tv = TargetVehicle.TargetVehicle(CASE_DIR) + tv.set_stl() + + vv = VisitingVehicle.VisitingVehicle(CASE_DIR) + vv.set_thruster_config() + vv.set_thruster_metrics() + + me = MissionEnvironment.MissionEnvironment(CASE_DIR) + + study = PlumeStrikeEstimationStudy.PlumeStrikeEstimationStudy(me) + study.study_init(jfh, tv, vv) + + # 2. Geometry sanity: the 95-firing sweep on the cylinder mesh. + n_firings = len(jfh.JFH) + self.assertEqual(n_firings, N_ANGLES * N_RADII, + msg=f'unexpected sweep length {n_firings}; regenerate ' + 'with case/plume/plume_cylinder_sweep/jfh/' + 'generate_cylinder_sweep_jfh.py') + n_faces = len(tv.mesh.vectors) + self.assertEqual(n_faces, N_FACES, + msg=f'unexpected target mesh ({n_faces} faces); ' + 'expected the cylinder_transformed.stl target') + + # 3. Execute the full pipeline end-to-end. + t0 = time.perf_counter() + firing_data = study.jfh_plume_strikes() + dt = time.perf_counter() - t0 + + # 4. Every firing produced well-formed per-face results. + self.assertEqual(len(firing_data), n_firings) + total_strikes = 0 + for key, cell in firing_data.items(): + strikes = np.asarray(cell['strikes']) + self.assertEqual(strikes.shape, (n_faces,), + msg=f'firing {key}: bad strikes shape') + # strikes are a 0/1 membership mask + self.assertTrue(np.all((strikes == 0) | (strikes == 1)), + msg=f'firing {key}: strikes not binary') + # cumulative strikes never fall below this firing's own strikes + cum = np.asarray(cell['cum_strikes']) + self.assertTrue(np.all(cum >= strikes), + msg=f'firing {key}: cum_strikes < strikes') + total_strikes += int(strikes.sum()) + + # 5. The sweep must actually illuminate the cylinder somewhere + # (a geometry/aiming regression would strike zero faces everywhere). + self.assertGreater(total_strikes, 0, + msg='no faces struck across the whole sweep -- ' + 'the orbit never illuminates the cylinder') + + peak = max(int(np.asarray(c['strikes']).sum()) + for c in firing_data.values()) + print(f'[cylinder-sweep] {n_firings} firings x {n_faces} faces ran in ' + f'{dt:.2f} s; {total_strikes} total face-strikes, peak ' + f'{peak}/{n_faces} struck in a single firing') + + +if __name__ == '__main__': + unittest.main()