---
title: Mesh files
summary: "A case can take its mesh from a file instead of a built-in generator: Gmsh MSH 2.2 or 4.1 (ASCII) with quadrilaterals or hexahedra, or Gyre's own NPZ arrays. This page shows how to produce such a file, how its boundary groups become boundary conditions, what the reader checks, and how a mesh reaches a service."
section: guide
order: 2
level: 2
audience: [CFD practitioners, MDO integrators, agent builders]
keywords: [mesh, Gmsh, MSH, NPZ, import, physical groups, boundary groups, periodic, mesh store, upload, gyre mesh]
---
<!-- gyre docs: expanded Markdown of guide/meshes.md (generated tables, verification numbers and chart data are written out). Base level of this page: L2 Practice; passages at another level are bracketed by 'begin level N' / 'end level N' comments and start with [LN Name]. Built from revision ceb1f4b0be. -->

The built-in generators cover aerofoil O-meshes, cylinders, bump channels and boxes
([Cases](cases.md#mesh-generators)). For any other geometry the mesh comes from a file, written
by Gmsh or by your own program. A mesh read from a file becomes the same object a generator
builds, so everything else — solver, outputs, gradients, error estimates, sessions — works
unchanged.

<!-- begin level 1 (Essentials) -->
**[L1 Essentials] In short**

Mesh the domain with **quadrilaterals** (2-D) or **hexahedra** (3-D) in Gmsh, give **every
boundary a named physical group**, save as **MSH 4.1 or 2.2 ASCII**, and write a case with
`mesh: {generator: file, params: {path: my.msh}}` and one boundary condition per group.
`gyre mesh info my.msh` tells you whether the file is usable and lists its groups.

<!-- end level 1 -->

## A case on a mesh file

```yaml title="cylinder_channel.yaml"
name: cylinder_channel
mesh:
  generator: file
  params: {path: examples/meshes/cylinder_channel_p3.msh}     # optional: scale: 0.001
flow: {mach: 0.2, reynolds: 40.0}
discretization:
  order: 2
  boundaries:                       # one entry per boundary group of the mesh
    cylinder: noslip_adiabatic
    inflow: farfield
    outflow: farfield
    walls: slip_wall
reference:
  moment_center: [0.0, 0.0]         # moment about the cylinder axis
```

```bash
gyre run cylinder_channel.yaml -o runs/channel
```

| `mesh.params` | Meaning |
|---|---|
| `path` | the mesh file; the extension selects the reader: `.msh` (Gmsh) or `.npz` (Gyre arrays) |
| `scale` | positive factor applied to all coordinates (default 1), for a mesh drawn in other units than the reference length |

Any other key in `mesh.params` is a validation error. `mesh.geometry_order` has no effect on a
mesh file: the file carries its own geometry degree.

<!-- begin level 2 (Practice) -->
**[L2 Practice] Where the file is looked for**

With the CLI and in Python, `path` is used as given (a relative path is relative to the
working directory). If a relative path does not exist and the environment variable
`GYRE_MESH_DIR` names a directory, the file is looked up there. With `GYRE_MESH_RESTRICT=1`
only bare file names inside `GYRE_MESH_DIR` are accepted; a service sets both for itself (see
[Meshes in a service](#meshes-in-a-service)).

The case digest covers the **contents** of the file, not its name: editing the file changes
the digest, renaming it does not.

<!-- end level 2 -->

## Making a mesh with Gmsh

Gmsh is not a dependency of Gyre; any installation that writes MSH files will do. The script
below uses Gmsh's Python API (`pip install gmsh`) and is the construction that produced
`examples/meshes/cylinder_channel_p3.msh` (the repository regenerates it with
`tools/make_mesh_fixtures.py`): a 5 × 3 channel with a circular hole of radius 0.5, meshed with
unstructured cubic quadrilaterals.

```python title="cylinder_channel.py"
import gmsh

gmsh.initialize()
gmsh.model.add("plate")

# Geometry: rectangle [-2, 3] x [-1.5, 1.5] minus a disk of radius 0.5 at the origin.
occ = gmsh.model.occ
rect = occ.addRectangle(-2.0, -1.5, 0.0, 5.0, 3.0)
disk = occ.addDisk(0.0, 0.0, 0.0, 0.5, 0.5)
occ.cut([(2, rect)], [(2, disk)])
occ.synchronize()

# A named physical group on every boundary curve, sorted by position.
def side(box):
    x0, y0, _, x1, y1, _ = box
    if abs(x0 + 2.0) < 1e-6 and abs(x1 + 2.0) < 1e-6:
        return "inflow"
    if abs(x0 - 3.0) < 1e-6 and abs(x1 - 3.0) < 1e-6:
        return "outflow"
    if abs(y0 - y1) < 1e-6 and abs(abs(y0) - 1.5) < 1e-6:
        return "walls"
    return "cylinder"

groups = {}
for _, tag in gmsh.model.getEntities(1):
    groups.setdefault(side(gmsh.model.getBoundingBox(1, tag)), []).append(tag)
for name, tags in sorted(groups.items()):
    gmsh.model.addPhysicalGroup(1, tags, name=name)
gmsh.model.addPhysicalGroup(2, [t for _, t in gmsh.model.getEntities(2)], name="fluid")

# Quadrilaterals only, complete Lagrange elements.
gmsh.option.setNumber("Mesh.RecombineAll", 1)
gmsh.option.setNumber("Mesh.Algorithm", 8)                # frontal-Delaunay for quads
gmsh.option.setNumber("Mesh.RecombinationAlgorithm", 1)   # blossom
gmsh.option.setNumber("Mesh.SubdivisionAlgorithm", 1)     # split every element into quads
gmsh.option.setNumber("Mesh.SecondOrderIncomplete", 0)
gmsh.option.setNumber("Mesh.MeshSizeMin", 0.6)
gmsh.option.setNumber("Mesh.MeshSizeMax", 0.6)
gmsh.model.mesh.generate(2)
gmsh.model.mesh.setOrder(3)                               # cubic elements

# MSH 4.1, ASCII; only elements that belong to a physical group are written.
gmsh.option.setNumber("Mesh.SaveAll", 0)
gmsh.option.setNumber("Mesh.MshFileVersion", 4.1)
gmsh.option.setNumber("Mesh.Binary", 0)
gmsh.write("cylinder_channel_p3.msh")
gmsh.finalize()
```

In a `.geo` script the same settings are the options of the same names
(`Mesh.RecombineAll = 1;`, `Mesh.ElementOrder = 3;`, `Mesh.MshFileVersion = 4.1;`) and the
groups are `Physical Curve("walls") = {…};` and `Physical Surface("fluid") = {…};`.

<!-- begin level 2 (Practice) -->
**[L2 Practice] What the file may contain**

| | Supported | Gmsh element types |
|---|---|---|
| File format | MSH **2.2** and **4.1**, ASCII | — |
| 2-D elements | quadrilaterals of degree 1 to 5 | 3, 10, 36, 37, 38 |
| 3-D elements | hexahedra of degree 1 to 4 | 5, 12, 92, 93 |
| Boundary elements | lines of degree 1 to 5 (2-D), quadrilaterals (3-D), in physical groups | 1, 8, 26, 27, 28 |
| Points | accepted and ignored | 15 |

All elements are complete Lagrange elements (`Mesh.SecondOrderIncomplete = 0`) and all volume
elements of a mesh have the same degree. Triangles, tetrahedra, prisms, pyramids and
serendipity elements are refused. A 2-D mesh must lie in a plane $z = \text{const}$. Binary MSH
files and other format versions are refused; save again with `-format msh41` or
`-format msh22`.

The Gyre NPZ format has no upper limit on the geometry degree; see
[its arrays](#npz-format) below.

<!-- end level 2 -->

> **Tip: All-quadrilateral meshes**
>
> Recombination alone can leave a few triangles. `Mesh.SubdivisionAlgorithm = 1` splits every
> element into quadrilaterals and removes them; transfinite surfaces with `Recombine` give
> structured quadrilaterals directly. In 3-D use transfinite volumes or extrusions with
> `Recombine`.

## Boundary groups and conditions

Each **physical group of boundary curves** (2-D) or **boundary surfaces** (3-D) becomes a
boundary group of the mesh, under the group's name (`group<tag>` if the group has no name).
The physical group of the elements themselves (`fluid` above) is not used.

* Every boundary face must belong to a physical group. A file with untagged boundary faces is
  refused.
* A face in several physical groups gets the one with the smallest tag. Physical groups on
  interior curves or surfaces are ignored.
* A mesh file has **no default boundary conditions**. `discretization.boundaries` must give a
  condition for every group, and must not name a group the mesh does not have. The available
  conditions and the data some of them need in `flow.extra` are listed under
  [Cases](cases.md#boundary-conditions).

```console
$ gyre mesh info examples/meshes/cylinder_channel_p3.msh
{
  "dim": 2,
  "elements": 284,
  "geometry_order": 3,
  "nodes": 2676,
  "boundaries": {
    "cylinder": 16,
    "inflow": 12,
    "outflow": 12,
    "walls": 40
  },
  …
}
```

The output is shortened here: the quality numbers, the bounding box, `sha256`, `bytes` and
`lines` (the [lines of the line smoother](#mesh-lines) on this mesh) follow.

> **Warning: Which groups carry the forces**
>
> The force and moment coefficients are integrated over **every group with a wall condition**
> (`slip_wall`, `symmetry`, `slip_wall_mirror`, `noslip_adiabatic`, `noslip_isothermal`), and the
> shape input `x_surface` and the load output `f_surface` cover the nodes of the same groups. In
> the channel case above the slip walls therefore contribute to `cl` and `cm` next to the
> cylinder.

## Reference quantities

The generators know their geometry; a mesh file does not say what its reference length is. The
`reference` section of the case sets the quantities the coefficients are normalised with:

| Field | Default | Meaning |
|---|---|---|
| `area` | 1 | reference area of the force coefficients (a length in 2-D) |
| `length` | 1 | reference length of the moment coefficient |
| `moment_center` | `[0.25, 0.0, 0.0]` | point the moment is taken about (two or three coordinates) |

The flow conditions are non-dimensional with a unit reference length (`flow.reynolds` is the
Reynolds number per unit length), so either draw the mesh with the reference length equal to
one or bring it there with `mesh.params.scale`.

## Periodic meshes

Periodic node pairs of the file (`$Periodic`, written by Gmsh for curves or surfaces declared
periodic, for example with `gmsh.model.mesh.setPeriodic`) identify the two sides: the faces on
them become interior faces, and a physical group on a periodic side no longer appears among the
boundary groups, so it needs no boundary condition. `periodic_nodes` in the summary counts the
identified nodes. A periodic direction needs **at least three elements** across it.

## What the reader checks { #reader-checks }

A file is read and validated in full before it is used; nothing is repaired silently. Each
refusal is a message that says what is wrong. `gyre mesh info FILE` shows it on the command
line, the service returns it as the `message` of a 422 `InvalidMesh`.

<!-- begin level 2 (Practice) -->
**[L2 Practice] Messages and remedies**

| Message (beginning) | Cause | Remedy |
|---|---|---|
| `mesh file '…' does not exist` | wrong path, or the file is not in `GYRE_MESH_DIR` | correct `mesh.params.path` |
| `unknown mesh file type '.xyz'` | extension is neither `.msh` nor `.npz` | export as MSH, or convert |
| `not a Gmsh MSH file (no $MeshFormat header)` | the file is something else | export from Gmsh as MSH |
| `binary MSH files are not supported` | written with `-bin` or `Mesh.Binary = 1` | `Mesh.Binary = 0` |
| `MSH format version … is not supported` | for example 4.0 or 1.0 | `-format msh41` or `-format msh22` |
| `the mesh contains triangles` (tetrahedra, prisms, pyramids, serendipity …) | not an all-quad / all-hex mesh of complete elements | recombined or transfinite meshing, `Mesh.SecondOrderIncomplete = 0` |
| `the mesh contains no quadrilaterals or hexahedra` | only points or lines were saved | add a physical group for the surface or volume, or `Mesh.SaveAll = 1` |
| `elements of several degrees in one mesh` | mixed element orders | one `Mesh.ElementOrder` for the whole mesh |
| `N boundary faces belong to no physical group (the first one is near (x, y))` | a boundary without a physical group; for MSH 4.1 also a file without `$Entities` | add `Physical Curve` / `Physical Surface` for that boundary; export again |
| `two physical groups share a name` | duplicate names | rename one |
| `a 2-D mesh must lie in a plane z = const` | a surface mesh in space | mesh in the x-y plane |
| `N of K elements have a non-positive Jacobian determinant (element k near …)` | inverted element or tangled curved edges | `Mesh.HighOrderOptimize = 2`, or refine near strongly curved boundaries |
| `$Nodes holds coordinates that are not finite numbers` (MSH), `N nodes have coordinates that are not finite numbers` (NPZ) | NaN or infinity in the file | regenerate the mesh |
| `a face is shared by more than two elements` | non-manifold mesh, or fewer than three elements across a periodic direction | repair the topology; refine the periodic direction |
| `periodic identification maps two nodes of one element onto each other`, `an element edge joins a vertex to itself` | fewer than three elements across a periodic direction, or a collapsed element | refine; remove degenerate elements |
| `unmatched interior faces`, `a face is both interior and tagged as boundary` | inconsistent connectivity (usually an NPZ file) | correct `elem_nodes` / `face_bc` |
| `the file ends inside section $…`, `the file declares N … but holds at most M`, `$Nodes declares …`, `malformed MSH … file: …` | truncated or damaged file | export again |
| `the group name '…' cannot be written to an MSH file` | (writing) a name with a double quote or a line break | rename the group |
| `no boundary condition for groups […]` | the case omits a group | add it to `discretization.boundaries` |
| `discretization.boundaries names boundary groups […] that the mesh does not have` | misspelt or stale group name | use the names `gyre mesh info` lists |

A damaged file is never read as a smaller or a non-periodic mesh: counts declared in the file
are checked against its contents, and a section that is cut off or has lost its opening line
is an error.

<!-- end level 2 -->

The summary reports quality numbers for a mesh that passed:

| Key | Meaning |
|---|---|
| `min_scaled_jacobian` | smallest ratio of the minimum to the maximum Jacobian determinant within one element (1 for a parallelogram; small values mean strongly distorted elements) |
| `interior_faces`, `boundary_faces` | number of interior face pairs and of boundary faces |
| `reoriented_elements` | left-handed elements of the file that were turned right-handed |
| `reoriented_face_pairs` | interior face pairs whose two elements use different local axes on the face (zero for a structured mesh) |
| `periodic_nodes` | nodes identified with a periodic master |
| `bounding_box` | `[[xmin, ymin(, zmin)], [xmax, ymax(, zmax)]]` after scaling |
| `sha256`, `bytes` | hash and size of the file; the hash is what the case digest uses |

## Command-line tools

```bash
gyre mesh info FILE [--scale S]        # read, validate, print the summary as JSON
gyre mesh convert IN OUT               # .msh <-> .npz, chosen by the extension of OUT
gyre mesh export CASE OUT [--set PATH=VALUE ...]   # write the mesh of a preset or case file
```

* `info` prints the summary shown above; its last key, `lines`, is described
  [below](#mesh-lines). `--scale` applies a factor to the coordinates before the mesh is
  measured.
* `convert` reads `IN` (which validates it) and writes `OUT` as Gmsh MSH **2.2** ASCII (`.msh`)
  or as Gyre NPZ (`.npz`). It prints `{"written": OUT, …}` followed by the summary of the
  written file, read back. An `.npz` file is smaller than the Gmsh text file.
* `export` writes the mesh of a preset or case file, for example to look at a generator mesh
  in Gmsh: `gyre mesh export cylinder_re40 annulus.msh`. `--set` works as for `gyre run`. It
  prints the same as `convert`, so it is also the way to the summary of a generator mesh.

Any other extension of `OUT` is refused before anything is read. A command that cannot use a
file or case prints `gyre mesh <action>: <message>` to standard error, prints nothing to
standard output and exits with status 1.

The writer produces `$Periodic` node pairs for periodic meshes, names the element group
`fluid`, and writes degrees for which Gmsh has an element type (quadrilaterals up to 5,
hexahedra up to 4); a generator mesh of a higher degree can be exported as `.npz` only.

### Will the line smoother act on this mesh? { #mesh-lines }

`solver.smoother: line` solves exactly along lines of thin cells and is block Jacobi wherever
a mesh has none
([how the lines are formed](../theory/implicit-solver.md#preconditioning-without-assembling-a-matrix)).
The lines depend on the mesh alone, so the three commands report them, under `lines`, before
anything is solved. For the mesh of the preset `flat_plate_sa`:

```console
$ gyre mesh export flat_plate_sa plate.npz
{
  "written": "plate.npz",
  "dim": 2,
  "elements": 150,
  …
  "lines": {
    "lines": 18,
    "elements_in_lines": 131,
    "elements": 150,
    "longest": 12,
    "colors": 3,
    "slots": 152,
    "threshold": 4.0,
    "anisotropy_max": 50679888.014390774,
    "anisotropy_median": 463.07670555770085
  }
}
```

Eighteen lines hold 131 of the 150 elements; the other 19 are smoothed by block Jacobi. For
`examples/meshes/cylinder_channel_p3.msh`, `gyre mesh info` prints `"lines": 0` with an
`anisotropy_max` of 2.12 and an `anisotropy_median` of 1.11: no cell reaches the threshold of
4, and `smoother: line` would change nothing on that mesh. Both outputs were recorded on the
CPU of the development machine with the working tree that followed revision `15e5de9` (the
change that added `slots`); `…` marks omitted keys and the two numbers of the channel mesh
are rounded.

| Key of `lines` | Meaning |
|---|---|
| `lines` | number of lines; 0 for a mesh without lines |
| `elements_in_lines`, `elements` | elements that belong to a line, and all elements of the mesh |
| `longest` | number of elements of the longest line |
| `colors` | colours the smoother needs to assemble the blocks that couple the elements of a line (three for aligned lines); 0 without lines |
| `slots` | line slots the smoother stores: the lines are kept in a few tables by length, each as long as its longest line, so this exceeds `elements_in_lines` by the padding; 0 without lines |
| `threshold` | anisotropy from which a cell qualifies for a line. It is 4, the value the solver uses: a box-shaped cell at least twice as wide in every other direction as it is thick |
| `anisotropy_max`, `anisotropy_median` | largest and median anisotropy of the cells |

The **anisotropy** of a cell is the number the threshold is compared with: the coupling of
the cell with its neighbours in its strongest direction divided by that in its second
strongest, where the coupling across a face is the face area divided by the distance of the
two cell centres. For a box-shaped cell it is the square of the ratio of its two shortest edges: the
square of the aspect ratio in two dimensions, and in three dimensions only when the two
longer edges are equal.

* `anisotropy_max` below `threshold`: no cell qualifies, the mesh has no lines.
* `anisotropy_median` above `threshold`: at least half of the cells qualify. A cell that
  qualifies is in a line only if a neighbour across its strongest direction qualifies through
  the same face, so `elements_in_lines` can be smaller than the number of such cells, and
  `anisotropy_max` can exceed the threshold on a mesh without lines.
* `null` instead of a number: the value is not finite. A cell with neighbours in one direction
  only (a mesh one element wide) has no second direction; it qualifies whatever its shape. A
  mesh of a single cell has no neighbours to measure.

<!-- begin level 2 (Practice) -->
**[L2 Practice] Where else the lines appear**

A run with `solver.smoother: line` records the first five keys as `case.lines` of its result
(`null` if the mesh has no lines or another smoother is used). In Python,
`meshio.inspect_mesh(path, lines=True)` returns the summary with `lines`, and
`meshio.line_summary(mesh)` the `lines` of a mesh object. The summary a service keeps for a
stored mesh, and `inspect_mesh` without the argument, do not contain `lines`.

<!-- end level 2 -->

## Meshes in a service

A service never opens a path from a request. It keeps a **mesh store** (the directory
`meshes` of its data directory): a client uploads a file under a name and then refers to it by
that name in `mesh.params.path`. Below, `API` is `http://HOST:8600/api/v1`.

**[Alternative: curl]**

```bash
curl -s -X PUT --data-binary @cylinder_channel_p3.msh \
     -H 'content-type: application/octet-stream' $API/meshes/cylinder_channel_p3.msh
# → 201 {"name": "cylinder_channel_p3.msh", "bytes": …, "sha256": "…", "dim": 2, "elements": 284,
#        "boundaries": {"cylinder": 16, "inflow": 12, "outflow": 12, "walls": 40}, …}

curl -s $API/meshes                               # {"meshes": [summary, …]}
curl -s $API/meshes/cylinder_channel_p3.msh       # one summary
curl -s -X DELETE $API/meshes/cylinder_channel_p3.msh

curl -s $API/jobs -H 'content-type: application/json' -d '{
  "case": {"name": "cylinder_channel",
           "mesh": {"generator": "file", "params": {"path": "cylinder_channel_p3.msh"}},
           "flow": {"mach": 0.2, "reynolds": 40.0},
           "discretization": {"order": 2, "boundaries": {
               "cylinder": "noslip_adiabatic", "inflow": "farfield",
               "outflow": "farfield", "walls": "slip_wall"}},
           "reference": {"moment_center": [0.0, 0.0]}}}'
```

**[Alternative: Python]**

```python
from gyre.client import GyreClient

gyre = GyreClient("http://HOST:8600")
info = gyre.upload_mesh("cylinder_channel_p3.msh")       # name defaults to the file name
print(info["boundaries"])                                # faces per boundary group
gyre.meshes()                                            # list of summaries
gyre.mesh(info["name"])                                  # one summary
case = {"name": "cylinder_channel",
        "mesh": {"generator": "file", "params": {"path": info["name"]}},
        "flow": {"mach": 0.2, "reynolds": 40.0},
        "discretization": {"order": 2, "boundaries": {
            "cylinder": "noslip_adiabatic", "inflow": "farfield",
            "outflow": "farfield", "walls": "slip_wall"}},
        "reference": {"moment_center": [0.0, 0.0]}}
result = gyre.run(case)
gyre.delete_mesh(info["name"])
```

**[Alternative: MCP]**

```text
→ upload_mesh(name="cylinder_channel_p3.msh", content="$MeshFormat\n4.1 0 8\n…")
← {"name": "cylinder_channel_p3.msh", "elements": 284, "geometry_order": 3,
   "boundaries": {"cylinder": 16, "inflow": 12, "outflow": 12, "walls": 40}, …}
→ validate_case(case={"name": "cylinder_channel",
                      "mesh": {"generator": "file", "params": {"path": "cylinder_channel_p3.msh"}},
                      "flow": {"mach": 0.2, "reynolds": 40.0},
                      "discretization": {"order": 2, "boundaries": {…one per group…}}})
→ list_meshes()   → inspect_mesh(name)   → delete_mesh(name)
```

An `.npz` file is sent as base64 with `encoding="base64"`.

<!-- begin level 2 (Practice) -->
**[L2 Practice] Rules of the store**

* **Names, never paths.** A name matches `^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}\.(msh|npz)$`
  (lower-case extension, no directories). Any other name, in the URL or in
  `mesh.params.path` of a case (also when it arrives through `overrides`), is answered 422
  `InvalidMeshName`, and nothing is opened or written.
* **Validated on upload.** The request body is the file. It is read by the same reader as on
  the command line before it enters the store; a file the reader refuses is answered 422
  `InvalidMesh` with the reader's message and is not stored. A mesh already stored under that
  name stays as it was. A valid upload replaces an existing mesh of the same name.
* **A case names a stored mesh.** `POST /cases/validate`, `/jobs` and `/sessions` answer 404
  `UnknownMesh` (the hint names the `PUT` to make) if the mesh is not in the store, and 422
  `InvalidCase` if `discretization.boundaries` does not match the boundary groups of the mesh.
* **Replacing a mesh.** The result cache of jobs is keyed on the contents of the mesh file, so
  a job on a replaced mesh is computed anew. A session reads its mesh when it starts and keeps
  running on it, and reports the digest of that moment, if the mesh is replaced or deleted
  afterwards. A job that is still queued when its mesh is deleted fails.
* **Access.** With `GYRE_API_TOKEN` set, every mesh route, `GET` included, requires the bearer
  token, like the rest of the API.

<!-- end level 2 -->

<!-- begin level 2 (Practice) -->
**[L2 Practice] Limits**

| Limit | Default | Variable | Answer when exceeded |
|---|---|---|---|
| size of one upload | 64 MiB | `GYRE_MESH_MAX_BYTES` | 413 `MeshTooLarge` |
| uncompressed size of the arrays of an `.npz` upload | the smaller of 4 × the upload limit and 1 GiB | `GYRE_MESH_MAX_BYTES`, `GYRE_MESH_MAX_EXPANDED_BYTES` | 422 `InvalidMesh` |
| number of meshes in the store | 256 | `GYRE_MESH_STORE_MAX_FILES` | 507 `MeshStoreFull` |
| total size of the store | 4 GiB | `GYRE_MESH_STORE_MAX_BYTES` | 507 `MeshStoreFull` |

The variables belong to the service's environment and are read at each request. A mesh that
replaces one of the same name does not count against the number of meshes.

An `.npz` upload must pass two limits on the uncompressed size of its arrays: four times
`GYRE_MESH_MAX_BYTES`, which the service checks, and `GYRE_MESH_MAX_EXPANDED_BYTES` (1 GiB by
default), which the reader checks in every process, the service included. With an upload limit
above 256 MiB the second one is the smaller unless it is raised as well.

All four values are positive integers in bytes (a count for `GYRE_MESH_STORE_MAX_FILES`). For
`GYRE_MESH_MAX_BYTES`, `GYRE_MESH_STORE_MAX_FILES` and `GYRE_MESH_STORE_MAX_BYTES` any other
value, zero included, is replaced by the default, so 0 does not switch uploads off. An invalid
`GYRE_MESH_MAX_EXPANDED_BYTES` is an error instead: every read of an `.npz` file fails with
`GYRE_MESH_MAX_EXPANDED_BYTES must be a positive integer, got '…'` (a 422 `InvalidMesh` for an
upload).

<!-- end level 2 -->

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] How the service confines the reader**

The store is `<data_dir>/meshes`. While the service runs it sets `GYRE_MESH_DIR` to that
directory and `GYRE_MESH_RESTRICT=1` in its own environment; job workers, session processes
and pipeline runs inherit both, and the previous values are restored at shutdown. With the
restriction the reader accepts only a bare file name inside that directory, so the check holds
in every process that could read a mesh, not only in the request handler. The request handler
checks the name against the pattern before it touches the file system, and error messages name
the mesh as the client named it, never a server directory.

An upload is received into a temporary file of the store's directory, with the size limit
enforced on the `Content-Length` header and again while the body arrives. It is validated
there, one upload at a time, and moved to its name only if it is accepted. Leftovers of an
upload that was interrupted by a crash are removed at the next start.

An application object that is used without its start-up phase has not set the two variables;
it answers a case that names a stored mesh with 503 `MeshStoreInactive` instead of resolving
it. The name check and the lookup in the store come first, so a name that is not a store name
is still a 422 `InvalidMeshName` and a mesh that is not stored a 404 `UnknownMesh`.

<!-- end level 3 -->

## How a Gmsh mesh becomes a Gyre mesh

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] The six steps of the reader**

A Gyre mesh stores, per element, the numbers of its $(p_g + 1)^d$ geometry nodes on a lattice
indexed `[i, j(, l)]` along the reference directions, with the nodes at the
Legendre–Gauss–Lobatto (LGL) points of degree $p_g$ and shared between neighbours. Gmsh stores
each element as a list of nodes in its own order, at equally spaced points.

**Diagram** (`_diagrams/mesh-node-ordering.svg`, drawn in the HTML page): A cubic quadrilateral. Gmsh numbers the four vertices counter-clockwise, then the interior nodes of the four edges in the same sense, then the interior as a quadrilateral of degree $p_g - 2$, recursively. The reader places each node at its lattice position and moves the nodes from equal spacing to Gauss–Lobatto spacing.

Text in the diagram: Node ordering of a cubic quadrilateral in a Gmsh file and in Gyre; Left: the sixteen nodes of a cubic Gmsh quadrilateral, equally spaced, numbered vertices first, then edge interiors counter-clockwise, then the interior. Right: the same element as a lattice indexed i, j with Legendre-Gauss-Lobatto spacing; each lattice position shows the Gmsh number it is read from.; Gmsh element (file); equally spaced nodes, numbered 0 to 15; 0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; Gyre lattice [i, j]; Gauss–Lobatto nodes; the number is the Gmsh node read; permute,; interpolate; u; v; i; j.

1. **Ordering.** A table maps the Gmsh node order of each element type to lattice positions.
   For a hexahedron the order is vertices, edge interiors, face interiors (each as a
   quadrilateral of degree $p_g - 2$ in the frame of the face's vertices) and the volume
   interior, recursively. An element whose first two lattice directions (and the third, in
   3-D) form a left-handed frame has its first two directions exchanged
   (`reoriented_elements`).
2. **Node distribution.** Along every lattice direction the equally spaced nodes are
   interpolated to the LGL points of the same degree. Up to degree 2 the two sets coincide and
   nothing is done.
3. **Topological numbering.** Lattice nodes are numbered from the mesh topology, not from
   their coordinates: a vertex by its node in the file; an edge node by the two vertices of
   its edge and its position counted from the vertex with the smaller number; a face node
   (3-D) by the four vertices of its face and its position in a frame fixed by the smallest
   vertex; interior nodes are private to their element. Two elements that share an edge or a
   face therefore share its nodes exactly, whatever their local axes, and nodes that merely
   coincide in space — the two sides of a slit, a periodic pair — stay distinct.
4. **Boundary faces.** A face that belongs to one element only is a boundary face and takes
   the physical group of the boundary element with the same vertices. An element listed
   several times (MSH 2.2 repeats it per physical group) is used once.
5. **Periodicity.** The `$Periodic` node pairs are treated as an equivalence of nodes, so that
   a corner node that is the slave of two periodic curves is identified consistently. Each
   node gets a periodic master; faces are matched through the masters.
6. **Validation.** All coordinates must be finite and the Jacobian determinant positive at
   every geometry node of every element; then faces are matched and each interior pair gets
   its relative orientation.

<!-- end level 3 -->

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Element orientation**

In an unstructured mesh two neighbours generally disagree about the direction, and in 3-D
about the order, of the axes on their common face. The connectivity records for each interior
face which index symmetry (reversal of either face axis, exchange of the two) maps its nodes
onto the neighbour's; the spatial operator applies it when it reads the neighbour's trace.
`reoriented_face_pairs` counts the pairs for which that map is not the identity. The
[verification study on imported meshes](../verification/imported-meshes.md) tests free-stream
preservation, conservation and neighbour coupling on such meshes.

<!-- end level 3 -->

<!-- begin level 4 (Theory) -->
**[L4 Theory] Why interpolation and topological numbering are exact**

The $p_g + 1$ equally spaced nodes on an edge and the $p_g + 1$ LGL nodes both determine the
same polynomial of degree $p_g$, so evaluating the Lagrange interpolant of the first set at
the second changes the representation and not the curve: the geometry of the file is
reproduced exactly, up to round-off. The interpolation acts along lattice lines, and on an
edge or face it uses only the nodes of that edge or face. Two neighbours thus compute the
same coordinates for a shared node; because the node is stored once, under its topological
number, the mesh is watertight by construction, with no tolerance for merging nodes by
distance.

<!-- end level 4 -->

## The NPZ format { #npz-format }

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Array by array**

An `.npz` file holds the arrays of a Gyre mesh. `gyre mesh convert` and
`gyre.meshio.save_mesh` write it; a program that builds meshes can write it directly with
`numpy.savez`.

| Array | Shape | Content |
|---|---|---|
| `format` | scalar string | `gyre-mesh-1` |
| `dim` | scalar integer | 2 or 3 |
| `pg` | scalar integer | geometry degree, at least 1 |
| `nodes` | `(Nn, dim)` float | coordinates of the unique geometry nodes |
| `elem_nodes` | `(K, pg+1, pg+1[, pg+1])` integer | node numbers on each element's lattice, indexed `[k, i, j(, l)]` along the reference directions; the nodes of an element are LGL-distributed in its reference element and shared with its neighbours |
| `face_bc` | `(K, 2*dim)` integer | boundary-group index of each local face, −1 for interior faces; local face `2*a + s` is normal to reference direction `a`, at its lower (`s = 0`) or upper (`s = 1`) end |
| `bc_names` | `(G,)` strings | names of the boundary groups, indexed by `face_bc` |
| `topo` | `(Nn,)` integer, optional | periodic master of each node; a master is its own master (`topo[topo] == topo`); absent for a mesh without periodicity |

The reader checks shapes, integer types, index ranges and unique group names, and then
validates the mesh like a Gmsh mesh (finite coordinates, positive Jacobians, face matching).
Elements must be right-handed and neighbours must share node numbers: the NPZ reader does not
re-orient or renumber.

The archive is measured before any array is read: every member must be an `.npy` array
without Python objects whose header describes exactly the bytes stored for it, and the
members together must stay within the size limit (see [Limits](#limits)).

<!-- end level 3 -->

See the [imported-mesh example](../examples/imported-mesh.md) for the channel case with
results, and the [REST](../reference/rest-api.md) and [MCP](../reference/mcp-tools.md)
references for the routes and tools.
