MED / Salome (.med)
The MED format (Salome/Code-Aster), stored in HDF5. This is the most structurally involved format meshio++ supports.
| Format name | med |
| Extensions | .med |
| Read / Write | ✓ / ✓ |
| Extra dependencies | h5py |
Reading & writing
import meshioplusplus
mesh = meshioplusplus.read("mesh.med")
meshioplusplus.med.write("out.med", mesh, med_version="4.1.0")med_version— theMAJ.MIN.RELtriple written toINFOS_GENERALES(e.g."4.1.0","4.0.0","3.0.0"); default"4.1.0". An unparsable string falls back to4, 1, 0.
meshioplusplus.med also exposes multi-mesh functions and explicit named selection:
meshes, mesh_names = meshioplusplus.med.read_med_multi("multi.med")
meshioplusplus.med.write_med_multi("out.med", meshes, mesh_names=["fluid", "solid"])
solid = meshioplusplus.med.read("multi.med", mesh_name="solid")read_med_multi(filename, **kwargs)— reads every mesh underENS_MAA, returning(list[Mesh], list[str]).read(filename, time_step=0, *, mesh_name=None)— selects one explicitly named mesh; withoutmesh_name, a multi-mesh file is rejected rather than choosing an arbitrary mesh.write_med_multi(filename, meshes, mesh_names=None, med_version="4.1.0", **kwargs)— writes several meshes into one file. Missing names default tomesh_<i>; duplicates are de-duplicated with a numeric suffix. Field names that collide across meshes are disambiguated with an@<mesh_name>suffix. Forces HDF5 link-creation-order tracking for the whole file (required by medfile/Salome/mdump to read the result), restoring the previoush5pyglobal config afterward.
Native acceleration: with MESHIOPLUSPLUS_WITH_HDF5=ON, the core handles geometry, tags/families, named point/cell regions, global numbering, orientation, ragged polygons/polyhedra, ordinary single-step fields, named meshes and ordinary nodal/element profiles. Multi-mesh writes retain link creation order and disambiguate colliding field names with @<mesh_name>; reads filter fields by their owning mesh and restore names. Python still handles enhanced units/component names, multi-timestep metadata or name-encoding ("Name[idx] - pdt"), and ELNO/ELGA fields when native strict reads decline them. The Python wrappers preserve their existing signatures and return tuples, adding only keyword-only mesh_name selection to med.read.
Native named meshes and profiles
C++ provides med_mesh_names(path), read_med_named(path, name, info, options) and write_med_multi(path, meshes, infos, version). The writer borrows a vector of mesh pointers only for the call and requires one MedInfo per mesh with unique nonempty names (no / or @). C exposes mio_med_mesh_count, caller-buffer mio_med_mesh_name, owning-handle mio_med_read_named and mio_med_write_multi; Fortran, Julia, R and WASM mirror them. Builds without HDF5 reject these APIs with a clear capability error instead of offering a Python fallback.
An ordinary NOE or MAI.<type> field can select PFL=<profile>; PROFILS/<profile>/PFL contains one-based entity indices, and the support's CO contains column-major values for those rows. Native reads expand them into full-size Float64 arrays with NaN on uncovered entities. Index shape/count and bounds are validated; the profile definition's NBR must match its index count. Repeated indices retain the last stored value. A subsequent ordinary write emits the expanded field, not its original compact profile definition. ELNO/ELGA and NGA != 1 remain unsupported natively.
Both CLIs select a MED mesh with convert multi.med solid.vtu --mesh-name=solid. Named selection can use --time-step, but cannot be combined with sequences or selective-array flags. The native CLI also accepts --lenient for unsupported enhanced fields.
File structure
HDF5 groups, in write order:
INFOS_GENERALES # attrs MAJ/MIN/REL from med_version
ENS_MAA/<mesh_name> # mesh_name defaults to "mesh"
(attrs DIM, ESP = points.shape[1]; REP=0; UNT/UNI = mesh.unit_time/unit_coords;
SRT=1; NOM=<16-char-padded axis names>; DES = mesh.description or
"Mesh created with meshio++"; TYP=0)
-0000000000000000001-0000000000000000001 # the (single) time-step group
(attrs CGT=1, NDT=-1, NOR=-1, PDT=-1.0)
NOE # nodes
COO # Fortran-order-flattened coordinates
FAM # optional: per-point family/tag id
MAI # mailles (cells)
<MED type>/ # one group per cell block, e.g. "HE8"
NOD # Fortran-order 1-based connectivity
FAM # optional: per-cell family/tag id
FAS/<mesh_name>
FAMILLE_ZERO # attr NUM=0, always present
NOEUD/ # optional: point-tag family info
FAM_<id>_<name1>_<name2>.../GRO/NOM # 80-byte NUL-padded names
ELEME/ # optional: cell-tag family info, same layout
CHA/<field_name>/ # fields (name may carry a bitmask, see below)
(attrs MAI=mesh_name, TYP=6, NCO=n_components, NOM=<16-char-padded component names>)
<step key> # one group per (NDT, NOR) time step
NOE | NOE.<MEDTYPE> | MAI.<MEDTYPE> # support: nodal / ELNO / ELEM
MED_NO_PROFILE_INTERNAL/ # (or a real profile name)
CO # Fortran-order-flattened valuesPoint/cell coordinate and connectivity arrays are stored Fortran-ordered (column-major); the C++ core flattens/unflattens explicitly to match, since C++ has no native Fortran-order array type.
Multi-timestep field names: a field written at several times is stored as one CHA group per base name, with a step group per (NDT, NOR) pair; on read, all but the first step are surfaced as separate point_data/cell_data keys named "{base_name}[{NDT}] - {PDT:g}" (parsed back by _parse_med_field_name, a regex (.+)\[(\d+)\]\s*-\s*([0-9.eE+-]+)$).
Cell types
| meshio++ | MED | meshio++ | MED |
|---|---|---|---|
vertex | PO1 | tetra | TE4 |
line | SE2 | tetra10 | T10 |
line3 | SE3 | hexahedron | HE8 |
line4 | SE4 | hexahedron20 | H20 |
triangle | TR3 | hexahedron27 | H27 |
triangle6 | TR6 | pyramid | PY5 |
triangle7 | TR7 | pyramid13 | P13 |
quad | QU4 | wedge | PE6 |
quad8 | QU8 | wedge15 | P15 |
quad9 | QU9 | wedge18 | P18 |
polygon | POG | polygon2 | POG2 |
polygon/polygon2 (both mapped to MED's MED_POLYGON/MED_POLYGON2, entity MED_CELL) support ragged cell blocks — a Voronoi-style mesh mixing 4-gons through 7-gons in one block reads back as a Python list of per-polygon node arrays rather than a rectangular ndarray (see _mesh.py's CellBlock uniform-vs-ragged detection: a block is still stored as an ndarray when every polygon in it happens to have the same vertex count).
Node-orientation permutation. The tables live in the shared node-ordering registry under "med" (read direction shown, meshio[k] = med[perm[k]]):
tetra: [0, 1, 3, 2]
pyramid: [0, 3, 2, 1, 4]
wedge: [3, 4, 5, 0, 1, 2]
hexahedron: [4, 5, 6, 7, 0, 1, 2, 3]
tetra10: [0, 1, 3, 2, 4, 8, 7, 6, 5, 9]
pyramid13: [0, 3, 2, 1, 4, 8, 7, 6, 5, 9, 12, 11, 10]
wedge15: [3, 4, 5, 0, 1, 2, 9, 10, 11, 6, 7, 8, 12, 13, 14]
wedge18: [3, 4, 5, 0, 1, 2, 9, 10, 11, 6, 7, 8, 12, 13, 14, 15, 16, 17]
hexahedron20: [4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11, 16, 17, 18, 19]
hexahedron27: [4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11, 16, 17, 18, 19,
24, 22, 21, 23, 25, 20, 26]Since v9.20.0 the quadratic types (tetra10, hexahedron20, pyramid13, wedge15) are permuted too, not just their linear siblings: each quadratic entry's corner portion is identical to its linear sibling's, and the mid-edge portion is derived from MEDCoupling's own INTERP_KERNEL/CellModel.cxx edge tables — the same authoritative source the linear entries' faces are pinned against, tests/python/test_med.py's _MED_ORIENT_REF — and geometrically verified: every MED mid-edge slot lands at the exact arithmetic midpoint of the two MED corners it should sit between.
hexahedron27 and wedge18 (H27/P18, v16.0.0) extend hexahedron20/wedge15 with the face and body centres Code_Aster's MED reader implies, and read back as the reference element from a file written by MED-fichier itself (tests/python/meshes/med/hexa27_penta18.med, tools/gen_med_quadratic_fixture.py). hexahedron27's table is not its own inverse, so the writer uses the registry's other direction. Before v16.0.0 the Python engine spelled the wedge18 group PE18; MED's name is P18.
Data mapping
point_data["point_tags"]— per-point family/tag id.cell_data["cell_tags"]— per-cell-block family/tag id array.mesh.point_tags/mesh.cell_tags— mesh-level attributes (notpoint_data/cell_data), holding{set_id: [subset_name, ...]}read fromFAS/NOEUD/FAS/ELEME.mesh.point_tag_groups/mesh.cell_tag_groups— mesh-level attributes,{set_id: "FAM_<id>"}short link names; always present (as a dict, possibly empty) after any Pythonread(), regardless of whether the source file had aFASsection at all.mesh.mesh_name/mesh.description/mesh.unit_time/mesh.unit_coords— mesh-level metadata attributes read from/written toENS_MAA'sNOM(mesh group name)/DES/UNT/UNI. All default to""/"mesh"when absent;descriptiondefaults to"Mesh created with meshio++"on write if unset. Values round-trip throughlatin-1and are stripped of surrounding whitespace and NUL padding on read (MED files from other tools may fixed-width-pad these attributes).field_data["med:nom"]— list of component-name-lists, one per field, in field-iteration order (point_data fields, then cell_data fields).field_data["med:field_units"]/field_data["med:step_meta"]— dicts (not arrays) carrying per-field physical units and per-step(NDT, NOR, PDT)metadata;tests/python/helpers.py::write_read's generic field_data comparison explicitly skips these threemed:*keys since they aren't array-like.- Arbitrary named point/cell data →
CHAfields. - Gmsh physical-group bridging (write-only, unconditional, native in C++ since v9.8.0): if
cell_data["gmsh:physical"]is present, each distinct physical id becomes an element family (negative id, per MED convention) even when nocell_tags/cell_setswere set explicitly — named viafield_dataif a matching Gmsh physical-group name exists, elsef"FAM_{fid}"/group_{id}; an id already covered by a namedCellregion (e.g. one Gmsh 4.1$Entitiesalready attached on read) is not also added under the fallback name. This is what lets a Gmsh-imported mesh round-trip its physical groups through a.medwrite without the caller doing anything extra — including through the flat bindings (WASM/C API/Fortran), which have no Python to fall back to and used to hit an unconditionalWriteErrorhere. See also_pick_best_formatwhich prefers the gmsh writer over other.msh-extension candidates specifically when it detectscell_tags/point_tags/med:*markers headed the other way. - MED 4.1 bitmask attributes (
LEN/LGC/LNA/LAA/etc.) are written on every field to record which entity/geometry types are present across time steps, as a single 32-bit integer per attribute rather than a list of strings — required for medfile/Salome/mdump compatibility with MED ≥4.1. Since v9.20.0 both engines write them: Python'smed/_med41.pyFieldBitmaskWritertracks genuinely varying per-timestep data; the C++ writer, which only ever writes a single timestep, computes the same masks directly with no tracker state (every "how many steps agree with the global mask" count is trivially 1). Neither reader consults these attributes — their value is purely for other MED tools (Salome/MEDCoupling/mdump), not for a meshio++↔meshio++ round-trip.
Named regions
Since v9.6.0, FAS/GRO family group names attach as mesh.regions — one Point region per NOEUD group name and one Cell region (global block-major indices) per ELEME group name — in addition to (not instead of) the existing point_tags/cell_tags/point_data["point_tags"]/cell_data["cell_tags"] representation, which is unaffected and still what makes a MED→MED round-trip byte-identical. This is what promotes MED out of the regions PHASE_2 deferred table (see doc/regions.md); mesh.point_sets/mesh.cell_sets (the compat views over .regions) are populated exactly as before.
- Read: a family with
Ngroup names contributes its points/cells to allNregions (a multi-group family, MED's mechanism for "this entity belongs to several groups at once"). A region'sdim/tagare left unspecified (-1) — a group name may be spread across several family ids that disagree on dimension, so there is no single honest value to report. - Write precedence: when the mesh already carries
point_data["point_tags"](resp.cell_data["cell_tags"]), that native data is written exactly as before and anyRegionof the same kind is ignored. Only when the mesh carries no native tags of its own — the common case for a mesh converted from another format, e.g. Abaqus — are families synthesized fromPoint/Cellregions: one family per unique combination of region names an entity belongs to, node families numbered positive from+1, element families negative from-1(the same MED convention_ensure_med_familiesalready used for the Python writer). This closes a real gap: before v9.6.0, a regions-only mesh (no native tags) written through the C++ path silently produced a file with noFASgroups at all, because nothing about the write ever raised to trigger the Python bridging. Since v9.8.0 the same cell-side synthesis also folds incell_data["gmsh:physical"](every Gmsh-sourced mesh's raw per-cell tag column) into the same per-cell name sets as anyCellregion — an id resolves to a name viafield_data(first element only, no dimension disambiguation) when one exists, elsef"group_{id}"; an id that already resolves to an existingCellregion name is not added again. This is the C++ port of_ensure_med_families's cell-side bridging in_med.py, so the two stay byte-compatible. Sideregions have no MED equivalent (a facet is not a node or an element) and are dropped with a warning; they never preventPoint/Cellregions in the same mesh from being written.- The
.in a group name is not treated specially by this reader/writer. Kratos's own MED application (MedApplication) uses.as a SubModelPart nesting separator when it writes MED family names, and meshio++'s KRATOS mesh backend correspondingly warn-skips a region name containing.when materializing SubModelParts (BuildSubModelPartsFromRegions, which reserves.formdpa's/-joined nesting translated at that layer). A MED file carrying such a name round-trips its region unaffected through every other backend; only KRATOS-backend SubModelPart materialization is affected, and only for that one name.
Global numbering
Since v9.6.0, the optional NUM datasets Salome/Code_Aster/Kratos write (global node/element ids) are carried as point_data["med:num"]/cell_data["med:num"], following the repository's <format>:<thing> data convention rather than the MedInfo side channel (which the flat bindings — WASM, C API, Fortran — cannot see). Cell NUM is carried only when every cell block has it; a partial NUM array is not a mesh-wide numbering and is dropped with a warning rather than fabricated (no iota filler, unlike some MED consumers). A mesh with no med:num writes exactly as before — no NUM dataset appears anywhere.
MED version check
Since v9.6.0, both readers check INFOS_GENERALES's MAJ attribute and reject (ReadError) a file written by a MED major version newer than 4, with a message naming the file's actual MAJ.MIN.REL — instead of failing later with an unrelated structural error. Older majors are unaffected (the repository's own cylinder.med fixture is MED 3.0.0 and keeps reading); a file with no INFOS_GENERALES at all is also unaffected. The check is deliberately duplicated in the C++ reader and the Python fallback with the same message, so a file rejected by one is rejected by the other rather than silently misread by whichever path is tried second.
Lenient reads
Since v9.9.0 the C++ reader accepts ReadOptions (MED is in registry_readers_ex(), which is what makes the options reach WASM, the C API, Fortran, Julia, R and both CLIs with no per-binding code), and ReadOptions::mLenient — the mechanism mdpa established in v9.1.0 — opens the whole set of constructs that previously threw "handled by Python fallback".
Strict reads still decline unsupported enhanced units, step metadata and ELNO/ELGA data so the Python wrapper can use its reference reader. Ordinary named profiles are now supported in strict mode; a multi-mesh file is read using explicit named selection.
Under mLenient, constructs that can be described are read into MedInfo rather than merely skipped:
MedInfo field | carries |
|---|---|
mFieldUnits | each field's (UNI, UNT) where non-empty |
mStepMeta | each field's (NDT, NOR, PDT) where not the write-side default |
mFieldTimeValues | every step's PDT, in step order |
mSkippedConstructs | one entry per construct that was skipped, for diagnostics |
Constructs that have no supported native representation drop that one field with a log::warn and keep the rest of the file: an ELNO/ELGA support or a field mixing nodal and cell supports. Ordinary named PFL profiles are expanded, not skipped.
MedInfo is a side channel the registry drops (ReadFn has no info slot), so the flat bindings get the lenient read but not the recorded metadata — the same documented gap ExodusInfo and GmshInfo have.
Selecting a time step
ReadOptions::mTimeStep selects one step of a multi-step CHA field (0-based, negative counts from the end — the ResolveTimeStep contract, see selective reads). A multi-step field used to fail the read outright.
A non-default step is honoured without mLenient, deliberately: it is a request the Python shim never makes, so no Python behaviour depends on it. Step order comes from the (NDT, NOR) subgroup names, which MED zero-pads, so name order is step order.
Since v11.3.0 meshioplusplus.med.read(path, time_step=...) reaches this too — the Python shim was the one surface that never plumbed ReadOptions through at all, unlike the C API/Fortran/Julia/R/WASM, which already honoured mTimeStep. A non-default time_step forces the C++ path (it has no Python-reference equivalent) and re-raises rather than silently falling back to the single-step Python reader on failure.
Reading time values without a full read
read_med_metadata (v11.3.0, roadmap §1 tier B1) is MED's native registry_metadata_readers() entry: MeshMetadata::mTimeValues is filled by walking only ENS_MAA/MAI attributes and dataset extents (never point coordinates or cell connectivity) plus every CHA/<field>/<step>'s PDT attribute — the sorted, deduplicated union across every field, since a MeshMetadata reports one timeline per file rather than one per field. mFellBackToFullRead is false. Unlike read_med itself, this never throws on a multi-step field or a units-carrying one: a metadata call declining to report the very thing it exists to report would defeat its purpose, so the strict/lenient CHA distinction read_med enforces does not apply here. This is what lets sequences treat a multi-step .med file as an ordered dataset with no extra plumbing.
Quirks & limitations
- Two supports for cell data:
ELEM(one value per cell, exactly 1 Gauss point) andELNO(one value per node-per-cell, "defined at every node"); which one is used is decided by shape (ndim <= 2→ ELEM,shape[1] == num_nodes_per_cell[type]→ ELNO, elseELGA).ELGA(general Gauss-point data at unknown points) is silently skipped on write — there's no representation for arbitrary Gauss-point layouts. - A field's
NOMcarries 16 characters per component (v9.9.0). Both writers previously wrote a single blank 16-char slot whatever the component count, which deviates from MED's convention for any k>1 field. When no explicit component names are supplied (field_data["med:nom"], a Python-only convention), MED's own default spellingV1..Vkis generated. A scalar field keeps the single blank 16-char slot, so its bytes are unchanged. Consequence: a k>1 field written by either writer now reads back withmed:nompopulated where it previously came back empty. - A field's row count must equal its entity count (v9.9.0).
write_medraises a namedWriteErrorwhen apoint_dataarray's rows disagree withNumPoints()(or acell_datablock's with that block's cell count), naming the array and both counts. There was previously no write-side check at all: a mis-shaped array -- most often an(n,k)vector flattened to(nk,)by a caller that lost its component count -- wroteNBR = nkagainstnpoints and produced a file this very reader rejects, so the failure surfaced far from its cause. Multi-component fields themselves round-trip normally. - Family names longer than 80 bytes (after
latin-1encoding) raiseWriteErrorrather than silently truncating. - A family with no groups omits the
GROdataset entirely (rather than writing an empty one); an all-default/no-tags mesh likewise omitsFASfamily groups it doesn't need. - Same-type cell blocks are consolidated on write (since v9.8.0; before that, rejected up front with
WriteError("MED files cannot have two sections of the same cell type.")): MED has no way to represent twoMAIsections of one type, but MSH 4.1's canonical structure is one cell block per entity, so real Gmsh-4.1-sourced meshes routinely carry several same-type blocks.write_mednow groups blocks by type (first-seen order) and writes one section per type, concatenating the contributing blocks' connectivity/FAM/NUM— mirroring the Python reference's own write-time merge (_med.py'scells_by_type), so this was never a Python-vs-C++ divergence, only a C++-vs-flat-bindings one. Blocks of the same type must still agree on node count to be merged; a disagreement is aWriteErrornaming the type.FAM/NUMare written for a consolidated section only when the source data (nativecell_tags/med:num, or the region-synthesized families above) covers every contributing block — a partial array is dropped with alog::warnrather than partially written. - Re-writing a field under an already-used name appends a new support group under that field's most recent timestep, rather than creating a distinct field.
FAS(the families group) may live either under the mesh's own time-step group or at the top level (f["FAS"][mesh_name]) — both readers check the nested location first, then fall back to top-level.- Core/reference split: see Native named meshes and profiles and Lenient reads. Fields resolve by cell type, not block position. Enhanced write guards live in the Python shim because dict-valued units/step metadata cannot survive conversion to native numeric field data. Ordinary named profiles and multi-mesh access no longer force a fallback; ELNO/ELGA and enhanced metadata still may.
- A cell block with no
FAMarray reads as family 0 (v9.9.0), instead of failing the whole file. MED spells "belongs to no family" as id 0, so this is the file's own meaning rather than a guess, and a cell_data array must cover every block for the uniform mesh API to hold it at all. The Python reference was also wrong here, differently: it appended nothing for such a block, leavingcell_tagsshorter thanmesh.cells. Both now zero-fill identically. - Ragged
polygon/polygon2blocks (mixed vertex counts) round-trip through the C++ core asPOG/POG2(CSRNOD+INNoffset arrays); they cross the C++↔Python boundary as a copied list of arrays (ragged data cannot be zero-copy).polyhedron*blocks round-trip asMED_POLYHEDRON(POE) since v9.19.0, which needs three 1-based arrays where a polygon needs two:NOD(every face's nodes, flat),INN(face → start inNOD) andIND(cell → start in the face list). MED holds one section per type inside aMAIgroup, so everypolyhedron<N>block is canonicalised to a singlePOEon write and regrouped by unique node count on read — the same bucketing the OpenFOAM, EnSight and CGNS readers use. (Before v9.19.0 this line claimed polyhedra were "Python-only for MED"; that was simply wrong — neither path had aPOEentry.)
Notes
tests/python/meshes/med/box.med(Code_Aster 13.6) — single hexahedron, 8 points (sum 12), point_dataresu____DEPL(displacement, shape(8,3)), cell_dataresu____EPSI_ELNO/resu____SIEF_ELNO(ELNO strain/stress, shape(1,8,6)),resu____ENEL_ELNO/resu____ENEL_ELEM(energy, both supports).tests/python/meshes/med/cylinder.med(Salome 9.2.2, version downgraded to 3.0.0) — mixed cell types{pyramid:18, quad:18, line:17, tetra:63, triangle:4}, point tags summing to 52 with named families like{2:["Side"], 3:["Side","Top"], 4:["Top"]}, cell tags e.g.{-6:["Top circle"], -9:["A","B"], ...}.tests/python/meshes/med/input_code_aster.med(~4.8 MB) andtests/python/meshes/med/voronoi_hex.med(~15 KB, ragged Voronoi polygons) — larger fixtures covering the multi-mesh/polygon/metadata read paths above.- Originally ported from upstream meshio; the multi-mesh, ragged-polygon, MED-4.1 bitmask, node-orientation, and gmsh-family-bridging enhancements were contributed by Simvia's meshlane fork and brought back into this repository (see
CHANGELOG.md).