PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
check_statistics_nodal_consistency.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""!
5@file check_statistics_nodal_consistency.py
6@brief Cross-check derived statistics VTK output against the convergence-history CSV.
7
8The two are produced by paths that share no code: the CSV mean comes from
9`PicurvWindowSpatialMean`, which reduces the cell-centred accumulators over the
10resolved spatial target, while the VTK field comes from `ComputeNodalAverage`
11interpolating the derived staging buffer onto grid nodes. Agreement between them
12is therefore a real check rather than a restatement.
13
14It catches the specific failure this check was written for: an interior-only
15producer leaving structural zeros on the layout boundary, which halves every
16boundary node and biases a whole-domain mean without any other symptom.
17
18@code
19check_statistics_nodal_consistency.py RUN_DIR VIZ_SUBDIR WINDOW_NAME
20@endcode
21"""
22
23#: Usage line reported when the arguments do not parse.
24USAGE = "usage: check_statistics_nodal_consistency.py RUN_DIR VIZ_SUBDIR WINDOW_NAME"
25
26import csv
27import os
28import re
29import sys
30
31
33 """!
34 @brief Import NumPy, which this checker needs to read the binary VTK payload.
35 @return Imported NumPy module.
36 """
37 import numpy
38 return numpy
39
40
42 """!
43 @brief Read appended raw Float64 point arrays from a PICurv `.vts`.
44 @param[in] path VTK structured-grid file path.
45 @return Tuple of the node extent per direction and a name-to-array mapping.
46 """
47 numpy = require_numpy()
48 raw = open(path, "rb").read()
49 head = raw[:8192].decode("utf-8", "replace")
50 extent = re.search(r'WholeExtent="0 (\d+) 0 (\d+) 0 (\d+)"', head)
51 if not extent:
52 raise ValueError(f"{path}: no WholeExtent in header.")
53 nodes = tuple(int(extent.group(i)) + 1 for i in (1, 2, 3))
54 marker = raw.find(b"<AppendedData")
55 if marker < 0:
56 raise ValueError(f"{path}: no appended data section.")
57 start = raw.find(b"_", marker) + 1
58 arrays = {}
59 for match in re.finditer(
60 r'Name="([^"]+)" NumberOfComponents="(\d+)" format="appended" offset="(\d+)"', head
61 ):
62 name, ncomp, offset = match.group(1), int(match.group(2)), int(match.group(3))
63 base = start + offset
64 nbytes = int(numpy.frombuffer(raw, dtype="<u4", count=1, offset=base)[0])
65 values = numpy.frombuffer(raw, dtype="<f8", count=nbytes // 8, offset=base + 4)
66 arrays[name] = values.reshape(-1, ncomp) if ncomp > 1 else values
67 return nodes, arrays
68
69
71 """!
72 @brief Read the periodicity the solver recorded in any committed checkpoint.
73 @param[in] run_dir Run directory to search.
74 @return Tuple of three booleans for i, j, k, or None when no checkpoint is present.
75 """
76 for root, _dirs, files in os.walk(os.path.join(run_dir, "output")):
77 if "checkpoint.meta" not in files:
78 continue
79 with open(os.path.join(root, "checkpoint.meta"), "r", encoding="utf-8") as stream:
80 for line in stream:
81 if line.startswith("-checkpoint_periodic "):
82 flags = line.split(None, 1)[1].strip().split(",")
83 return tuple(token.strip() == "1" for token in flags)
84 return None
85
86
87def newest_window_vts(viz_dir, window):
88 """!
89 @brief Locate the highest-step derived statistics file for one window.
90 @param[in] viz_dir Directory holding the post-processing output.
91 @param[in] window Window name.
92 @return Path to the selected file.
93 """
94 pattern = re.compile(rf"_statistics_{re.escape(window)}_(\d+)\.vts$")
95 best, best_step = None, -1
96 for name in sorted(os.listdir(viz_dir)):
97 match = pattern.search(name)
98 if match and int(match.group(1)) > best_step:
99 best, best_step = os.path.join(viz_dir, name), int(match.group(1))
100 if best is None:
101 raise ValueError(f"no derived statistics VTK output for window '{window}' in {viz_dir}.")
102 return best
103
104
105def check_periodic_wrap(arrays, nodes, periodic):
106 """!
107 @brief Verify every derived array wraps across each periodic layout boundary.
108
109 @details Independent of whether the flow is turbulent, so a steady case still guards
110 the layout boundary. An interior-only producer leaves the two boundary
111 planes holding different partial averages, which this detects directly.
112
113 @param[in] arrays Name-to-array mapping from `read_vts_point_arrays()`.
114 @param[in] nodes Node extent per direction, in i, j, k order.
115 @param[in] periodic Per-direction periodicity in i, j, k order.
116 @return Zero when every array wraps, or one on the first disagreement.
117 """
118 numpy = require_numpy()
119 checked = 0
120 for name, values in sorted(arrays.items()):
121 if name == "Position":
122 continue
123 ncomp = values.shape[1] if values.ndim > 1 else 1
124 field = values.reshape(nodes[2], nodes[1], nodes[0], ncomp)
125 # checkpoint_periodic is recorded in i, j, k order; the array is k, j, i.
126 for flag, axis, label in zip(periodic, (2, 1, 0), ("i", "j", "k")):
127 if not flag:
128 continue
129 low = numpy.take(field, 0, axis=axis)
130 high = numpy.take(field, field.shape[axis] - 1, axis=axis)
131 if not numpy.allclose(low, high):
132 print(f"[FAIL] '{name}': the two layout boundary planes in the periodic "
133 f"{label} direction differ, so the wrap was not applied. See "
134 f"ExtendToLayoutBoundary().", file=sys.stderr)
135 return 1
136 checked += 1
137 print(f"[INFO] periodic layout boundary wraps verified on {checked} array/direction pair(s).")
138 return 0
139
140
141def main(argv=None):
142 """!
143 @brief Run the consistency check.
144 @param[in] argv Optional argument override for tests.
145 @return Process exit code.
146 """
147 numpy = require_numpy()
148 args = list(sys.argv[1:] if argv is None else argv)
149 if len(args) != 3:
150 print(USAGE, file=sys.stderr)
151 return 2
152 run_dir, viz_subdir, window = args
153 viz_dir = os.path.join(run_dir, viz_subdir)
154
155 try:
156 vts_path = newest_window_vts(viz_dir, window)
157 nodes, arrays = read_vts_point_arrays(vts_path)
158
159 periodic = read_checkpoint_periodicity(run_dir)
160 if periodic and any(periodic):
161 if check_periodic_wrap(arrays, nodes, periodic) != 0:
162 return 1
163
164 tke_name = next((n for n in arrays if n.endswith("_tke")), None)
165 if tke_name is None:
166 print(f"[SKIP] {os.path.basename(vts_path)} carries no turbulent kinetic energy field.")
167 return 0
168 field = arrays[tke_name].reshape(nodes[2], nodes[1], nodes[0])
169
170 csv_path = next(
171 (os.path.join(viz_dir, n) for n in sorted(os.listdir(viz_dir))
172 if n.endswith(f"_statistics_{window}.csv")), None
173 )
174 if csv_path is None:
175 raise ValueError(f"no convergence-history CSV for window '{window}' in {viz_dir}.")
176 rows = list(csv.DictReader(open(csv_path, "r", encoding="utf-8")))
177 if not rows or "mean_tke" not in rows[0]:
178 print(f"[SKIP] {os.path.basename(csv_path)} records no mean_tke column.")
179 return 0
180 reference = float(rows[-1]["mean_tke"])
181 if reference <= 0.0:
182 print(f"[SKIP] window '{window}' has no accumulated energy to compare.")
183 return 0
184
185 # Interior nodes only: the outermost node layer is a periodic duplicate of the
186 # opposite face, so counting it weights that plane twice.
187 interior = field[1:-1, 1:-1, 1:-1].mean()
188 deviation = abs(interior / reference - 1.0)
189 tolerance = 0.05
190 print(f"[INFO] {window}: nodal interior mean {interior:.6e} vs CSV mean_tke "
191 f"{reference:.6e} ({100*deviation:+.2f}%)")
192 if deviation > tolerance:
193 print(
194 f"[FAIL] derived statistics VTK and convergence CSV disagree by "
195 f"{100*deviation:.2f}%, above the {100*tolerance:.0f}% interpolation "
196 f"allowance. A layout boundary left unwritten by an interior-only "
197 f"producer is the usual cause; see ExtendToLayoutBoundary().",
198 file=sys.stderr,
199 )
200 return 1
201
202 if periodic and all(periodic):
203 # On a fully periodic layout every boundary node is defined, so the whole
204 # domain must also agree. This is the assertion that catches an unwritten
205 # layout boundary: the interior stays correct either way, so an
206 # interior-only comparison would pass straight through the defect.
207 whole = field.mean()
208 whole_deviation = abs(whole / reference - 1.0)
209 print(f"[INFO] {window}: whole-domain nodal mean {whole:.6e} "
210 f"({100*whole_deviation:+.2f}%)")
211 if whole_deviation > tolerance:
212 print(
213 f"[FAIL] every boundary node is defined on a fully periodic layout, "
214 f"but the whole-domain nodal mean is off by {100*whole_deviation:.2f}%. "
215 f"The layout boundary was left unwritten; see ExtendToLayoutBoundary().",
216 file=sys.stderr,
217 )
218 return 1
219
220 print("[PASS] derived statistics VTK is consistent with the convergence CSV.")
221 return 0
222 except Exception as exc: # noqa: BLE001 - a check reports rather than raises
223 print(f"[ERROR] statistics nodal consistency check failed: {exc}", file=sys.stderr)
224 return 1
225
226
227if __name__ == "__main__":
228 sys.exit(main())
check_periodic_wrap(arrays, nodes, periodic)
Verify every derived array wraps across each periodic layout boundary.
read_vts_point_arrays(path)
Read appended raw Float64 point arrays from a PICurv .vts.
require_numpy()
Import NumPy, which this checker needs to read the binary VTK payload.
newest_window_vts(viz_dir, window)
Locate the highest-step derived statistics file for one window.
read_checkpoint_periodicity(run_dir)
Read the periodicity the solver recorded in any committed checkpoint.
Head of a generic C-style linked list.
Definition variables.h:445