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
50ENTRY_RE = re.compile(r"\b(?:FIELD_ENTRY|FIELD_COORDINATE_ENTRY|PARTICLE_FIELD_ENTRY)\‍(")
51DIMENSION_RE = re.compile(r"FIELD_DIM_(\w+)")
52NAME_RE = re.compile(r'"([^"]+)"')
53INDEX_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(\w+)\s*\|\s*`?(\w+)`?\s*\|", re.M)
54
55
56def load_module(name: str, path: Path):
57 """!
58 @brief Load a repository script as an importable module.
59 @param[in] name Module name to register.
60 @param[in] path Source path of the script.
61 @return Loaded module object.
62 """
63 loader = importlib.machinery.SourceFileLoader(name, str(path))
64 spec = importlib.util.spec_from_loader(name, loader)
65 module = importlib.util.module_from_spec(spec)
66 loader.exec_module(module)
67 return module
68
69
70def schema_leaves(schema: dict):
71 """!
72 @brief Yield every key path a schema accepts that is not itself a nested mapping.
73 @details A path mapped to `None` accepts arbitrary keys and is yielded as a leaf: its
74 contents are covered as a whole or by a dedicated table. A list's children
75 are keyed under `path + ("[]",)`, so any path prefixing an entry is interior.
76 @param[in] schema Key schema mapping parent paths to allowed key sets.
77 @return Generator of key-path tuples.
78 """
79 interiors = {
80 parent[:length]
81 for parent, keys in schema.items() if keys is not None
82 for length in range(1, len(parent) + 1)
83 }
84 for parent, keys in schema.items():
85 for key in keys or ():
86 path = parent + (key,)
87 if path not in interiors:
88 yield path
89
90
91def uncovered_inputs(core) -> list:
92 """!
93 @brief Name every solver-facing input with no recorded physical dimension.
94 @param[in] core Loaded conductor core.
95 @return Dotted names of uncovered inputs; empty when complete.
96 """
97 problems = []
98 for role, schema_name in SOLVER_INPUT_SCHEMAS.items():
99 for path in schema_leaves(getattr(core, schema_name)):
100 full = (role,) + path
101 if full in DEDICATED_TABLE_SURFACES:
102 continue
103 try:
104 core.input_quantity(full)
105 except KeyError:
106 problems.append(".".join(full))
107
108 accepted = set(core._DEPRECATED_BC_PARAM_ALIASES)
109 for spec in core.BC_HANDLER_SPECS.values():
110 accepted |= spec["required_params"] | spec["optional_params"]
111 problems.extend(f"boundary_conditions[].params.{key}"
112 for key in sorted(accepted - set(core.BC_PARAM_QUANTITIES)))
113
114 generators = set(core._PYTHON_INITIAL_CONDITION_PROVIDERS)
115 problems.extend(f"initial_conditions.generator {name}"
116 for name in sorted(generators ^ set(core.IC_PARAM_QUANTITIES)))
117 ic_gen = load_module("audit_units_ic_gen", IC_GENERATOR)
118 provider_params = {
119 "spectral_velocity": ic_gen.WALL_SPECTRAL_PARAMS | ic_gen.PERIODIC_SPECTRAL_ONLY_PARAMS,
120 "stability_mode_velocity": ic_gen.STABILITY_MODE_PARAMS,
121 }
122 for generator, keys in provider_params.items():
123 roots = {name.split(".", 1)[0] for name in core.IC_PARAM_QUANTITIES.get(generator, {})}
124 problems.extend(f"initial_conditions.params.{key} ({generator})" for key in sorted(set(keys) - roots))
125 return problems
126
127
128def dimension_name(core, dimension) -> str:
129 """!
130 @brief Name a dimension triple by its constant in the conductor core.
131 @param[in] core Loaded conductor core.
132 @param[in] dimension `(length, velocity, density)` exponents.
133 @return Constant name, e.g. `VELOCITY`.
134 """
135 for name in ("DIMENSIONLESS", "LENGTH", "VELOCITY", "TIME", "WAVENUMBER", "VOLUME_FLUX",
136 "DIFFUSIVITY", "PRESSURE", "DENSITY", "DYNAMIC_VISCOSITY", "VELOCITY_SQUARED"):
137 if getattr(core, name) == dimension:
138 return name
139 raise ValueError(f"dimension {dimension} has no named constant")
140
141
142def physical_inputs(core) -> dict:
143 """!
144 @brief Every input that carries a physical dimension, as the input index lists it.
145 @details Dimensionless and non-quantity inputs are omitted: there is nothing to
146 convert. Payload inputs whose dimension follows their content are listed
147 with dimension `PAYLOAD`.
148 @param[in] core Loaded conductor core.
149 @return Mapping of documented input name to `(dimension name, conversion site)`.
150 """
151 rows = {}
152
153 def record(name, quantity):
154 """!
155 @brief Add one input to the index when it carries something to convert.
156 @param[in] name Documented input name.
157 @param[in] quantity `(dimension, site)` entry.
158 """
159 dimension, site = quantity
160 if site in ("", "passthrough"):
161 return
162 if dimension is not None and dimension == core.DIMENSIONLESS:
163 return
164 rows[name] = ("PAYLOAD" if dimension is None else dimension_name(core, dimension), site)
165
166 for path, quantity in core.INPUT_QUANTITIES.items():
167 record(f"{path[0]}.yml: " + ".".join(path[1:]), quantity)
168 for key, quantity in core.BC_PARAM_QUANTITIES.items():
169 record(f"case.yml: boundary_conditions[].params.{key}", quantity)
170 for generator, table in core.IC_PARAM_QUANTITIES.items():
171 for key, quantity in table.items():
172 record(f"case.yml: initial_conditions.params.{key} ({generator})", quantity)
173 return rows
174
175
177 """!
178 @brief Field name to `(catalog, FIELD_DIM_* suffix)` for every compiled entry.
179 @return Mapping of canonical field names to their catalog and dimension.
180 @throws ValueError when an entry carries no dimension argument.
181 """
182 fields = {}
183 for catalog, path in (("Eulerian", EULERIAN_CATALOG), ("particle", PARTICLE_CATALOG)):
184 text = path.read_text(encoding="utf-8")
185 body = text[text.index("gFieldCatalog" if catalog == "Eulerian" else "gParticleFieldCatalog"):]
186 for match in ENTRY_RE.finditer(body):
187 depth, index = 1, match.end()
188 while depth:
189 depth += {"(": 1, ")": -1}.get(body[index], 0)
190 index += 1
191 entry = body[match.end():index - 1]
192 name = NAME_RE.search(entry).group(1)
193 dimension = DIMENSION_RE.search(entry)
194 if dimension is None:
195 raise ValueError(f"{catalog} field '{name}' has no FIELD_DIM_* argument")
196 fields[(catalog, name)] = dimension.group(1)
197 return fields
198
199
200def page_section(heading: str) -> str:
201 """!
202 @brief One `@section` body of page 19.
203 @param[in] heading Section anchor to extract.
204 @return Section text up to the next section.
205 """
206 text = PAGE.read_text(encoding="utf-8")
207 start = text.index(f"@section {heading}")
208 following = text.find("@section", start + 1)
209 return text[start: following if following != -1 else len(text)]
210
211
212def documented_rows(heading: str) -> dict:
213 """!
214 @brief Rows of the first table in a section: first column to second and third.
215 @param[in] heading Section anchor.
216 @return Mapping of backticked first-column text to the next two cells.
217 """
218 return {(first, second): third
219 for first, second, third in INDEX_ROW_RE.findall(page_section(heading))}
220
221
222def main() -> int:
223 """!
224 @brief Fail when an input lacks a dimension or an index disagrees with the code.
225 @return Process status code.
226 """
227 core = load_module("audit_units_core", CORE)
228 problems = [f"no recorded dimension: {name}" for name in uncovered_inputs(core)]
229
230 inputs = physical_inputs(core)
231 documented_inputs = {name: (dimension, site)
232 for (name, dimension), site in documented_rows("p19_inputs_sec").items()}
233 for name in sorted(set(inputs) - set(documented_inputs)):
234 problems.append(f"input index: '{name}' is physical but not listed")
235 for name in sorted(set(documented_inputs) - set(inputs)):
236 problems.append(f"input index: '{name}' is listed but records nothing to convert")
237 for name in sorted(set(inputs) & set(documented_inputs)):
238 if inputs[name] != documented_inputs[name]:
239 problems.append(f"input index: '{name}' records {inputs[name]}, page says {documented_inputs[name]}")
240
242 documented_fields = {(catalog, name): dimension
243 for (name, catalog), dimension in documented_rows("p19_fields_sec").items()}
244 for key in sorted(set(fields) - set(documented_fields)):
245 problems.append(f"field index: {key[0]} field '{key[1]}' is not listed")
246 for key in sorted(set(documented_fields) - set(fields)):
247 problems.append(f"field index: {key[0]} field '{key[1]}' is not in the compiled catalog")
248 for key in sorted(set(fields) & set(documented_fields)):
249 if fields[key] != documented_fields[key]:
250 problems.append(f"field index: {key[1]} is FIELD_DIM_{fields[key]}, page says {documented_fields[key]}")
251
252 if problems:
253 print("Units rule or its published indexes do not match the code:", file=sys.stderr)
254 for problem in problems:
255 print(f" {problem}", file=sys.stderr)
256 print("\nRecord a new input in picurv_cli/core.py INPUT_QUANTITIES (or BC_PARAM_QUANTITIES /\n"
257 "IC_PARAM_QUANTITIES), give a new field a FIELD_DIM_* argument, and update the\n"
258 "indexes in docs/pages/19_Nondimensionalization.md.", file=sys.stderr)
259 return 1
260 print(f"Units audit passed: every solver-facing input records a dimension; {len(inputs)} "
261 f"physical inputs and {len(fields)} catalogued fields match page 19. Whether each "
262 "conversion is performed is checked by the ingress tests and smoke run, not here.")
263 return 0
264
265
266if __name__ == "__main__":
267 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.