100M2_PAIRS = ((0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2))
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).
126 sys.path.insert(0, REPO_ROOT)
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)
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:
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}.")
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}.")
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).
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}.")
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)
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.")
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}
218 @param[in] argv Command-line style argument list supplied to the function.
219 @return Process exit status.
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 "
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)
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)
253 numpy, nodes, window = stats[
"numpy"], stats[
"nodes"], stats[
"window"]
254 plane_mean = stats[
"mean"]
256 def covariance(a, b):
257 return stats[
"covariance"][:, M2_PAIRS.index(tuple(sorted((a, b))))]
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]}.")
271 orientation = 1.0
if axis_coord[-1] > axis_coord[0]
else -1.0
272 half = (y_centres.size + 1) // 2
274 def fold(values, sign=1.0):
275 return 0.5 * (values[:half] + sign * values[::-1][:half])
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)
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"])
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:
295 source_note =
"supplied with --u-tau"
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)"
300 raise SystemExit(
"computed a non-positive friction velocity; check the inputs.")
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")
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")
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}")