PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_units.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""!
3@file audit_units.py
4@brief Enforce the physical-units rule and the published units indexes.
5
6Every configuration input is physical and is converted to solver units exactly once;
7every catalogued field records the dimension post-processing scales it by. This checks
8that no solver-facing input lacks a recorded dimension, and that both indexes on page 19
9match what the code records: the input index against `INPUT_QUANTITIES` and its sibling
10tables in `picurv_cli/core.py`, the field index against the `FIELD_DIM_*` argument of
11every entry in `src/field_catalog.c` and `src/particle_field_catalog.c`.
12
13It does not check that a conversion is performed where the table says; the non-unit
14ingress tests and the units-equivalence smoke run check that.
15"""
16
17from __future__ import annotations
18
19import importlib.machinery
20import importlib.util
21import re
22import sys
23from pathlib import Path
24
25
26REPO_ROOT = Path(__file__).resolve().parents[2]
27CORE = REPO_ROOT / "picurv_cli" / "core.py"
28IC_GENERATOR = REPO_ROOT / "generators" / "ic.gen"
29EULERIAN_CATALOG = REPO_ROOT / "src" / "field_catalog.c"
30PARTICLE_CATALOG = REPO_ROOT / "src" / "particle_field_catalog.c"
31PAGE = REPO_ROOT / "docs" / "pages" / "19_Nondimensionalization.md"
32
33#: Schemas whose keys configure the solver. Cluster, study, and workspace files hold
34#: scheduling and bookkeeping; study overrides address case keys, which are covered.
35SOLVER_INPUT_SCHEMAS = {
36 "case": "_CASE_SCHEMA",
37 "solver": "_SOLVER_SCHEMA",
38 "monitor": "_MONITOR_SCHEMA",
39 "post": "_POST_SCHEMA",
40}
41
42#: Free-form mappings whose keys have their own dimension tables.
43DEDICATED_TABLE_SURFACES = {
44 ("case", "boundary_conditions", "[]", "params"),
45 ("case", "boundary_conditions", "[]", "[]", "params"),
46 ("case", "properties", "initial_conditions", "params"),
47}
48
49#: Built-in initial-condition generators, which are not Python providers.
50BUILTIN_IC_GENERATORS = {"zero", "constant", "streamwise_constant", "poiseuille"}
51
52ENTRY_RE = re.compile(r"\b(?:FIELD_ENTRY|FIELD_COORDINATE_ENTRY|PARTICLE_FIELD_ENTRY)\‍(")
53DIMENSION_RE = re.compile(r"FIELD_DIM_(\w+)")
54NAME_RE = re.compile(r'"([^"]+)"')
55INDEX_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(\w+)\s*\|\s*`?(\w+)`?\s*\|", re.M)
56
57
58def load_module(name: str, path: Path):
59 """!
60 @brief Load a repository script as an importable module.
61 @param[in] name Module name to register.
62 @param[in] path Source path of the script.
63 @return Loaded module object.
64 """
65 loader = importlib.machinery.SourceFileLoader(name, str(path))
66 spec = importlib.util.spec_from_loader(name, loader)
67 module = importlib.util.module_from_spec(spec)
68 loader.exec_module(module)
69 return module
70
71
72def schema_leaves(schema: dict):
73 """!
74 @brief Yield every key path a schema accepts that is not itself a nested mapping.
75 @details A path mapped to `None` accepts arbitrary keys and is yielded as a leaf: its
76 contents are covered as a whole or by a dedicated table. A list's children
77 are keyed under `path + ("[]",)`, so any path prefixing an entry is interior.
78 @param[in] schema Key schema mapping parent paths to allowed key sets.
79 @return Generator of key-path tuples.
80 """
81 interiors = {
82 parent[:length]
83 for parent, keys in schema.items() if keys is not None
84 for length in range(1, len(parent) + 1)
85 }
86 for parent, keys in schema.items():
87 for key in keys or ():
88 path = parent + (key,)
89 if path not in interiors:
90 yield path
91
92
93def uncovered_inputs(core) -> list:
94 """!
95 @brief Name every solver-facing input with no recorded physical dimension.
96 @param[in] core Loaded conductor core.
97 @return Dotted names of uncovered inputs; empty when complete.
98 """
99 problems = []
100 for role, schema_name in SOLVER_INPUT_SCHEMAS.items():
101 for path in schema_leaves(getattr(core, schema_name)):
102 full = (role,) + path
103 if full in DEDICATED_TABLE_SURFACES:
104 continue
105 try:
106 core.input_quantity(full)
107 except KeyError:
108 problems.append(".".join(full))
109
110 accepted = set(core._DEPRECATED_BC_PARAM_ALIASES)
111 for spec in core.BC_HANDLER_SPECS.values():
112 accepted |= spec["required_params"] | spec["optional_params"]
113 problems.extend(f"boundary_conditions[].params.{key}"
114 for key in sorted(accepted - set(core.BC_PARAM_QUANTITIES)))
115
116 generators = BUILTIN_IC_GENERATORS | core._PYTHON_INITIAL_CONDITION_PROVIDERS
117 problems.extend(f"initial_conditions.generator {name}"
118 for name in sorted(generators ^ set(core.IC_PARAM_QUANTITIES)))
119 ic_gen = load_module("audit_units_ic_gen", IC_GENERATOR)
120 provider_params = {
121 "spectral_random_velocity": core.SPECTRAL_RANDOM_VELOCITY_PARAMS,
122 "channel_spectral_velocity": ic_gen.WALL_SPECTRAL_PARAMS,
123 "duct_spectral_velocity": ic_gen.WALL_SPECTRAL_PARAMS,
124 }
125 for generator, keys in provider_params.items():
126 roots = {name.split(".", 1)[0] for name in core.IC_PARAM_QUANTITIES.get(generator, {})}
127 problems.extend(f"initial_conditions.params.{key} ({generator})" for key in sorted(set(keys) - roots))
128 return problems
129
130
131def dimension_name(core, dimension) -> str:
132 """!
133 @brief Name a dimension triple by its constant in the conductor core.
134 @param[in] core Loaded conductor core.
135 @param[in] dimension `(length, velocity, density)` exponents.
136 @return Constant name, e.g. `VELOCITY`.
137 """
138 for name in ("DIMENSIONLESS", "LENGTH", "VELOCITY", "TIME", "WAVENUMBER", "VOLUME_FLUX",
139 "DIFFUSIVITY", "PRESSURE", "DENSITY", "DYNAMIC_VISCOSITY"):
140 if getattr(core, name) == dimension:
141 return name
142 raise ValueError(f"dimension {dimension} has no named constant")
143
144
145def physical_inputs(core) -> dict:
146 """!
147 @brief Every input that carries a physical dimension, as the input index lists it.
148 @details Dimensionless and non-quantity inputs are omitted: there is nothing to
149 convert. Payload inputs whose dimension follows their content are listed
150 with dimension `PAYLOAD`.
151 @param[in] core Loaded conductor core.
152 @return Mapping of documented input name to `(dimension name, conversion site)`.
153 """
154 rows = {}
155
156 def record(name, quantity):
157 """!
158 @brief Add one input to the index when it carries something to convert.
159 @param[in] name Documented input name.
160 @param[in] quantity `(dimension, site)` entry.
161 """
162 dimension, site = quantity
163 if site in ("", "passthrough"):
164 return
165 if dimension is not None and dimension == core.DIMENSIONLESS:
166 return
167 rows[name] = ("PAYLOAD" if dimension is None else dimension_name(core, dimension), site)
168
169 for path, quantity in core.INPUT_QUANTITIES.items():
170 record(f"{path[0]}.yml: " + ".".join(path[1:]), quantity)
171 for key, quantity in core.BC_PARAM_QUANTITIES.items():
172 record(f"case.yml: boundary_conditions[].params.{key}", quantity)
173 for generator, table in core.IC_PARAM_QUANTITIES.items():
174 for key, quantity in table.items():
175 record(f"case.yml: initial_conditions.params.{key} ({generator})", quantity)
176 return rows
177
178
180 """!
181 @brief Field name to `(catalog, FIELD_DIM_* suffix)` for every compiled entry.
182 @return Mapping of canonical field names to their catalog and dimension.
183 @throws ValueError when an entry carries no dimension argument.
184 """
185 fields = {}
186 for catalog, path in (("Eulerian", EULERIAN_CATALOG), ("particle", PARTICLE_CATALOG)):
187 text = path.read_text(encoding="utf-8")
188 body = text[text.index("gFieldCatalog" if catalog == "Eulerian" else "gParticleFieldCatalog"):]
189 for match in ENTRY_RE.finditer(body):
190 depth, index = 1, match.end()
191 while depth:
192 depth += {"(": 1, ")": -1}.get(body[index], 0)
193 index += 1
194 entry = body[match.end():index - 1]
195 name = NAME_RE.search(entry).group(1)
196 dimension = DIMENSION_RE.search(entry)
197 if dimension is None:
198 raise ValueError(f"{catalog} field '{name}' has no FIELD_DIM_* argument")
199 fields[(catalog, name)] = dimension.group(1)
200 return fields
201
202
203def page_section(heading: str) -> str:
204 """!
205 @brief One `@section` body of page 19.
206 @param[in] heading Section anchor to extract.
207 @return Section text up to the next section.
208 """
209 text = PAGE.read_text(encoding="utf-8")
210 start = text.index(f"@section {heading}")
211 following = text.find("@section", start + 1)
212 return text[start: following if following != -1 else len(text)]
213
214
215def documented_rows(heading: str) -> dict:
216 """!
217 @brief Rows of the first table in a section: first column to second and third.
218 @param[in] heading Section anchor.
219 @return Mapping of backticked first-column text to the next two cells.
220 """
221 return {(first, second): third
222 for first, second, third in INDEX_ROW_RE.findall(page_section(heading))}
223
224
225def main() -> int:
226 """!
227 @brief Fail when an input lacks a dimension or an index disagrees with the code.
228 @return Process status code.
229 """
230 core = load_module("audit_units_core", CORE)
231 problems = [f"no recorded dimension: {name}" for name in uncovered_inputs(core)]
232
233 inputs = physical_inputs(core)
234 documented_inputs = {name: (dimension, site)
235 for (name, dimension), site in documented_rows("p19_inputs_sec").items()}
236 for name in sorted(set(inputs) - set(documented_inputs)):
237 problems.append(f"input index: '{name}' is physical but not listed")
238 for name in sorted(set(documented_inputs) - set(inputs)):
239 problems.append(f"input index: '{name}' is listed but records nothing to convert")
240 for name in sorted(set(inputs) & set(documented_inputs)):
241 if inputs[name] != documented_inputs[name]:
242 problems.append(f"input index: '{name}' records {inputs[name]}, page says {documented_inputs[name]}")
243
245 documented_fields = {(catalog, name): dimension
246 for (name, catalog), dimension in documented_rows("p19_fields_sec").items()}
247 for key in sorted(set(fields) - set(documented_fields)):
248 problems.append(f"field index: {key[0]} field '{key[1]}' is not listed")
249 for key in sorted(set(documented_fields) - set(fields)):
250 problems.append(f"field index: {key[0]} field '{key[1]}' is not in the compiled catalog")
251 for key in sorted(set(fields) & set(documented_fields)):
252 if fields[key] != documented_fields[key]:
253 problems.append(f"field index: {key[1]} is FIELD_DIM_{fields[key]}, page says {documented_fields[key]}")
254
255 if problems:
256 print("Units rule or its published indexes do not match the code:", file=sys.stderr)
257 for problem in problems:
258 print(f" {problem}", file=sys.stderr)
259 print("\nRecord a new input in picurv_cli/core.py INPUT_QUANTITIES (or BC_PARAM_QUANTITIES /\n"
260 "IC_PARAM_QUANTITIES), give a new field a FIELD_DIM_* argument, and update the\n"
261 "indexes in docs/pages/19_Nondimensionalization.md.", file=sys.stderr)
262 return 1
263 print(f"Units audit passed: every solver-facing input records a dimension; {len(inputs)} "
264 f"physical inputs and {len(fields)} catalogued fields match page 19. Whether each "
265 "conversion is performed is checked by the ingress tests and smoke run, not here.")
266 return 0
267
268
269if __name__ == "__main__":
270 raise SystemExit(main())
str page_section(str heading)
One @section body of page 19.
int main()
Fail when an input lacks a dimension or an index disagrees with the code.
str dimension_name(core, dimension)
Name a dimension triple by its constant in the conductor core.
dict documented_rows(str heading)
Rows of the first table in a section: first column to second and third.
dict physical_inputs(core)
Every input that carries a physical dimension, as the input index lists it.
list uncovered_inputs(core)
Name every solver-facing input with no recorded physical dimension.
dict compiled_field_dimensions()
Field name to (catalog, FIELD_DIM_* suffix) for every compiled entry.
schema_leaves(dict schema)
Yield every key path a schema accepts that is not itself a nested mapping.
load_module(str name, Path path)
Load a repository script as an importable module.