flow-props 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flow_props-0.2.1 → flow_props-0.3.0}/PKG-INFO +1 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/shock.md +2 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/shock.md +35 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/config-files.md +4 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/output.md +2 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/bl.py +11 -2
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/common.py +75 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/entropy_layer.py +12 -2
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/profiles.py +12 -2
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/shock.py +70 -8
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/shock.py +38 -11
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/PKG-INFO +1 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/scm_version.json +2 -2
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/synthetic.py +14 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_calculators.py +39 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_cli.py +96 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_shock.py +27 -1
- {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/docs.yml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/package.yml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/publish.yml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/test.yml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/.gitignore +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/.vscode/settings.json +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/CITATION.cff +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/LICENSE +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/README.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/architecture.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/boundary-layer.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/entropy-layer.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/profiles.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/wall.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/cli-reference/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/examples.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/installation.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/javascripts/mathjax.js +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/nomenclature.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/boundary-layer.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/entropy-layer.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/wall-quantities.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/basic-usage.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/cli.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/configuration.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/workflow.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/validation/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/verification/index.md +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/mkdocs.yml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/pyproject.toml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/setup.cfg +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/__init__.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/bl.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/__init__.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/__init__.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/app.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/callbacks.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/cmd_calculator.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/examples_command.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/entropy_layer.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/cone_7deg_m14/bl.toml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/ogive_m5pt3/bl.toml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/sharp_flat_plate_m5/bl.toml +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/output.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/__init__.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/api.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/geometry.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/grid_line.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/wall_normal.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/wall.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/SOURCES.txt +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/dependency_links.txt +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/entry_points.txt +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/requires.txt +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/scm_file_list.json +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/top_level.txt +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_bl.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_entropy_layer.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_output.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_profiles.py +0 -0
- {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_wall.py +0 -0
|
@@ -11,7 +11,8 @@ writes the shock curve (see [Shock Extraction](../theory/shock.md) for the metho
|
|
|
11
11
|
|
|
12
12
|
- `find_shock_on_line(values, level=0.5, min_rise=0.05, max_thickness=20.0, method="walk",
|
|
13
13
|
indicator_threshold=0.0025)`: the shock on one line, as a `ShockCrossing` (fractional
|
|
14
|
-
`j`, `j_tail`, `j_foot`, `thickness_cells`, `detected_by
|
|
14
|
+
`j`, `j_tail`, the outer edge `j_foot`, `thickness_cells`, `detected_by`, and each
|
|
15
|
+
detector's own point `j_walk`, `j_indicator`, whatever the method), or `None`
|
|
15
16
|
- `extract_shock(ds, quantity="pressure", ..., method="walk", indicator_threshold=0.0025)`:
|
|
16
17
|
every line of a dataset, as a `ShockExtraction` (station, x, y, j, distance,
|
|
17
18
|
thickness_cells, detected_by, and the stations without a shock)
|
|
@@ -65,6 +65,41 @@ On a shock-line grid the shock is meant to lie on grid line `shock_line_j`. The
|
|
|
65
65
|
gives each line's offset $j_\text{shock} - j_s$ in cells, and the run summary the range of
|
|
66
66
|
offsets and the number of lines more than a quarter cell off.
|
|
67
67
|
|
|
68
|
+
## Checks in the Report
|
|
69
|
+
|
|
70
|
+
Both detectors run on every line, whichever the method, so each result can be checked
|
|
71
|
+
against the other. The run summary warns when
|
|
72
|
+
|
|
73
|
+
- the shock's outer edge (where $q$ crosses 10 % of the jump) lies within 5 cells of the
|
|
74
|
+
outer boundary: too little freestream outside it to read $q_\infty$ from, or a
|
|
75
|
+
disturbance ahead of the shock was taken for it;
|
|
76
|
+
- the walk and the indicator differ by more than 3 cells on a line: one of them found
|
|
77
|
+
something else. A smooth compression ahead of the shock (e.g. freestream compressed
|
|
78
|
+
against a bent symmetry line) is met first by the walk, while the indicator, which
|
|
79
|
+
only flags sharp jumps, finds the shock.
|
|
80
|
+
|
|
81
|
+
It also counts how often the shock's outer edge changes grid row along the body and, on
|
|
82
|
+
a shock-line grid, how often it crosses the shock line `shock_line_j`. A captured shock
|
|
83
|
+
that moves between rows makes a solver's wall quantities wave; on a converged
|
|
84
|
+
shock-line grid the edge stays on one side of the shock line.
|
|
85
|
+
|
|
86
|
+
## Solutions at Cell Centres
|
|
87
|
+
|
|
88
|
+
A finite-volume solution holds cell values. Read through cfd-io as fields at the cell
|
|
89
|
+
centres of the vertex grid (association `cell`: one point fewer than the grid in each
|
|
90
|
+
direction), they are put on a grid of the cell centres, each the mean of its cell's
|
|
91
|
+
corners, and the shock is found along the lines through them. Those lines lie half a
|
|
92
|
+
cell from the vertex grid's lines, so:
|
|
93
|
+
|
|
94
|
+
- the shock line `shock_line_j` counts vertices, and cell $j$ lies at $j + 0.5$ on the
|
|
95
|
+
vertex grid; the report's `j_shock` and the offsets use that, and the run summary
|
|
96
|
+
says the solution was at cell centres;
|
|
97
|
+
- the first line runs half a cell off the symmetry axis, so the curve starts there;
|
|
98
|
+
grid-generator continues a curve that starts slightly off the axis to it.
|
|
99
|
+
|
|
100
|
+
A solution whose grid is the cell centres themselves (stored as an ordinary grid)
|
|
101
|
+
can't be told from vertex data; write cell values on the vertex grid instead.
|
|
102
|
+
|
|
68
103
|
## Smoothing the Curve
|
|
69
104
|
|
|
70
105
|
The extracted curve becomes a grid line of the next grid, so line-to-line jitter of the
|
|
@@ -27,6 +27,10 @@ dx = 0.05
|
|
|
27
27
|
# Mode 3: list = [10, 50, 100]
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
A solution with cell values (a finite-volume solver's, read by cfd-io as fields at the
|
|
31
|
+
cell centres of the vertex grid) is handled by every calculation: the profiles run
|
|
32
|
+
through the cell centres, starting at the first one, half a cell off the wall.
|
|
33
|
+
|
|
30
34
|
`bl` (with the `htot_derivative` criterion) and `entropy-layer` need $\gamma$ and $R$.
|
|
31
35
|
They come from the solution's HDF5 attributes (`gamma`, `r_gas`), then a
|
|
32
36
|
`flow_conditions.json` next to the solution file, and can be overridden:
|
|
@@ -28,7 +28,8 @@ in the local wall frame).
|
|
|
28
28
|
- `shock_report.dat`: the shock location for Tecplot, one I-ordered zone with one row per
|
|
29
29
|
grid line with a shock. `x`, `y` are the shock points as found, so loading the file
|
|
30
30
|
over the flow field draws the shock; then the wall point `x_wall`, `y_wall`, `station`,
|
|
31
|
-
the shock point's fractional grid index `j_shock`,
|
|
31
|
+
the shock point's fractional grid index `j_shock`, its outer edge `j_foot` (where the
|
|
32
|
+
quantity crosses 10 % of the jump), the `distance` from the wall along
|
|
32
33
|
the grid line, the captured `thickness_cells`, and,
|
|
33
34
|
when a shock line is known, `offset_cells` (`j_shock - shock_line_j`; positive means the
|
|
34
35
|
solution's shock lies outside the grid's shock line). With smoothing, also the smoothed
|
|
@@ -18,6 +18,7 @@ import numpy as np
|
|
|
18
18
|
from pydantic import BaseModel, ConfigDict, Field
|
|
19
19
|
|
|
20
20
|
from flow_props.calculators.common import (
|
|
21
|
+
AT_CELL_CENTRES,
|
|
21
22
|
GAS_BLOCK,
|
|
22
23
|
INPUT_BLOCK,
|
|
23
24
|
STATIONS_BLOCK,
|
|
@@ -29,6 +30,7 @@ from flow_props.calculators.common import (
|
|
|
29
30
|
load_dataset,
|
|
30
31
|
resolve_gas_properties,
|
|
31
32
|
resolve_stations,
|
|
33
|
+
solution_points,
|
|
32
34
|
)
|
|
33
35
|
|
|
34
36
|
logger = logging.getLogger(__name__)
|
|
@@ -109,8 +111,8 @@ def run_case(cfg: BLCaseConfig, config_dir: Path) -> list[str]:
|
|
|
109
111
|
# -- resolve file paths relative to config directory --
|
|
110
112
|
fname, gname = input_paths(cfg, config_dir)
|
|
111
113
|
|
|
112
|
-
# -- load CFD dataset via cfd_io --
|
|
113
|
-
ds = load_dataset(fname, gname)
|
|
114
|
+
# -- load CFD dataset via cfd_io (cell values go on a grid of the cell centres) --
|
|
115
|
+
ds, location = solution_points(load_dataset(fname, gname))
|
|
114
116
|
|
|
115
117
|
# -- resolve station list --
|
|
116
118
|
stations = resolve_stations(ds, cfg.stations)
|
|
@@ -144,6 +146,13 @@ def run_case(cfg: BLCaseConfig, config_dir: Path) -> list[str]:
|
|
|
144
146
|
f"Wrote: {output_path}",
|
|
145
147
|
]
|
|
146
148
|
|
|
149
|
+
if location == AT_CELL_CENTRES:
|
|
150
|
+
report.insert(
|
|
151
|
+
1,
|
|
152
|
+
" solution at cell centres: profiles run through them, from the first cell "
|
|
153
|
+
"centre (half a cell off the wall)",
|
|
154
|
+
)
|
|
155
|
+
|
|
147
156
|
return report
|
|
148
157
|
|
|
149
158
|
|
|
@@ -21,6 +21,8 @@ from typing import TypeVar
|
|
|
21
21
|
|
|
22
22
|
import numpy as np
|
|
23
23
|
from cfd_io.dataset import Dataset, StructuredGrid
|
|
24
|
+
from cfd_io.dataset import Field as FlowField
|
|
25
|
+
from numpy.typing import NDArray
|
|
24
26
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
@@ -28,6 +30,10 @@ logger = logging.getLogger(__name__)
|
|
|
28
30
|
# any calculation's config model (load_config returns the model it is given)
|
|
29
31
|
ConfigModel = TypeVar("ConfigModel", bound=BaseModel)
|
|
30
32
|
|
|
33
|
+
# where a solution's fields sit (see solution_points)
|
|
34
|
+
AT_VERTICES = "vertices"
|
|
35
|
+
AT_CELL_CENTRES = "cell centres"
|
|
36
|
+
|
|
31
37
|
# --------------------------------------------------
|
|
32
38
|
# config models (input boundary)
|
|
33
39
|
# --------------------------------------------------
|
|
@@ -230,6 +236,60 @@ def load_dataset(fname: Path, gname: Path | None) -> Dataset:
|
|
|
230
236
|
return read_file(fname)
|
|
231
237
|
|
|
232
238
|
|
|
239
|
+
def solution_points(ds: Dataset) -> tuple[Dataset, str]:
|
|
240
|
+
"""The dataset whose grid points carry the flow fields, and where those points are.
|
|
241
|
+
|
|
242
|
+
Fields at the grid points (cfd-io association "node", e.g. SU2's vertex data) are
|
|
243
|
+
used as they are. Fields at the cell centres ("cell": a finite-volume solution's
|
|
244
|
+
cell values on its vertex grid, e.g. Eilmer's) are put on a grid of the cell
|
|
245
|
+
centres, each the mean of its cell's corners. That grid's lines run through the
|
|
246
|
+
cell centres, so a profile starts at the first cell centre (half a cell off the
|
|
247
|
+
wall), and its j counts cells: cell j lies at j + 0.5 on the vertex grid.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
ds: Dataset as read by cfd-io.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The dataset to take profiles from, and AT_VERTICES or AT_CELL_CENTRES.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If the fields sit at different places (some at the grid points,
|
|
257
|
+
some at the cell centres), or the cell fields don't fit the grid.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
# where the fields sit
|
|
261
|
+
associations = {field.association for field in ds.flow.values()}
|
|
262
|
+
if not associations or associations == {"node"}:
|
|
263
|
+
return ds, AT_VERTICES
|
|
264
|
+
if associations != {"cell"}:
|
|
265
|
+
places = ", ".join(sorted(associations))
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"the solution's fields sit at different places ({places}); flow-props needs "
|
|
268
|
+
"them all at the grid points or all at the cell centres"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# the cell-centre grid: each centre the mean of its cell's corners
|
|
272
|
+
centre_x = _cell_centres(np.asarray(ds.grid.x, dtype=float))
|
|
273
|
+
centre_y = _cell_centres(np.asarray(ds.grid.y, dtype=float))
|
|
274
|
+
centre_z = _cell_centres(np.asarray(ds.grid.z, dtype=float))
|
|
275
|
+
grid = StructuredGrid(x=centre_x, y=centre_y, z=centre_z)
|
|
276
|
+
|
|
277
|
+
# the cell values, now at the points of that grid
|
|
278
|
+
flow: dict[str, FlowField] = {}
|
|
279
|
+
for name, field in ds.flow.items():
|
|
280
|
+
if field.data.shape != centre_x.shape:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"cell field {name} has shape {field.data.shape}; the grid's cells are "
|
|
283
|
+
f"{centre_x.shape}"
|
|
284
|
+
)
|
|
285
|
+
flow[name] = FlowField(data=field.data, association="node")
|
|
286
|
+
|
|
287
|
+
# debug output for devs
|
|
288
|
+
logger.debug("solution at cell centres: profiles on a %s grid of cell centres", grid.shape)
|
|
289
|
+
|
|
290
|
+
return Dataset(grid=grid, flow=flow, attrs=ds.attrs), AT_CELL_CENTRES
|
|
291
|
+
|
|
292
|
+
|
|
233
293
|
def input_paths(config: CaseConfig, config_dir: Path) -> tuple[Path, Path | None]:
|
|
234
294
|
"""Resolve the solution and grid file paths relative to the config file's folder.
|
|
235
295
|
|
|
@@ -345,6 +405,21 @@ def resolve_gas_properties(
|
|
|
345
405
|
# --------------------------------------------------
|
|
346
406
|
|
|
347
407
|
|
|
408
|
+
def _cell_centres(corners: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
409
|
+
"""Cell centres of a structured (ni, nj, nk) array: the mean of each cell's corners.
|
|
410
|
+
|
|
411
|
+
A 2D grid (nk = 1) keeps its single k-plane.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
# average neighbours along i, then j, then k (if the grid has cells in k)
|
|
415
|
+
centres = 0.5 * (corners[:-1, :, :] + corners[1:, :, :])
|
|
416
|
+
centres = 0.5 * (centres[:, :-1, :] + centres[:, 1:, :])
|
|
417
|
+
if centres.shape[2] > 1:
|
|
418
|
+
centres = 0.5 * (centres[:, :, :-1] + centres[:, :, 1:])
|
|
419
|
+
|
|
420
|
+
return centres
|
|
421
|
+
|
|
422
|
+
|
|
348
423
|
def _metadata_number(value: object) -> float | None:
|
|
349
424
|
"""Read a scalar or unit-tagged value from cfd-io attributes or JSON."""
|
|
350
425
|
if value is None:
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from pydantic import BaseModel, ConfigDict, Field
|
|
17
17
|
|
|
18
18
|
from flow_props.calculators.common import (
|
|
19
|
+
AT_CELL_CENTRES,
|
|
19
20
|
GAS_BLOCK,
|
|
20
21
|
INPUT_BLOCK,
|
|
21
22
|
STATIONS_BLOCK,
|
|
@@ -27,6 +28,7 @@ from flow_props.calculators.common import (
|
|
|
27
28
|
load_dataset,
|
|
28
29
|
resolve_gas_properties,
|
|
29
30
|
resolve_stations,
|
|
31
|
+
solution_points,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
logger = logging.getLogger(__name__)
|
|
@@ -115,9 +117,10 @@ def run_case(cfg: EntropyLayerCaseConfig, config_dir: Path) -> list[str]:
|
|
|
115
117
|
from flow_props.entropy_layer import run_entropy
|
|
116
118
|
from flow_props.output import write_station_output
|
|
117
119
|
|
|
118
|
-
# resolve file paths relative to the config directory, then load the dataset
|
|
120
|
+
# resolve file paths relative to the config directory, then load the dataset (cell
|
|
121
|
+
# values go on a grid of the cell centres)
|
|
119
122
|
fname, gname = input_paths(cfg, config_dir)
|
|
120
|
-
ds = load_dataset(fname, gname)
|
|
123
|
+
ds, location = solution_points(load_dataset(fname, gname))
|
|
121
124
|
|
|
122
125
|
# resolve the stations and the gas constants (the entropy rise needs both)
|
|
123
126
|
stations = resolve_stations(ds, cfg.stations)
|
|
@@ -159,4 +162,11 @@ def run_case(cfg: EntropyLayerCaseConfig, config_dir: Path) -> list[str]:
|
|
|
159
162
|
f"Wrote: {output_path}",
|
|
160
163
|
]
|
|
161
164
|
|
|
165
|
+
if location == AT_CELL_CENTRES:
|
|
166
|
+
report.insert(
|
|
167
|
+
1,
|
|
168
|
+
" solution at cell centres: profiles run through them, from the first cell "
|
|
169
|
+
"centre (half a cell off the wall)",
|
|
170
|
+
)
|
|
171
|
+
|
|
162
172
|
return report
|
|
@@ -17,6 +17,7 @@ from typing import Literal
|
|
|
17
17
|
from pydantic import BaseModel, ConfigDict, Field
|
|
18
18
|
|
|
19
19
|
from flow_props.calculators.common import (
|
|
20
|
+
AT_CELL_CENTRES,
|
|
20
21
|
INPUT_BLOCK,
|
|
21
22
|
STATIONS_BLOCK,
|
|
22
23
|
CaseConfig,
|
|
@@ -25,6 +26,7 @@ from flow_props.calculators.common import (
|
|
|
25
26
|
input_paths,
|
|
26
27
|
load_dataset,
|
|
27
28
|
resolve_stations,
|
|
29
|
+
solution_points,
|
|
28
30
|
)
|
|
29
31
|
|
|
30
32
|
logger = logging.getLogger(__name__)
|
|
@@ -108,9 +110,10 @@ def run_case(cfg: ProfilesCaseConfig, config_dir: Path) -> list[str]:
|
|
|
108
110
|
from flow_props.output import write_profiles_output
|
|
109
111
|
from flow_props.profiles import extract_profiles
|
|
110
112
|
|
|
111
|
-
# resolve file paths relative to the config directory, then load the dataset
|
|
113
|
+
# resolve file paths relative to the config directory, then load the dataset (cell
|
|
114
|
+
# values go on a grid of the cell centres)
|
|
112
115
|
fname, gname = input_paths(cfg, config_dir)
|
|
113
|
-
ds = load_dataset(fname, gname)
|
|
116
|
+
ds, location = solution_points(load_dataset(fname, gname))
|
|
114
117
|
|
|
115
118
|
# resolve the stations
|
|
116
119
|
stations = resolve_stations(ds, cfg.stations)
|
|
@@ -134,4 +137,11 @@ def run_case(cfg: ProfilesCaseConfig, config_dir: Path) -> list[str]:
|
|
|
134
137
|
f"Wrote: {output_path}",
|
|
135
138
|
]
|
|
136
139
|
|
|
140
|
+
if location == AT_CELL_CENTRES:
|
|
141
|
+
report.insert(
|
|
142
|
+
1,
|
|
143
|
+
" solution at cell centres: profiles run through them, from the first cell "
|
|
144
|
+
"centre (half a cell off the wall)",
|
|
145
|
+
)
|
|
146
|
+
|
|
137
147
|
return report
|
|
@@ -10,6 +10,9 @@ finds the shock on every wall-normal grid line (see flow_props.shock) and writes
|
|
|
10
10
|
with per line the wall point, distance from the wall, captured thickness in cells and,
|
|
11
11
|
on a shock-line grid, the offset from the grid line meant to hold the shock.
|
|
12
12
|
|
|
13
|
+
A solution with cell values is taken along the lines through the cell centres, whose j
|
|
14
|
+
counts cells; the offsets from shock_line_j (a vertex index) use j + 0.5 for them.
|
|
15
|
+
|
|
13
16
|
Detection (``method``): walk (the first rise of min_rise over freestream, from outside),
|
|
14
17
|
indicator (the first point whose WENO smoothness indicator reaches indicator_threshold)
|
|
15
18
|
or hybrid (the outermost jump either finds); the point is where the quantity crosses
|
|
@@ -29,11 +32,13 @@ import numpy as np
|
|
|
29
32
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
30
33
|
|
|
31
34
|
from flow_props.calculators.common import (
|
|
35
|
+
AT_CELL_CENTRES,
|
|
32
36
|
INPUT_BLOCK,
|
|
33
37
|
CaseConfig,
|
|
34
38
|
build_template,
|
|
35
39
|
input_paths,
|
|
36
40
|
load_dataset,
|
|
41
|
+
solution_points,
|
|
37
42
|
)
|
|
38
43
|
|
|
39
44
|
logger = logging.getLogger(__name__)
|
|
@@ -45,6 +50,14 @@ logger = logging.getLogger(__name__)
|
|
|
45
50
|
# within a quarter cell of its grid line, the shock counts as sitting on it
|
|
46
51
|
ALIGNED_OFFSET_CELLS = 0.25
|
|
47
52
|
|
|
53
|
+
# fewer cells than this between the shock's outer edge and the outer boundary leave too
|
|
54
|
+
# little freestream to read q_inf from, or mean a disturbance ahead of the shock was
|
|
55
|
+
# taken for it (e.g. freestream compressed against a bent symmetry line)
|
|
56
|
+
OUTER_EDGE_CELLS = 5
|
|
57
|
+
|
|
58
|
+
# walk and indicator farther apart than this found different things on a line
|
|
59
|
+
DISAGREEMENT_CELLS = 3.0
|
|
60
|
+
|
|
48
61
|
# --------------------------------------------------
|
|
49
62
|
# config models (input boundary)
|
|
50
63
|
# --------------------------------------------------
|
|
@@ -206,11 +219,15 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
206
219
|
write_shock_curve,
|
|
207
220
|
)
|
|
208
221
|
|
|
209
|
-
# resolve file paths relative to the config directory, then load the dataset
|
|
222
|
+
# resolve file paths relative to the config directory, then load the dataset (cell
|
|
223
|
+
# values go on a grid of the cell centres)
|
|
210
224
|
fname, gname = input_paths(cfg, config_dir)
|
|
211
|
-
ds = load_dataset(fname, gname)
|
|
225
|
+
ds, location = solution_points(load_dataset(fname, gname))
|
|
212
226
|
options = cfg.shock
|
|
213
227
|
|
|
228
|
+
# j on the grid the shock line counts (the vertex grid): cell j lies at j + 0.5
|
|
229
|
+
j_to_vertex = 0.5 if location == AT_CELL_CENTRES else 0.0
|
|
230
|
+
|
|
214
231
|
# extract: one shock point per grid line
|
|
215
232
|
extraction = extract_shock(
|
|
216
233
|
ds,
|
|
@@ -222,7 +239,8 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
222
239
|
indicator_threshold=options.indicator_threshold,
|
|
223
240
|
)
|
|
224
241
|
ni = ds.grid.shape[0]
|
|
225
|
-
|
|
242
|
+
nj_vertices = ds.grid.shape[1] + (1 if location == AT_CELL_CENTRES else 0)
|
|
243
|
+
j_shock = extraction.j + j_to_vertex
|
|
226
244
|
|
|
227
245
|
# the grid line meant to hold the shock: config first, then the solution's attributes
|
|
228
246
|
shock_line_j = options.shock_line_j
|
|
@@ -230,8 +248,10 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
230
248
|
if shock_line_j is None and ds.attrs.get("shock_line_j") is not None:
|
|
231
249
|
shock_line_j = int(ds.attrs["shock_line_j"])
|
|
232
250
|
source = "the solution file"
|
|
233
|
-
if shock_line_j is not None and shock_line_j >=
|
|
234
|
-
raise ValueError(
|
|
251
|
+
if shock_line_j is not None and shock_line_j >= nj_vertices:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"shock_line_j = {shock_line_j} lies outside the grid (j = 0 to {nj_vertices - 1})"
|
|
254
|
+
)
|
|
235
255
|
|
|
236
256
|
# debug output for devs
|
|
237
257
|
logger.debug("shock line: %s (from %s)", shock_line_j, source)
|
|
@@ -244,7 +264,8 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
244
264
|
"x_wall": ds.grid.x[extraction.station, 0, 0],
|
|
245
265
|
"y_wall": ds.grid.y[extraction.station, 0, 0],
|
|
246
266
|
"station": extraction.station.astype(float),
|
|
247
|
-
"j_shock":
|
|
267
|
+
"j_shock": j_shock,
|
|
268
|
+
"j_foot": extraction.j_foot + j_to_vertex,
|
|
248
269
|
"distance": extraction.distance,
|
|
249
270
|
"thickness_cells": extraction.thickness_cells,
|
|
250
271
|
}
|
|
@@ -254,7 +275,7 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
254
275
|
offsets = None
|
|
255
276
|
if shock_line_j is not None:
|
|
256
277
|
# positive: the solution's shock lies outside the grid's shock line
|
|
257
|
-
offsets =
|
|
278
|
+
offsets = j_shock - shock_line_j
|
|
258
279
|
table["offset_cells"] = offsets
|
|
259
280
|
|
|
260
281
|
# smooth: the curve that is written becomes a grid line
|
|
@@ -265,7 +286,7 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
265
286
|
moved = curve.j - extraction.j
|
|
266
287
|
table["x_smooth"] = curve.x
|
|
267
288
|
table["y_smooth"] = curve.y
|
|
268
|
-
table["j_smooth"] = curve.j
|
|
289
|
+
table["j_smooth"] = curve.j + j_to_vertex
|
|
269
290
|
table["smoothing_cells"] = moved
|
|
270
291
|
|
|
271
292
|
# write: the curve, then the report
|
|
@@ -298,6 +319,11 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
298
319
|
f" found by the walk on {by_walk} lines, by the indicator on {by_indicator} "
|
|
299
320
|
f"(both on {both})"
|
|
300
321
|
)
|
|
322
|
+
if location == AT_CELL_CENTRES:
|
|
323
|
+
report.append(
|
|
324
|
+
" solution at cell centres: lines run through them, j is counted on the vertex "
|
|
325
|
+
"grid (cell j + 0.5)"
|
|
326
|
+
)
|
|
301
327
|
if extraction.stations_without_shock:
|
|
302
328
|
report.append(f" no shock on i = {_index_ranges(extraction.stations_without_shock)}")
|
|
303
329
|
thickness = extraction.thickness_cells
|
|
@@ -306,7 +332,43 @@ def run_case(cfg: ShockCaseConfig, config_dir: Path) -> list[str]:
|
|
|
306
332
|
f"{thickness.max():.1f} cells (median {np.median(thickness):.1f})"
|
|
307
333
|
)
|
|
308
334
|
|
|
335
|
+
# warn: the shock's outer edge close to the outer boundary
|
|
336
|
+
nj = ds.grid.shape[1]
|
|
337
|
+
cells_outside = (nj - 1) - extraction.j_foot
|
|
338
|
+
near_outer = extraction.station[cells_outside < OUTER_EDGE_CELLS].tolist()
|
|
339
|
+
if near_outer:
|
|
340
|
+
report.append(
|
|
341
|
+
f" warning: the shock lies within {OUTER_EDGE_CELLS} cells of the outer boundary "
|
|
342
|
+
f"on {len(near_outer)} lines (i = {_index_ranges(near_outer)}): too little "
|
|
343
|
+
"freestream outside it, or a disturbance ahead of the shock was taken for it"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# warn: walk and indicator found different jumps (both run on every line)
|
|
347
|
+
both_found = ~np.isnan(extraction.j_walk) & ~np.isnan(extraction.j_indicator)
|
|
348
|
+
gap = np.abs(extraction.j_walk - extraction.j_indicator)
|
|
349
|
+
apart = both_found & (gap > DISAGREEMENT_CELLS)
|
|
350
|
+
if np.any(apart):
|
|
351
|
+
walk_outside = int(np.count_nonzero(apart & (extraction.j_walk > extraction.j_indicator)))
|
|
352
|
+
apart_lines = extraction.station[apart].tolist()
|
|
353
|
+
report.append(
|
|
354
|
+
f" warning: walk and indicator differ by more than {DISAGREEMENT_CELLS:g} cells "
|
|
355
|
+
f"on {len(apart_lines)} lines (i = {_index_ranges(apart_lines)}); the walk's "
|
|
356
|
+
f"shock lies farther out on {walk_outside} of them"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# report: the shock's outer edge, row by row (row changes along the body make the
|
|
360
|
+
# captured shock jump between cells, which a solver feels at the wall)
|
|
361
|
+
edge_j = extraction.j_foot + j_to_vertex
|
|
362
|
+
edge_row = np.floor(edge_j).astype(int)
|
|
363
|
+
row_changes = int(np.count_nonzero(np.diff(edge_row)))
|
|
364
|
+
edge_text = f" shock edge (10 % of the jump): grid row changes along the body {row_changes}"
|
|
365
|
+
|
|
309
366
|
# report: how the shock sits on the grid's shock line
|
|
367
|
+
if shock_line_j is not None:
|
|
368
|
+
outside = edge_j > shock_line_j
|
|
369
|
+
crossings = int(np.count_nonzero(np.diff(outside.astype(int))))
|
|
370
|
+
edge_text += f", crossings of the shock line {crossings}"
|
|
371
|
+
report.append(edge_text)
|
|
310
372
|
if offsets is None:
|
|
311
373
|
report.append(" no shock line to compare with (set [shock] shock_line_j)")
|
|
312
374
|
else:
|
|
@@ -103,12 +103,16 @@ class ShockCrossing:
|
|
|
103
103
|
j: float
|
|
104
104
|
# last grid point of the jump on the post-shock side (q there is the post-shock value)
|
|
105
105
|
j_tail: int
|
|
106
|
-
#
|
|
107
|
-
j_foot:
|
|
106
|
+
# the shock's outer edge: fractional j where q crosses 10 % of the jump
|
|
107
|
+
j_foot: float
|
|
108
108
|
# captured shock thickness: from 10 % to 90 % of the jump, in cells
|
|
109
109
|
thickness_cells: float
|
|
110
110
|
# which detector found it: DETECTED_BY_WALK, DETECTED_BY_INDICATOR, or their sum
|
|
111
111
|
detected_by: int
|
|
112
|
+
# each detector's own shock point, whatever the method (nan: it found none); a
|
|
113
|
+
# cross-check: far apart, one of them took something else for the shock
|
|
114
|
+
j_walk: float = float("nan")
|
|
115
|
+
j_indicator: float = float("nan")
|
|
112
116
|
|
|
113
117
|
|
|
114
118
|
@dataclass(slots=True)
|
|
@@ -128,6 +132,11 @@ class ShockExtraction:
|
|
|
128
132
|
thickness_cells: NDArray[np.float64]
|
|
129
133
|
# which detector found each line's shock (see ShockCrossing.detected_by)
|
|
130
134
|
detected_by: NDArray[np.int64]
|
|
135
|
+
# the shock's outer edge (10 % of the jump), fractional j
|
|
136
|
+
j_foot: NDArray[np.float64]
|
|
137
|
+
# each detector's own shock point, fractional j (nan: it found none on that line)
|
|
138
|
+
j_walk: NDArray[np.float64]
|
|
139
|
+
j_indicator: NDArray[np.float64]
|
|
131
140
|
# i of the grid lines without a shock
|
|
132
141
|
stations_without_shock: list[int]
|
|
133
142
|
|
|
@@ -233,13 +242,17 @@ def find_shock_on_line(
|
|
|
233
242
|
if not q_inf > 0.0:
|
|
234
243
|
raise ValueError(f"the freestream value (outer end of the line) is {q_inf}; expected > 0")
|
|
235
244
|
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
245
|
+
# every detector: its first jump from outside that is a shock (the method picks
|
|
246
|
+
# among them; the others are kept as a cross-check)
|
|
247
|
+
found: dict[str, ShockCrossing] = {}
|
|
248
|
+
for name, detector in DETECTORS.items():
|
|
239
249
|
detection = detector(values, q_inf, min_rise, indicator_threshold)
|
|
240
250
|
crossing = _first_shock(values, detection, q_inf, level, max_thickness)
|
|
241
251
|
if crossing is not None:
|
|
242
|
-
|
|
252
|
+
found[name] = crossing
|
|
253
|
+
|
|
254
|
+
# the method's detectors
|
|
255
|
+
crossings = [found[name] for name in METHODS[method] if name in found]
|
|
243
256
|
if not crossings:
|
|
244
257
|
return None
|
|
245
258
|
|
|
@@ -256,6 +269,8 @@ def find_shock_on_line(
|
|
|
256
269
|
j_foot=outermost.j_foot,
|
|
257
270
|
thickness_cells=outermost.thickness_cells,
|
|
258
271
|
detected_by=detected_by,
|
|
272
|
+
j_walk=found["walk"].j if "walk" in found else float("nan"),
|
|
273
|
+
j_indicator=found["indicator"].j if "indicator" in found else float("nan"),
|
|
259
274
|
)
|
|
260
275
|
|
|
261
276
|
return shock
|
|
@@ -322,6 +337,9 @@ def extract_shock(
|
|
|
322
337
|
distance_values: list[float] = []
|
|
323
338
|
thickness_values: list[float] = []
|
|
324
339
|
detected_values: list[int] = []
|
|
340
|
+
foot_values: list[float] = []
|
|
341
|
+
walk_values: list[float] = []
|
|
342
|
+
indicator_values: list[float] = []
|
|
325
343
|
without_shock: list[int] = []
|
|
326
344
|
|
|
327
345
|
# find the shock on each line
|
|
@@ -354,6 +372,9 @@ def extract_shock(
|
|
|
354
372
|
distance_values.append(distance)
|
|
355
373
|
thickness_values.append(crossing.thickness_cells)
|
|
356
374
|
detected_values.append(crossing.detected_by)
|
|
375
|
+
foot_values.append(crossing.j_foot)
|
|
376
|
+
walk_values.append(crossing.j_walk)
|
|
377
|
+
indicator_values.append(crossing.j_indicator)
|
|
357
378
|
|
|
358
379
|
# check: at least one line crosses the shock
|
|
359
380
|
if not found_stations:
|
|
@@ -374,6 +395,9 @@ def extract_shock(
|
|
|
374
395
|
distance=np.asarray(distance_values, dtype=float),
|
|
375
396
|
thickness_cells=np.asarray(thickness_values, dtype=float),
|
|
376
397
|
detected_by=np.asarray(detected_values, dtype=np.int64),
|
|
398
|
+
j_foot=np.asarray(foot_values, dtype=float),
|
|
399
|
+
j_walk=np.asarray(walk_values, dtype=float),
|
|
400
|
+
j_indicator=np.asarray(indicator_values, dtype=float),
|
|
377
401
|
stations_without_shock=without_shock,
|
|
378
402
|
)
|
|
379
403
|
|
|
@@ -484,6 +508,9 @@ def smooth_shock(ds: Dataset, extraction: ShockExtraction, length: float) -> Sho
|
|
|
484
508
|
distance=smoothed_distance,
|
|
485
509
|
thickness_cells=extraction.thickness_cells.copy(),
|
|
486
510
|
detected_by=extraction.detected_by.copy(),
|
|
511
|
+
j_foot=extraction.j_foot.copy(),
|
|
512
|
+
j_walk=extraction.j_walk.copy(),
|
|
513
|
+
j_indicator=extraction.j_indicator.copy(),
|
|
487
514
|
stations_without_shock=list(extraction.stations_without_shock),
|
|
488
515
|
)
|
|
489
516
|
|
|
@@ -571,10 +598,10 @@ DETECTORS: dict[str, Detector] = {
|
|
|
571
598
|
"walk": _detect_by_walk,
|
|
572
599
|
"indicator": _detect_by_indicator,
|
|
573
600
|
}
|
|
574
|
-
METHODS: dict[str, tuple[
|
|
575
|
-
"walk": (
|
|
576
|
-
"indicator": (
|
|
577
|
-
"hybrid": (
|
|
601
|
+
METHODS: dict[str, tuple[str, ...]] = {
|
|
602
|
+
"walk": ("walk",),
|
|
603
|
+
"indicator": ("indicator",),
|
|
604
|
+
"hybrid": ("walk", "indicator"),
|
|
578
605
|
}
|
|
579
606
|
|
|
580
607
|
|
|
@@ -643,7 +670,7 @@ def _first_shock(
|
|
|
643
670
|
crossing = ShockCrossing(
|
|
644
671
|
j=j_shock,
|
|
645
672
|
j_tail=j_tail,
|
|
646
|
-
j_foot=
|
|
673
|
+
j_foot=j_outer,
|
|
647
674
|
thickness_cells=thickness_cells,
|
|
648
675
|
detected_by=detection.code,
|
|
649
676
|
)
|
|
@@ -103,3 +103,17 @@ def shock_solution(ni: int = 21, nj: int = 81, width: float = 1.0) -> tuple[Data
|
|
|
103
103
|
dataset = Dataset(grid=StructuredGrid(x=x, y=y, z=np.zeros_like(x)), flow=flow, attrs={})
|
|
104
104
|
|
|
105
105
|
return dataset, j_true
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shock_behind_a_bump(nj: int = 81) -> np.ndarray:
|
|
109
|
+
"""A strong, sharp shock at j = 40.3, and a smooth 18 % compression near the outer end.
|
|
110
|
+
|
|
111
|
+
Like the freestream compressed against a bent symmetry line ahead of the shock: the
|
|
112
|
+
walk meets the compression first; the indicator, which only flags sharp jumps,
|
|
113
|
+
doesn't see it and finds the shock.
|
|
114
|
+
"""
|
|
115
|
+
j = np.arange(nj, dtype=float)
|
|
116
|
+
shock = 20.0 * 0.5 * (1.0 - np.tanh((j - 40.3) / 0.8))
|
|
117
|
+
bump = 0.18 * 0.5 * (1.0 - np.tanh((j - 75.0) / 2.5))
|
|
118
|
+
values = 1.0 + shock + bump
|
|
119
|
+
return values
|
|
@@ -9,11 +9,14 @@ from cfd_io.dataset import Dataset, Field, StructuredGrid
|
|
|
9
9
|
|
|
10
10
|
from flow_props.calculators import CALCULATORS
|
|
11
11
|
from flow_props.calculators.common import (
|
|
12
|
+
AT_CELL_CENTRES,
|
|
13
|
+
AT_VERTICES,
|
|
12
14
|
GasConfig,
|
|
13
15
|
StationsConfig,
|
|
14
16
|
load_config,
|
|
15
17
|
resolve_gas_properties,
|
|
16
18
|
resolve_stations,
|
|
19
|
+
solution_points,
|
|
17
20
|
)
|
|
18
21
|
from flow_props.cli.examples_command import list_examples
|
|
19
22
|
|
|
@@ -143,3 +146,39 @@ def test_gas_metadata_missing_names_who_needs_it(tmp_path):
|
|
|
143
146
|
resolve_gas_properties(
|
|
144
147
|
GasConfig(), {}, tmp_path / "a.h5", required=True, needed_for="the entropy layer"
|
|
145
148
|
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------
|
|
152
|
+
# solutions at the vertices or at the cell centres
|
|
153
|
+
# --------------------------------------------------
|
|
154
|
+
def test_vertex_solution_is_used_as_it_is():
|
|
155
|
+
ds = _make_dataset()
|
|
156
|
+
|
|
157
|
+
points, location = solution_points(ds)
|
|
158
|
+
|
|
159
|
+
assert points is ds
|
|
160
|
+
assert location == AT_VERTICES
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_cell_solution_moves_to_a_grid_of_cell_centres():
|
|
164
|
+
"""Cell values on a vertex grid: the grid becomes the cell centres (corner means)."""
|
|
165
|
+
ds = _make_dataset(ni=8, nj=4)
|
|
166
|
+
cell_values = np.arange(7 * 3, dtype=float).reshape(7, 3, 1)
|
|
167
|
+
ds.flow = {"pres": Field(data=cell_values, association="cell")}
|
|
168
|
+
|
|
169
|
+
points, location = solution_points(ds)
|
|
170
|
+
|
|
171
|
+
assert location == AT_CELL_CENTRES
|
|
172
|
+
assert points.grid.shape == (7, 3, 1)
|
|
173
|
+
np.testing.assert_allclose(points.grid.x[:, 0, 0], 0.1 * (np.arange(7) + 0.5))
|
|
174
|
+
np.testing.assert_allclose(points.grid.y[0, :, 0], 0.01 * (np.arange(3) + 0.5))
|
|
175
|
+
np.testing.assert_array_equal(points.flow["pres"].data, cell_values)
|
|
176
|
+
assert points.flow["pres"].association == "node"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_fields_in_different_places_are_refused():
|
|
180
|
+
ds = _make_dataset(ni=8, nj=4)
|
|
181
|
+
ds.flow["pres"] = Field(data=np.zeros((7, 3, 1)), association="cell")
|
|
182
|
+
|
|
183
|
+
with pytest.raises(ValueError, match="different places"):
|
|
184
|
+
solution_points(ds)
|
|
@@ -6,7 +6,8 @@ from pathlib import Path
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
import pytest
|
|
8
8
|
from cfd_io import write_file
|
|
9
|
-
from
|
|
9
|
+
from cfd_io.dataset import Dataset, Field
|
|
10
|
+
from synthetic import boundary_layer_solution, shock_behind_a_bump, shock_solution
|
|
10
11
|
from typer.testing import CliRunner
|
|
11
12
|
|
|
12
13
|
from flow_props.calculators import CALCULATORS
|
|
@@ -245,6 +246,100 @@ def test_shock_run_names_its_method(method, tmp_path):
|
|
|
245
246
|
assert '"detected_by"' not in report_text
|
|
246
247
|
|
|
247
248
|
|
|
249
|
+
def _analytic_shock(at_cells: bool, shock_j: float = 50.3) -> Dataset:
|
|
250
|
+
"""The same captured shock (at vertex index shock_j) at the vertices or the cell centres.
|
|
251
|
+
|
|
252
|
+
The vertex grid is the synthetic solution's; at the cell centres, the pressure is the
|
|
253
|
+
same function evaluated at cell index j + 0.5, as a finite-volume solution holds it.
|
|
254
|
+
"""
|
|
255
|
+
vertex_dataset, _ = shock_solution(width=1.5)
|
|
256
|
+
grid = vertex_dataset.grid
|
|
257
|
+
ni, nj, _ = grid.shape
|
|
258
|
+
|
|
259
|
+
# pressure as a function of the vertex index: a captured jump centred at shock_j
|
|
260
|
+
def pressure(j_vertex: np.ndarray) -> np.ndarray:
|
|
261
|
+
shape = 0.5 * (1.0 - np.tanh((j_vertex - shock_j) / 1.5))
|
|
262
|
+
return 1000.0 * (1.0 + 4.0 * shape)
|
|
263
|
+
|
|
264
|
+
# at the vertices, or at the cell centres (one fewer per direction)
|
|
265
|
+
if at_cells:
|
|
266
|
+
j_index = np.arange(nj - 1, dtype=float) + 0.5
|
|
267
|
+
values = np.broadcast_to(pressure(j_index)[None, :, None], (ni - 1, nj - 1, 1)).copy()
|
|
268
|
+
flow = {"pres": Field(data=values, association="cell")}
|
|
269
|
+
else:
|
|
270
|
+
j_index = np.arange(nj, dtype=float)
|
|
271
|
+
values = np.broadcast_to(pressure(j_index)[None, :, None], (ni, nj, 1)).copy()
|
|
272
|
+
flow = {"pres": Field(data=values, association="node")}
|
|
273
|
+
|
|
274
|
+
return Dataset(grid=grid, flow=flow, attrs={})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_shock_offsets_agree_on_vertex_and_cell_centre_input(tmp_path, monkeypatch):
|
|
278
|
+
"""Cell j counts on the vertex grid as j + 0.5, so both inputs give the same offsets."""
|
|
279
|
+
offsets = {}
|
|
280
|
+
outputs = {}
|
|
281
|
+
for label, at_cells in (("vertices", False), ("cells", True)):
|
|
282
|
+
dataset = _analytic_shock(at_cells)
|
|
283
|
+
monkeypatch.setattr(
|
|
284
|
+
"flow_props.calculators.shock.load_dataset", lambda fname, gname, ds=dataset: ds
|
|
285
|
+
)
|
|
286
|
+
folder = tmp_path / label
|
|
287
|
+
folder.mkdir()
|
|
288
|
+
path = _write_config(folder, "shock", "[shock]\nshock_line_j = 52\n")
|
|
289
|
+
|
|
290
|
+
result = runner.invoke(app, ["shock", "run", "-c", str(path)])
|
|
291
|
+
|
|
292
|
+
assert result.exit_code == 0, result.output
|
|
293
|
+
outputs[label] = result.output
|
|
294
|
+
lines = (folder / "shock_report.dat").read_text().splitlines()
|
|
295
|
+
variables = [name.strip('" ') for name in lines[1].split("=")[1].split(",")]
|
|
296
|
+
rows = np.loadtxt(lines[3:], ndmin=2)
|
|
297
|
+
offsets[label] = rows[:, variables.index("offset_cells")]
|
|
298
|
+
|
|
299
|
+
# check: the true offset is 50.3 - 52 on every line, from either input
|
|
300
|
+
np.testing.assert_allclose(offsets["vertices"], -1.7, atol=0.05)
|
|
301
|
+
np.testing.assert_allclose(offsets["cells"], -1.7, atol=0.05)
|
|
302
|
+
assert abs(np.mean(offsets["cells"]) - np.mean(offsets["vertices"])) < 0.05
|
|
303
|
+
|
|
304
|
+
# check: the report says which was used
|
|
305
|
+
assert "solution at cell centres" in outputs["cells"]
|
|
306
|
+
assert "solution at cell centres" not in outputs["vertices"]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_shock_run_warns_about_a_disturbance_ahead_of_the_shock(tmp_path, monkeypatch):
|
|
310
|
+
"""A compression near the outer boundary: both warnings name the lines."""
|
|
311
|
+
# every line: the shock at j = 40.3, a smooth compression near the outer end
|
|
312
|
+
dataset, _ = shock_solution()
|
|
313
|
+
ni, nj, _ = dataset.grid.shape
|
|
314
|
+
values = 1000.0 * shock_behind_a_bump(nj)
|
|
315
|
+
dataset.flow["pres"].data[:] = values[None, :, None]
|
|
316
|
+
monkeypatch.setattr("flow_props.calculators.shock.load_dataset", lambda fname, gname: dataset)
|
|
317
|
+
path = _write_config(tmp_path, "shock", "")
|
|
318
|
+
|
|
319
|
+
result = runner.invoke(app, ["shock", "run", "-c", str(path)])
|
|
320
|
+
|
|
321
|
+
assert result.exit_code == 0, result.output
|
|
322
|
+
assert (
|
|
323
|
+
f"warning: the shock lies within 5 cells of the outer boundary on {ni} lines"
|
|
324
|
+
in result.output
|
|
325
|
+
)
|
|
326
|
+
assert f"warning: walk and indicator differ by more than 3 cells on {ni} lines" in result.output
|
|
327
|
+
assert f"the walk's shock lies farther out on {ni} of them" in result.output
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_shock_run_counts_row_changes_of_the_shock_edge(tmp_path):
|
|
331
|
+
"""The synthetic shock climbs 10 cells along the body; it crosses j = 55 once."""
|
|
332
|
+
dataset, _ = shock_solution()
|
|
333
|
+
_write_solution(tmp_path, dataset)
|
|
334
|
+
path = _write_config(tmp_path, "shock", "[shock]\nshock_line_j = 55\n")
|
|
335
|
+
|
|
336
|
+
result = runner.invoke(app, ["shock", "run", "-c", str(path)])
|
|
337
|
+
|
|
338
|
+
assert result.exit_code == 0, result.output
|
|
339
|
+
assert "grid row changes along the body 10, crossings of the shock line 1" in result.output
|
|
340
|
+
assert "warning" not in result.output
|
|
341
|
+
|
|
342
|
+
|
|
248
343
|
def test_shock_run_reads_the_shock_line_from_the_solution_attributes(tmp_path):
|
|
249
344
|
dataset, _ = shock_solution()
|
|
250
345
|
dataset.attrs["shock_line_j"] = 52
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pytest
|
|
5
5
|
from cfd_io.dataset import Dataset, StructuredGrid
|
|
6
|
-
from synthetic import shock_solution
|
|
6
|
+
from synthetic import shock_behind_a_bump, shock_solution
|
|
7
7
|
|
|
8
8
|
from flow_props.calculators.shock import _index_ranges
|
|
9
9
|
from flow_props.shock import (
|
|
@@ -116,6 +116,26 @@ def test_no_shock_on_a_line_without_a_jump():
|
|
|
116
116
|
assert find_shock_on_line(np.linspace(1.04, 1.0, 50)) is None
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def test_each_detector_reports_its_own_point():
|
|
120
|
+
"""Whatever the method, the crossing carries the walk's and the indicator's points."""
|
|
121
|
+
crossing = find_shock_on_line(_captured_shock(1.0, 4.0), method="walk")
|
|
122
|
+
assert crossing.j_walk == crossing.j
|
|
123
|
+
assert crossing.j_indicator == pytest.approx(crossing.j, abs=1e-12)
|
|
124
|
+
assert crossing.j < crossing.j_foot < crossing.j + 3.0
|
|
125
|
+
|
|
126
|
+
weak = find_shock_on_line(_captured_shock(4.0, 0.3), method="walk")
|
|
127
|
+
assert np.isnan(weak.j_indicator)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_walk_and_indicator_part_ways_at_a_disturbance_ahead_of_the_shock():
|
|
131
|
+
"""The walk takes the compression for the shock; the indicator finds the real one."""
|
|
132
|
+
crossing = find_shock_on_line(shock_behind_a_bump(), method="walk")
|
|
133
|
+
|
|
134
|
+
assert crossing.j > 70.0
|
|
135
|
+
assert abs(crossing.j_indicator - 40.3) < TOLERANCE_CELLS
|
|
136
|
+
assert crossing.j_walk - crossing.j_indicator > 30.0
|
|
137
|
+
|
|
138
|
+
|
|
119
139
|
def test_a_smooth_compression_is_not_a_shock():
|
|
120
140
|
"""A rise spread over many cells (a line ending inside the shock layer) is rejected."""
|
|
121
141
|
values = np.linspace(1.5, 1.0, 60)
|
|
@@ -273,6 +293,9 @@ def _with_distance(dataset, extraction: ShockExtraction, distance: np.ndarray) -
|
|
|
273
293
|
distance=distance,
|
|
274
294
|
thickness_cells=extraction.thickness_cells,
|
|
275
295
|
detected_by=extraction.detected_by,
|
|
296
|
+
j_foot=extraction.j_foot,
|
|
297
|
+
j_walk=extraction.j_walk,
|
|
298
|
+
j_indicator=extraction.j_indicator,
|
|
276
299
|
stations_without_shock=[],
|
|
277
300
|
)
|
|
278
301
|
|
|
@@ -305,6 +328,9 @@ def test_smoothing_keeps_a_straight_shock_over_lines_that_turn_at_a_junction():
|
|
|
305
328
|
distance=distance,
|
|
306
329
|
thickness_cells=np.full(ni, 2.0),
|
|
307
330
|
detected_by=np.full(ni, DETECTED_BY_WALK),
|
|
331
|
+
j_foot=distance / (2.0 * height) * (nj - 1) + 1.0,
|
|
332
|
+
j_walk=distance / (2.0 * height) * (nj - 1),
|
|
333
|
+
j_indicator=np.full(ni, np.nan),
|
|
308
334
|
stations_without_shock=[],
|
|
309
335
|
)
|
|
310
336
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/sharp_flat_plate_m5/bl.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|