4@brief Enforce the physical-units rule and the published units indexes.
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`.
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.
17from __future__
import annotations
19import importlib.machinery
23from pathlib
import Path
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"
35SOLVER_INPUT_SCHEMAS = {
36 "case":
"_CASE_SCHEMA",
37 "solver":
"_SOLVER_SCHEMA",
38 "monitor":
"_MONITOR_SCHEMA",
39 "post":
"_POST_SCHEMA",
43DEDICATED_TABLE_SURFACES = {
44 (
"case",
"boundary_conditions",
"[]",
"params"),
45 (
"case",
"boundary_conditions",
"[]",
"[]",
"params"),
46 (
"case",
"properties",
"initial_conditions",
"params"),
50BUILTIN_IC_GENERATORS = {
"zero",
"constant",
"streamwise_constant",
"poiseuille"}
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)
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.
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)
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.
83 for parent, keys
in schema.items()
if keys
is not None
84 for length
in range(1, len(parent) + 1)
86 for parent, keys
in schema.items():
87 for key
in keys
or ():
88 path = parent + (key,)
89 if path
not in interiors:
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.
100 for role, schema_name
in SOLVER_INPUT_SCHEMAS.items():
102 full = (role,) + path
103 if full
in DEDICATED_TABLE_SURFACES:
106 core.input_quantity(full)
108 problems.append(
".".join(full))
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)))
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)
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,
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))
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`.
138 for name
in (
"DIMENSIONLESS",
"LENGTH",
"VELOCITY",
"TIME",
"WAVENUMBER",
"VOLUME_FLUX",
139 "DIFFUSIVITY",
"PRESSURE",
"DENSITY",
"DYNAMIC_VISCOSITY"):
140 if getattr(core, name) == dimension:
142 raise ValueError(f
"dimension {dimension} has no named constant")
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)`.
156 def record(name, quantity):
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.
162 dimension, site = quantity
163 if site
in (
"",
"passthrough"):
165 if dimension
is not None and dimension == core.DIMENSIONLESS:
167 rows[name] = (
"PAYLOAD" if dimension
is None else dimension_name(core, dimension), site)
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)
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.
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()
192 depth += {
"(": 1,
")": -1}.get(body[index], 0)
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)
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.
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)]
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.
221 return {(first, second): third
222 for first, second, third
in INDEX_ROW_RE.findall(
page_section(heading))}
227 @brief Fail when an input lacks a dimension or an index disagrees with the code.
228 @return Process status code.
231 problems = [f
"no recorded dimension: {name}" for name
in uncovered_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]}")
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]}")
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)
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.")
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.