PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
wall_normal_profile.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""!
3@file wall_normal_profile.py
4@brief Reduce a field-statistics window into wall-normal profiles for DNS comparison.
5
6@details
7`field_statistics` accumulates per-cell time moments and is BC-agnostic, so it
8works unchanged under periodic boundaries. What the postprocessor does not have
9is a *spatial* reduction: nothing averages a statistics field over homogeneous
10directions. Comparing against a channel DNS needs exactly that, so this script
11does the reduction outside the postprocessor, reading the window payloads
12directly.
13
14It averages the window's mean and second-moment fields over the two homogeneous
15(periodic) directions, converts to wall units using the friction velocity implied
16by the driven body force, and writes a CSV of
17
18 y, y+, U+, u'+, v'+, w'+, -<u'v'>+
19
20together with the log-law and viscous-sublayer reference curves.
21
22The PICGRID and PETSc-binary readers are imported from `generators/spectra.gen`
23rather than duplicated; that module already owns the DMDA interior-extraction
24convention (a cell-centred payload is sized `(IM+1, JM+1, KM+1)` and the physical
25interior is `[1:KM, 1:JM, 1:IM]`).
26
27Usage:
28
29@code
30 wall_normal_profile.py --checkpoint CHECKPOINT_DIR --window stationary \\
31 --grid GRID.picgrid --wall-axis Eta --stream-axis Zeta \\
32 --viscosity 3.5714286e-04 --output profile.csv
33@endcode
34"""
35
36import argparse
37import importlib.machinery
38import importlib.util
39import math
40import os
41import sys
42
43REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
44SPECTRA_GEN = os.path.join(REPO_ROOT, "generators", "spectra.gen")
45
46# Axis token -> index into the (k, j, i) array order the readers produce.
47AXIS_TO_KJI = {"Xi": 2, "Eta": 1, "Zeta": 0}
48# Axis token -> Cartesian velocity component index.
49AXIS_TO_COMPONENT = {"Xi": 0, "Eta": 1, "Zeta": 2}
50
51
53 """!
54 @brief Import the PICGRID and PETSc-Vec readers from generators/spectra.gen.
55 @return The loaded module.
56 """
57 if not os.path.isfile(SPECTRA_GEN):
58 raise SystemExit(f"cannot find {SPECTRA_GEN}; run this from a PICurv checkout.")
59 loader = importlib.machinery.SourceFileLoader("picurv_spectra_gen", SPECTRA_GEN)
60 spec = importlib.util.spec_from_loader("picurv_spectra_gen", loader)
61 module = importlib.util.module_from_spec(spec)
62 loader.exec_module(module)
63 return module
64
65
66def find_payload(checkpoint_dir, window, field, moment, block):
67 """!
68 @brief Locate one statistics-window payload inside a checkpoint bundle.
69 @param[in] checkpoint_dir Checkpoint directory holding the window payloads.
70 @param[in] window Window name from monitor.yml.
71 @param[in] field Field name, e.g. Ucat.
72 @param[in] moment Moment name, e.g. first or second.
73 @param[in] block Block index.
74 @return Absolute path to the payload file.
75 """
76 wanted = (window.lower(), field.lower(), moment.lower())
77 candidates = []
78 for root, _dirs, files in os.walk(checkpoint_dir):
79 for name in files:
80 if not name.endswith(".dat"):
81 continue
82 haystack = os.path.join(root, name).lower()
83 if all(token in haystack for token in wanted) and f"block_{block:04d}" in haystack:
84 candidates.append(os.path.join(root, name))
85 if not candidates:
86 raise SystemExit(
87 f"no payload found for window={window!r} field={field!r} moment={moment!r} "
88 f"block={block} under {checkpoint_dir}.\n"
89 "Check that monitor.yml requested this moment and that the window reached "
90 "'active' or 'complete' before the checkpoint was written."
91 )
92 if len(candidates) > 1:
93 raise SystemExit("ambiguous payloads:\n " + "\n ".join(sorted(candidates)))
94 return candidates[0]
95
96
97def homogeneous_average(numpy, field, wall_kji):
98 """!
99 @brief Average a cell array over both directions that are not the wall-normal one.
100 @param[in] numpy The numpy module.
101 @param[in] field Cell array shaped (nk, nj, ni) or (nk, nj, ni, 3).
102 @param[in] wall_kji Index of the wall-normal axis in (k, j, i) order.
103 @return Array indexed by the wall-normal coordinate, keeping any trailing component axis.
104 """
105 homogeneous = tuple(axis for axis in (0, 1, 2) if axis != wall_kji)
106 return numpy.mean(field, axis=homogeneous)
107
108
109def main(argv=None):
110 """!
111 @brief Entry point.
112 @param[in] argv Command-line style argument list supplied to the function.
113 @return Process exit status.
114 """
115 parser = argparse.ArgumentParser(description=__doc__,
116 formatter_class=argparse.RawDescriptionHelpFormatter)
117 parser.add_argument("--checkpoint", required=True,
118 help="Checkpoint directory containing the statistics window payloads.")
119 parser.add_argument("--grid", required=True, help="Canonical PICGRID path for the run.")
120 parser.add_argument("--window", default="stationary", help="Statistics window name.")
121 parser.add_argument("--block", type=int, default=0, help="Block index.")
122 parser.add_argument("--wall-axis", default="Eta", choices=sorted(AXIS_TO_KJI),
123 help="Wall-normal axis token.")
124 parser.add_argument("--stream-axis", default="Zeta", choices=sorted(AXIS_TO_KJI),
125 help="Streamwise (driven) axis token.")
126 parser.add_argument("--viscosity", type=float, required=True,
127 help="Kinematic viscosity in solver units (1/Re for length_ref=velocity_ref=1).")
128 parser.add_argument("--half-height", type=float, default=1.0,
129 help="Channel half-height h in solver units.")
130 parser.add_argument("--body-force", type=float,
131 help="Converged driving body force f. If given, u_tau = sqrt(f*h) is used "
132 "instead of the wall-shear estimate, which is the exact mean force balance.")
133 parser.add_argument("--output", required=True, help="Output CSV path.")
134 args = parser.parse_args(argv)
135
136 helpers = load_spectra_helpers()
137 numpy = helpers.require_numpy()
138
139 blocks = helpers.read_picgrid_blocks(args.grid)
140 if args.block >= len(blocks):
141 raise SystemExit(f"block {args.block} is out of range; the grid holds {len(blocks)}.")
142 node_dims = blocks[args.block]["dims"]
143 nodes = blocks[args.block]["coords"]
144
145 wall_kji = AXIS_TO_KJI[args.wall_axis]
146 stream_c = AXIS_TO_COMPONENT[args.stream_axis]
147 wall_c = AXIS_TO_COMPONENT[args.wall_axis]
148 span_c = ({0, 1, 2} - {stream_c, wall_c}).pop()
149
150 mean_path = find_payload(args.checkpoint, args.window, "Ucat", "first", args.block)
151 second_path = find_payload(args.checkpoint, args.window, "Ucat", "second", args.block)
152 mean = helpers.extract_interior_cells(helpers.read_petsc_vec_binary(mean_path), node_dims)
153 second = helpers.extract_interior_cells(helpers.read_petsc_vec_binary(second_path), node_dims)
154
155 # <u_i> and <u_i u_j> both reduce over the homogeneous directions; the
156 # fluctuation intensities then follow as <u_i^2> - <u_i>^2. Averaging the
157 # moments before differencing (rather than after) is what makes this a true
158 # ensemble average over the homogeneous plane as well as over time.
159 mean_p = homogeneous_average(numpy, mean, wall_kji)
160 second_p = homogeneous_average(numpy, second, wall_kji)
161 variance = numpy.maximum(second_p - mean_p ** 2, 0.0)
162
163 # Wall-normal cell-centre coordinates, taken from the node coordinates of the
164 # wall-normal axis. nodes is (KM, JM, IM, 3) in the same (k, j, i) order.
165 axis_nodes = {0: nodes[:, 0, 0, :], 1: nodes[0, :, 0, :], 2: nodes[0, 0, :, :]}[wall_kji]
166 axis_coord = axis_nodes[:, wall_c]
167 y_centres = 0.5 * (axis_coord[:-1] + axis_coord[1:])
168 if y_centres.size != mean_p.shape[0]:
169 raise SystemExit(f"wall-normal cell count mismatch: grid gives {y_centres.size}, "
170 f"statistics give {mean_p.shape[0]}.")
171
172 nu, h = args.viscosity, args.half_height
173 if args.body_force is not None:
174 u_tau = math.sqrt(args.body_force * h)
175 source = f"sqrt(f*h) with f={args.body_force:.8e}"
176 else:
177 # Wall-shear estimate from the first cell: du/dy at the wall.
178 dudy = abs(mean_p[0, stream_c]) / abs(y_centres[0] - axis_coord[0])
179 u_tau = math.sqrt(nu * dudy)
180 source = "sqrt(nu*du/dy) from the first cell (approximate; prefer --body-force)"
181 if u_tau <= 0.0:
182 raise SystemExit("computed a non-positive friction velocity; check the inputs.")
183
184 wall_distance = numpy.minimum(y_centres - axis_coord[0], axis_coord[-1] - y_centres)
185 with open(args.output, "w", encoding="utf-8") as handle:
186 handle.write(f"# u_tau = {u_tau:.10e} ({source})\n")
187 handle.write(f"# Re_tau = u_tau*h/nu = {u_tau * h / nu:.6f}\n")
188 handle.write(f"# nu = {nu:.10e}, h = {h:.10e}, window = {args.window}\n")
189 handle.write("y,y_plus,U_plus,u_rms_plus,v_rms_plus,w_rms_plus,"
190 "uv_plus,U_plus_loglaw,U_plus_sublayer\n")
191 for index in range(y_centres.size):
192 y_plus = wall_distance[index] * u_tau / nu
193 loglaw = (1.0 / 0.41) * math.log(y_plus) + 5.2 if y_plus > 0.0 else float("nan")
194 handle.write(
195 f"{y_centres[index]:.10e},{y_plus:.10e},"
196 f"{mean_p[index, stream_c] / u_tau:.10e},"
197 f"{math.sqrt(variance[index, stream_c]) / u_tau:.10e},"
198 f"{math.sqrt(variance[index, wall_c]) / u_tau:.10e},"
199 f"{math.sqrt(variance[index, span_c]) / u_tau:.10e},"
200 f"{(second_p[index, stream_c] - mean_p[index, stream_c] ** 2) / (u_tau ** 2):.10e},"
201 f"{loglaw:.10e},{y_plus:.10e}\n")
202
203 print(f"u_tau = {u_tau:.10e} ({source})")
204 print(f"Re_tau = {u_tau * h / nu:.4f}")
205 print(f"wrote {y_centres.size} wall-normal stations to {args.output}")
206 return 0
207
208
209if __name__ == "__main__":
210 sys.exit(main())
load_spectra_helpers()
Import the PICGRID and PETSc-Vec readers from generators/spectra.gen.
main(argv=None)
Entry point.
homogeneous_average(numpy, field, wall_kji)
Average a cell array over both directions that are not the wall-normal one.
find_payload(checkpoint_dir, window, field, moment, block)
Locate one statistics-window payload inside a checkpoint bundle.