Skip to content

Core Data Structures

The voids.core sub-package defines the three primary data containers that every workflow depends on.


Network

voids.core.network.Network dataclass

Store pore-network topology, geometry, labels, and metadata.

Parameters:

Name Type Description Default
throat_conns ndarray

Integer array with shape (Nt, 2). Each row stores the two pore indices connected by one throat.

required
pore_coords ndarray

Floating-point array with shape (Np, 3) containing pore centroid coordinates in physical units.

required
sample SampleGeometry

Sample-scale geometry used by porosity and permeability calculations.

required
provenance Provenance

Metadata describing how the network was created or imported.

Provenance()
schema_version str

Version tag used by the serialized network schema.

'0.1.0'
pore dict[str, ndarray]

Dictionaries mapping field names to pore-wise and throat-wise arrays.

dict()
throat dict[str, ndarray]

Dictionaries mapping field names to pore-wise and throat-wise arrays.

dict()
pore_labels dict[str, ndarray]

Dictionaries of boolean masks selecting pore and throat subsets.

dict()
throat_labels dict[str, ndarray]

Dictionaries of boolean masks selecting pore and throat subsets.

dict()
extra dict[str, Any]

Additional metadata not yet promoted to the formal schema.

dict()
Notes

The class represents a pore network as a graph

G = (V, E)

where pores are vertices V and throats are edges E. The array throat_conns is the primary topological object used to construct adjacency matrices, incidence matrices, and transport operators.

Source code in src/voids/core/network.py
@dataclass(slots=True)
class Network:
    """Store pore-network topology, geometry, labels, and metadata.

    Parameters
    ----------
    throat_conns :
        Integer array with shape ``(Nt, 2)``. Each row stores the two pore
        indices connected by one throat.
    pore_coords :
        Floating-point array with shape ``(Np, 3)`` containing pore centroid
        coordinates in physical units.
    sample :
        Sample-scale geometry used by porosity and permeability calculations.
    provenance :
        Metadata describing how the network was created or imported.
    schema_version :
        Version tag used by the serialized network schema.
    pore, throat :
        Dictionaries mapping field names to pore-wise and throat-wise arrays.
    pore_labels, throat_labels :
        Dictionaries of boolean masks selecting pore and throat subsets.
    extra :
        Additional metadata not yet promoted to the formal schema.

    Notes
    -----
    The class represents a pore network as a graph

    ``G = (V, E)``

    where pores are vertices ``V`` and throats are edges ``E``. The array
    ``throat_conns`` is the primary topological object used to construct
    adjacency matrices, incidence matrices, and transport operators.
    """

    throat_conns: np.ndarray
    pore_coords: np.ndarray
    sample: SampleGeometry
    provenance: Provenance = field(default_factory=Provenance)
    schema_version: str = "0.1.0"
    pore: dict[str, np.ndarray] = field(default_factory=dict)
    throat: dict[str, np.ndarray] = field(default_factory=dict)
    pore_labels: dict[str, np.ndarray] = field(default_factory=dict)
    throat_labels: dict[str, np.ndarray] = field(default_factory=dict)
    extra: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Normalize arrays immediately after initialization.

        Notes
        -----
        Topology is converted to ``int64``, coordinates to floating point, pore
        and throat fields to NumPy arrays, and label dictionaries to boolean
        arrays. The method performs coercion only; semantic validation is left to
        :func:`voids.core.validation.validate_network`.
        """

        self.throat_conns = np.asarray(self.throat_conns, dtype=np.int64)
        self.pore_coords = np.asarray(self.pore_coords, dtype=float)
        for d in (self.pore, self.throat):
            for k, v in list(d.items()):
                d[k] = np.asarray(v)
        for d in (self.pore_labels, self.throat_labels):
            for k, v in list(d.items()):
                d[k] = np.asarray(v, dtype=bool)

    @property
    def Np(self) -> int:
        """Return the number of pores.

        Returns
        -------
        int
            Number of rows in :attr:`pore_coords`.
        """

        return int(self.pore_coords.shape[0])

    @property
    def Nt(self) -> int:
        """Return the number of throats.

        Returns
        -------
        int
            Number of rows in :attr:`throat_conns`.
        """

        return int(self.throat_conns.shape[0])

    def get_pore_array(self, name: str) -> np.ndarray:
        """Return a pore field by name.

        Parameters
        ----------
        name :
            Key in :attr:`pore`.

        Returns
        -------
        numpy.ndarray
            Requested pore-wise array.

        Raises
        ------
        KeyError
            If the field is not present.
        """

        if name not in self.pore:
            raise KeyError(f"Missing pore field '{name}'")
        return self.pore[name]

    def get_throat_array(self, name: str) -> np.ndarray:
        """Return a throat field by name.

        Parameters
        ----------
        name :
            Key in :attr:`throat`.

        Returns
        -------
        numpy.ndarray
            Requested throat-wise array.

        Raises
        ------
        KeyError
            If the field is not present.
        """

        if name not in self.throat:
            raise KeyError(f"Missing throat field '{name}'")
        return self.throat[name]

    def copy(self) -> "Network":
        """Return a deep-array copy of the network.

        Returns
        -------
        Network
            New network instance whose topology, coordinates, field arrays, and
            labels are copied.

        Notes
        -----
        Array-valued data are copied to avoid in-place aliasing. Metadata
        containers such as :class:`SampleGeometry` and :class:`Provenance` are
        reused because they are treated as immutable records in current usage.
        """

        return Network(
            throat_conns=self.throat_conns.copy(),
            pore_coords=self.pore_coords.copy(),
            sample=self.sample,
            provenance=self.provenance,
            schema_version=self.schema_version,
            pore={k: v.copy() for k, v in self.pore.items()},
            throat={k: v.copy() for k, v in self.throat.items()},
            pore_labels={k: v.copy() for k, v in self.pore_labels.items()},
            throat_labels={k: v.copy() for k, v in self.throat_labels.items()},
            extra={**self.extra},
        )

Np property

Np

Return the number of pores.

Returns:

Type Description
int

Number of rows in :attr:pore_coords.

Nt property

Nt

Return the number of throats.

Returns:

Type Description
int

Number of rows in :attr:throat_conns.

get_pore_array

get_pore_array(name)

Return a pore field by name.

Parameters:

Name Type Description Default
name str

Key in :attr:pore.

required

Returns:

Type Description
ndarray

Requested pore-wise array.

Raises:

Type Description
KeyError

If the field is not present.

Source code in src/voids/core/network.py
def get_pore_array(self, name: str) -> np.ndarray:
    """Return a pore field by name.

    Parameters
    ----------
    name :
        Key in :attr:`pore`.

    Returns
    -------
    numpy.ndarray
        Requested pore-wise array.

    Raises
    ------
    KeyError
        If the field is not present.
    """

    if name not in self.pore:
        raise KeyError(f"Missing pore field '{name}'")
    return self.pore[name]

get_throat_array

get_throat_array(name)

Return a throat field by name.

Parameters:

Name Type Description Default
name str

Key in :attr:throat.

required

Returns:

Type Description
ndarray

Requested throat-wise array.

Raises:

Type Description
KeyError

If the field is not present.

Source code in src/voids/core/network.py
def get_throat_array(self, name: str) -> np.ndarray:
    """Return a throat field by name.

    Parameters
    ----------
    name :
        Key in :attr:`throat`.

    Returns
    -------
    numpy.ndarray
        Requested throat-wise array.

    Raises
    ------
    KeyError
        If the field is not present.
    """

    if name not in self.throat:
        raise KeyError(f"Missing throat field '{name}'")
    return self.throat[name]

copy

copy()

Return a deep-array copy of the network.

Returns:

Type Description
Network

New network instance whose topology, coordinates, field arrays, and labels are copied.

Notes

Array-valued data are copied to avoid in-place aliasing. Metadata containers such as :class:SampleGeometry and :class:Provenance are reused because they are treated as immutable records in current usage.

Source code in src/voids/core/network.py
def copy(self) -> "Network":
    """Return a deep-array copy of the network.

    Returns
    -------
    Network
        New network instance whose topology, coordinates, field arrays, and
        labels are copied.

    Notes
    -----
    Array-valued data are copied to avoid in-place aliasing. Metadata
    containers such as :class:`SampleGeometry` and :class:`Provenance` are
    reused because they are treated as immutable records in current usage.
    """

    return Network(
        throat_conns=self.throat_conns.copy(),
        pore_coords=self.pore_coords.copy(),
        sample=self.sample,
        provenance=self.provenance,
        schema_version=self.schema_version,
        pore={k: v.copy() for k, v in self.pore.items()},
        throat={k: v.copy() for k, v in self.throat.items()},
        pore_labels={k: v.copy() for k, v in self.pore_labels.items()},
        throat_labels={k: v.copy() for k, v in self.throat_labels.items()},
        extra={**self.extra},
    )

SampleGeometry

voids.core.sample.SampleGeometry dataclass

Store sample-scale geometry needed for bulk property calculations.

Attributes:

Name Type Description
voxel_size float | tuple[float, float, float] | None

Scalar or anisotropic voxel spacing in physical units.

bulk_shape_voxels tuple[int, int, int] | None

Image-domain shape used to derive bulk volume when a direct value is not available.

bulk_volume float | None

Total bulk volume in physical units.

lengths dict[str, float]

Representative sample lengths by axis.

cross_sections dict[str, float]

Cross-sectional areas normal to each flow axis.

axis_map dict[str, str]

Optional mapping from custom axis names to canonical identifiers.

units dict[str, str]

Unit metadata used for reporting and serialization.

Source code in src/voids/core/sample.py
@dataclass(slots=True)
class SampleGeometry:
    """Store sample-scale geometry needed for bulk property calculations.

    Attributes
    ----------
    voxel_size :
        Scalar or anisotropic voxel spacing in physical units.
    bulk_shape_voxels :
        Image-domain shape used to derive bulk volume when a direct value is not
        available.
    bulk_volume :
        Total bulk volume in physical units.
    lengths :
        Representative sample lengths by axis.
    cross_sections :
        Cross-sectional areas normal to each flow axis.
    axis_map :
        Optional mapping from custom axis names to canonical identifiers.
    units :
        Unit metadata used for reporting and serialization.
    """

    voxel_size: float | tuple[float, float, float] | None = None
    bulk_shape_voxels: tuple[int, int, int] | None = None
    bulk_volume: float | None = None
    lengths: dict[str, float] = field(default_factory=dict)
    cross_sections: dict[str, float] = field(default_factory=dict)
    axis_map: dict[str, str] = field(default_factory=dict)
    units: dict[str, str] = field(default_factory=lambda: {"length": "m", "pressure": "Pa"})

    def resolved_bulk_volume(self) -> float:
        """Return the bulk volume, deriving it from voxel metadata when needed.

        Returns
        -------
        float
            Bulk volume of the sample.

        Raises
        ------
        ValueError
            If ``bulk_volume`` is unavailable and the voxel-based metadata is
            incomplete.

        Notes
        -----
        When ``bulk_volume`` is not explicitly stored, the method computes

        ``V_bulk = nx * ny * nz * vx * vy * vz``

        using either an isotropic scalar voxel size or an anisotropic voxel-size
        tuple ``(vx, vy, vz)``.
        """

        if self.bulk_volume is not None:
            return float(self.bulk_volume)
        if self.bulk_shape_voxels is None or self.voxel_size is None:
            raise ValueError("bulk_volume is unavailable and cannot be derived")
        if isinstance(self.voxel_size, tuple):
            vx, vy, vz = self.voxel_size
        else:
            vx = vy = vz = float(self.voxel_size)
        nx, ny, nz = self.bulk_shape_voxels
        return float(nx * ny * nz * vx * vy * vz)

    def length_for_axis(self, axis: str) -> float:
        """Return the representative sample length for one axis.

        Parameters
        ----------
        axis :
            Axis key such as ``"x"``, ``"y"``, or ``"z"``.

        Returns
        -------
        float
            Length associated with the requested axis.

        Raises
        ------
        KeyError
            If no length is registered for the requested axis.
        """

        if axis not in self.lengths:
            raise KeyError(f"Missing sample length for axis '{axis}'")
        return float(self.lengths[axis])

    def area_for_axis(self, axis: str) -> float:
        """Return the sample cross-section normal to one axis.

        Parameters
        ----------
        axis :
            Axis key such as ``"x"``, ``"y"``, or ``"z"``.

        Returns
        -------
        float
            Cross-sectional area used in Darcy-type calculations.

        Raises
        ------
        KeyError
            If no cross-section is registered for the requested axis.
        """

        if axis not in self.cross_sections:
            raise KeyError(f"Missing sample cross-section for axis '{axis}'")
        return float(self.cross_sections[axis])

    def to_metadata(self) -> dict[str, Any]:
        """Serialize the sample geometry to a JSON-friendly dictionary.

        Returns
        -------
        dict[str, Any]
            Mapping suitable for HDF5 or JSON serialization.
        """

        return {
            "voxel_size": self.voxel_size,
            "bulk_shape_voxels": self.bulk_shape_voxels,
            "bulk_volume": self.bulk_volume,
            "lengths": self.lengths,
            "cross_sections": self.cross_sections,
            "axis_map": self.axis_map,
            "units": self.units,
        }

    @classmethod
    def from_metadata(cls, data: dict[str, Any]) -> "SampleGeometry":
        """Reconstruct sample geometry from serialized metadata.

        Parameters
        ----------
        data :
            Metadata dictionary previously produced by :meth:`to_metadata`.

        Returns
        -------
        SampleGeometry
            Reconstructed sample-geometry record.
        """

        return cls(
            voxel_size=data.get("voxel_size"),
            bulk_shape_voxels=tuple(data["bulk_shape_voxels"])
            if data.get("bulk_shape_voxels") is not None
            else None,
            bulk_volume=data.get("bulk_volume"),
            lengths={str(k): float(v) for k, v in (data.get("lengths") or {}).items()},
            cross_sections={
                str(k): float(v) for k, v in (data.get("cross_sections") or {}).items()
            },
            axis_map={str(k): str(v) for k, v in (data.get("axis_map") or {}).items()},
            units={str(k): str(v) for k, v in (data.get("units") or {}).items()},
        )

resolved_bulk_volume

resolved_bulk_volume()

Return the bulk volume, deriving it from voxel metadata when needed.

Returns:

Type Description
float

Bulk volume of the sample.

Raises:

Type Description
ValueError

If bulk_volume is unavailable and the voxel-based metadata is incomplete.

Notes

When bulk_volume is not explicitly stored, the method computes

V_bulk = nx * ny * nz * vx * vy * vz

using either an isotropic scalar voxel size or an anisotropic voxel-size tuple (vx, vy, vz).

Source code in src/voids/core/sample.py
def resolved_bulk_volume(self) -> float:
    """Return the bulk volume, deriving it from voxel metadata when needed.

    Returns
    -------
    float
        Bulk volume of the sample.

    Raises
    ------
    ValueError
        If ``bulk_volume`` is unavailable and the voxel-based metadata is
        incomplete.

    Notes
    -----
    When ``bulk_volume`` is not explicitly stored, the method computes

    ``V_bulk = nx * ny * nz * vx * vy * vz``

    using either an isotropic scalar voxel size or an anisotropic voxel-size
    tuple ``(vx, vy, vz)``.
    """

    if self.bulk_volume is not None:
        return float(self.bulk_volume)
    if self.bulk_shape_voxels is None or self.voxel_size is None:
        raise ValueError("bulk_volume is unavailable and cannot be derived")
    if isinstance(self.voxel_size, tuple):
        vx, vy, vz = self.voxel_size
    else:
        vx = vy = vz = float(self.voxel_size)
    nx, ny, nz = self.bulk_shape_voxels
    return float(nx * ny * nz * vx * vy * vz)

length_for_axis

length_for_axis(axis)

Return the representative sample length for one axis.

Parameters:

Name Type Description Default
axis str

Axis key such as "x", "y", or "z".

required

Returns:

Type Description
float

Length associated with the requested axis.

Raises:

Type Description
KeyError

If no length is registered for the requested axis.

Source code in src/voids/core/sample.py
def length_for_axis(self, axis: str) -> float:
    """Return the representative sample length for one axis.

    Parameters
    ----------
    axis :
        Axis key such as ``"x"``, ``"y"``, or ``"z"``.

    Returns
    -------
    float
        Length associated with the requested axis.

    Raises
    ------
    KeyError
        If no length is registered for the requested axis.
    """

    if axis not in self.lengths:
        raise KeyError(f"Missing sample length for axis '{axis}'")
    return float(self.lengths[axis])

area_for_axis

area_for_axis(axis)

Return the sample cross-section normal to one axis.

Parameters:

Name Type Description Default
axis str

Axis key such as "x", "y", or "z".

required

Returns:

Type Description
float

Cross-sectional area used in Darcy-type calculations.

Raises:

Type Description
KeyError

If no cross-section is registered for the requested axis.

Source code in src/voids/core/sample.py
def area_for_axis(self, axis: str) -> float:
    """Return the sample cross-section normal to one axis.

    Parameters
    ----------
    axis :
        Axis key such as ``"x"``, ``"y"``, or ``"z"``.

    Returns
    -------
    float
        Cross-sectional area used in Darcy-type calculations.

    Raises
    ------
    KeyError
        If no cross-section is registered for the requested axis.
    """

    if axis not in self.cross_sections:
        raise KeyError(f"Missing sample cross-section for axis '{axis}'")
    return float(self.cross_sections[axis])

to_metadata

to_metadata()

Serialize the sample geometry to a JSON-friendly dictionary.

Returns:

Type Description
dict[str, Any]

Mapping suitable for HDF5 or JSON serialization.

Source code in src/voids/core/sample.py
def to_metadata(self) -> dict[str, Any]:
    """Serialize the sample geometry to a JSON-friendly dictionary.

    Returns
    -------
    dict[str, Any]
        Mapping suitable for HDF5 or JSON serialization.
    """

    return {
        "voxel_size": self.voxel_size,
        "bulk_shape_voxels": self.bulk_shape_voxels,
        "bulk_volume": self.bulk_volume,
        "lengths": self.lengths,
        "cross_sections": self.cross_sections,
        "axis_map": self.axis_map,
        "units": self.units,
    }

from_metadata classmethod

from_metadata(data)

Reconstruct sample geometry from serialized metadata.

Parameters:

Name Type Description Default
data dict[str, Any]

Metadata dictionary previously produced by :meth:to_metadata.

required

Returns:

Type Description
SampleGeometry

Reconstructed sample-geometry record.

Source code in src/voids/core/sample.py
@classmethod
def from_metadata(cls, data: dict[str, Any]) -> "SampleGeometry":
    """Reconstruct sample geometry from serialized metadata.

    Parameters
    ----------
    data :
        Metadata dictionary previously produced by :meth:`to_metadata`.

    Returns
    -------
    SampleGeometry
        Reconstructed sample-geometry record.
    """

    return cls(
        voxel_size=data.get("voxel_size"),
        bulk_shape_voxels=tuple(data["bulk_shape_voxels"])
        if data.get("bulk_shape_voxels") is not None
        else None,
        bulk_volume=data.get("bulk_volume"),
        lengths={str(k): float(v) for k, v in (data.get("lengths") or {}).items()},
        cross_sections={
            str(k): float(v) for k, v in (data.get("cross_sections") or {}).items()
        },
        axis_map={str(k): str(v) for k, v in (data.get("axis_map") or {}).items()},
        units={str(k): str(v) for k, v in (data.get("units") or {}).items()},
    )

Provenance

voids.core.provenance.Provenance dataclass

Store metadata describing the origin of a network.

Attributes:

Name Type Description
source_kind str

Broad category of origin, such as "porespy" or "synthetic_mesh".

source_version str | None

Version string of the generating package or workflow, when known.

extraction_method str | None

Short description of the extraction or construction procedure.

segmentation_notes str | None

Free-form notes about segmentation or preprocessing assumptions.

voxel_size_original float | tuple[float, float, float] | None

Original voxel spacing before any physical-unit conversion.

image_hash, preprocessing_hash

Optional hashes identifying input images or preprocessing recipes.

random_seed int | None

Seed used by any stochastic preprocessing or synthetic generator.

created_at str

UTC timestamp encoded as an ISO 8601 string.

user_notes dict[str, Any]

Additional JSON-serializable metadata.

Source code in src/voids/core/provenance.py
@dataclass(slots=True)
class Provenance:
    """Store metadata describing the origin of a network.

    Attributes
    ----------
    source_kind :
        Broad category of origin, such as ``"porespy"`` or
        ``"synthetic_mesh"``.
    source_version :
        Version string of the generating package or workflow, when known.
    extraction_method :
        Short description of the extraction or construction procedure.
    segmentation_notes :
        Free-form notes about segmentation or preprocessing assumptions.
    voxel_size_original :
        Original voxel spacing before any physical-unit conversion.
    image_hash, preprocessing_hash :
        Optional hashes identifying input images or preprocessing recipes.
    random_seed :
        Seed used by any stochastic preprocessing or synthetic generator.
    created_at :
        UTC timestamp encoded as an ISO 8601 string.
    user_notes :
        Additional JSON-serializable metadata.
    """

    source_kind: str = "custom"
    source_version: str | None = None
    extraction_method: str | None = None
    segmentation_notes: str | None = None
    voxel_size_original: float | tuple[float, float, float] | None = None
    image_hash: str | None = None
    preprocessing_hash: str | None = None
    random_seed: int | None = None
    created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
    user_notes: dict[str, Any] = field(default_factory=dict)

    def to_metadata(self) -> dict[str, Any]:
        """Serialize the provenance record to a JSON-friendly mapping.

        Returns
        -------
        dict[str, Any]
            Dictionary suitable for storage in HDF5 attributes or JSON payloads.
        """

        return {
            "source_kind": self.source_kind,
            "source_version": self.source_version,
            "extraction_method": self.extraction_method,
            "segmentation_notes": self.segmentation_notes,
            "voxel_size_original": self.voxel_size_original,
            "image_hash": self.image_hash,
            "preprocessing_hash": self.preprocessing_hash,
            "random_seed": self.random_seed,
            "created_at": self.created_at,
            "user_notes": self.user_notes,
        }

    @classmethod
    def from_metadata(cls, data: dict[str, Any]) -> "Provenance":
        """Construct a provenance record from serialized metadata.

        Parameters
        ----------
        data :
            Metadata dictionary previously produced by :meth:`to_metadata`.

        Returns
        -------
        Provenance
            Reconstructed provenance record.
        """

        return cls(**data)

to_metadata

to_metadata()

Serialize the provenance record to a JSON-friendly mapping.

Returns:

Type Description
dict[str, Any]

Dictionary suitable for storage in HDF5 attributes or JSON payloads.

Source code in src/voids/core/provenance.py
def to_metadata(self) -> dict[str, Any]:
    """Serialize the provenance record to a JSON-friendly mapping.

    Returns
    -------
    dict[str, Any]
        Dictionary suitable for storage in HDF5 attributes or JSON payloads.
    """

    return {
        "source_kind": self.source_kind,
        "source_version": self.source_version,
        "extraction_method": self.extraction_method,
        "segmentation_notes": self.segmentation_notes,
        "voxel_size_original": self.voxel_size_original,
        "image_hash": self.image_hash,
        "preprocessing_hash": self.preprocessing_hash,
        "random_seed": self.random_seed,
        "created_at": self.created_at,
        "user_notes": self.user_notes,
    }

from_metadata classmethod

from_metadata(data)

Construct a provenance record from serialized metadata.

Parameters:

Name Type Description Default
data dict[str, Any]

Metadata dictionary previously produced by :meth:to_metadata.

required

Returns:

Type Description
Provenance

Reconstructed provenance record.

Source code in src/voids/core/provenance.py
@classmethod
def from_metadata(cls, data: dict[str, Any]) -> "Provenance":
    """Construct a provenance record from serialized metadata.

    Parameters
    ----------
    data :
        Metadata dictionary previously produced by :meth:`to_metadata`.

    Returns
    -------
    Provenance
        Reconstructed provenance record.
    """

    return cls(**data)

Validation

assert_finite

assert_finite(name, arr)

Validate that an array contains only finite values.

Parameters:

Name Type Description Default
name str

Descriptive name of the array, used in the error message.

required
arr ndarray

Array to validate.

required

Raises:

Type Description
ValueError

If the array contains NaN or infinite values.

Source code in src/voids/core/validation.py
def assert_finite(name: str, arr: np.ndarray) -> None:
    """Validate that an array contains only finite values.

    Parameters
    ----------
    name :
        Descriptive name of the array, used in the error message.
    arr :
        Array to validate.

    Raises
    ------
    ValueError
        If the array contains ``NaN`` or infinite values.
    """

    if not np.all(np.isfinite(arr)):
        raise ValueError(f"{name} contains non-finite values")

validate_network

validate_network(net, *, allow_parallel_throats=True)

Validate network topology, field shapes, and basic geometric sanity.

Parameters:

Name Type Description Default
net Network

Network to validate.

required
allow_parallel_throats bool

If False, repeated pore pairs are treated as an error. If True, repeated pairs are accepted but reported with a warning.

True

Raises:

Type Description
ValueError

If topology, field shapes, or sign conventions are invalid.

Warns:

Type Description
RuntimeWarning

If parallel throats are detected while allowed, or if recommended pore and throat fields are missing.

Notes

The checks enforce structural constraints such as

throat_conns.shape == (Nt, 2)

and

pore_coords.shape == (Np, 3)

together with sign and dimensionality checks for commonly used geometric quantities such as volume, area, and conduit lengths.

Source code in src/voids/core/validation.py
def validate_network(net: Network, *, allow_parallel_throats: bool = True) -> None:
    """Validate network topology, field shapes, and basic geometric sanity.

    Parameters
    ----------
    net :
        Network to validate.
    allow_parallel_throats :
        If ``False``, repeated pore pairs are treated as an error. If ``True``,
        repeated pairs are accepted but reported with a warning.

    Raises
    ------
    ValueError
        If topology, field shapes, or sign conventions are invalid.

    Warns
    -----
    RuntimeWarning
        If parallel throats are detected while allowed, or if recommended pore
        and throat fields are missing.

    Notes
    -----
    The checks enforce structural constraints such as

    ``throat_conns.shape == (Nt, 2)``

    and

    ``pore_coords.shape == (Np, 3)``

    together with sign and dimensionality checks for commonly used geometric
    quantities such as volume, area, and conduit lengths.
    """

    tc = net.throat_conns
    if tc.ndim != 2 or tc.shape[1] != 2:
        raise ValueError("throat_conns must have shape (Nt, 2)")
    if net.pore_coords.ndim != 2 or net.pore_coords.shape[1] != 3:
        raise ValueError("pore_coords must have shape (Np, 3)")
    if np.isnan(net.pore_coords).any():
        raise ValueError("pore_coords contains NaNs")
    if (tc < 0).any() or (tc >= net.Np).any():
        raise ValueError("throat_conns contains out-of-range pore indices")
    if (tc[:, 0] == tc[:, 1]).any():
        raise ValueError("self-loop throats are not allowed in v0.1")

    if not allow_parallel_throats:
        edges = np.sort(tc, axis=1)
        uniq = np.unique(edges, axis=0)
        if uniq.shape[0] != tc.shape[0]:
            raise ValueError("parallel throats found")
    else:
        edges = np.sort(tc, axis=1)
        uniq = np.unique(edges, axis=0)
        if uniq.shape[0] != tc.shape[0]:
            warnings.warn("parallel throats detected", RuntimeWarning, stacklevel=2)

    for k, arr in net.pore.items():
        if arr.shape[0] != net.Np:
            raise ValueError(f"Pore field '{k}' has wrong first dimension")
        if np.issubdtype(arr.dtype, np.number):
            if np.isnan(arr).any():
                raise ValueError(f"Pore field '{k}' contains NaNs")
            if (
                k in {"volume", "area", "diameter_inscribed", "radius_inscribed", "length"}
                and (arr < 0).any()
            ):
                raise ValueError(f"Pore field '{k}' contains negative values")
    for k, arr in net.throat.items():
        if arr.shape[0] != net.Nt:
            raise ValueError(f"Throat field '{k}' has wrong first dimension")
        if np.issubdtype(arr.dtype, np.number):
            if np.isnan(arr).any():
                raise ValueError(f"Throat field '{k}' contains NaNs")
            if (
                k in {"volume", "area", "diameter_inscribed", "radius_inscribed"}
                and (arr < 0).any()
            ):
                raise ValueError(f"Throat field '{k}' contains negative values")
            if k in {"length", "core_length", "pore1_length", "pore2_length"} and (arr <= 0).any():
                raise ValueError(f"Throat field '{k}' contains nonpositive values")

    for label, mask in net.pore_labels.items():
        if mask.shape != (net.Np,):
            raise ValueError(f"Pore label '{label}' has wrong shape")
    for label, mask in net.throat_labels.items():
        if mask.shape != (net.Nt,):
            raise ValueError(f"Throat label '{label}' has wrong shape")

    if net.sample is not None:
        try:
            bv = net.sample.resolved_bulk_volume()
            if bv <= 0:
                raise ValueError("sample bulk volume must be positive")
        except ValueError:
            pass

    for name in RECOMMENDED_PORE_FIELDS:
        if name not in net.pore:
            warnings.warn(f"Recommended pore field missing: '{name}'", RuntimeWarning, stacklevel=2)
    for name in RECOMMENDED_THROAT_FIELDS:
        if name not in net.throat:
            warnings.warn(
                f"Recommended throat field missing: '{name}'", RuntimeWarning, stacklevel=2
            )