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.
Files changed (83) hide show
  1. {flow_props-0.2.1 → flow_props-0.3.0}/PKG-INFO +1 -1
  2. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/shock.md +2 -1
  3. {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/shock.md +35 -0
  4. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/config-files.md +4 -0
  5. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/output.md +2 -1
  6. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/bl.py +11 -2
  7. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/common.py +75 -0
  8. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/entropy_layer.py +12 -2
  9. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/profiles.py +12 -2
  10. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/shock.py +70 -8
  11. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/shock.py +38 -11
  12. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/PKG-INFO +1 -1
  13. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/scm_version.json +2 -2
  14. {flow_props-0.2.1 → flow_props-0.3.0}/tests/synthetic.py +14 -0
  15. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_calculators.py +39 -0
  16. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_cli.py +96 -1
  17. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_shock.py +27 -1
  18. {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/docs.yml +0 -0
  19. {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/package.yml +0 -0
  20. {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/publish.yml +0 -0
  21. {flow_props-0.2.1 → flow_props-0.3.0}/.github/workflows/test.yml +0 -0
  22. {flow_props-0.2.1 → flow_props-0.3.0}/.gitignore +0 -0
  23. {flow_props-0.2.1 → flow_props-0.3.0}/.vscode/settings.json +0 -0
  24. {flow_props-0.2.1 → flow_props-0.3.0}/CITATION.cff +0 -0
  25. {flow_props-0.2.1 → flow_props-0.3.0}/LICENSE +0 -0
  26. {flow_props-0.2.1 → flow_props-0.3.0}/README.md +0 -0
  27. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/architecture.md +0 -0
  28. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/boundary-layer.md +0 -0
  29. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/entropy-layer.md +0 -0
  30. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/index.md +0 -0
  31. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/profiles.md +0 -0
  32. {flow_props-0.2.1 → flow_props-0.3.0}/docs/api/wall.md +0 -0
  33. {flow_props-0.2.1 → flow_props-0.3.0}/docs/cli-reference/index.md +0 -0
  34. {flow_props-0.2.1 → flow_props-0.3.0}/docs/examples.md +0 -0
  35. {flow_props-0.2.1 → flow_props-0.3.0}/docs/index.md +0 -0
  36. {flow_props-0.2.1 → flow_props-0.3.0}/docs/installation.md +0 -0
  37. {flow_props-0.2.1 → flow_props-0.3.0}/docs/javascripts/mathjax.js +0 -0
  38. {flow_props-0.2.1 → flow_props-0.3.0}/docs/nomenclature.md +0 -0
  39. {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/boundary-layer.md +0 -0
  40. {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/entropy-layer.md +0 -0
  41. {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/index.md +0 -0
  42. {flow_props-0.2.1 → flow_props-0.3.0}/docs/theory/wall-quantities.md +0 -0
  43. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/basic-usage.md +0 -0
  44. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/cli.md +0 -0
  45. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/configuration.md +0 -0
  46. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/index.md +0 -0
  47. {flow_props-0.2.1 → flow_props-0.3.0}/docs/user-guide/workflow.md +0 -0
  48. {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/index.md +0 -0
  49. {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/validation/index.md +0 -0
  50. {flow_props-0.2.1 → flow_props-0.3.0}/docs/vnv/verification/index.md +0 -0
  51. {flow_props-0.2.1 → flow_props-0.3.0}/mkdocs.yml +0 -0
  52. {flow_props-0.2.1 → flow_props-0.3.0}/pyproject.toml +0 -0
  53. {flow_props-0.2.1 → flow_props-0.3.0}/setup.cfg +0 -0
  54. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/__init__.py +0 -0
  55. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/bl.py +0 -0
  56. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/calculators/__init__.py +0 -0
  57. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/__init__.py +0 -0
  58. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/app.py +0 -0
  59. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/callbacks.py +0 -0
  60. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/cmd_calculator.py +0 -0
  61. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/cli/examples_command.py +0 -0
  62. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/entropy_layer.py +0 -0
  63. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/cone_7deg_m14/bl.toml +0 -0
  64. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/ogive_m5pt3/bl.toml +0 -0
  65. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/examples/bl/sharp_flat_plate_m5/bl.toml +0 -0
  66. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/output.py +0 -0
  67. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/__init__.py +0 -0
  68. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/api.py +0 -0
  69. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/geometry.py +0 -0
  70. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/grid_line.py +0 -0
  71. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/profiles/wall_normal.py +0 -0
  72. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props/wall.py +0 -0
  73. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/SOURCES.txt +0 -0
  74. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/dependency_links.txt +0 -0
  75. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/entry_points.txt +0 -0
  76. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/requires.txt +0 -0
  77. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/scm_file_list.json +0 -0
  78. {flow_props-0.2.1 → flow_props-0.3.0}/src/flow_props.egg-info/top_level.txt +0 -0
  79. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_bl.py +0 -0
  80. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_entropy_layer.py +0 -0
  81. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_output.py +0 -0
  82. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_profiles.py +0 -0
  83. {flow_props-0.2.1 → flow_props-0.3.0}/tests/test_wall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flow-props
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Extract flow properties from CFD data: boundary layer, entropy layer, shock
5
5
  Author-email: Christoph Hader <christoph.hader@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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`), or `None`
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`, the `distance` from the wall along
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
- nj = ds.grid.shape[1]
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 >= nj:
234
- raise ValueError(f"shock_line_j = {shock_line_j} lies outside the grid (j = 0 to {nj - 1})")
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": extraction.j,
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 = extraction.j - shock_line_j
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
- # first grid point on the freestream side of the jump (outside its 10 % crossing)
107
- j_foot: int
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
- # each detector of the method: its first jump from outside that is a shock
237
- crossings: list[ShockCrossing] = []
238
- for detector in METHODS[method]:
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
- crossings.append(crossing)
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[Detector, ...]] = {
575
- "walk": (DETECTORS["walk"],),
576
- "indicator": (DETECTORS["indicator"],),
577
- "hybrid": (DETECTORS["walk"], DETECTORS["indicator"]),
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=int(j_outer) + 1,
673
+ j_foot=j_outer,
647
674
  thickness_cells=thickness_cells,
648
675
  detected_by=detection.code,
649
676
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flow-props
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Extract flow properties from CFD data: boundary layer, entropy layer, shock
5
5
  Author-email: Christoph Hader <christoph.hader@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.2.1",
2
+ "tag": "0.3.0",
3
3
  "distance": 0,
4
- "node": "g7d2d8d8d7e8897e8f6d88f6b053492c04e8db282",
4
+ "node": "gda4867c5e8d65682efe0809b4a9c7561de6cbcad",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-11-05"
@@ -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 synthetic import boundary_layer_solution, shock_solution
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