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 from one committed checkpoint bundle.
13
14The window is looked up by name in the bundle's `checkpoint.meta`, which also
15supplies its sample count and effective time bounds; its payloads are
16`statistics/window_NNNN/block_NNNN/{Ucat_mean,Ucat_m2,weight}.dat`. `Ucat_m2`
17holds the six centred, weighted sums M2 in the order (xx, xy, xz, yy, yz, zz),
18so the per-cell temporal covariance is M2/W. The covariance over the
19homogeneous plane adds the spatial covariance of the per-cell time means:
20
21 <u_a' u_b'> = mean(M2_ab / W) + mean(U_a U_b) - mean(U_a) mean(U_b)
22
23where mean() runs over the two directions that are not wall-normal. The two
24walls are then folded onto one half-channel (the Reynolds shear stress changes
25sign under the reflection), and the result is written as a CSV of
26
27 y, y+, U+, u'+, v'+, w'+, -<u'v'>+
28
29together with the log-law and viscous-sublayer reference curves.
30
31The friction velocity comes from one of:
32
33- `--body-force f`: u_tau = sqrt(f h), the exact mean force balance of a
34 body-force-driven channel;
35- `--wall-model-csv`: the run's `wall_model.csv`, as sqrt(<u_tau^2>) over the
36 rows inside the window's effective bounds, which is the mean wall shear a
37 wall-modelled run actually applies (a wall-modelled LES resolves no wall
38 gradient to fit);
39- `--u-tau`: a value supplied directly;
40- otherwise sqrt(nu dU/dy) from the first cell, which is only meaningful when the
41 first cell sits in the viscous sublayer.
42
43The PICGRID and PETSc-binary readers are imported from `generators/spectra.gen`,
44which owns the DMDA interior-extraction convention (a cell-centred payload is
45sized `(IM+1, JM+1, KM+1)` and the physical interior is `[1:KM, 1:JM, 1:IM]`), and
46the metadata parser from `picurv_cli`, rather than duplicated.
47
48Usage:
49
50@code
51 wall_normal_profile.py \\
52 --checkpoint RUN/output/checkpoints/step_000000050000 --window stationary \\
53 --grid RUN/inputs/grid/grid.run --wall-axis Eta --stream-axis Zeta \\
54 --viscosity 5.0e-05 --wall-model-csv RUN/output/analysis/metrics/wall_model.csv \\
55 --output profile.csv
56@endcode
57"""
58
59import argparse
60import csv
61import importlib.machinery
62import importlib.util
63import json
64import math
65import os
66import sys
67
69 """!
70 @brief Locate the PICurv checkout from the shipped example or from a case `picurv init` made.
71 @details In the repository this file sits four levels below the root. A copy inside an
72 initialized case does not, so the case's `.picurv-origin.json`, found by
73 walking up from this file, supplies the checkout it was made from.
74 @return Absolute path to the checkout.
75 """
76 here = os.path.dirname(os.path.abspath(__file__))
77 shipped = os.path.abspath(os.path.join(here, "..", "..", "..", ".."))
78 if os.path.isfile(os.path.join(shipped, "generators", "spectra.gen")):
79 return shipped
80 current = here
81 while True:
82 origin = os.path.join(current, ".picurv-origin.json")
83 if os.path.isfile(origin):
84 with open(origin, encoding="utf-8") as handle:
85 return json.load(handle).get("source_repo_root", shipped)
86 parent = os.path.dirname(current)
87 if parent == current:
88 return shipped
89 current = parent
90
91
92REPO_ROOT = find_repo_root()
93SPECTRA_GEN = os.path.join(REPO_ROOT, "generators", "spectra.gen")
94
95# Axis token -> index into the (k, j, i) array order the readers produce.
96AXIS_TO_KJI = {"Xi": 2, "Eta": 1, "Zeta": 0}
97# Axis token -> Cartesian velocity component index.
98AXIS_TO_COMPONENT = {"Xi": 0, "Eta": 1, "Zeta": 2}
99# Component pairs of a dof-6 second-moment payload, in its stored order.
100M2_PAIRS = ((0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2))
101
102
104 """!
105 @brief Import the PICGRID and PETSc-Vec readers from generators/spectra.gen.
106 @return The loaded module.
107 """
108 if not os.path.isfile(SPECTRA_GEN):
109 raise SystemExit(f"cannot find {SPECTRA_GEN}; run this from a PICurv checkout "
110 "or a case initialized from one.")
111 loader = importlib.machinery.SourceFileLoader("picurv_spectra_gen", SPECTRA_GEN)
112 spec = importlib.util.spec_from_loader("picurv_spectra_gen", loader)
113 module = importlib.util.module_from_spec(spec)
114 loader.exec_module(module)
115 return module
116
117
118def read_window(checkpoint_dir, window, block):
119 """!
120 @brief Resolve a statistics window by name inside one committed checkpoint bundle.
121 @param[in] checkpoint_dir Checkpoint bundle directory holding `checkpoint.meta`.
122 @param[in] window Window name from monitor.yml.
123 @param[in] block Block index.
124 @return Tuple of (payload directory, window metadata dict, block node dims or None).
125 """
126 sys.path.insert(0, REPO_ROOT)
127 from picurv_cli.core import _read_checkpoint_options
128
129 metadata = os.path.join(checkpoint_dir, "checkpoint.meta")
130 if not os.path.isfile(metadata):
131 raise SystemExit(f"{checkpoint_dir} is not a committed checkpoint bundle: no checkpoint.meta.")
132 options = _read_checkpoint_options(metadata)
133 names = []
134 for index in range(int(options.get("checkpoint_statistics_window_count", 0) or 0)):
135 prefix = f"checkpoint_statistics_window_{index}_"
136 names.append(options.get(prefix + "name"))
137 if names[-1] != window:
138 continue
139 info = {key[len(prefix):]: value for key, value in options.items() if key.startswith(prefix)}
140 payload_dir = os.path.join(checkpoint_dir, "statistics", f"window_{index:04d}", f"block_{block:04d}")
141 if not os.path.isdir(payload_dir):
142 raise SystemExit(f"window {window!r} has no payloads for block {block} under {payload_dir}.")
143 dims = None
144 if f"checkpoint_block_{block}_im" in options:
145 dims = tuple(int(options[f"checkpoint_block_{block}_{axis}"]) for axis in ("im", "jm", "km"))
146 return payload_dir, info, dims
147 raise SystemExit(f"no statistics window named {window!r} in {metadata}; it holds {names}.")
148
149
150def homogeneous_statistics(checkpoint_dir, window_name, grid_path, block, homogeneous):
151 """!
152 @brief Reduce one statistics window's velocity moments over homogeneous directions.
153 @details The covariance over the homogeneous set is the mean of the per-cell
154 temporal covariance M2/W plus the spatial covariance of the per-cell time
155 means: <u_a' u_b'> = mean(M2_ab / W) + mean(U_a U_b) - mean(U_a) mean(U_b).
156 @param[in] checkpoint_dir Committed checkpoint bundle directory.
157 @param[in] window_name Statistics window name from monitor.yml.
158 @param[in] grid_path Canonical PICGRID path for the run.
159 @param[in] block Block index.
160 @param[in] homogeneous Axes to average over, as indices in (k, j, i) order.
161 @return Dict with `numpy`, `nodes` ((KM, JM, IM, 3) node coordinates), `window`
162 (its checkpoint.meta scalars), `mean` (remaining axes + (3,)) and
163 `covariance` (remaining axes + (6,), in M2_PAIRS order).
164 """
165 helpers = load_spectra_helpers()
166 numpy = helpers.require_numpy()
167 payload_dir, window, meta_dims = read_window(checkpoint_dir, window_name, block)
168 blocks = helpers.read_picgrid_blocks(grid_path)
169 if block >= len(blocks):
170 raise SystemExit(f"block {block} is out of range; the grid holds {len(blocks)}.")
171 node_dims = blocks[block]["dims"]
172 if meta_dims is not None and tuple(meta_dims) != tuple(node_dims):
173 raise SystemExit(f"grid dimensions {node_dims} do not match the checkpoint's {meta_dims}.")
174
175 def payload(name, components):
176 values = helpers.read_petsc_vec_binary(os.path.join(payload_dir, f"{name}.dat"))
177 return helpers.extract_interior_cells(values, node_dims, components)
178
179 mean = payload("Ucat_mean", 3)
180 m2 = payload("Ucat_m2", 6)
181 weight = payload("weight", 1)[..., 0]
182 if not numpy.all(weight > 0.0):
183 raise SystemExit(f"window {window_name!r} has cells with no accepted weight.")
184
185 reduced_mean = numpy.mean(mean, axis=homogeneous)
186 temporal = numpy.mean(m2 / weight[..., None], axis=homogeneous)
187 spatial = numpy.stack(
188 [numpy.mean(mean[..., a] * mean[..., b], axis=homogeneous)
189 - reduced_mean[..., a] * reduced_mean[..., b] for a, b in M2_PAIRS], axis=-1)
190 return {"numpy": numpy, "nodes": blocks[block]["coords"], "window": window,
191 "mean": reduced_mean, "covariance": temporal + spatial}
192
193
194def friction_velocity_from_wall_model(csv_path, start, end):
195 """!
196 @brief Mean wall shear the wall model applied over a time interval, as a velocity.
197 @details Each wall_model.csv row carries the wall-face mean and standard deviation
198 of u_tau, so the row's mean of u_tau^2 is mean^2 + rms^2.
199 @param[in] csv_path Path to the run's wall_model.csv.
200 @param[in] start Window effective start, in solver time.
201 @param[in] end Window effective end, in solver time.
202 @return Tuple of (sqrt(<u_tau^2>), number of rows used).
203 """
204 total, rows = 0.0, 0
205 with open(csv_path, newline="", encoding="utf-8") as handle:
206 for row in csv.DictReader(handle):
207 if start <= float(row["time"]) <= end:
208 total += float(row["u_tau_mean"]) ** 2 + float(row["u_tau_rms"]) ** 2
209 rows += 1
210 if rows == 0:
211 raise SystemExit(f"{csv_path} has no rows with time in the window [{start}, {end}].")
212 return math.sqrt(total / rows), rows
213
214
215def main(argv=None):
216 """!
217 @brief Entry point.
218 @param[in] argv Command-line style argument list supplied to the function.
219 @return Process exit status.
220 """
221 parser = argparse.ArgumentParser(description=__doc__,
222 formatter_class=argparse.RawDescriptionHelpFormatter)
223 parser.add_argument("--checkpoint", required=True,
224 help="Committed checkpoint bundle directory (output/checkpoints/step_NNN).")
225 parser.add_argument("--grid", required=True, help="Canonical PICGRID path for the run.")
226 parser.add_argument("--window", default="stationary", help="Statistics window name.")
227 parser.add_argument("--block", type=int, default=0, help="Block index.")
228 parser.add_argument("--wall-axis", default="Eta", choices=sorted(AXIS_TO_KJI),
229 help="Wall-normal axis token.")
230 parser.add_argument("--stream-axis", default="Zeta", choices=sorted(AXIS_TO_KJI),
231 help="Streamwise (driven) axis token.")
232 parser.add_argument("--viscosity", type=float, required=True,
233 help="Kinematic viscosity in solver units (1/Re for length_ref=velocity_ref=1).")
234 parser.add_argument("--half-height", type=float, default=1.0,
235 help="Channel half-height h in solver units.")
236 source = parser.add_mutually_exclusive_group()
237 source.add_argument("--body-force", type=float,
238 help="Converged driving body force f; u_tau = sqrt(f*h), the exact mean "
239 "force balance.")
240 source.add_argument("--wall-model-csv",
241 help="The run's wall_model.csv; u_tau = sqrt(<u_tau^2>) over the window.")
242 source.add_argument("--u-tau", type=float, help="Friction velocity supplied directly.")
243 parser.add_argument("--output", required=True, help="Output CSV path.")
244 args = parser.parse_args(argv)
245
246 wall_kji = AXIS_TO_KJI[args.wall_axis]
247 stream_c = AXIS_TO_COMPONENT[args.stream_axis]
248 wall_c = AXIS_TO_COMPONENT[args.wall_axis]
249 span_c = ({0, 1, 2} - {stream_c, wall_c}).pop()
250 homogeneous = tuple(axis for axis in (0, 1, 2) if axis != wall_kji)
251
252 stats = homogeneous_statistics(args.checkpoint, args.window, args.grid, args.block, homogeneous)
253 numpy, nodes, window = stats["numpy"], stats["nodes"], stats["window"]
254 plane_mean = stats["mean"]
255
256 def covariance(a, b):
257 return stats["covariance"][:, M2_PAIRS.index(tuple(sorted((a, b))))]
258
259 # Wall-normal cell-centre coordinates, taken from the node coordinates of the
260 # wall-normal axis. nodes is (KM, JM, IM, 3) in the same (k, j, i) order.
261 axis_nodes = {0: nodes[:, 0, 0, :], 1: nodes[0, :, 0, :], 2: nodes[0, 0, :, :]}[wall_kji]
262 axis_coord = axis_nodes[:, wall_c]
263 y_centres = 0.5 * (axis_coord[:-1] + axis_coord[1:])
264 if y_centres.size != plane_mean.shape[0]:
265 raise SystemExit(f"wall-normal cell count mismatch: grid gives {y_centres.size}, "
266 f"statistics give {plane_mean.shape[0]}.")
267
268 # Fold the upper half onto the lower one. Reflection reverses the wall-normal
269 # velocity, so <u'v'> changes sign; the orientation term makes the reported
270 # value the shear stress relative to the wall the point is measured from.
271 orientation = 1.0 if axis_coord[-1] > axis_coord[0] else -1.0
272 half = (y_centres.size + 1) // 2
273
274 def fold(values, sign=1.0):
275 return 0.5 * (values[:half] + sign * values[::-1][:half])
276
277 wall_distance = fold(numpy.minimum(numpy.abs(y_centres - axis_coord[0]),
278 numpy.abs(axis_coord[-1] - y_centres)))
279 U = fold(plane_mean[:, stream_c])
280 uu = fold(covariance(stream_c, stream_c))
281 vv = fold(covariance(wall_c, wall_c))
282 ww = fold(covariance(span_c, span_c))
283 uv = orientation * fold(covariance(stream_c, wall_c), -1.0)
284
285 nu, h = args.viscosity, args.half_height
286 if args.body_force is not None:
287 u_tau = math.sqrt(args.body_force * h)
288 source_note = f"sqrt(f*h) with f={args.body_force:.8e}"
289 elif args.wall_model_csv is not None:
290 start, end = float(window["effective_start"]), float(window["effective_end"])
291 u_tau, rows = friction_velocity_from_wall_model(args.wall_model_csv, start, end)
292 source_note = f"sqrt(<u_tau^2>) over {rows} wall_model.csv rows, t in [{start:g}, {end:g}]"
293 elif args.u_tau is not None:
294 u_tau = args.u_tau
295 source_note = "supplied with --u-tau"
296 else:
297 u_tau = math.sqrt(nu * abs(U[0]) / wall_distance[0])
298 source_note = "sqrt(nu*U/y) from the first cell (approximate; needs a resolved sublayer)"
299 if not u_tau > 0.0:
300 raise SystemExit("computed a non-positive friction velocity; check the inputs.")
301
302 with open(args.output, "w", encoding="utf-8") as handle:
303 handle.write(f"# u_tau = {u_tau:.10e} ({source_note})\n")
304 handle.write(f"# Re_tau = u_tau*h/nu = {u_tau * h / nu:.6f}\n")
305 handle.write(f"# nu = {nu:.10e}, h = {h:.10e}, window = {args.window} "
306 f"({window.get('state')}, {window.get('sample_count')} samples, "
307 f"represented time {float(window.get('represented_time', 'nan')):g})\n")
308 handle.write("y,y_plus,U_plus,u_rms_plus,v_rms_plus,w_rms_plus,"
309 "minus_uv_plus,U_plus_loglaw,U_plus_sublayer\n")
310 for index in range(half):
311 y_plus = wall_distance[index] * u_tau / nu
312 loglaw = (1.0 / 0.41) * math.log(y_plus) + 5.2 if y_plus > 0.0 else float("nan")
313 handle.write(
314 f"{wall_distance[index]:.10e},{y_plus:.10e},"
315 f"{U[index] / u_tau:.10e},"
316 f"{math.sqrt(max(uu[index], 0.0)) / u_tau:.10e},"
317 f"{math.sqrt(max(vv[index], 0.0)) / u_tau:.10e},"
318 f"{math.sqrt(max(ww[index], 0.0)) / u_tau:.10e},"
319 f"{-uv[index] / (u_tau ** 2):.10e},"
320 f"{loglaw:.10e},{y_plus:.10e}\n")
321
322 print(f"u_tau = {u_tau:.10e} ({source_note})")
323 print(f"Re_tau = {u_tau * h / nu:.4f}")
324 print(f"wrote {half} wall-normal stations to {args.output}")
325 return 0
326
327
328if __name__ == "__main__":
329 sys.exit(main())
load_spectra_helpers()
Import the PICGRID and PETSc-Vec readers from generators/spectra.gen.
find_repo_root()
Locate the PICurv checkout from the shipped example or from a case picurv init made.
main(argv=None)
Entry point.
homogeneous_statistics(checkpoint_dir, window_name, grid_path, block, homogeneous)
Reduce one statistics window's velocity moments over homogeneous directions.
read_window(checkpoint_dir, window, block)
Resolve a statistics window by name inside one committed checkpoint bundle.
friction_velocity_from_wall_model(csv_path, start, end)
Mean wall shear the wall model applied over a time interval, as a velocity.