PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
cross_section_profile.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""!
3@file cross_section_profile.py
4@brief Reduce a duct's field-statistics window to cross-section fields and bisector profiles.
5
6@details
7A square duct has one homogeneous direction, the streamwise one, so its statistics
8reduce to a cross-section rather than a wall-normal line. The reduction itself, the
9window lookup in `checkpoint.meta` and the covariance over the homogeneous set, is
10the one `driven_channel/tools/wall_normal_profile.py` performs and is imported from
11it; this script averages along the stream only and then reads off what the case's
12acceptance criteria name (`driven_duct.md` section 5):
13
14- `<prefix>_cross_section.csv`: every cell's mean velocity and the six covariance
15 components, in solver units, for contouring the secondary flow;
16- `<prefix>_wall_bisector.csv`: wall units along the wall bisector, with the
17 wall-normal secondary velocity;
18- `<prefix>_corner_bisector.csv`: wall units along the corner bisector, with the
19 secondary velocity along it (square cross-sections only);
20- `<prefix>_wall_shear.csv`: the local wall shear around the perimeter, normalized
21 by its perimeter mean.
22
23The cross-section is folded onto one octant by default: its four reflections and,
24when the two cross-stream axes carry identical node distributions, the diagonal
25swap. A reflection reverses the velocity component normal to its mirror line, so
26that component's mean and its covariances with the other two change sign. Folding
27averages eight images of one long average, which is what makes a 1-3% secondary
28flow readable; `--no-fold` keeps the raw cross-section, whose asymmetry is a
29measure of the remaining sampling error.
30
31The friction velocity is the perimeter mean of the wall shear: from `--body-force`
32as sqrt(f A / P), the exact mean force balance; from `--u-tau`; or by default from
33the first cells' resolved gradient, nu U / d, which is meaningful only when the
34first cells lie in the viscous sublayer.
35
36Usage:
37
38@code
39 cross_section_profile.py \\
40 --checkpoint RUN/output/checkpoints/step_000000200000 --window stationary \\
41 --grid RUN/inputs/grid/grid.run --viscosity 4.5351474e-04 \\
42 --output-prefix duct
43@endcode
44"""
45
46import argparse
47import importlib.machinery
48import importlib.util
49import math
50import os
51import sys
52
53CHANNEL_TOOL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..",
54 "driven_channel", "tools", "wall_normal_profile.py")
55
56
58 """!
59 @brief Import the window reduction from the channel tool rather than duplicating it.
60 @return The loaded module.
61 """
62 loader = importlib.machinery.SourceFileLoader("picurv_wall_normal_profile", CHANNEL_TOOL)
63 spec = importlib.util.spec_from_loader("picurv_wall_normal_profile", loader)
64 module = importlib.util.module_from_spec(spec)
65 loader.exec_module(module)
66 return module
67
68
69def transform(numpy, mean, covariance, pairs, permutation, sign):
70 """!
71 @brief Apply a component permutation with sign changes to a mean and its covariance.
72 @param[in] numpy The numpy module.
73 @param[in] mean Array ending in 3 velocity components.
74 @param[in] covariance Array ending in 6 covariance components, in `pairs` order.
75 @param[in] pairs Component pairs of the covariance layout.
76 @param[in] permutation New component index of each old component.
77 @param[in] sign Sign applied to each old component.
78 @return Transformed (mean, covariance).
79 """
80 new_mean = numpy.empty_like(mean)
81 new_cov = numpy.empty_like(covariance)
82 for c in range(3):
83 new_mean[..., permutation[c]] = sign[c] * mean[..., c]
84 for index, (a, b) in enumerate(pairs):
85 target = pairs.index(tuple(sorted((permutation[a], permutation[b]))))
86 new_cov[..., target] = sign[a] * sign[b] * covariance[..., index]
87 return new_mean, new_cov
88
89
90def main(argv=None):
91 """!
92 @brief Entry point.
93 @param[in] argv Command-line style argument list supplied to the function.
94 @return Process exit status.
95 """
96 parser = argparse.ArgumentParser(description=__doc__,
97 formatter_class=argparse.RawDescriptionHelpFormatter)
98 parser.add_argument("--checkpoint", required=True,
99 help="Committed checkpoint bundle directory (output/checkpoints/step_NNN).")
100 parser.add_argument("--grid", required=True, help="Canonical PICGRID path for the run.")
101 parser.add_argument("--window", default="stationary", help="Statistics window name.")
102 parser.add_argument("--block", type=int, default=0, help="Block index.")
103 parser.add_argument("--stream-axis", default="Zeta", choices=("Xi", "Eta", "Zeta"),
104 help="Streamwise (driven, homogeneous) axis token.")
105 parser.add_argument("--viscosity", type=float, required=True,
106 help="Kinematic viscosity in solver units.")
107 source = parser.add_mutually_exclusive_group()
108 source.add_argument("--body-force", type=float,
109 help="Converged driving body force f; u_tau = sqrt(f*A/P).")
110 source.add_argument("--u-tau", type=float, help="Friction velocity supplied directly.")
111 parser.add_argument("--no-fold", action="store_true",
112 help="Keep the raw cross-section instead of folding it onto one octant.")
113 parser.add_argument("--output-prefix", required=True, help="Prefix for the four output CSVs.")
114 args = parser.parse_args(argv)
115
116 channel = load_channel_tool()
117 pairs = channel.M2_PAIRS
118 stream_kji = channel.AXIS_TO_KJI[args.stream_axis]
119 stream_c = channel.AXIS_TO_COMPONENT[args.stream_axis]
120 stats = channel.homogeneous_statistics(args.checkpoint, args.window, args.grid, args.block,
121 (stream_kji,))
122 numpy, nodes, window = stats["numpy"], stats["nodes"], stats["window"]
123 mean, covariance = stats["mean"], stats["covariance"]
124
125 # The two remaining axes, in (k, j, i) order, and the velocity component normal
126 # to each one's walls. The reduced arrays are indexed (first, second).
127 cross_kji = [axis for axis in (0, 1, 2) if axis != stream_kji]
128 kji_to_component = {0: 2, 1: 1, 2: 0}
129 comp = [kji_to_component[axis] for axis in cross_kji]
130 names = "xyz"
131
132 def axis_nodes(kji, component):
133 line = {0: nodes[:, 0, 0, :], 1: nodes[0, :, 0, :], 2: nodes[0, 0, :, :]}[kji]
134 return line[:, component]
135
136 edges = [axis_nodes(kji, c) for kji, c in zip(cross_kji, comp)]
137 # Bisectors and wall shear need a rectilinear cross-section: each cross-stream
138 # coordinate must depend on its own index alone.
139 span = [abs(e[-1] - e[0]) for e in edges]
140 for kji, c, e, length in zip(cross_kji, comp, edges, span):
141 full = nodes[..., c]
142 reference = {0: e[:, None, None], 1: e[None, :, None], 2: e[None, None, :]}[kji]
143 if numpy.max(numpy.abs(full - reference)) > 1.0e-9 * length:
144 raise SystemExit("the cross-section is not rectilinear; this reduction assumes it is.")
145 centres = [0.5 * (e[:-1] + e[1:]) for e in edges]
146 widths = [numpy.abs(numpy.diff(e)) for e in edges]
147 n0, n1 = mean.shape[0], mean.shape[1]
148 if (centres[0].size, centres[1].size) != (n0, n1):
149 raise SystemExit(f"cross-section cell counts {(centres[0].size, centres[1].size)} "
150 f"do not match the statistics {(n0, n1)}.")
151
152 def symmetric(c, e, length):
153 return numpy.max(numpy.abs((c - e[0]) - (e[-1] - c[::-1]))) <= 1.0e-9 * length
154
155 folded = "raw (--no-fold)"
156 if not args.no_fold:
157 if not all(symmetric(c, e, length) for c, e, length in zip(centres, edges, span)):
158 raise SystemExit("the cross-section grid is not mirror-symmetric; rerun with --no-fold.")
159 identity = (0, 1, 2)
160 images = []
161 for flip0 in (False, True):
162 for flip1 in (False, True):
163 m, v = mean, covariance
164 sign = [1.0, 1.0, 1.0]
165 if flip0:
166 m, v = m[::-1, :], v[::-1, :]
167 sign[comp[0]] = -1.0
168 if flip1:
169 m, v = m[:, ::-1], v[:, ::-1]
170 sign[comp[1]] = -1.0
171 images.append(transform(numpy, m, v, pairs, identity, sign))
172 diagonal = n0 == n1 and numpy.max(
173 numpy.abs((edges[0] - edges[0][0]) - (edges[1] - edges[1][0]))) <= 1.0e-9 * span[0]
174 if diagonal:
175 swap = list(identity)
176 swap[comp[0]], swap[comp[1]] = comp[1], comp[0]
177 images += [transform(numpy, numpy.swapaxes(m, 0, 1), numpy.swapaxes(v, 0, 1),
178 pairs, swap, (1.0, 1.0, 1.0)) for m, v in images]
179 mean = sum(m for m, _ in images) / len(images)
180 covariance = sum(v for _, v in images) / len(images)
181 folded = f"folded over {len(images)} symmetry images"
182
183 nu = args.viscosity
184 U = mean[..., stream_c]
185 area = numpy.outer(widths[0], widths[1])
186 bulk = float(numpy.sum(U * area) / numpy.sum(area))
187
188 # Local wall shear from the first cell on each of the four walls: nu U / d.
189 first = [abs(centres[0][0] - edges[0][0]), abs(edges[0][-1] - centres[0][-1]),
190 abs(centres[1][0] - edges[1][0]), abs(edges[1][-1] - centres[1][-1])]
191 walls = [
192 (f"{names[comp[0]]}_min", centres[1], widths[1], nu * U[0, :] / first[0]),
193 (f"{names[comp[0]]}_max", centres[1], widths[1], nu * U[-1, :] / first[1]),
194 (f"{names[comp[1]]}_min", centres[0], widths[0], nu * U[:, 0] / first[2]),
195 (f"{names[comp[1]]}_max", centres[0], widths[0], nu * U[:, -1] / first[3]),
196 ]
197 perimeter = sum(float(numpy.sum(w)) for _, _, w, _ in walls)
198 tau_mean = sum(float(numpy.sum(t * w)) for _, _, w, t in walls) / perimeter
199
200 if args.body_force is not None:
201 cross_area = span[0] * span[1]
202 u_tau = math.sqrt(args.body_force * cross_area / perimeter)
203 source_note = f"sqrt(f*A/P) with f={args.body_force:.8e}"
204 elif args.u_tau is not None:
205 u_tau = args.u_tau
206 source_note = "supplied with --u-tau"
207 else:
208 u_tau = math.sqrt(tau_mean)
209 source_note = "sqrt of the perimeter-mean nu*U/d from the first cells (needs a resolved sublayer)"
210 if not u_tau > 0.0:
211 raise SystemExit("computed a non-positive friction velocity; check the inputs.")
212
213 secondary = numpy.hypot(mean[..., comp[0]], mean[..., comp[1]])
214 peak = numpy.unravel_index(numpy.argmax(secondary), secondary.shape)
215 half_width = 0.5 * min(span)
216 header = (f"# u_tau = {u_tau:.10e} ({source_note})\n"
217 f"# Re_tau = u_tau*a/nu = {u_tau * half_width / nu:.6f} with half-width a = {half_width:.10e}\n"
218 f"# U_b = {bulk:.10e}, max secondary speed / U_b = {float(secondary[peak]) / bulk:.6e} "
219 f"at ({centres[0][peak[0]]:.6e}, {centres[1][peak[1]]:.6e})\n"
220 f"# nu = {nu:.10e}, window = {args.window} ({window.get('state')}, "
221 f"{window.get('sample_count')} samples), {folded}\n")
222 prefix = args.output_prefix
223 pair_names = [names[a] + names[b] for a, b in pairs]
224
225 with open(prefix + "_cross_section.csv", "w", encoding="utf-8") as handle:
226 handle.write(header)
227 handle.write(f"{names[comp[0]]},{names[comp[1]]},u_x,u_y,u_z,"
228 + ",".join("cov_" + p for p in pair_names) + "\n")
229 for a in range(n0):
230 for b in range(n1):
231 handle.write(f"{centres[0][a]:.10e},{centres[1][b]:.10e},"
232 + ",".join(f"{value:.10e}" for value in mean[a, b])
233 + "," + ",".join(f"{value:.10e}" for value in covariance[a, b]) + "\n")
234
235 def cov(a_index, b_index, c0, c1):
236 return covariance[a_index, b_index, pairs.index(tuple(sorted((c0, c1))))]
237
238 # Wall bisector: from the first axis's lower wall, at the centre of the second.
239 middle = [n1 // 2] if n1 % 2 else [n1 // 2 - 1, n1 // 2]
240 orientation = 1.0 if edges[0][-1] > edges[0][0] else -1.0
241 with open(prefix + "_wall_bisector.csv", "w", encoding="utf-8") as handle:
242 handle.write(header)
243 handle.write("d,d_plus,U_plus,u_rms_plus,v_rms_plus,w_rms_plus,minus_uv_plus,V_over_Ub\n")
244 for a in range((n0 + 1) // 2):
245 pick = lambda f: sum(f(a, b) for b in middle) / len(middle)
246 d = abs(centres[0][a] - edges[0][0])
247 values = (
248 pick(lambda i, j: U[i, j]) / u_tau,
249 math.sqrt(max(pick(lambda i, j: cov(i, j, stream_c, stream_c)), 0.0)) / u_tau,
250 math.sqrt(max(pick(lambda i, j: cov(i, j, comp[0], comp[0])), 0.0)) / u_tau,
251 math.sqrt(max(pick(lambda i, j: cov(i, j, comp[1], comp[1])), 0.0)) / u_tau,
252 -orientation * pick(lambda i, j: cov(i, j, stream_c, comp[0])) / u_tau ** 2,
253 orientation * pick(lambda i, j: mean[i, j, comp[0]]) / bulk,
254 )
255 handle.write(f"{d:.10e},{d * u_tau / nu:.10e}," + ",".join(f"{v:.10e}" for v in values) + "\n")
256
257 if n0 == n1:
258 # Corner bisector: the diagonal cells from the (min, min) corner. Positive
259 # along-diagonal velocity points away from the corner.
260 sign0 = 1.0 if edges[0][-1] > edges[0][0] else -1.0
261 sign1 = 1.0 if edges[1][-1] > edges[1][0] else -1.0
262 with open(prefix + "_corner_bisector.csv", "w", encoding="utf-8") as handle:
263 handle.write(header)
264 handle.write("d,d_plus,U_plus,along_diagonal_over_Ub,k_plus\n")
265 for a in range((n0 + 1) // 2):
266 d = math.hypot(centres[0][a] - edges[0][0], centres[1][a] - edges[1][0])
267 along = (sign0 * mean[a, a, comp[0]] + sign1 * mean[a, a, comp[1]]) / math.sqrt(2.0)
268 k = 0.5 * sum(cov(a, a, c, c) for c in range(3))
269 handle.write(f"{d:.10e},{d * u_tau / nu:.10e},{U[a, a] / u_tau:.10e},"
270 f"{along / bulk:.10e},{k / u_tau ** 2:.10e}\n")
271
272 with open(prefix + "_wall_shear.csv", "w", encoding="utf-8") as handle:
273 handle.write(header)
274 handle.write("wall,s,tau_w,tau_w_over_mean\n")
275 for name, coordinate, _width, tau in walls:
276 for s_value, t_value in zip(coordinate, tau):
277 handle.write(f"{name},{s_value:.10e},{t_value:.10e},{t_value / tau_mean:.10e}\n")
278
279 print(header, end="")
280 print(f"wrote {prefix}_cross_section.csv, {prefix}_wall_bisector.csv, "
281 + (f"{prefix}_corner_bisector.csv, " if n0 == n1 else "")
282 + f"{prefix}_wall_shear.csv")
283 return 0
284
285
286if __name__ == "__main__":
287 sys.exit(main())
load_channel_tool()
Import the window reduction from the channel tool rather than duplicating it.
main(argv=None)
Entry point.
transform(numpy, mean, covariance, pairs, permutation, sign)
Apply a component permutation with sign changes to a mean and its covariance.
Head of a generic C-style linked list.
Definition variables.h:476