From f7ff1006995cde77a05feec30ee878f03136308d Mon Sep 17 00:00:00 2001 From: Chris Green Date: Thu, 13 Aug 2026 19:49:11 +1000 Subject: [PATCH] Add --mesh-quality element shape report The existing Jacobian check answers whether a mesh is valid. It says nothing about whether the elements are well shaped, which is what governs how well the mesh actually solves. Corner point grids routinely contain cells that are perfectly valid but very flat, and a finite element code handles those far worse than the finite volume simulator the grid was built for. Add --mesh-quality, which reports aspect ratio, skew, thickness and volume as percentile distributions, followed by counts of elements exceeding the aspect ratio and skew thresholds and the location of the worst cells. --aspect-ratio-limit adds a further threshold to the counts. Reporting only: the mesh is never modified and the exit code is never changed. It runs before the Jacobian check so the distributions are still printed when --strict-jacobians aborts, and works from connectivity and node coordinates alone, so it covers Leapfrog models as well as Eclipse. Element volume uses 2x2x2 Gauss quadrature, which integrates the trilinear Jacobian determinant exactly. Verified against analytic volumes for a parallelepiped, an anisotropic box and a frustum; the frustum is the case a naive tetrahedral decomposition would get wrong. Degenerate cells have an infinite aspect ratio, and np.percentile interpolates linearly, so a single infinity would turn most of a percentile row into NaN. They are held out of both the rows and the threshold counts, so every number describes the same population, and counted on a separate line. They still sort to the top of the worst-elements list. Also extract the element corner gather and the Exodus element ID mapping that checkElementJacobians and the new report both need. Co-Authored-By: Claude Opus 5 --- README.md | 47 ++++- em2ex.py | 14 ++ readers/reader_utils.py | 235 ++++++++++++++++++++-- test/eclipse/simple_cube_stretched.grdecl | 53 +++++ test/eclipse/tests | 41 ++++ 5 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 test/eclipse/simple_cube_stretched.grdecl diff --git a/README.md b/README.md index 731b5e7..f0c942b 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,9 @@ usage: em2ex.py [-h] [--config FILE] [-o OUTPUT_FILE] [--pinch-tol PINCH_TOL] [--refine-xy RX RY] [--extract-i I_LO I_HI] [--extract-j J_LO J_HI] [--extract-k K_LO K_HI] [--extra-keywords KEY [KEY ...]] - [--fault-sidesets] [--convert-to-m] - [--no-check-jacobians] [--strict-jacobians] + [--fault-sidesets] [--convert-to-m] [--no-check-jacobians] + [--strict-jacobians] [--remove-distorted] [--mesh-quality] + [--aspect-ratio-limit AR] [filename] Converts earth model to Exodus II format @@ -352,6 +353,48 @@ By default the output file is still written regardless of warnings. Three flags - `--strict-jacobians` upgrades any non-positive Jacobian to a fatal error (exit code 1). Useful in CI / scripted workflows where a bad mesh should stop the pipeline. - `--no-check-jacobians` skips the check entirely (a small time saving on very large grids, but disables a useful safety net). +### Mesh quality report + +The Jacobian check answers "is this mesh *valid*?". `--mesh-quality` answers the different question "is this mesh *well shaped*?" — which is what governs how well it will actually solve. Corner point grids out of Petrel routinely contain cells that are perfectly valid but very flat (100 m x 100 m laterally by 0.5 m thick is unremarkable), and while a finite volume reservoir simulator is built for that, a finite element code is not: high aspect ratio cells degrade matrix conditioning and preconditioner performance. + +```bash +./em2ex.py --mesh-quality model.grdecl +``` + +``` +Mesh quality report: 27 elements + min 5% 25% median 75% 95% max + aspect ratio 5.000 5.000 5.000 25.00 100.0 100.0 100.0 + skew 0.000 0.000 0.000 0.000 0.000 0.000 0.000 + thickness 0.005000 0.005000 0.005000 0.02000 0.1000 0.1000 0.1000 + volume 0.001250 0.001250 0.001250 0.005000 0.02500 0.02500 0.02500 + Elements exceeding quality thresholds: + aspect ratio > 10 18 (66.7%) + aspect ratio > 50 9 (33.3%) + skew > 0.5 0 (0.0%) + Worst elements by aspect ratio: + element 1: aspect ratio 100, thickness 0.005, centroid (0.25, 0.25, 0.0025) +``` + +The four metrics are: + +- **aspect ratio** — longest edge divided by shortest edge. 1.0 is a cube. In a reservoir grid a large value nearly always means a thin layer rather than an over-wide cell, which is why thickness is reported next to it. +- **skew** — the largest absolute cosine of the angle between the element's three principal axes (each running between the centroids of a pair of opposite faces). 0 is perfectly orthogonal, 1 is collapsed. This is what picks up sheared cells along faults, which an aspect ratio close to 1 will happily hide. +- **thickness** — mean length of the four k-direction edges. +- **volume** — exact volume of the trilinear element, computed by 2x2x2 Gauss quadrature (which integrates the trilinear Jacobian determinant exactly). Volumes are *signed*, so inverted elements report negative values, consistent with the Jacobian check. + +`--aspect-ratio-limit AR` adds a threshold to the counts alongside the built-in 10 and 50, for gauging how many cells sit above whatever limit your solver setup tolerates. + +This is a reporting flag only: it never modifies the mesh and never changes the exit code. It runs before the Jacobian check, so the distributions are still printed when `--strict-jacobians` aborts the run. It works from the element connectivity and node coordinates alone, so it applies to Leapfrog models as well as Eclipse ones. + +**Degenerate cells are held out of the statistics.** An element with a collapsed edge has an infinite aspect ratio, and a single infinity would turn most of a percentile row into `NaN`. Such cells are therefore excluded from every row *and* from the threshold counts — so all of the numbers above describe the same population — and counted on their own line instead: + +``` + zero-length edge (excluded above) 1 (3.7%) +``` + +They still sort to the top of the "worst elements" list, so their locations are reported. A non-zero count here is the same population that `--pinch` removes and that the Jacobian check reports as zero-Jacobian. + **Relationship to `--pinch`.** `--remove-distorted` and `--pinch` are complementary rather than interchangeable. `--pinch` detects cells where any two corners are within `--pinch-tol` of each other — it catches *near*-coincident corners (e.g. a 0.5 m thick cell in a grid measured in metres) even when the Jacobian is still technically positive. `--remove-distorted` catches cells whose Jacobian has already reached zero or gone negative, which only happens once corners are *exactly* coincident or the cell has become inverted. In practice, running both is the safest option for grids with thin reservoir layers near faults: `--pinch` handles the near-zero cells that `--remove-distorted` would miss, and `--remove-distorted` catches any remaining inverted cells. `em2ex` attempts to guess the reservoir model format from the file extension (see supported formats below). If the reservoir model has a non-standard file extension, the user can force diff --git a/em2ex.py b/em2ex.py index a1df4a1..dd23cbd 100755 --- a/em2ex.py +++ b/em2ex.py @@ -144,6 +144,11 @@ def get_parser(): help = 'Treat any non-positive element Jacobian as a fatal error and exit non-zero. By default such elements only produce a warning. Useful for CI / scripted workflows.') parser.add_argument('--remove-distorted', dest = 'remove_distorted', action = 'store_true', help = 'Remove elements with non-positive Jacobians (degenerate or inverted) from the output mesh, reporting a count of those removed. By default such elements are kept and only a warning is printed.') + parser.add_argument('--mesh-quality', dest = 'mesh_quality', action = 'store_true', + help = 'Print a report of element shape quality (aspect ratio, skew, thickness and volume) as percentile distributions, with counts of elements exceeding the aspect ratio and skew thresholds and the location of the worst cells. Reporting only: the mesh is not modified.') + parser.add_argument('--aspect-ratio-limit', dest = 'aspect_ratio_limit', type = float, default = None, + metavar = 'AR', + help = 'Additional aspect ratio threshold to count in the --mesh-quality report, on top of the default 10 and 50.') return parser def main(): @@ -194,6 +199,15 @@ def main(): print('File extension ', file_extension, ' not supported') exit() + # Element shape quality report. Printed before the Jacobian check so the + # distributions are still shown when --strict-jacobians aborts the run. + if getattr(args, 'mesh_quality', False): + from readers.reader_utils import meshQualityReport + thresholds = [10.0, 50.0] + if getattr(args, 'aspect_ratio_limit', None): + thresholds.append(args.aspect_ratio_limit) + meshQualityReport(model, aspect_thresholds=sorted(thresholds)) + # Mesh quality: check element Jacobians before writing the Exodus file. # Default is to warn but continue; --strict-jacobians upgrades to a fatal # error; --no-check-jacobians skips the check entirely. diff --git a/readers/reader_utils.py b/readers/reader_utils.py index 91ddce8..d660f56 100644 --- a/readers/reader_utils.py +++ b/readers/reader_utils.py @@ -114,6 +114,224 @@ def addNodeSets(model): ] +# Edge node pairs for a HEX8 element, in the standard Exodus corner ordering +# (nodes 0-3 on the lower face, 4-7 on the upper face). The last four are the +# k-direction edges, reported separately as the cell thickness. +_HEX8_EDGES = [ + (0, 1), (1, 2), (2, 3), (3, 0), # lower face + (4, 5), (5, 6), (6, 7), (7, 4), # upper face + (0, 4), (1, 5), (2, 6), (3, 7), # vertical (k-direction) +] +_HEX8_VERTICAL_EDGES = _HEX8_EDGES[8:] + +# Opposite-face node groups defining the three principal axes of a HEX8, used +# for the skew metric. Each axis runs from the centroid of the low face to the +# centroid of the high face in one parametric direction; skew is the largest +# absolute cosine between any pair of (normalised) axes, so 0 is a perfectly +# orthogonal cell and 1 is fully collapsed. +_HEX8_PRINCIPAL_AXES = [ + ((1, 2, 6, 5), (0, 3, 7, 4)), # xi + ((2, 3, 7, 6), (0, 1, 5, 4)), # eta + ((4, 5, 6, 7), (0, 1, 2, 3)), # zeta +] + +# Parametric corner coordinates of the HEX8 shape functions, in element order. +_HEX8_XI = np.array([-1., 1., 1., -1., -1., 1., 1., -1.]) +_HEX8_ETA = np.array([-1., -1., 1., 1., -1., -1., 1., 1.]) +_HEX8_ZETA = np.array([-1., -1., -1., -1., 1., 1., 1., 1.]) + + +def _elementCornerPoints(model): + ''' Return the (num_elems, 8, 3) array of corner coordinates for every + element, gathered from the model's node coordinates via elemNodes. ''' + node_idx = model.elemNodes - 1 # 0-based node indices + x = np.asarray(model.xcoords)[node_idx] # (num_elems, 8) + y = np.asarray(model.ycoords)[node_idx] + z = np.asarray(model.zcoords)[node_idx] + return np.stack([x, y, z], axis=-1) + + +def _exodusElementIds(model): + ''' Map each elemNodes row to its 1-based Exodus element ID. elemIds[k, j, i] + holds the ID (0 for inactive); the non-zero values in (k, j, i) flat order + align with elemNodes' row ordering. ''' + if model.elemIds is not None: + exodus_ids = np.asarray(model.elemIds).flatten() + return exodus_ids[exodus_ids > 0] + return np.arange(1, model.numElems + 1) + + +def hex8Volumes(P): + ''' Exact volume of each trilinear HEX8 element. + + The Jacobian determinant of a trilinear map is at most quadratic in each + parametric coordinate, so 2x2x2 Gauss quadrature (all weights 1) integrates + it exactly. Input is (num_elems, 8, 3); output is (num_elems,). Volumes are + signed — inverted elements return negative values, matching the sign + convention of the Jacobian check. + ''' + g = 1.0 / np.sqrt(3.0) + volumes = np.zeros(P.shape[0]) + for s1 in (-g, g): + for s2 in (-g, g): + for s3 in (-g, g): + dN = np.empty((8, 3)) + dN[:, 0] = 0.125 * _HEX8_XI * (1 + _HEX8_ETA * s2) * (1 + _HEX8_ZETA * s3) + dN[:, 1] = 0.125 * _HEX8_ETA * (1 + _HEX8_XI * s1) * (1 + _HEX8_ZETA * s3) + dN[:, 2] = 0.125 * _HEX8_ZETA * (1 + _HEX8_XI * s1) * (1 + _HEX8_ETA * s2) + volumes += np.linalg.det(np.einsum('nac,ad->ncd', P, dN)) + return volumes + + +def hex8AspectRatios(P): + ''' Ratio of longest to shortest edge for each HEX8 element (the edge + ratio). 1.0 is a cube; large values mean a stretched cell, which for a + corner point reservoir grid almost always means thin in z relative to its + lateral extent. Elements with a zero-length edge return inf. ''' + edge_lengths = _hex8EdgeLengths(P, _HEX8_EDGES) + shortest = edge_lengths.min(axis=1) + longest = edge_lengths.max(axis=1) + with np.errstate(divide='ignore', invalid='ignore'): + return np.where(shortest > 0, longest / shortest, np.inf) + + +def hex8Thicknesses(P): + ''' Mean length of the four k-direction edges of each HEX8 element. This is + the cell thickness, and is reported alongside the aspect ratio because it + identifies whether a poor ratio comes from thin layers (the usual cause in + a reservoir grid) or from large lateral cells. ''' + return _hex8EdgeLengths(P, _HEX8_VERTICAL_EDGES).mean(axis=1) + + +def hex8Skews(P): + ''' Skew of each HEX8 element: the largest absolute cosine of the angle + between any pair of principal axes. 0 is perfectly orthogonal, 1 is + collapsed. Elements with a zero-length principal axis are already + degenerate and return 1.0. ''' + axes = np.empty((P.shape[0], 3, 3)) + for a, (high_face, low_face) in enumerate(_HEX8_PRINCIPAL_AXES): + axes[:, a, :] = (P[:, high_face, :].sum(axis=1) + - P[:, low_face, :].sum(axis=1)) + + norms = np.linalg.norm(axes, axis=2) # (num_elems, 3) + degenerate = np.any(norms == 0, axis=1) + with np.errstate(divide='ignore', invalid='ignore'): + unit_axes = axes / norms[:, :, None] + + skew = np.zeros(P.shape[0]) + for a, b in ((0, 1), (0, 2), (1, 2)): + skew = np.maximum( + skew, np.abs(np.einsum('ij,ij->i', unit_axes[:, a], unit_axes[:, b]))) + + skew[degenerate] = 1.0 + return skew + + +def _hex8EdgeLengths(P, edges): + ''' Lengths of the given node-pair edges for every element. Output is + (num_elems, len(edges)). ''' + starts = [e[0] for e in edges] + ends = [e[1] for e in edges] + return np.linalg.norm(P[:, ends, :] - P[:, starts, :], axis=2) + + +# Percentiles reported for every metric in the mesh quality report. +_QUALITY_PERCENTILES = (0, 5, 25, 50, 75, 95, 100) +_QUALITY_HEADINGS = ('min', '5%', '25%', 'median', '75%', '95%', 'max') + + +def _formatStatRow(label, values, width=11): + ''' Format one metric row to four significant figures. The `#.4g` format + keeps trailing zeros so the columns line up, and falls back to an exponent + only for values too large or small to show otherwise — which matters + because cell volumes routinely span several orders of magnitude while + aspect ratio and skew do not. ''' + cells = ''.join('{:>#{w}.4g}'.format(v, w=width) for v in values) + return ' {:<16}{}'.format(label, cells) + + +def meshQualityReport(model, aspect_thresholds=(10.0, 50.0), skew_threshold=0.5, + num_examples=5): + ''' Print a distribution summary of element shape quality for the mesh. + + Reports aspect ratio (longest edge / shortest edge), skew, cell thickness + and cell volume as percentile spreads, followed by the count of elements + exceeding each aspect ratio threshold and the skew threshold, and the + location of the worst offenders. + + This is a reporting function only — it never alters the mesh and never + exits non-zero. It is reader-agnostic, working from elemNodes and the node + coordinates, so it applies to both Eclipse and Leapfrog models. + + Returns a dict of the computed per-element metric arrays, so callers (and + tests) can assert on the numbers rather than parsing stdout. + ''' + if model.elemNodes is None or not model.numElems: + print('Mesh quality report: no elements to report on') + return {} + + P = _elementCornerPoints(model) + metrics = { + 'aspect ratio': hex8AspectRatios(P), + 'skew': hex8Skews(P), + 'thickness': hex8Thicknesses(P), + 'volume': hex8Volumes(P), + } + + aspect = metrics['aspect ratio'] + skew = metrics['skew'] + num_elems = model.numElems + + # Degenerate elements (a collapsed edge, so an infinite aspect ratio) are + # held out of the statistics below and counted on their own line. They + # cannot be included: np.percentile interpolates linearly, so a single + # infinity turns most of the row into NaN. Holding them out keeps the + # percentile rows and the threshold counts describing the same population. + finite = np.isfinite(aspect) + num_degenerate = int(np.sum(~finite)) + + print('Mesh quality report: {} elements'.format(model.numElems)) + print(' {:<16}{}'.format( + '', ''.join('{:>11}'.format(h) for h in _QUALITY_HEADINGS))) + if not np.any(finite): + print(' (every element has a zero-length edge; no statistics to report)') + else: + for label, values in metrics.items(): + print(_formatStatRow( + label, np.percentile(values[finite], _QUALITY_PERCENTILES))) + + def _count_line(text, count): + print(' {:<40}{:>8} ({:.1f}%)'.format( + text, count, 100.0 * count / num_elems)) + + print(' Elements exceeding quality thresholds:') + for threshold in aspect_thresholds: + _count_line('aspect ratio > {:g}'.format(threshold), + int(np.sum(aspect[finite] > threshold))) + _count_line('skew > {:g}'.format(skew_threshold), + int(np.sum(skew[finite] > skew_threshold))) + + if num_degenerate: + _count_line('zero-length edge (excluded above)', num_degenerate) + + # Locate the worst cells by aspect ratio so the user can see whether the + # problem is concentrated in a few layers or spread through the model. + # Degenerate cells sort to the top, which is where they belong. + worst_threshold = max(aspect_thresholds) if aspect_thresholds else np.inf + if num_degenerate or np.any(aspect[finite] > worst_threshold): + exodus_ids = _exodusElementIds(model) + worst_rows = np.argsort(aspect)[::-1][:num_examples] + print(' Worst elements by aspect ratio:') + for r in worst_rows: + eid = int(exodus_ids[r]) if r < len(exodus_ids) else r + 1 + cx, cy, cz = P[r].mean(axis=0) + print(' element {}: aspect ratio {:.4g}, thickness {:.4g}, ' + 'centroid ({:.4g}, {:.4g}, {:.4g})'.format( + eid, aspect[r], metrics['thickness'][r], cx, cy, cz)) + + return metrics + + def checkElementJacobians(model, strict=False): ''' Compute the per-corner Jacobian for every HEX8 element and report any elements with non-positive Jacobian (degenerate or inverted). @@ -129,11 +347,7 @@ def checkElementJacobians(model, strict=False): if model.elemNodes is None or model.numElems == 0: return True - node_idx = model.elemNodes - 1 # 0-based node indices - x = np.asarray(model.xcoords)[node_idx] # (num_elems, 8) - y = np.asarray(model.ycoords)[node_idx] - z = np.asarray(model.zcoords)[node_idx] - P = np.stack([x, y, z], axis=-1) # (num_elems, 8, 3) + P = _elementCornerPoints(model) # (num_elems, 8, 3) jacobians = np.empty((P.shape[0], 8)) for c, ((xi_a, xi_b), (eta_a, eta_b), (zeta_a, zeta_b)) in enumerate(_HEX8_JAC_EDGES): @@ -160,14 +374,7 @@ def checkElementJacobians(model, strict=False): print(' - Orientation-reversing coordinate system (e.g. MAPAXES handedness)') print(' Use --remove-distorted to remove these elements and proceed anyway.') - # Map elemNodes row -> Exodus element ID. elemIds[k, j, i] holds the - # 1-based Exodus ID (0 for inactive); the non-zero values in (k, j, i) - # flat order align with elemNodes' row ordering. - if model.elemIds is not None: - exodus_ids = np.asarray(model.elemIds).flatten() - exodus_ids = exodus_ids[exodus_ids > 0] - else: - exodus_ids = np.arange(1, model.numElems + 1) + exodus_ids = _exodusElementIds(model) for label, mask in (('negative', min_jac < 0), ('zero', min_jac == 0)): bad_rows = np.where(mask)[0] @@ -177,7 +384,7 @@ def checkElementJacobians(model, strict=False): label, len(bad_rows))) for r in bad_rows[:5]: eid = int(exodus_ids[r]) if r < len(exodus_ids) else r + 1 - cx, cy, cz = float(x[r].mean()), float(y[r].mean()), float(z[r].mean()) + cx, cy, cz = P[r].mean(axis=0) print(' element {}: centroid ({:.4g}, {:.4g}, {:.4g}), min Jacobian = {:.3e}'.format( eid, cx, cy, cz, min_jac[r])) diff --git a/test/eclipse/simple_cube_stretched.grdecl b/test/eclipse/simple_cube_stretched.grdecl new file mode 100644 index 0000000..255adfd --- /dev/null +++ b/test/eclipse/simple_cube_stretched.grdecl @@ -0,0 +1,53 @@ +-- 3x3x3 grid with 0.5 x 0.5 lateral cells and three layers of differing +-- thickness, used to exercise the --mesh-quality report: +-- layer 1: thickness 0.005 -> aspect ratio 100 +-- layer 2: thickness 0.02 -> aspect ratio 25 +-- layer 3: thickness 0.1 -> aspect ratio 5 +-- Cells stay axis-aligned rectangular boxes, so skew is 0 throughout and the +-- aspect ratios above are exact. The thicknesses are deliberately chosen so +-- that no aspect ratio lands on a reporting threshold (10 or 50), where +-- floating point rounding would make the counts ambiguous. + +SPECGRID +3 3 3 1 F / + +GRIDUNIT + METRES / + +COORD + 0.000 0.000 0.000 0.000 0.000 1.000 + 0.500 0.000 0.000 0.500 0.000 1.000 + 1.000 0.000 0.000 1.000 0.000 1.000 + 1.500 0.000 0.000 1.500 0.000 1.000 + 0.000 0.500 0.000 0.000 0.500 1.000 + 0.500 0.500 0.000 0.500 0.500 1.000 + 1.000 0.500 0.000 1.000 0.500 1.000 + 1.500 0.500 0.000 1.500 0.500 1.000 + 0.000 1.000 0.000 0.000 1.000 1.000 + 0.500 1.000 0.000 0.500 1.000 1.000 + 1.000 1.000 0.000 1.000 1.000 1.000 + 1.500 1.000 0.000 1.500 1.000 1.000 + 0.000 1.500 0.000 0.000 1.500 1.000 + 0.500 1.500 0.000 0.500 1.500 1.000 + 1.000 1.500 0.000 1.000 1.500 1.000 + 1.500 1.500 0.000 1.500 1.500 1.000 +/ + +-- Each plane below is 2*nx by 2*ny = 36 corner values. Planes alternate +-- top, bottom for each of the three layers. +ZCORN + 36*0.000 + 36*0.005 + 36*0.005 + 36*0.025 + 36*0.025 + 36*0.125 +/ + +ACTNUM + 27*1 +/ + +SATNUM + 27*1 +/ diff --git a/test/eclipse/tests b/test/eclipse/tests index c006c01..39e52b3 100644 --- a/test/eclipse/tests +++ b/test/eclipse/tests @@ -275,3 +275,44 @@ extra_keywords_typo: type: exception cli_args: --extra-keywords PVTNUMM -- expected_error: --extra-keywords requested PVTNUMM but the keyword was not found + +# --mesh-quality on a grid of perfect 0.5 m cubes: every percentile of the +# aspect ratio must be exactly 1. +mesh_quality_cubes: + filename: simple_cube.grdecl + type: output + cli_args: --mesh-quality + expected_output: "aspect ratio 1.000 1.000 1.000 1.000 1.000 1.000 1.000" + +# Layer thicknesses of 0.005 / 0.02 / 0.1 against 0.5 m lateral cells give +# aspect ratios of exactly 100 / 25 / 5, so 9 of the 27 cells exceed 50 and +# 18 exceed 10. +mesh_quality_stretched_counts: + filename: simple_cube_stretched.grdecl + type: output + cli_args: --mesh-quality + expected_output: "aspect ratio > 50 9 (33.3%)" + +mesh_quality_stretched_percentiles: + filename: simple_cube_stretched.grdecl + type: output + cli_args: --mesh-quality + expected_output: "aspect ratio 5.000 5.000 5.000 25.00 100.0 100.0 100.0" + +# The pinched cell has a collapsed edge, so its aspect ratio is infinite. It +# must be held out of the percentile rows and counted on its own line, rather +# than silently skewing the statistics or being dropped entirely. +mesh_quality_degenerate: + filename: simple_cube_pinch.grdecl + type: output + cli_args: --mesh-quality + expected_output: "zero-length edge (excluded above) 1 (3.7%)" + +# An extra user-supplied threshold is counted alongside the built-in 10 and 50, +# and is placed in sorted order between them. Cells of aspect ratio 100 and 25 +# both exceed 20, so the count is 18 of 27. +mesh_quality_custom_limit: + filename: simple_cube_stretched.grdecl + type: output + cli_args: --mesh-quality --aspect-ratio-limit 20 + expected_output: "aspect ratio > 20 18 (66.7%)"