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"),
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)
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.
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)
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.
81 for parent, keys
in schema.items()
if keys
is not None
82 for length
in range(1, len(parent) + 1)
84 for parent, keys
in schema.items():
85 for key
in keys
or ():
86 path = parent + (key,)
87 if path
not in interiors:
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.
98 for role, schema_name
in SOLVER_INPUT_SCHEMAS.items():
100 full = (role,) + path
101 if full
in DEDICATED_TABLE_SURFACES:
104 core.input_quantity(full)
106 problems.append(
".".join(full))
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)))
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)
119 "spectral_velocity": ic_gen.WALL_SPECTRAL_PARAMS | ic_gen.PERIODIC_SPECTRAL_ONLY_PARAMS,
120 "stability_mode_velocity": ic_gen.STABILITY_MODE_PARAMS,
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))
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`.
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:
139 raise ValueError(f
"dimension {dimension} has no named constant")
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)`.
153 def record(name, quantity):
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.
159 dimension, site = quantity
160 if site
in (
"",
"passthrough"):
162 if dimension
is not None and dimension == core.DIMENSIONLESS:
164 rows[name] = (
"PAYLOAD" if dimension
is None else dimension_name(core, dimension), site)
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)
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.
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()
189 depth += {
"(": 1,
")": -1}.get(body[index], 0)
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)
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.
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)]
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.
218 return {(first, second): third
219 for first, second, third
in INDEX_ROW_RE.findall(
page_section(heading))}
224 @brief Fail when an input lacks a dimension or an index disagrees with the code.
225 @return Process status code.
228 problems = [f
"no recorded dimension: {name}" for name
in uncovered_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]}")
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]}")
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)
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.")
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.