Skip to content

I/O

The voids.io sub-package handles serialization and import/export for canonical networks, PoreSpy interoperability, OpenPNM interoperability, and image-volume cases.


Network I/O And Interoperability

voids supports network import/export through the canonical Network data model. The native disk format is an HDF5 schema written by save_hdf5 and read by load_hdf5. External network interoperability is handled by adapters that normalize topology, geometry aliases, labels, sample metadata, and provenance before numerical use.

Network IO and interoperability workflow

Supported Network Paths

Path Direction Primary API Notes
Canonical HDF5 read/write save_hdf5, load_hdf5 Native voids round trip for Network, SampleGeometry, Provenance, labels, properties, and JSON-compatible extra metadata
PoreSpy/OpenPNM-style dictionary import from_porespy Imports flat mappings with keys such as pore.coords and throat.conns; common geometry aliases are normalized
PoreSpy voxel-unit geometry preprocessing scale_porespy_geometry Converts common length, area, volume, and perimeter fields from voxel units to physical units for isotropic voxels
Cartesian boundary labels preprocessing ensure_cartesian_boundary_labels Infers labels such as pore.inlet_xmin and pore.outlet_xmax from pore coordinates
OpenPNM-style dictionary export to_openpnm_dict Exports a Network to a flat dictionary suitable for OpenPNM/PoreSpy-style workflows
OpenPNM network object export to_openpnm_network Requires optional openpnm; constructor handling is version tolerant
Imperial CNM text files import load_pnflow_cnm Imports *_node1.dat, *_node2.dat, *_link1.dat, and *_link2.dat files into a canonical Network

Canonical HDF5 Round Trip

Use HDF5 when the goal is a native round trip for later voids calculations:

from voids.io import load_hdf5, save_hdf5

save_hdf5(net, "network.h5")
reloaded = load_hdf5("network.h5")

The HDF5 layout stores the schema version, sample geometry, provenance, pore and throat arrays, boolean labels, and JSON-compatible net.extra metadata.

Importing PoreSpy/OpenPNM-Style Networks

PoreSpy and OpenPNM commonly represent networks as flat mappings. The minimal required topology keys are pore.coords and throat.conns:

from voids.io import (
    ensure_cartesian_boundary_labels,
    from_porespy,
    scale_porespy_geometry,
)

scaled = scale_porespy_geometry(network_dict, voxel_size=2.5e-6)
labeled = ensure_cartesian_boundary_labels(scaled, axes=("x",))
net = from_porespy(labeled, sample=sample, provenance=provenance)

The importer maps common aliases such as throat.cross_sectional_area, throat.total_length, pore.inscribed_diameter, and throat.conduit_lengths.* to canonical voids fields. Two-dimensional coordinate arrays are embedded in 3-D as (x, y, 0).

Exporting To OpenPNM-Style Objects

Use to_openpnm_dict when a flat mapping is enough:

from voids.io import to_openpnm_dict

op_dict = to_openpnm_dict(net)

Use to_openpnm_network when an actual OpenPNM object is needed:

from voids.io import to_openpnm_network

op_net = to_openpnm_network(net)

to_openpnm_network depends on the optional openpnm package. If OpenPNM is not installed, use the dictionary export or install the optional stack required for the target workflow.

Importing CNM Text Networks

load_pnflow_cnm imports the four-file CNM text layout used by pnextract/pnflow workflows:

from voids.io import load_pnflow_cnm

imported = load_pnflow_cnm("case_dir/case_name")
net = imported.net

The prefix should omit the _node1.dat, _node2.dat, _link1.dat, and _link2.dat suffixes. The importer attaches sample lengths, pore/throat geometry, boundary labels, and import metadata. It currently supports the x-directed boundary convention used by the committed CNM benchmark files.

voids does not currently provide a general CNM exporter. For external network export, use either canonical HDF5 or the OpenPNM-style dictionary/object adapters, depending on the downstream solver.

Network API Reference

HDF5

voids.io.hdf5

save_hdf5

save_hdf5(net, path)

Serialize a network to the project HDF5 interchange format.

Parameters:

Name Type Description Default
net Network

Network to store.

required
path str | Path

Destination file path. Parent directories must already exist.

required
Notes

The file layout is intentionally explicit:

  • /meta stores schema and provenance metadata.
  • /sample stores the sample geometry payload.
  • /network/pore and /network/throat store arrays.
  • /labels stores boolean pore and throat labels as uint8 datasets.
  • / attribute extra stores JSON-compatible auxiliary metadata.
Source code in src/voids/io/hdf5.py
def save_hdf5(net: Network, path: str | Path) -> None:
    """Serialize a network to the project HDF5 interchange format.

    Parameters
    ----------
    net :
        Network to store.
    path :
        Destination file path. Parent directories must already exist.

    Notes
    -----
    The file layout is intentionally explicit:

    - ``/meta`` stores schema and provenance metadata.
    - ``/sample`` stores the sample geometry payload.
    - ``/network/pore`` and ``/network/throat`` store arrays.
    - ``/labels`` stores boolean pore and throat labels as ``uint8`` datasets.
    - ``/`` attribute ``extra`` stores JSON-compatible auxiliary metadata.
    """

    path = Path(path)
    with h5py.File(path, "w") as f:
        meta = f.create_group("meta")
        meta.create_dataset("schema_version", data=np.bytes_(net.schema_version))
        _write_json_attr(meta, "provenance", net.provenance.to_metadata())

        sample = f.create_group("sample")
        _write_json_attr(sample, "payload", net.sample.to_metadata())

        ng = f.create_group("network")
        pg = ng.create_group("pore")
        tg = ng.create_group("throat")
        pg.create_dataset("coords", data=net.pore_coords)
        tg.create_dataset("conns", data=net.throat_conns)
        for k, v in net.pore.items():
            pg.create_dataset(k, data=v)
        for k, v in net.throat.items():
            tg.create_dataset(k, data=v)

        labels = f.create_group("labels")
        lpg = labels.create_group("pore")
        ltg = labels.create_group("throat")
        for k, v in net.pore_labels.items():
            lpg.create_dataset(k, data=v.astype(np.uint8))
        for k, v in net.throat_labels.items():
            ltg.create_dataset(k, data=v.astype(np.uint8))

        _write_json_attr(f, "extra", net.extra)

load_hdf5

load_hdf5(path)

Load a network from the project HDF5 interchange format.

Parameters:

Name Type Description Default
path str | Path

Path to an HDF5 file produced by :func:save_hdf5.

required

Returns:

Type Description
Network

Reconstructed network object.

Notes

Boolean labels are stored on disk as uint8 arrays for portability and are converted back to bool arrays during import.

Source code in src/voids/io/hdf5.py
def load_hdf5(path: str | Path) -> Network:
    """Load a network from the project HDF5 interchange format.

    Parameters
    ----------
    path :
        Path to an HDF5 file produced by :func:`save_hdf5`.

    Returns
    -------
    Network
        Reconstructed network object.

    Notes
    -----
    Boolean labels are stored on disk as ``uint8`` arrays for portability and are
    converted back to ``bool`` arrays during import.
    """

    path = Path(path)
    with h5py.File(path, "r") as f:
        schema_version = f["meta"]["schema_version"][()].decode("utf-8")
        prov = Provenance.from_metadata(_read_json_attr(f["meta"], "provenance", {}))
        sample = SampleGeometry.from_metadata(_read_json_attr(f["sample"], "payload", {}))

        pore_coords = f["network"]["pore"]["coords"][()]
        throat_conns = f["network"]["throat"]["conns"][()]
        pore = {k: ds[()] for k, ds in f["network"]["pore"].items() if k != "coords"}
        throat = {k: ds[()] for k, ds in f["network"]["throat"].items() if k != "conns"}
        pore_labels = (
            {k: ds[()].astype(bool) for k, ds in f["labels"]["pore"].items()}
            if "labels" in f
            else {}
        )
        throat_labels = (
            {k: ds[()].astype(bool) for k, ds in f["labels"]["throat"].items()}
            if "labels" in f
            else {}
        )
        extra = _read_json_attr(f, "extra", {})

    return Network(
        throat_conns=throat_conns,
        pore_coords=pore_coords,
        sample=sample,
        provenance=prov,
        schema_version=schema_version,
        pore=pore,
        throat=throat,
        pore_labels=pore_labels,
        throat_labels=throat_labels,
        extra=extra,
    )

PoreSpy Import

voids.io.porespy

scale_porespy_geometry

scale_porespy_geometry(network_dict, *, voxel_size)

Scale common PoreSpy geometry fields from voxel units to physical units.

Parameters:

Name Type Description Default
network_dict Mapping[str, object]

PoreSpy/OpenPNM-style mapping containing keys such as pore.coords, throat.cross_sectional_area and pore.volume.

required
voxel_size float

Edge length of one voxel in physical units.

required

Returns:

Type Description
dict of str to object

New mapping with common geometric fields rescaled.

Raises:

Type Description
ValueError

If voxel_size is not positive.

Notes

This helper assumes isotropic voxels. The conversion factors are:

  • lengths: L_phys = L_vox * voxel_size
  • areas: A_phys = A_vox * voxel_size**2
  • volumes: V_phys = V_vox * voxel_size**3
  • perimeters: P_phys = P_vox * voxel_size

When throat.volume is absent but throat.cross_sectional_area and throat.total_length are available, a simple conduit approximation is used:

throat.volume = throat.cross_sectional_area * throat.total_length

This is convenient for manufactured examples and notebook workflows, but it is still a geometric approximation rather than an exact segmented volume.

Source code in src/voids/io/porespy.py
def scale_porespy_geometry(
    network_dict: Mapping[str, object], *, voxel_size: float
) -> dict[str, object]:
    """Scale common PoreSpy geometry fields from voxel units to physical units.

    Parameters
    ----------
    network_dict :
        PoreSpy/OpenPNM-style mapping containing keys such as ``pore.coords``,
        ``throat.cross_sectional_area`` and ``pore.volume``.
    voxel_size :
        Edge length of one voxel in physical units.

    Returns
    -------
    dict of str to object
        New mapping with common geometric fields rescaled.

    Raises
    ------
    ValueError
        If ``voxel_size`` is not positive.

    Notes
    -----
    This helper assumes isotropic voxels. The conversion factors are:

    - lengths: ``L_phys = L_vox * voxel_size``
    - areas: ``A_phys = A_vox * voxel_size**2``
    - volumes: ``V_phys = V_vox * voxel_size**3``
    - perimeters: ``P_phys = P_vox * voxel_size``

    When ``throat.volume`` is absent but ``throat.cross_sectional_area`` and
    ``throat.total_length`` are available, a simple conduit approximation is used:

    ``throat.volume = throat.cross_sectional_area * throat.total_length``

    This is convenient for manufactured examples and notebook workflows, but it is
    still a geometric approximation rather than an exact segmented volume.
    """

    L = float(voxel_size)
    if L <= 0:
        raise ValueError("voxel_size must be positive")

    scaled = dict(network_dict)
    for key, value in list(scaled.items()):
        arr = np.asarray(value)
        if not np.issubdtype(arr.dtype, np.number):
            continue
        if key in _AREA_KEYS:
            scaled[key] = arr.astype(float) * L**2
        elif key in _LENGTH_KEYS:
            scaled[key] = arr.astype(float) * L
        elif key in _VOLUME_KEYS:
            scaled[key] = arr.astype(float) * L**3
        elif key in _PERIMETER_KEYS:
            scaled[key] = arr.astype(float) * L

    if "throat.volume" not in scaled and all(
        key in scaled for key in ("throat.cross_sectional_area", "throat.total_length")
    ):
        scaled["throat.volume"] = np.asarray(
            scaled["throat.cross_sectional_area"], dtype=float
        ) * np.asarray(scaled["throat.total_length"], dtype=float)
    if "pore.volume" not in scaled and "pore.region_volume" in scaled:
        scaled["pore.volume"] = np.asarray(scaled["pore.region_volume"], dtype=float)
    return scaled

ensure_cartesian_boundary_labels

ensure_cartesian_boundary_labels(
    network_dict, *, axes=None, tol_fraction=0.05
)

Infer Cartesian inlet and outlet pore labels from coordinates.

Parameters:

Name Type Description Default
network_dict Mapping[str, object]

Mapping containing at least pore.coords.

required
axes tuple[str, ...] | None

Axes to label. If omitted, all axes present in the coordinate array are used.

None
tol_fraction float

Fraction of the domain span used as a geometric tolerance near each boundary.

0.05

Returns:

Type Description
dict of str to object

Updated mapping with labels such as pore.inlet_xmin and pore.outlet_xmax.

Raises:

Type Description
ValueError

If the coordinate array has invalid shape, if tol_fraction is negative, or if an invalid axis name is requested.

Notes

For each active axis, the helper marks pores satisfying

  • x <= x_min + tol as inlet pores
  • x >= x_max - tol as outlet pores

where tol = tol_fraction * max(x_max - x_min, 1e-12).

Source code in src/voids/io/porespy.py
def ensure_cartesian_boundary_labels(
    network_dict: Mapping[str, object],
    *,
    axes: tuple[str, ...] | None = None,
    tol_fraction: float = 0.05,
) -> dict[str, object]:
    """Infer Cartesian inlet and outlet pore labels from coordinates.

    Parameters
    ----------
    network_dict :
        Mapping containing at least ``pore.coords``.
    axes :
        Axes to label. If omitted, all axes present in the coordinate array are used.
    tol_fraction :
        Fraction of the domain span used as a geometric tolerance near each boundary.

    Returns
    -------
    dict of str to object
        Updated mapping with labels such as ``pore.inlet_xmin`` and ``pore.outlet_xmax``.

    Raises
    ------
    ValueError
        If the coordinate array has invalid shape, if ``tol_fraction`` is negative,
        or if an invalid axis name is requested.

    Notes
    -----
    For each active axis, the helper marks pores satisfying

    - ``x <= x_min + tol`` as inlet pores
    - ``x >= x_max - tol`` as outlet pores

    where ``tol = tol_fraction * max(x_max - x_min, 1e-12)``.
    """

    coords = np.asarray(network_dict["pore.coords"], dtype=float)
    if coords.ndim != 2 or coords.shape[1] not in {2, 3}:
        raise ValueError("pore.coords must have shape (Np, 2) or (Np, 3)")
    if tol_fraction < 0:
        raise ValueError("tol_fraction must be nonnegative")

    ndim = coords.shape[1]
    active_axes = axes if axes is not None else tuple(("x", "y", "z")[:ndim])
    updated = dict(network_dict)
    boundary = np.asarray(
        updated.get("pore.boundary", np.zeros(coords.shape[0], dtype=bool)), dtype=bool
    ).copy()

    for axis in active_axes:
        if axis not in _AXIS_INDEX:
            raise ValueError("axes entries must be drawn from {'x', 'y', 'z'}")
        axis_index = _AXIS_INDEX[axis]
        if axis_index >= ndim:
            raise ValueError(
                f"axis '{axis}' is not available in pore.coords with shape {coords.shape}"
            )
        values = coords[:, axis_index]
        # Use argmin/argmax instead of ndarray.min/max for compatibility with
        # environments where numpy reduction defaults are monkeypatched.
        lo = float(values[np.argmin(values)])
        hi = float(values[np.argmax(values)])
        tol = tol_fraction * max(hi - lo, 1e-12)
        inlet_key = f"pore.inlet_{axis}min"
        outlet_key = f"pore.outlet_{axis}max"
        updated.setdefault(inlet_key, values <= lo + tol)
        updated.setdefault(outlet_key, values >= hi - tol)
        boundary |= np.asarray(updated[inlet_key], dtype=bool) | np.asarray(
            updated[outlet_key], dtype=bool
        )

    updated["pore.boundary"] = boundary
    return updated

from_porespy

from_porespy(
    network_dict,
    *,
    sample=None,
    provenance=None,
    strict=True,
    geometry_repairs=None,
    repair_seed=0,
)

Build a :class:Network from a PoreSpy/OpenPNM-style mapping.

Parameters:

Name Type Description Default
network_dict Mapping[str, object]

Mapping containing PoreSpy/OpenPNM keys such as pore.coords and throat.conns.

required
sample SampleGeometry | None

Sample geometry metadata attached to the resulting network. If omitted, a default empty :class:SampleGeometry is used.

None
provenance Provenance | None

Provenance metadata. If omitted, a default record with source_kind="porespy" is created.

None
strict bool

If True, missing topology keys immediately raise an error.

True
geometry_repairs str | None

Optional extraction-style preprocessing mode. Set to "imperial_export" to apply the Imperial College export heuristics for throat shape-factor repair and pore shape-factor reconstruction. The default None preserves the imported geometry as-is apart from basic alias normalization. The legacy name "pnextract" is still accepted as a deprecated alias for backward compatibility.

None
repair_seed int | None

Seed for any stochastic repair branch. Only used when geometry_repairs="imperial_export".

0

Returns:

Type Description
Network

Imported network in the canonical voids representation.

Raises:

Type Description
KeyError

If the required topology keys are missing.

Notes

The importer performs several normalizations:

  • PoreSpy/OpenPNM aliases are mapped to canonical voids names.
  • Two-dimensional coordinates are embedded into 3D as (x, y, 0).
  • Basic missing geometry is derived when possible.
  • Common boundary aliases such as left and right are mirrored to inlet_xmin and outlet_xmax.

OpenPNM-style arrays that are not part of the formal schema, such as throat.hydraulic_size_factors, are preserved in net.extra so that information is not silently lost.

Source code in src/voids/io/porespy.py
def from_porespy(
    network_dict: Mapping[str, object],
    *,
    sample: SampleGeometry | None = None,
    provenance: Provenance | None = None,
    strict: bool = True,
    geometry_repairs: str | None = None,
    repair_seed: int | None = 0,
) -> Network:
    """Build a :class:`Network` from a PoreSpy/OpenPNM-style mapping.

    Parameters
    ----------
    network_dict :
        Mapping containing PoreSpy/OpenPNM keys such as ``pore.coords`` and
        ``throat.conns``.
    sample :
        Sample geometry metadata attached to the resulting network. If omitted,
        a default empty :class:`SampleGeometry` is used.
    provenance :
        Provenance metadata. If omitted, a default record with
        ``source_kind="porespy"`` is created.
    strict :
        If ``True``, missing topology keys immediately raise an error.
    geometry_repairs :
        Optional extraction-style preprocessing mode. Set to
        ``"imperial_export"`` to apply the Imperial College export heuristics
        for throat shape-factor repair and pore shape-factor reconstruction.
        The default ``None`` preserves the imported geometry as-is apart from
        basic alias normalization. The legacy name ``"pnextract"`` is still
        accepted as a deprecated alias for backward compatibility.
    repair_seed :
        Seed for any stochastic repair branch. Only used when
        ``geometry_repairs="imperial_export"``.

    Returns
    -------
    Network
        Imported network in the canonical ``voids`` representation.

    Raises
    ------
    KeyError
        If the required topology keys are missing.

    Notes
    -----
    The importer performs several normalizations:

    - PoreSpy/OpenPNM aliases are mapped to canonical ``voids`` names.
    - Two-dimensional coordinates are embedded into 3D as ``(x, y, 0)``.
    - Basic missing geometry is derived when possible.
    - Common boundary aliases such as ``left`` and ``right`` are mirrored to
      ``inlet_xmin`` and ``outlet_xmax``.

    OpenPNM-style arrays that are not part of the formal schema, such as
    ``throat.hydraulic_size_factors``, are preserved in ``net.extra`` so that
    information is not silently lost.
    """

    if "throat.conns" not in network_dict or "pore.coords" not in network_dict:
        if strict:
            raise KeyError(
                "PoreSpy/OpenPNM-style dict must include 'throat.conns' and 'pore.coords'"
            )

    pore_data: dict[str, np.ndarray] = {}
    throat_data: dict[str, np.ndarray] = {}
    pore_labels: dict[str, np.ndarray] = {}
    throat_labels: dict[str, np.ndarray] = {}
    extra: dict[str, object] = {}

    throat_conns = None
    pore_coords = None

    for key, value in network_dict.items():
        arr = _normalize_value(value)
        if key in _PORESPY_KEYMAP:
            family, canonical, reserved = _PORESPY_KEYMAP[key]
            if reserved == "conns":
                throat_conns = np.asarray(arr, dtype=int)
                continue
            if reserved == "coords":
                pore_coords = np.asarray(arr, dtype=float)
                continue
            if canonical is None:
                continue
            if family == "pore":
                pore_data[canonical] = np.asarray(arr)
            else:
                throat_data[canonical] = np.asarray(arr)
            continue

        if key.startswith("pore."):
            sub = key[5:]
            if np.asarray(arr).dtype == bool:
                pore_labels[sub] = np.asarray(arr, dtype=bool)
            else:
                pore_data[sub.replace(".", "_")] = np.asarray(arr)
        elif key.startswith("throat."):
            sub = key[7:]
            if np.asarray(arr).dtype == bool:
                throat_labels[sub] = np.asarray(arr, dtype=bool)
            else:
                throat_data[sub.replace(".", "_")] = np.asarray(arr)
        else:
            extra[key] = value

    if throat_conns is None or pore_coords is None:
        throat_conns = np.asarray(network_dict.get("throat.conns"))
        pore_coords = np.asarray(network_dict.get("pore.coords"), dtype=float)
        if throat_conns.ndim == 0 or pore_coords.ndim == 0:
            raise KeyError("Required keys 'throat.conns' and/or 'pore.coords' missing")

    if pore_coords.ndim == 2 and pore_coords.shape[1] == 2:
        pore_coords = np.column_stack([pore_coords, np.zeros(pore_coords.shape[0])])

    for alias, canonical in _AXIS_LABEL_ALIASES:
        if alias in pore_labels and canonical not in pore_labels:
            pore_labels[canonical] = pore_labels[alias]

    _ensure_inscribed_size_aliases(pore_data)
    _ensure_inscribed_size_aliases(throat_data)

    geometry_repairs = _normalize_geometry_repairs_mode(geometry_repairs)

    if geometry_repairs not in {None, "imperial_export"}:
        raise ValueError("geometry_repairs must be None or 'imperial_export'")
    if geometry_repairs == "imperial_export":
        extra["geometry_repairs"] = _apply_imperial_export_geometry_repairs(
            pore_data,
            throat_data,
            np.asarray(throat_conns, dtype=int),
            num_pores=int(pore_coords.shape[0]),
            random_seed=repair_seed,
        )

    conduit_summary = _derive_missing_conduit_lengths(
        pore_data,
        throat_data,
        np.asarray(throat_conns, dtype=int),
        np.asarray(pore_coords, dtype=float),
    )
    if conduit_summary is not None:
        extra["conduit_lengths"] = conduit_summary

    _derive_missing_geometry(pore_data, throat_data)

    if "hydraulic_size_factors" in throat_data:
        extra["throat.hydraulic_size_factors"] = throat_data.pop("hydraulic_size_factors")
        warnings.warn(
            "Stored throat.hydraulic_size_factors in net.extra; the auto conductance model can use it",
            RuntimeWarning,
            stacklevel=2,
        )

    net = Network(
        throat_conns=throat_conns,
        pore_coords=pore_coords,
        sample=sample or SampleGeometry(),
        provenance=provenance or Provenance(source_kind="porespy"),
        pore=pore_data,
        throat=throat_data,
        pore_labels=pore_labels,
        throat_labels=throat_labels,
        extra=extra,
    )
    validate_network(net)
    return net

OpenPNM Export

voids.io.openpnm

from_porespy

from_porespy(
    network_dict,
    *,
    sample=None,
    provenance=None,
    strict=True,
    geometry_repairs=None,
    repair_seed=0,
)

Build a :class:Network from a PoreSpy/OpenPNM-style mapping.

Parameters:

Name Type Description Default
network_dict Mapping[str, object]

Mapping containing PoreSpy/OpenPNM keys such as pore.coords and throat.conns.

required
sample SampleGeometry | None

Sample geometry metadata attached to the resulting network. If omitted, a default empty :class:SampleGeometry is used.

None
provenance Provenance | None

Provenance metadata. If omitted, a default record with source_kind="porespy" is created.

None
strict bool

If True, missing topology keys immediately raise an error.

True
geometry_repairs str | None

Optional extraction-style preprocessing mode. Set to "imperial_export" to apply the Imperial College export heuristics for throat shape-factor repair and pore shape-factor reconstruction. The default None preserves the imported geometry as-is apart from basic alias normalization. The legacy name "pnextract" is still accepted as a deprecated alias for backward compatibility.

None
repair_seed int | None

Seed for any stochastic repair branch. Only used when geometry_repairs="imperial_export".

0

Returns:

Type Description
Network

Imported network in the canonical voids representation.

Raises:

Type Description
KeyError

If the required topology keys are missing.

Notes

The importer performs several normalizations:

  • PoreSpy/OpenPNM aliases are mapped to canonical voids names.
  • Two-dimensional coordinates are embedded into 3D as (x, y, 0).
  • Basic missing geometry is derived when possible.
  • Common boundary aliases such as left and right are mirrored to inlet_xmin and outlet_xmax.

OpenPNM-style arrays that are not part of the formal schema, such as throat.hydraulic_size_factors, are preserved in net.extra so that information is not silently lost.

Source code in src/voids/io/porespy.py
def from_porespy(
    network_dict: Mapping[str, object],
    *,
    sample: SampleGeometry | None = None,
    provenance: Provenance | None = None,
    strict: bool = True,
    geometry_repairs: str | None = None,
    repair_seed: int | None = 0,
) -> Network:
    """Build a :class:`Network` from a PoreSpy/OpenPNM-style mapping.

    Parameters
    ----------
    network_dict :
        Mapping containing PoreSpy/OpenPNM keys such as ``pore.coords`` and
        ``throat.conns``.
    sample :
        Sample geometry metadata attached to the resulting network. If omitted,
        a default empty :class:`SampleGeometry` is used.
    provenance :
        Provenance metadata. If omitted, a default record with
        ``source_kind="porespy"`` is created.
    strict :
        If ``True``, missing topology keys immediately raise an error.
    geometry_repairs :
        Optional extraction-style preprocessing mode. Set to
        ``"imperial_export"`` to apply the Imperial College export heuristics
        for throat shape-factor repair and pore shape-factor reconstruction.
        The default ``None`` preserves the imported geometry as-is apart from
        basic alias normalization. The legacy name ``"pnextract"`` is still
        accepted as a deprecated alias for backward compatibility.
    repair_seed :
        Seed for any stochastic repair branch. Only used when
        ``geometry_repairs="imperial_export"``.

    Returns
    -------
    Network
        Imported network in the canonical ``voids`` representation.

    Raises
    ------
    KeyError
        If the required topology keys are missing.

    Notes
    -----
    The importer performs several normalizations:

    - PoreSpy/OpenPNM aliases are mapped to canonical ``voids`` names.
    - Two-dimensional coordinates are embedded into 3D as ``(x, y, 0)``.
    - Basic missing geometry is derived when possible.
    - Common boundary aliases such as ``left`` and ``right`` are mirrored to
      ``inlet_xmin`` and ``outlet_xmax``.

    OpenPNM-style arrays that are not part of the formal schema, such as
    ``throat.hydraulic_size_factors``, are preserved in ``net.extra`` so that
    information is not silently lost.
    """

    if "throat.conns" not in network_dict or "pore.coords" not in network_dict:
        if strict:
            raise KeyError(
                "PoreSpy/OpenPNM-style dict must include 'throat.conns' and 'pore.coords'"
            )

    pore_data: dict[str, np.ndarray] = {}
    throat_data: dict[str, np.ndarray] = {}
    pore_labels: dict[str, np.ndarray] = {}
    throat_labels: dict[str, np.ndarray] = {}
    extra: dict[str, object] = {}

    throat_conns = None
    pore_coords = None

    for key, value in network_dict.items():
        arr = _normalize_value(value)
        if key in _PORESPY_KEYMAP:
            family, canonical, reserved = _PORESPY_KEYMAP[key]
            if reserved == "conns":
                throat_conns = np.asarray(arr, dtype=int)
                continue
            if reserved == "coords":
                pore_coords = np.asarray(arr, dtype=float)
                continue
            if canonical is None:
                continue
            if family == "pore":
                pore_data[canonical] = np.asarray(arr)
            else:
                throat_data[canonical] = np.asarray(arr)
            continue

        if key.startswith("pore."):
            sub = key[5:]
            if np.asarray(arr).dtype == bool:
                pore_labels[sub] = np.asarray(arr, dtype=bool)
            else:
                pore_data[sub.replace(".", "_")] = np.asarray(arr)
        elif key.startswith("throat."):
            sub = key[7:]
            if np.asarray(arr).dtype == bool:
                throat_labels[sub] = np.asarray(arr, dtype=bool)
            else:
                throat_data[sub.replace(".", "_")] = np.asarray(arr)
        else:
            extra[key] = value

    if throat_conns is None or pore_coords is None:
        throat_conns = np.asarray(network_dict.get("throat.conns"))
        pore_coords = np.asarray(network_dict.get("pore.coords"), dtype=float)
        if throat_conns.ndim == 0 or pore_coords.ndim == 0:
            raise KeyError("Required keys 'throat.conns' and/or 'pore.coords' missing")

    if pore_coords.ndim == 2 and pore_coords.shape[1] == 2:
        pore_coords = np.column_stack([pore_coords, np.zeros(pore_coords.shape[0])])

    for alias, canonical in _AXIS_LABEL_ALIASES:
        if alias in pore_labels and canonical not in pore_labels:
            pore_labels[canonical] = pore_labels[alias]

    _ensure_inscribed_size_aliases(pore_data)
    _ensure_inscribed_size_aliases(throat_data)

    geometry_repairs = _normalize_geometry_repairs_mode(geometry_repairs)

    if geometry_repairs not in {None, "imperial_export"}:
        raise ValueError("geometry_repairs must be None or 'imperial_export'")
    if geometry_repairs == "imperial_export":
        extra["geometry_repairs"] = _apply_imperial_export_geometry_repairs(
            pore_data,
            throat_data,
            np.asarray(throat_conns, dtype=int),
            num_pores=int(pore_coords.shape[0]),
            random_seed=repair_seed,
        )

    conduit_summary = _derive_missing_conduit_lengths(
        pore_data,
        throat_data,
        np.asarray(throat_conns, dtype=int),
        np.asarray(pore_coords, dtype=float),
    )
    if conduit_summary is not None:
        extra["conduit_lengths"] = conduit_summary

    _derive_missing_geometry(pore_data, throat_data)

    if "hydraulic_size_factors" in throat_data:
        extra["throat.hydraulic_size_factors"] = throat_data.pop("hydraulic_size_factors")
        warnings.warn(
            "Stored throat.hydraulic_size_factors in net.extra; the auto conductance model can use it",
            RuntimeWarning,
            stacklevel=2,
        )

    net = Network(
        throat_conns=throat_conns,
        pore_coords=pore_coords,
        sample=sample or SampleGeometry(),
        provenance=provenance or Provenance(source_kind="porespy"),
        pore=pore_data,
        throat=throat_data,
        pore_labels=pore_labels,
        throat_labels=throat_labels,
        extra=extra,
    )
    validate_network(net)
    return net

to_openpnm_dict

to_openpnm_dict(net, *, include_extra=False)

Export a network to an OpenPNM/PoreSpy-style flat dictionary.

Parameters:

Name Type Description Default
net Network

Network to export.

required
include_extra bool

If True, merge net.extra into the output dictionary.

False

Returns:

Type Description
dict of str to Any

Flat mapping using keys such as "pore.coords" and "throat.conns".

Notes

The mapping preserves aliases expected by :func:voids.io.porespy.from_porespy. Conduit-length fields are emitted both under their internal names (for round-tripping within voids) and under OpenPNM-style names such as throat.conduit_lengths.pore1.

Source code in src/voids/io/openpnm.py
def to_openpnm_dict(net: Network, *, include_extra: bool = False) -> dict[str, Any]:
    """Export a network to an OpenPNM/PoreSpy-style flat dictionary.

    Parameters
    ----------
    net :
        Network to export.
    include_extra :
        If ``True``, merge ``net.extra`` into the output dictionary.

    Returns
    -------
    dict of str to Any
        Flat mapping using keys such as ``"pore.coords"`` and ``"throat.conns"``.

    Notes
    -----
    The mapping preserves aliases expected by :func:`voids.io.porespy.from_porespy`.
    Conduit-length fields are emitted both under their internal names
    (for round-tripping within ``voids``) and under OpenPNM-style names
    such as ``throat.conduit_lengths.pore1``.
    """

    out: dict[str, Any] = {
        "pore.coords": np.asarray(net.pore_coords, dtype=float).copy(),
        "throat.conns": np.asarray(net.throat_conns, dtype=int).copy(),
    }
    for k, v in net.pore.items():
        out[f"pore.{k}"] = np.asarray(v).copy()
    for k, v in net.throat.items():
        if k in {"pore1_length", "core_length", "pore2_length"}:
            alias_map = {
                "pore1_length": "throat.conduit_lengths.pore1",
                "core_length": "throat.conduit_lengths.throat",
                "pore2_length": "throat.conduit_lengths.pore2",
            }
            out[alias_map[k]] = np.asarray(v).copy()
        out[f"throat.{k}"] = np.asarray(v).copy()
    for k, v in net.pore_labels.items():
        out[f"pore.{k}"] = np.asarray(v, dtype=bool).copy()
    for k, v in net.throat_labels.items():
        out[f"throat.{k}"] = np.asarray(v, dtype=bool).copy()
    if include_extra:
        out.update(net.extra)
    return out

to_openpnm_network

to_openpnm_network(
    net,
    *,
    copy_properties=True,
    copy_labels=True,
    include_extra=False,
)

Convert a :class:Network into an OpenPNM network object.

Parameters:

Name Type Description Default
net Network

Network to convert.

required
copy_properties bool

If True, copy numeric pore and throat properties into the OpenPNM object.

True
copy_labels bool

If True, copy pore and throat boolean labels.

True
include_extra bool

If True, attempt to copy entries from net.extra whose keys already follow the pore.* or throat.* naming convention.

False

Returns:

Type Description
Any

OpenPNM network object. The precise class depends on the installed OpenPNM version.

Raises:

Type Description
ImportError

If OpenPNM is not installed.

RuntimeError

If a compatible OpenPNM network object cannot be instantiated.

Notes

OpenPNM's constructor signatures vary across versions. This helper tries a small set of known call patterns and always assigns pore.coords and throat.conns explicitly afterward so that topology transfer is version-robust.

Source code in src/voids/io/openpnm.py
def to_openpnm_network(
    net: Network,
    *,
    copy_properties: bool = True,
    copy_labels: bool = True,
    include_extra: bool = False,
) -> openpnm.network.Network:
    """Convert a :class:`Network` into an OpenPNM network object.

    Parameters
    ----------
    net :
        Network to convert.
    copy_properties :
        If ``True``, copy numeric pore and throat properties into the OpenPNM object.
    copy_labels :
        If ``True``, copy pore and throat boolean labels.
    include_extra :
        If ``True``, attempt to copy entries from ``net.extra`` whose keys already
        follow the ``pore.*`` or ``throat.*`` naming convention.

    Returns
    -------
    Any
        OpenPNM network object. The precise class depends on the installed OpenPNM version.

    Raises
    ------
    ImportError
        If OpenPNM is not installed.
    RuntimeError
        If a compatible OpenPNM network object cannot be instantiated.

    Notes
    -----
    OpenPNM's constructor signatures vary across versions. This helper tries a small
    set of known call patterns and always assigns ``pore.coords`` and
    ``throat.conns`` explicitly afterward so that topology transfer is version-robust.
    """

    try:
        import openpnm as op
    except Exception as exc:  # pragma: no cover - optional dependency
        raise ImportError("OpenPNM is not installed") from exc

    coords = np.asarray(net.pore_coords, dtype=float)
    conns = np.asarray(net.throat_conns, dtype=int)

    pn = None
    errs: list[Exception] = []
    for ctor in (
        lambda: op.network.Network(coords=coords, conns=conns),
        lambda: op.network.Network(conns=conns, coords=coords),
        lambda: op.network.Network(),
    ):
        try:
            pn = ctor()
            break
        except Exception as e:  # pragma: no cover - depends on OpenPNM version
            errs.append(e)
    if pn is None:  # pragma: no cover
        raise RuntimeError(f"Unable to instantiate OpenPNM Network: {errs!r}")

    pn["pore.coords"] = coords
    pn["throat.conns"] = conns

    if copy_properties:
        for k, v in net.pore.items():
            pn[f"pore.{k}"] = np.asarray(v)
        for k, v in net.throat.items():
            pn[f"throat.{k}"] = np.asarray(v)
    if copy_labels:
        for k, v in net.pore_labels.items():
            pn[f"pore.{k}"] = np.asarray(v, dtype=bool)
        for k, v in net.throat_labels.items():
            pn[f"throat.{k}"] = np.asarray(v, dtype=bool)
    if include_extra:
        for k, v in net.extra.items():
            if isinstance(k, str) and (k.startswith("pore.") or k.startswith("throat.")):
                try:
                    pn[k] = np.asarray(v)
                except Exception:
                    pass
    return pn

CNM Import

voids.io.pnflow_cnm

PnflowCNMImportResult dataclass

Container for an imported Imperial College CNM network.

Attributes:

Name Type Description
net Network

Imported network ready for voids single-phase calculations.

prefix Path

File prefix used to locate the CNM text files.

box_lengths dict[str, float]

Physical sample lengths encoded in the CNM header.

n_physical_pores int

Number of pores listed in *_node*.dat, excluding mirrored inlet/outlet helper pores inserted during import.

n_boundary_mirror_pores int

Number of helper pores added to mimic pnflow reservoir semantics.

Source code in src/voids/io/pnflow_cnm.py
@dataclass(slots=True)
class PnflowCNMImportResult:
    """Container for an imported Imperial College CNM network.

    Attributes
    ----------
    net :
        Imported network ready for `voids` single-phase calculations.
    prefix :
        File prefix used to locate the CNM text files.
    box_lengths :
        Physical sample lengths encoded in the CNM header.
    n_physical_pores :
        Number of pores listed in `*_node*.dat`, excluding mirrored
        inlet/outlet helper pores inserted during import.
    n_boundary_mirror_pores :
        Number of helper pores added to mimic `pnflow` reservoir semantics.
    """

    net: Network
    prefix: Path
    box_lengths: dict[str, float]
    n_physical_pores: int
    n_boundary_mirror_pores: int

load_pnflow_cnm

load_pnflow_cnm(
    prefix,
    *,
    boundary_axis="x",
    length_unit="m",
    pressure_unit="Pa",
    boundary_length_epsilon=_BOUNDARY_LENGTH_EPS,
    boundary_radius_scale=1.1,
    pnflow_solver_box_compat=False,
)

Import an Imperial College pnextract / pnflow CNM text network.

Parameters:

Name Type Description Default
prefix str | Path

File prefix for the four CNM text files. For a benchmark case stored as case_dir/case_name_node1.dat, pass case_dir/case_name.

required
boundary_axis str

Axis along which the inlet/outlet reservoir labels are attached. The committed Imperial CNM format is x-directed, so "x" is the default and the only axis currently supported.

'x'
length_unit str

Unit metadata attached to the resulting SampleGeometry.

'm'
pressure_unit str

Unit metadata attached to the resulting SampleGeometry.

'm'
boundary_length_epsilon float

Small positive reservoir-side pore length used to reproduce the near-zero boundary resistance applied internally by pnflow.

_BOUNDARY_LENGTH_EPS
boundary_radius_scale float

Scale factor used for mirrored inlet/outlet helper pores. This follows the InOutBoundary::prepare2() construction in the Imperial code.

1.1
pnflow_solver_box_compat bool

If True, reproduce the Imperial CNM preprocessing quirk that excludes the first physical pore from the solver box when nBSs_ = 2 is hard-coded in FlowDomain.cpp. The excluded pore is then treated as an inlet or outlet solver-boundary pore based on its x-position relative to the sample mid-plane. This is kept opt-in because it reproduces checked-in pnflow behavior rather than a generic physical boundary rule. Enabling this option is required for near machine-precision single-phase parity with the saved pnflow benchmark cases.

False

Returns:

Type Description
PnflowCNMImportResult

Imported network together with import metadata.

Notes

The CNM text files store internal pores only. To match pnflow's single-phase boundary treatment more closely, this importer inserts one zero-volume mirrored pore for each inlet/outlet connection throat and collapses the reservoir-side pore segment length to a tiny positive value.

Source code in src/voids/io/pnflow_cnm.py
def load_pnflow_cnm(
    prefix: str | Path,
    *,
    boundary_axis: str = "x",
    length_unit: str = "m",
    pressure_unit: str = "Pa",
    boundary_length_epsilon: float = _BOUNDARY_LENGTH_EPS,
    boundary_radius_scale: float = 1.1,
    pnflow_solver_box_compat: bool = False,
) -> PnflowCNMImportResult:
    """Import an Imperial College `pnextract` / `pnflow` CNM text network.

    Parameters
    ----------
    prefix :
        File prefix for the four CNM text files. For a benchmark case stored as
        `case_dir/case_name_node1.dat`, pass `case_dir/case_name`.
    boundary_axis :
        Axis along which the inlet/outlet reservoir labels are attached. The
        committed Imperial CNM format is x-directed, so `"x"` is the default
        and the only axis currently supported.
    length_unit, pressure_unit :
        Unit metadata attached to the resulting `SampleGeometry`.
    boundary_length_epsilon :
        Small positive reservoir-side pore length used to reproduce the
        near-zero boundary resistance applied internally by `pnflow`.
    boundary_radius_scale :
        Scale factor used for mirrored inlet/outlet helper pores. This follows
        the `InOutBoundary::prepare2()` construction in the Imperial code.
    pnflow_solver_box_compat :
        If ``True``, reproduce the Imperial CNM preprocessing quirk that
        excludes the first physical pore from the solver box when
        ``nBSs_ = 2`` is hard-coded in `FlowDomain.cpp`. The excluded pore is
        then treated as an inlet or outlet solver-boundary pore based on its
        x-position relative to the sample mid-plane. This is kept opt-in
        because it reproduces checked-in `pnflow` behavior rather than a
        generic physical boundary rule. Enabling this option is required for
        near machine-precision single-phase parity with the saved `pnflow`
        benchmark cases.

    Returns
    -------
    PnflowCNMImportResult
        Imported network together with import metadata.

    Notes
    -----
    The CNM text files store internal pores only. To match `pnflow`'s
    single-phase boundary treatment more closely, this importer inserts one
    zero-volume mirrored pore for each inlet/outlet connection throat and
    collapses the reservoir-side pore segment length to a tiny positive value.
    """

    if boundary_axis != "x":
        raise ValueError("Imperial CNM import currently supports only boundary_axis='x'")
    if boundary_length_epsilon <= 0.0:
        raise ValueError("boundary_length_epsilon must be positive")
    if boundary_radius_scale <= 0.0:
        raise ValueError("boundary_radius_scale must be positive")

    prefix_path = Path(prefix)
    node1_path = prefix_path.with_name(f"{prefix_path.name}_node1.dat")
    node2_path = prefix_path.with_name(f"{prefix_path.name}_node2.dat")
    link1_path = prefix_path.with_name(f"{prefix_path.name}_link1.dat")
    link2_path = prefix_path.with_name(f"{prefix_path.name}_link2.dat")

    node1_lines = node1_path.read_text().splitlines()
    node2_lines = node2_path.read_text().splitlines()
    link1_lines = link1_path.read_text().splitlines()
    link2_lines = link2_path.read_text().splitlines()

    if not node1_lines:
        raise ValueError(f"CNM file is empty: {node1_path}")
    header = _split_numeric_line(node1_lines[0], expected_min_tokens=4, label=str(node1_path))
    n_physical_pores = int(header[0])
    lx, ly, lz = map(float, header[1:4])
    box_lengths = {"x": lx, "y": ly, "z": lz}
    cross_sections = {"x": ly * lz, "y": lx * lz, "z": lx * ly}

    if len(node1_lines) != n_physical_pores + 1:
        raise ValueError(
            f"{node1_path} header declares {n_physical_pores} pores but file contains "
            f"{len(node1_lines) - 1} pore rows"
        )
    if len(node2_lines) != n_physical_pores:
        raise ValueError(
            f"{node2_path} should contain {n_physical_pores} pore rows, got {len(node2_lines)}"
        )
    if not link1_lines:
        raise ValueError(f"CNM file is empty: {link1_path}")
    n_throats = int(
        _split_numeric_line(link1_lines[0], expected_min_tokens=1, label=str(link1_path))[0]
    )
    link1_rows = link1_lines[1:]
    if len(link1_rows) != n_throats or len(link2_lines) != n_throats:
        raise ValueError(
            f"Throat-row mismatch for prefix {prefix_path}: "
            f"header={n_throats}, link1_rows={len(link1_rows)}, link2_rows={len(link2_lines)}"
        )

    pore_coords = np.zeros((n_physical_pores, 3), dtype=float)
    pore_volume = np.zeros(n_physical_pores, dtype=float)
    pore_radius = np.zeros(n_physical_pores, dtype=float)
    pore_shape_factor_raw = np.zeros(n_physical_pores, dtype=float)
    pore_connected_inlet = np.zeros(n_physical_pores, dtype=bool)
    pore_connected_outlet = np.zeros(n_physical_pores, dtype=bool)

    for line in node1_lines[1:]:
        tokens = _split_numeric_line(line, expected_min_tokens=6, label=str(node1_path))
        idx = int(tokens[0]) - 1
        x, y, z = map(float, tokens[1:4])
        conn_number = int(tokens[4])
        inlet_pos = 5 + conn_number
        outlet_pos = inlet_pos + 1
        if len(tokens) < outlet_pos + 1 + conn_number:
            raise ValueError(f"Malformed pore-connectivity row in {node1_path}: {line}")
        pore_coords[idx] = (x, y, z)
        pore_connected_inlet[idx] = bool(int(tokens[inlet_pos]))
        pore_connected_outlet[idx] = bool(int(tokens[outlet_pos]))

    for line in node2_lines:
        tokens = _split_numeric_line(line, expected_min_tokens=5, label=str(node2_path))
        idx = int(tokens[0]) - 1
        pore_volume[idx] = float(tokens[1])
        pore_radius[idx] = max(float(tokens[2]), boundary_length_epsilon)
        pore_shape_factor_raw[idx] = max(float(tokens[3]), boundary_length_epsilon)

    coords_list = pore_coords.tolist()
    volume_list = pore_volume.tolist()
    radius_list = pore_radius.tolist()
    shape_factor_raw_list = pore_shape_factor_raw.tolist()
    inlet_label = np.zeros(n_physical_pores, dtype=bool).tolist()
    outlet_label = np.zeros(n_physical_pores, dtype=bool).tolist()

    throat_conns: list[list[int]] = []
    throat_radius = np.zeros(n_throats, dtype=float)
    throat_shape_factor = np.zeros(n_throats, dtype=float)
    throat_volume = np.zeros(n_throats, dtype=float)
    throat_core_length = np.zeros(n_throats, dtype=float)
    throat_pore1_length = np.zeros(n_throats, dtype=float)
    throat_pore2_length = np.zeros(n_throats, dtype=float)

    n_boundary_mirror_pores = 0
    for throat_idx, (line1, line2) in enumerate(zip(link1_rows, link2_lines, strict=True)):
        tokens1 = _split_numeric_line(line1, expected_min_tokens=6, label=str(link1_path))
        tokens2 = _split_numeric_line(line2, expected_min_tokens=8, label=str(link2_path))

        pore1_idx_raw = int(tokens1[1])
        pore2_idx_raw = int(tokens1[2])
        throat_radius[throat_idx] = max(float(tokens1[3]), boundary_length_epsilon)
        throat_shape_factor[throat_idx] = max(float(tokens1[4]), boundary_length_epsilon)
        throat_pore1_length[throat_idx] = max(float(tokens2[3]), boundary_length_epsilon)
        throat_pore2_length[throat_idx] = max(float(tokens2[4]), boundary_length_epsilon)
        throat_core_length[throat_idx] = max(float(tokens2[5]), boundary_length_epsilon)
        throat_volume[throat_idx] = float(tokens2[6])

        left = pore1_idx_raw - 1 if pore1_idx_raw > 0 else None
        right = pore2_idx_raw - 1 if pore2_idx_raw > 0 else None

        if pore1_idx_raw in {-1, 0}:
            if right is None:
                raise ValueError(
                    f"Boundary throat without an internal neighbor in {link1_path}: {line1}"
                )
            x_boundary = 0.0 if pore1_idx_raw == -1 else lx
            coords_list.append([x_boundary, pore_coords[right, 1], pore_coords[right, 2]])
            volume_list.append(0.0)
            radius_list.append(throat_radius[throat_idx] * boundary_radius_scale)
            shape_factor_raw_list.append(throat_shape_factor[throat_idx])
            inlet_label.append(pore1_idx_raw == -1)
            outlet_label.append(pore1_idx_raw == 0)
            left = len(coords_list) - 1
            throat_pore1_length[throat_idx] = boundary_length_epsilon
            n_boundary_mirror_pores += 1

        if pore2_idx_raw in {-1, 0}:
            if left is None:
                raise ValueError(
                    f"Boundary throat without an internal neighbor in {link1_path}: {line1}"
                )
            x_boundary = 0.0 if pore2_idx_raw == -1 else lx
            left_coords = coords_list[left]
            coords_list.append([x_boundary, left_coords[1], left_coords[2]])
            volume_list.append(0.0)
            radius_list.append(throat_radius[throat_idx] * boundary_radius_scale)
            shape_factor_raw_list.append(throat_shape_factor[throat_idx])
            inlet_label.append(pore2_idx_raw == -1)
            outlet_label.append(pore2_idx_raw == 0)
            right = len(coords_list) - 1
            throat_pore2_length[throat_idx] = boundary_length_epsilon
            n_boundary_mirror_pores += 1

        if left is None or right is None:
            raise ValueError(f"Unresolved throat endpoints while importing {link1_path}: {line1}")
        throat_conns.append([left, right])

    pore_coords_arr = np.asarray(coords_list, dtype=float)
    pore_volume_arr = np.asarray(volume_list, dtype=float)
    pore_radius_arr = np.asarray(radius_list, dtype=float)
    pore_shape_factor_raw_arr = np.asarray(shape_factor_raw_list, dtype=float)
    pore_shape_factor_arr = _pnflow_effective_shape_factor(pore_shape_factor_raw_arr)
    inlet_label_arr = np.asarray(inlet_label, dtype=bool)
    outlet_label_arr = np.asarray(outlet_label, dtype=bool)
    if pnflow_solver_box_compat and n_physical_pores > 0:
        if pore_coords_arr[0, 0] < 0.5 * lx:
            inlet_label_arr[0] = True
        else:
            outlet_label_arr[0] = True
    pore_area_arr = pore_radius_arr**2 / (4.0 * pore_shape_factor_arr)
    throat_shape_factor_raw = throat_shape_factor.copy()
    throat_shape_factor = _pnflow_effective_shape_factor(throat_shape_factor_raw)
    throat_area = throat_radius**2 / (4.0 * throat_shape_factor)

    net = Network(
        throat_conns=np.asarray(throat_conns, dtype=np.int64),
        pore_coords=pore_coords_arr,
        sample=SampleGeometry(
            bulk_volume=lx * ly * lz,
            lengths=box_lengths,
            cross_sections=cross_sections,
            units={"length": length_unit, "pressure": pressure_unit},
        ),
        provenance=Provenance(
            source_kind="external_network",
            extraction_method="pnextract_cnm_text",
            user_notes={
                "prefix": str(prefix_path),
                "boundary_axis": boundary_axis,
                "boundary_length_epsilon": boundary_length_epsilon,
                "boundary_radius_scale": boundary_radius_scale,
                "pnflow_solver_box_compat": pnflow_solver_box_compat,
            },
        ),
        pore={
            "volume": pore_volume_arr,
            "radius_inscribed": pore_radius_arr,
            "diameter_inscribed": 2.0 * pore_radius_arr,
            "shape_factor": pore_shape_factor_arr,
            "shape_factor_raw": pore_shape_factor_raw_arr,
            "area": pore_area_arr,
        },
        throat={
            "volume": throat_volume,
            "radius_inscribed": throat_radius,
            "diameter_inscribed": 2.0 * throat_radius,
            "shape_factor": throat_shape_factor,
            "shape_factor_raw": throat_shape_factor_raw,
            "area": throat_area,
            "core_length": throat_core_length,
            "pore1_length": throat_pore1_length,
            "pore2_length": throat_pore2_length,
            "length": throat_core_length + throat_pore1_length + throat_pore2_length,
        },
        pore_labels={
            "inlet_xmin": inlet_label_arr,
            "outlet_xmax": outlet_label_arr,
            "boundary": inlet_label_arr | outlet_label_arr,
            "boundary_connected_inlet_xmin": np.pad(
                pore_connected_inlet,
                (0, n_boundary_mirror_pores),
                constant_values=False,
            ),
            "boundary_connected_outlet_xmax": np.pad(
                pore_connected_outlet,
                (0, n_boundary_mirror_pores),
                constant_values=False,
            ),
        },
        extra={
            "pnflow_cnm": {
                "prefix": str(prefix_path),
                "n_physical_pores": n_physical_pores,
                "n_boundary_mirror_pores": n_boundary_mirror_pores,
                "box_lengths": box_lengths,
                "pnflow_solver_box_compat": pnflow_solver_box_compat,
            }
        },
    )
    validate_network(net)
    return PnflowCNMImportResult(
        net=net,
        prefix=prefix_path,
        box_lengths=box_lengths,
        n_physical_pores=n_physical_pores,
        n_boundary_mirror_pores=n_boundary_mirror_pores,
    )

Image Volume And Surface Mesh I/O

voids.io.volume provides the image-volume import/export surface used by the synthetic image workflows. The central object is VolumeData, which couples a 2-D or 3-D image array to its physical voxel spacing, length units, and provenance metadata.

Image volume and surface mesh IO workflow

Use VolumeData when the image is intended to leave Python or feed a continuum/FEM workflow:

from voids.io import VolumeData, save_volume_bundle

case_data = VolumeData(
    values=void_image,
    voxel_size=(40.0e-6, 40.0e-6, 40.0e-6),
    units={"length": "m"},
    metadata={"case": "macro_micro_vug"},
)

written = save_volume_bundle(
    case_data,
    "outputs/synthetic_case",
    stem="macro_micro_vug",
    formats=("raw", "npy", "h5", "nc", "tiff", "stl", "obj"),
)

Supported Formats

Format Kind Metadata handling
.raw voxel field Written with a .raw.json sidecar containing shape, dtype, voxel size, units, and provenance metadata
.npy voxel field NumPy-native array plus .npy.json sidecar for voxel size, units, and provenance metadata
.h5 voxel field HDF5 dataset /volume plus JSON metadata attributes
.nc voxel field Basic netCDF variable volume plus metadata attributes
.tif, .tiff voxel field TIFF stack plus .tif.json or .tiff.json sidecar for voxel size, units, and provenance metadata
.stl surface mesh 3-D binary interface extracted by marching cubes using voxel_size as physical spacing
.obj surface mesh 3-D binary interface extracted by marching cubes using voxel_size as physical spacing

STL and OBJ exports require a 3-D binary volume containing both void and solid voxels. The binary volume must be a boolean array or a numeric array whose values are limited to 0 and 1. These exports represent the void/solid interface as a triangular surface, not the full voxel field.

Loading Voxel Volumes

Use load_volume when only the array is needed:

from voids.io import load_volume

volume = load_volume("outputs/synthetic_case/macro_micro_vug.h5")

Use load_volume_data when physical resolution matters for porosity maps, permeability maps, surface exports, or external FEM/continuum solvers:

from voids.io import load_volume_data

volume_data = load_volume_data("outputs/synthetic_case/macro_micro_vug.tiff")

TIFF files may contain some resolution tags in particular software workflows, but they should not be treated as a reliable source of 3-D voxel spacing. If the TIFF was not written by voids with its JSON sidecar, pass the voxel size explicitly:

external_scan = load_volume_data(
    "micro_ct_stack.tiff",
    voxel_size=(40.0e-6, 40.0e-6, 40.0e-6),
    units={"length": "m"},
)

Raw binary files have no self-describing shape, dtype, or voxel resolution. If the voids sidecar is absent, provide shape, dtype, and voxel size explicitly when those quantities matter:

volume_data = load_volume_data(
    "macro_micro_vug.raw",
    shape=(160, 160, 160),
    dtype="uint8",
    voxel_size=(40.0e-6, 40.0e-6, 40.0e-6),
    units={"length": "m"},
)

Loading Surface Meshes

Surface meshes can be read back with:

from voids.io import load_surface_mesh

mesh = load_surface_mesh("outputs/synthetic_case/macro_micro_vug.obj")

Surface files are geometric interchange files. They do not replace the voxel field when voxel-wise phase information is needed.

voids.io.volume

SurfaceMesh dataclass

Triangular surface mesh used for STL/OBJ interchange.

Attributes:

Name Type Description
vertices ndarray

Floating-point vertex coordinates with shape (n_vertices, 3).

faces ndarray

Integer triangular face connectivity with shape (n_faces, 3).

metadata dict[str, Any]

JSON-serializable provenance metadata.

Source code in src/voids/io/volume.py
@dataclass(slots=True)
class SurfaceMesh:
    """Triangular surface mesh used for STL/OBJ interchange.

    Attributes
    ----------
    vertices :
        Floating-point vertex coordinates with shape ``(n_vertices, 3)``.
    faces :
        Integer triangular face connectivity with shape ``(n_faces, 3)``.
    metadata :
        JSON-serializable provenance metadata.
    """

    vertices: np.ndarray
    faces: np.ndarray
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate and normalize mesh arrays."""

        vertices = np.asarray(self.vertices, dtype=float)
        faces = np.asarray(self.faces, dtype=np.int64)
        if vertices.ndim != 2 or vertices.shape[1] != 3:
            raise ValueError("vertices must have shape (n_vertices, 3)")
        if faces.ndim != 2 or faces.shape[1] != 3:
            raise ValueError("faces must have shape (n_faces, 3)")
        if not np.all(np.isfinite(vertices)):
            raise ValueError("vertices must be finite")
        if np.any(faces < 0):
            raise ValueError("faces must be nonnegative")
        if faces.size and int(np.max(faces)) >= len(vertices):
            raise ValueError("faces reference a vertex outside vertices")
        self.vertices = vertices
        self.faces = faces
        self.metadata = dict(self.metadata)

VolumeData dataclass

Voxel image together with physical spacing and provenance metadata.

Attributes:

Name Type Description
values ndarray

Two- or three-dimensional image array.

voxel_size float | Sequence[float]

Physical spacing along each image axis. A scalar means isotropic spacing; a sequence must have one entry per array dimension.

units dict[str, str]

Unit metadata for the spacing. By convention, {"length": "voxel"} means dimensionless voxel units.

metadata dict[str, Any]

JSON-serializable provenance metadata.

Source code in src/voids/io/volume.py
@dataclass(slots=True)
class VolumeData:
    """Voxel image together with physical spacing and provenance metadata.

    Attributes
    ----------
    values :
        Two- or three-dimensional image array.
    voxel_size :
        Physical spacing along each image axis. A scalar means isotropic
        spacing; a sequence must have one entry per array dimension.
    units :
        Unit metadata for the spacing. By convention, ``{"length": "voxel"}``
        means dimensionless voxel units.
    metadata :
        JSON-serializable provenance metadata.
    """

    values: np.ndarray
    voxel_size: float | Sequence[float] = 1.0
    units: dict[str, str] = field(default_factory=lambda: {"length": "voxel"})
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate the array and normalize physical spacing metadata."""

        values = np.asarray(self.values)
        normalize_shape(values.shape, allowed_ndim=(2, 3))
        self.values = values
        self.voxel_size = _normalize_voxel_size(self.voxel_size, ndim=values.ndim)
        self.units = dict(self.units)
        self.metadata = dict(self.metadata)

    @property
    def ndim(self) -> int:
        """Number of image dimensions."""

        return int(self.values.ndim)

    @property
    def shape(self) -> tuple[int, ...]:
        """Image shape in NumPy axis order."""

        return tuple(int(v) for v in self.values.shape)

ndim property

ndim

Number of image dimensions.

shape property

shape

Image shape in NumPy axis order.

save_volume

save_volume(
    volume,
    path,
    *,
    file_format=None,
    metadata=None,
    raw_dtype=None,
    hdf5_dataset="volume",
    netcdf_variable="volume",
    voxel_size=None,
    units=None,
)

Save a 2D/3D synthetic image volume or surface mesh.

Parameters:

Name Type Description Default
volume VolumeData | ndarray

Two- or three-dimensional image, or :class:VolumeData with physical spacing metadata. Boolean arrays are interpreted as True=void for surface-mesh export.

required
path str | Path

Destination path. Supported suffixes are .raw, .npy, .h5, .nc, .tif/.tiff, .stl, and .obj.

required
file_format str | None

Optional explicit format when the suffix is ambiguous.

None
metadata dict[str, Any] | None

JSON-serializable provenance metadata stored by metadata-capable formats.

None
raw_dtype str | dtype[Any] | None

Storage dtype for raw binary export. Defaults to uint8 for boolean images and to the input dtype otherwise.

None
hdf5_dataset str

Dataset/variable names for HDF5 and netCDF.

'volume'
netcdf_variable str

Dataset/variable names for HDF5 and netCDF.

'volume'
voxel_size float | Sequence[float] | None

Physical voxel spacing. A scalar means isotropic spacing; a sequence must have one entry per image axis. The value is stored in metadata for voxel formats and used as marching-cubes spacing for STL/OBJ surfaces.

None
units dict[str, str] | None

Unit metadata for voxel_size. Defaults to {"length": "voxel"} for plain arrays, or to the units stored on :class:VolumeData.

None

Returns:

Type Description
Path

Path written.

Notes

Raw, NumPy, and TIFF files do not reliably carry the physical voxel spacing needed by downstream solvers, so a JSON sidecar with suffix .<ext>.json is written next to those files.

Source code in src/voids/io/volume.py
def save_volume(
    volume: VolumeData | np.ndarray,
    path: str | Path,
    *,
    file_format: str | None = None,
    metadata: dict[str, Any] | None = None,
    raw_dtype: str | np.dtype[Any] | None = None,
    hdf5_dataset: str = "volume",
    netcdf_variable: str = "volume",
    voxel_size: float | Sequence[float] | None = None,
    units: dict[str, str] | None = None,
) -> Path:
    """Save a 2D/3D synthetic image volume or surface mesh.

    Parameters
    ----------
    volume :
        Two- or three-dimensional image, or :class:`VolumeData` with physical
        spacing metadata. Boolean arrays are interpreted as ``True=void`` for
        surface-mesh export.
    path :
        Destination path. Supported suffixes are ``.raw``, ``.npy``, ``.h5``,
        ``.nc``, ``.tif``/``.tiff``, ``.stl``, and ``.obj``.
    file_format :
        Optional explicit format when the suffix is ambiguous.
    metadata :
        JSON-serializable provenance metadata stored by metadata-capable formats.
    raw_dtype :
        Storage dtype for raw binary export. Defaults to ``uint8`` for boolean
        images and to the input dtype otherwise.
    hdf5_dataset, netcdf_variable :
        Dataset/variable names for HDF5 and netCDF.
    voxel_size :
        Physical voxel spacing. A scalar means isotropic spacing; a sequence
        must have one entry per image axis. The value is stored in metadata for
        voxel formats and used as marching-cubes spacing for STL/OBJ surfaces.
    units :
        Unit metadata for ``voxel_size``. Defaults to ``{"length": "voxel"}``
        for plain arrays, or to the units stored on :class:`VolumeData`.

    Returns
    -------
    pathlib.Path
        Path written.

    Notes
    -----
    Raw, NumPy, and TIFF files do not reliably carry the physical voxel spacing
    needed by downstream solvers, so a JSON sidecar with suffix ``.<ext>.json``
    is written next to those files.
    """

    arr, spacing, resolved_units, resolved_metadata = _coerce_volume_input(
        volume,
        metadata=metadata,
        voxel_size=voxel_size,
        units=units,
    )
    fmt = _normalize_format(path, file_format)
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)

    if fmt in {"stl", "obj"}:
        mesh = surface_mesh_from_binary_volume(arr, voxel_size=spacing)
        mesh.metadata["units"] = dict(resolved_units)
        mesh.metadata.update(resolved_metadata)
        return save_surface_mesh(mesh, out, file_format=fmt)

    if fmt == "raw":
        dtype = np.dtype(
            np.uint8 if arr.dtype == bool and raw_dtype is None else raw_dtype or arr.dtype
        )
        arr.astype(dtype, copy=False).tofile(out)
        sidecar = _raw_sidecar_path(out)
        sidecar.write_text(
            _json_dumps(
                _volume_metadata(
                    arr,
                    metadata=resolved_metadata,
                    voxel_size=spacing,
                    units=resolved_units,
                    stored_dtype=dtype,
                )
            ),
            encoding="utf-8",
        )
        return out

    if fmt == "npy":
        np.save(out, arr)
        _volume_sidecar_path(out).write_text(
            _json_dumps(
                _volume_metadata(
                    arr,
                    metadata=resolved_metadata,
                    voxel_size=spacing,
                    units=resolved_units,
                )
            ),
            encoding="utf-8",
        )
        return out

    if fmt == "h5":
        with h5py.File(out, "w") as f:
            f.attrs["schema_version"] = _VOLUME_SCHEMA_VERSION
            f.attrs["metadata"] = _json_dumps(
                _volume_metadata(
                    arr,
                    metadata=resolved_metadata,
                    voxel_size=spacing,
                    units=resolved_units,
                )
            )
            f.create_dataset(hdf5_dataset, data=arr)
        return out

    if fmt == "nc":
        needs_signed_storage = arr.dtype == np.dtype(bool) or arr.dtype == np.dtype("uint8")
        stored = arr.astype(np.int16, copy=False) if needs_signed_storage else arr
        with netcdf_file(out, "w") as f:
            for axis, size in enumerate(stored.shape):
                f.createDimension(f"dim_{axis}", int(size))
            var = f.createVariable(
                netcdf_variable, stored.dtype, tuple(f"dim_{i}" for i in range(stored.ndim))
            )
            var[:] = stored
            f.schema_version = _VOLUME_SCHEMA_VERSION
            f.metadata = _json_dumps(
                _volume_metadata(
                    arr,
                    metadata=resolved_metadata,
                    voxel_size=spacing,
                    units=resolved_units,
                    stored_dtype=stored.dtype,
                )
            )
        return out

    if fmt == "tiff":
        stored = arr.astype(np.uint8, copy=False) if arr.dtype == bool else arr
        tifffile.imwrite(out, stored, photometric="minisblack")
        _volume_sidecar_path(out).write_text(
            _json_dumps(
                _volume_metadata(
                    arr,
                    metadata=resolved_metadata,
                    voxel_size=spacing,
                    units=resolved_units,
                    stored_dtype=stored.dtype,
                )
            ),
            encoding="utf-8",
        )
        return out

    raise ValueError(f"Unsupported volume format: {fmt}")  # pragma: no cover - guarded above

load_volume

load_volume(
    path,
    *,
    file_format=None,
    shape=None,
    dtype=None,
    hdf5_dataset="volume",
    netcdf_variable="volume",
)

Load a 2D/3D image volume from raw, NumPy, HDF5, netCDF, or TIFF.

Source code in src/voids/io/volume.py
def load_volume(
    path: str | Path,
    *,
    file_format: str | None = None,
    shape: Sequence[int] | None = None,
    dtype: str | np.dtype[Any] | None = None,
    hdf5_dataset: str = "volume",
    netcdf_variable: str = "volume",
) -> np.ndarray:
    """Load a 2D/3D image volume from raw, NumPy, HDF5, netCDF, or TIFF."""

    source = Path(path)
    fmt = _normalize_format(source, file_format)

    if fmt == "raw":
        sidecar_metadata = _load_volume_metadata(source, fmt)
        resolved_shape = tuple(int(v) for v in (shape or sidecar_metadata.get("shape", ())))
        if not resolved_shape:
            raise ValueError("shape is required to load raw volume without sidecar metadata")
        normalize_shape(resolved_shape, allowed_ndim=(2, 3))
        resolved_dtype = np.dtype(dtype or sidecar_metadata.get("stored_dtype", np.uint8))
        data = np.fromfile(source, dtype=resolved_dtype)
        expected = int(np.prod(np.asarray(resolved_shape, dtype=np.int64)))
        if data.size != expected:
            raise ValueError(f"raw file has {data.size} entries but shape requires {expected}")
        return data.reshape(resolved_shape)

    if fmt == "npy":
        arr = np.load(source)
        normalize_shape(arr.shape, allowed_ndim=(2, 3))
        return np.asarray(arr)

    if fmt == "h5":
        with h5py.File(source, "r") as f:
            arr = f[hdf5_dataset][()]
        normalize_shape(arr.shape, allowed_ndim=(2, 3))
        return np.asarray(arr)

    if fmt == "nc":
        with netcdf_file(source, "r", mmap=False) as f:
            arr = np.asarray(f.variables[netcdf_variable][()]).copy()
        normalize_shape(arr.shape, allowed_ndim=(2, 3))
        return arr

    if fmt == "tiff":
        arr = np.asarray(tifffile.imread(source))
        normalize_shape(arr.shape, allowed_ndim=(2, 3))
        return arr

    raise ValueError(f"Use load_surface_mesh for {fmt!r} files")

load_volume_data

load_volume_data(
    path,
    *,
    file_format=None,
    shape=None,
    dtype=None,
    hdf5_dataset="volume",
    netcdf_variable="volume",
    voxel_size=None,
    units=None,
    metadata=None,
)

Load a volume together with voxel spacing, units, and metadata.

load_volume intentionally returns only the image array. Use this helper whenever physical voxel resolution matters, especially for external TIFF, NumPy, or raw files that may not have a reliable sidecar.

Source code in src/voids/io/volume.py
def load_volume_data(
    path: str | Path,
    *,
    file_format: str | None = None,
    shape: Sequence[int] | None = None,
    dtype: str | np.dtype[Any] | None = None,
    hdf5_dataset: str = "volume",
    netcdf_variable: str = "volume",
    voxel_size: float | Sequence[float] | None = None,
    units: dict[str, str] | None = None,
    metadata: dict[str, Any] | None = None,
) -> VolumeData:
    """Load a volume together with voxel spacing, units, and metadata.

    ``load_volume`` intentionally returns only the image array. Use this helper
    whenever physical voxel resolution matters, especially for external TIFF,
    NumPy, or raw files that may not have a reliable sidecar.
    """

    source = Path(path)
    fmt = _normalize_format(source, file_format)
    values = load_volume(
        source,
        file_format=fmt,
        shape=shape,
        dtype=dtype,
        hdf5_dataset=hdf5_dataset,
        netcdf_variable=netcdf_variable,
    )
    stored_metadata = _load_volume_metadata(
        source,
        fmt,
        hdf5_dataset=hdf5_dataset,
        netcdf_variable=netcdf_variable,
    )
    values = _restore_metadata_dtype(
        values,
        stored_metadata,
        dtype_was_explicit=dtype is not None,
    )
    resolved_metadata = dict(stored_metadata.get("metadata", {}))
    if metadata:
        resolved_metadata.update(metadata)
    resolved_units = dict(
        units if units is not None else stored_metadata.get("units", {"length": "voxel"})
    )
    resolved_voxel_size = (
        voxel_size if voxel_size is not None else stored_metadata.get("voxel_size", 1.0)
    )
    return VolumeData(
        values=values,
        voxel_size=resolved_voxel_size,
        units=resolved_units,
        metadata=resolved_metadata,
    )

surface_mesh_from_binary_volume

surface_mesh_from_binary_volume(
    volume, *, voxel_size=1.0, level=0.5
)

Extract a triangular surface mesh from a 3D binary void image.

The surface is the interface between True void voxels and False solid voxels, computed with marching cubes. Coordinates are scaled by voxel_size.

Source code in src/voids/io/volume.py
def surface_mesh_from_binary_volume(
    volume: np.ndarray,
    *,
    voxel_size: float | Sequence[float] = 1.0,
    level: float = 0.5,
) -> SurfaceMesh:
    """Extract a triangular surface mesh from a 3D binary void image.

    The surface is the interface between ``True`` void voxels and ``False`` solid
    voxels, computed with marching cubes. Coordinates are scaled by
    ``voxel_size``.
    """

    mask = _binary_volume_mask(volume)
    if not (np.any(mask) and np.any(~mask)):
        raise ValueError("volume must contain both void and solid voxels for surface extraction")
    spacing = _normalize_voxel_size(voxel_size)
    vertices, faces, _normals, _values = measure.marching_cubes(
        mask.astype(float),
        level=float(level),
        spacing=spacing,
    )
    return SurfaceMesh(
        vertices=np.asarray(vertices, dtype=float),
        faces=np.asarray(faces, dtype=np.int64),
        metadata={
            "schema_version": _SURFACE_SCHEMA_VERSION,
            "source_kind": "binary_volume_marching_cubes",
            "voxel_size": spacing,
        },
    )

save_surface_mesh

save_surface_mesh(
    mesh, path, *, file_format=None, voxel_size=1.0
)

Save a triangular surface mesh as ASCII STL or OBJ.

mesh can be a :class:SurfaceMesh or a 3D binary volume, in which case marching cubes is applied first.

Source code in src/voids/io/volume.py
def save_surface_mesh(
    mesh: SurfaceMesh | np.ndarray,
    path: str | Path,
    *,
    file_format: str | None = None,
    voxel_size: float | Sequence[float] = 1.0,
) -> Path:
    """Save a triangular surface mesh as ASCII STL or OBJ.

    ``mesh`` can be a :class:`SurfaceMesh` or a 3D binary volume, in which case
    marching cubes is applied first.
    """

    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    fmt = _normalize_format(out, file_format)
    if not isinstance(mesh, SurfaceMesh):
        mesh = surface_mesh_from_binary_volume(mesh, voxel_size=voxel_size)

    if fmt == "obj":
        _write_obj(mesh, out)
        return out
    if fmt == "stl":
        _write_ascii_stl(mesh, out)
        return out
    raise ValueError("surface meshes can only be saved as STL or OBJ")

load_surface_mesh

load_surface_mesh(path, *, file_format=None)

Load an STL or OBJ triangular surface mesh.

Source code in src/voids/io/volume.py
def load_surface_mesh(path: str | Path, *, file_format: str | None = None) -> SurfaceMesh:
    """Load an STL or OBJ triangular surface mesh."""

    source = Path(path)
    fmt = _normalize_format(source, file_format)
    if fmt == "obj":
        return _read_obj(source)
    if fmt == "stl":
        return _read_stl(source)
    raise ValueError("surface meshes can only be loaded from STL or OBJ")

save_volume_bundle

save_volume_bundle(
    volume,
    directory,
    *,
    stem="synthetic_case",
    formats=("raw", "npy", "h5", "stl", "obj"),
    metadata=None,
    voxel_size=None,
    units=None,
)

Export one synthetic case to several interchange formats.

Parameters:

Name Type Description Default
volume VolumeData | ndarray

2D or 3D image. STL/OBJ formats require a 3D binary volume.

required
directory str | Path

Destination directory.

required
stem str

Base filename without suffix.

'synthetic_case'
formats Sequence[str]

Iterable of format labels such as ("raw", "npy", "h5", "stl", "obj").

('raw', 'npy', 'h5', 'stl', 'obj')
metadata dict[str, Any] | None

Forwarded to :func:save_volume.

None
voxel_size dict[str, Any] | None

Forwarded to :func:save_volume.

None
units dict[str, Any] | None

Forwarded to :func:save_volume.

None
Source code in src/voids/io/volume.py
def save_volume_bundle(
    volume: VolumeData | np.ndarray,
    directory: str | Path,
    *,
    stem: str = "synthetic_case",
    formats: Sequence[str] = ("raw", "npy", "h5", "stl", "obj"),
    metadata: dict[str, Any] | None = None,
    voxel_size: float | Sequence[float] | None = None,
    units: dict[str, str] | None = None,
) -> dict[str, Path]:
    """Export one synthetic case to several interchange formats.

    Parameters
    ----------
    volume :
        2D or 3D image. STL/OBJ formats require a 3D binary volume.
    directory :
        Destination directory.
    stem :
        Base filename without suffix.
    formats :
        Iterable of format labels such as ``("raw", "npy", "h5", "stl", "obj")``.
    metadata, voxel_size, units :
        Forwarded to :func:`save_volume`.
    """

    out_dir = Path(directory)
    out_dir.mkdir(parents=True, exist_ok=True)
    written: dict[str, Path] = {}
    for requested in formats:
        fmt = _normalize_format(f"{stem}.{requested}", requested)
        suffix = _FORMAT_EXTENSIONS[fmt]
        path = out_dir / f"{stem}{suffix}"
        written[fmt] = save_volume(
            volume,
            path,
            file_format=fmt,
            metadata=metadata,
            voxel_size=voxel_size,
            units=units,
        )
    return written