Skip to content

Benchmarks

The voids.benchmarks sub-package provides utilities for cross-checking voids results against reference implementations such as OpenPNM and XLB. In the broader project documentation, these utilities belong to the verification side of the Verification & Validation split: they benchmark voids against software references or alternative numerical workflows, not directly against experimental measurements.

The two high-level segmented-volume benchmark wrappers now share the same physical pressure convention:

  • the preferred public input is the physical pressure drop delta_p, typically in Pa
  • optional pin and pout values can also be supplied when the user wants to preserve a particular absolute pressure reference level
  • for the current incompressible permeability benchmark, only the pressure drop Δp = pin - pout affects the reported permeability
  • the applied p_inlet_physical, p_outlet_physical, and dp_physical values are recorded in the benchmark result tables

So delta_p=1.0, pin=1.0/pout=0.0, and delta_p=1.0 with pin=101326.0/pout=101325.0 all represent the same current benchmark driving condition.

The XLB benchmark API now has two distinct package layers:

  • voids.lbm.singlephase.xlb.solve_binary_volume_with_xlb is the low-level direct-image solver. It works in lattice units and accepts lattice pressure boundary conditions through pressure_inlet_lattice, pressure_outlet_lattice, or pressure_drop_lattice.
  • voids.benchmarks.xlb.benchmark_segmented_volume_with_xlb is the high-level verification wrapper. It resolves a physical pressure drop from delta_p and optional pin / pout, then maps that same physical Δp into lattice units before calling XLB on the original binary image.

For backward compatibility, voids.benchmarks.xlb re-exports the low-level XLB types and direct solver, but the implementation lives in voids.lbm.

For the high-level XLB benchmark, fluid.density must be provided because the shared physical pressure drop must be converted into lattice pressure units.


Cross-Check

voids.benchmarks.crosscheck

SinglePhaseCrosscheckSummary dataclass

Summary of a solver-to-reference comparison.

Attributes:

Name Type Description
reference str

Name of the reference implementation or workflow.

axis str

Flow axis used in the comparison.

permeability_abs_diff, permeability_rel_diff

Absolute and relative differences between apparent permeabilities.

total_flow_abs_diff, total_flow_rel_diff

Absolute and relative differences between total flow rates.

details dict[str, Any]

Auxiliary metadata useful for debugging and reporting.

Source code in src/voids/benchmarks/crosscheck.py
@dataclass(slots=True)
class SinglePhaseCrosscheckSummary:
    """Summary of a solver-to-reference comparison.

    Attributes
    ----------
    reference :
        Name of the reference implementation or workflow.
    axis :
        Flow axis used in the comparison.
    permeability_abs_diff, permeability_rel_diff :
        Absolute and relative differences between apparent permeabilities.
    total_flow_abs_diff, total_flow_rel_diff :
        Absolute and relative differences between total flow rates.
    details :
        Auxiliary metadata useful for debugging and reporting.
    """

    reference: str
    axis: str
    permeability_abs_diff: float
    permeability_rel_diff: float
    total_flow_abs_diff: float
    total_flow_rel_diff: float
    details: dict[str, Any]

ConduitConductanceAudit dataclass

Per-throat single-phase conduit conductance breakdown.

The arrays are defined on throat order and expose the exact pore1-core-pore2 decomposition used by the voids Valvatne-Blunt conduit model. The three segment conductances match the Imperial pnflow SPConductance(area, mu) semantics, meaning lengths are accounted for separately in the equivalent pore-to-pore resistance sum.

Source code in src/voids/benchmarks/crosscheck.py
@dataclass(slots=True)
class ConduitConductanceAudit:
    """Per-throat single-phase conduit conductance breakdown.

    The arrays are defined on throat order and expose the exact pore1-core-pore2
    decomposition used by the `voids` Valvatne-Blunt conduit model. The three
    segment conductances match the Imperial `pnflow` `SPConductance(area, mu)`
    semantics, meaning lengths are accounted for separately in the equivalent
    pore-to-pore resistance sum.
    """

    model: str
    throat_index: np.ndarray
    pore1_index: np.ndarray
    pore2_index: np.ndarray
    pore1_is_boundary: np.ndarray
    pore2_is_boundary: np.ndarray
    pore1_shape_factor: np.ndarray
    throat_shape_factor: np.ndarray
    pore2_shape_factor: np.ndarray
    pore1_area: np.ndarray
    throat_area: np.ndarray
    pore2_area: np.ndarray
    pore1_radius: np.ndarray
    throat_radius: np.ndarray
    pore2_radius: np.ndarray
    pore1_length: np.ndarray
    throat_length: np.ndarray
    pore2_length: np.ndarray
    pore1_conductance: np.ndarray
    throat_conductance: np.ndarray
    pore2_conductance: np.ndarray
    equivalent_conductance: np.ndarray

    def to_columns(self) -> dict[str, np.ndarray | str]:
        """Return a tabulation-friendly column mapping."""

        return {
            "model": self.model,
            "throat_index": self.throat_index,
            "pore1_index": self.pore1_index,
            "pore2_index": self.pore2_index,
            "pore1_is_boundary": self.pore1_is_boundary,
            "pore2_is_boundary": self.pore2_is_boundary,
            "pore1_shape_factor": self.pore1_shape_factor,
            "throat_shape_factor": self.throat_shape_factor,
            "pore2_shape_factor": self.pore2_shape_factor,
            "pore1_area": self.pore1_area,
            "throat_area": self.throat_area,
            "pore2_area": self.pore2_area,
            "pore1_radius": self.pore1_radius,
            "throat_radius": self.throat_radius,
            "pore2_radius": self.pore2_radius,
            "pore1_length": self.pore1_length,
            "throat_length": self.throat_length,
            "pore2_length": self.pore2_length,
            "pore1_conductance": self.pore1_conductance,
            "throat_conductance": self.throat_conductance,
            "pore2_conductance": self.pore2_conductance,
            "equivalent_conductance": self.equivalent_conductance,
        }

to_columns

to_columns()

Return a tabulation-friendly column mapping.

Source code in src/voids/benchmarks/crosscheck.py
def to_columns(self) -> dict[str, np.ndarray | str]:
    """Return a tabulation-friendly column mapping."""

    return {
        "model": self.model,
        "throat_index": self.throat_index,
        "pore1_index": self.pore1_index,
        "pore2_index": self.pore2_index,
        "pore1_is_boundary": self.pore1_is_boundary,
        "pore2_is_boundary": self.pore2_is_boundary,
        "pore1_shape_factor": self.pore1_shape_factor,
        "throat_shape_factor": self.throat_shape_factor,
        "pore2_shape_factor": self.pore2_shape_factor,
        "pore1_area": self.pore1_area,
        "throat_area": self.throat_area,
        "pore2_area": self.pore2_area,
        "pore1_radius": self.pore1_radius,
        "throat_radius": self.throat_radius,
        "pore2_radius": self.pore2_radius,
        "pore1_length": self.pore1_length,
        "throat_length": self.throat_length,
        "pore2_length": self.pore2_length,
        "pore1_conductance": self.pore1_conductance,
        "throat_conductance": self.throat_conductance,
        "pore2_conductance": self.pore2_conductance,
        "equivalent_conductance": self.equivalent_conductance,
    }

NetworkGeometrySummary dataclass

Compact geometry and connectivity summary for one pore network.

Source code in src/voids/benchmarks/crosscheck.py
@dataclass(slots=True)
class NetworkGeometrySummary:
    """Compact geometry and connectivity summary for one pore network."""

    axis: str
    n_pores: int
    n_throats: int
    n_components: int
    giant_component_fraction: float
    isolated_pore_fraction: float
    dead_end_fraction: float
    mean_coordination: float
    inlet_pore_count: int
    outlet_pore_count: int
    overlapping_boundary_count: int
    boundary_pore_count: int
    pore_volume_total: float
    throat_volume_total: float
    pore_radius_mean: float
    pore_radius_median: float
    throat_radius_mean: float
    throat_radius_median: float
    throat_area_mean: float
    throat_area_median: float
    throat_length_mean: float
    throat_length_median: float
    throat_core_length_mean: float
    throat_core_length_median: float
    pore_shape_factor_mean: float
    pore_shape_factor_median: float
    throat_shape_factor_mean: float
    throat_shape_factor_median: float
    throat_face_count_mean: float
    throat_face_count_median: float
    throat_support_radius_mean: float
    throat_support_radius_median: float

    def to_record(self, *, prefix: str) -> dict[str, float | int]:
        """Return a flat record with prefixed field names."""

        return {
            f"{prefix}_n_pores": self.n_pores,
            f"{prefix}_n_throats": self.n_throats,
            f"{prefix}_n_components": self.n_components,
            f"{prefix}_giant_component_fraction": self.giant_component_fraction,
            f"{prefix}_isolated_pore_fraction": self.isolated_pore_fraction,
            f"{prefix}_dead_end_fraction": self.dead_end_fraction,
            f"{prefix}_mean_coordination": self.mean_coordination,
            f"{prefix}_inlet_pore_count": self.inlet_pore_count,
            f"{prefix}_outlet_pore_count": self.outlet_pore_count,
            f"{prefix}_overlapping_boundary_count": self.overlapping_boundary_count,
            f"{prefix}_boundary_pore_count": self.boundary_pore_count,
            f"{prefix}_pore_volume_total": self.pore_volume_total,
            f"{prefix}_throat_volume_total": self.throat_volume_total,
            f"{prefix}_pore_radius_mean": self.pore_radius_mean,
            f"{prefix}_pore_radius_median": self.pore_radius_median,
            f"{prefix}_throat_radius_mean": self.throat_radius_mean,
            f"{prefix}_throat_radius_median": self.throat_radius_median,
            f"{prefix}_throat_area_mean": self.throat_area_mean,
            f"{prefix}_throat_area_median": self.throat_area_median,
            f"{prefix}_throat_length_mean": self.throat_length_mean,
            f"{prefix}_throat_length_median": self.throat_length_median,
            f"{prefix}_throat_core_length_mean": self.throat_core_length_mean,
            f"{prefix}_throat_core_length_median": self.throat_core_length_median,
            f"{prefix}_pore_shape_factor_mean": self.pore_shape_factor_mean,
            f"{prefix}_pore_shape_factor_median": self.pore_shape_factor_median,
            f"{prefix}_throat_shape_factor_mean": self.throat_shape_factor_mean,
            f"{prefix}_throat_shape_factor_median": self.throat_shape_factor_median,
            f"{prefix}_throat_face_count_mean": self.throat_face_count_mean,
            f"{prefix}_throat_face_count_median": self.throat_face_count_median,
            f"{prefix}_throat_support_radius_mean": self.throat_support_radius_mean,
            f"{prefix}_throat_support_radius_median": self.throat_support_radius_median,
        }

to_record

to_record(*, prefix)

Return a flat record with prefixed field names.

Source code in src/voids/benchmarks/crosscheck.py
def to_record(self, *, prefix: str) -> dict[str, float | int]:
    """Return a flat record with prefixed field names."""

    return {
        f"{prefix}_n_pores": self.n_pores,
        f"{prefix}_n_throats": self.n_throats,
        f"{prefix}_n_components": self.n_components,
        f"{prefix}_giant_component_fraction": self.giant_component_fraction,
        f"{prefix}_isolated_pore_fraction": self.isolated_pore_fraction,
        f"{prefix}_dead_end_fraction": self.dead_end_fraction,
        f"{prefix}_mean_coordination": self.mean_coordination,
        f"{prefix}_inlet_pore_count": self.inlet_pore_count,
        f"{prefix}_outlet_pore_count": self.outlet_pore_count,
        f"{prefix}_overlapping_boundary_count": self.overlapping_boundary_count,
        f"{prefix}_boundary_pore_count": self.boundary_pore_count,
        f"{prefix}_pore_volume_total": self.pore_volume_total,
        f"{prefix}_throat_volume_total": self.throat_volume_total,
        f"{prefix}_pore_radius_mean": self.pore_radius_mean,
        f"{prefix}_pore_radius_median": self.pore_radius_median,
        f"{prefix}_throat_radius_mean": self.throat_radius_mean,
        f"{prefix}_throat_radius_median": self.throat_radius_median,
        f"{prefix}_throat_area_mean": self.throat_area_mean,
        f"{prefix}_throat_area_median": self.throat_area_median,
        f"{prefix}_throat_length_mean": self.throat_length_mean,
        f"{prefix}_throat_length_median": self.throat_length_median,
        f"{prefix}_throat_core_length_mean": self.throat_core_length_mean,
        f"{prefix}_throat_core_length_median": self.throat_core_length_median,
        f"{prefix}_pore_shape_factor_mean": self.pore_shape_factor_mean,
        f"{prefix}_pore_shape_factor_median": self.pore_shape_factor_median,
        f"{prefix}_throat_shape_factor_mean": self.throat_shape_factor_mean,
        f"{prefix}_throat_shape_factor_median": self.throat_shape_factor_median,
        f"{prefix}_throat_face_count_mean": self.throat_face_count_mean,
        f"{prefix}_throat_face_count_median": self.throat_face_count_median,
        f"{prefix}_throat_support_radius_mean": self.throat_support_radius_mean,
        f"{prefix}_throat_support_radius_median": self.throat_support_radius_median,
    }

NetworkGeometryComparison dataclass

Geometry and topology mismatch summary between two pore networks.

Source code in src/voids/benchmarks/crosscheck.py
@dataclass(slots=True)
class NetworkGeometryComparison:
    """Geometry and topology mismatch summary between two pore networks."""

    reference_name: str
    candidate_name: str
    axis: str
    reference_summary: NetworkGeometrySummary
    candidate_summary: NetworkGeometrySummary
    pore_count_rel_diff: float
    throat_count_rel_diff: float
    inlet_count_rel_diff: float
    outlet_count_rel_diff: float
    mean_coordination_rel_diff: float
    pore_radius_ks: float
    throat_radius_ks: float
    throat_area_ks: float
    throat_length_ks: float
    throat_core_length_ks: float
    pore_shape_factor_ks: float
    throat_shape_factor_ks: float
    coordination_ks: float
    throat_face_count_ks: float

    def to_record(self) -> dict[str, float | int]:
        """Return a flat comparison record suitable for CSV export."""

        return {
            **self.reference_summary.to_record(prefix=f"{self.reference_name}"),
            **self.candidate_summary.to_record(prefix=f"{self.candidate_name}"),
            f"{self.candidate_name}_vs_{self.reference_name}_pore_count_rel_diff": self.pore_count_rel_diff,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_count_rel_diff": self.throat_count_rel_diff,
            f"{self.candidate_name}_vs_{self.reference_name}_inlet_count_rel_diff": self.inlet_count_rel_diff,
            f"{self.candidate_name}_vs_{self.reference_name}_outlet_count_rel_diff": self.outlet_count_rel_diff,
            f"{self.candidate_name}_vs_{self.reference_name}_mean_coordination_rel_diff": self.mean_coordination_rel_diff,
            f"{self.candidate_name}_vs_{self.reference_name}_pore_radius_ks": self.pore_radius_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_radius_ks": self.throat_radius_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_area_ks": self.throat_area_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_length_ks": self.throat_length_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_core_length_ks": self.throat_core_length_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_pore_shape_factor_ks": self.pore_shape_factor_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_shape_factor_ks": self.throat_shape_factor_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_coordination_ks": self.coordination_ks,
            f"{self.candidate_name}_vs_{self.reference_name}_throat_face_count_ks": self.throat_face_count_ks,
        }

to_record

to_record()

Return a flat comparison record suitable for CSV export.

Source code in src/voids/benchmarks/crosscheck.py
def to_record(self) -> dict[str, float | int]:
    """Return a flat comparison record suitable for CSV export."""

    return {
        **self.reference_summary.to_record(prefix=f"{self.reference_name}"),
        **self.candidate_summary.to_record(prefix=f"{self.candidate_name}"),
        f"{self.candidate_name}_vs_{self.reference_name}_pore_count_rel_diff": self.pore_count_rel_diff,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_count_rel_diff": self.throat_count_rel_diff,
        f"{self.candidate_name}_vs_{self.reference_name}_inlet_count_rel_diff": self.inlet_count_rel_diff,
        f"{self.candidate_name}_vs_{self.reference_name}_outlet_count_rel_diff": self.outlet_count_rel_diff,
        f"{self.candidate_name}_vs_{self.reference_name}_mean_coordination_rel_diff": self.mean_coordination_rel_diff,
        f"{self.candidate_name}_vs_{self.reference_name}_pore_radius_ks": self.pore_radius_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_radius_ks": self.throat_radius_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_area_ks": self.throat_area_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_length_ks": self.throat_length_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_core_length_ks": self.throat_core_length_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_pore_shape_factor_ks": self.pore_shape_factor_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_shape_factor_ks": self.throat_shape_factor_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_coordination_ks": self.coordination_ks,
        f"{self.candidate_name}_vs_{self.reference_name}_throat_face_count_ks": self.throat_face_count_ks,
    }

summarize_network_geometry

summarize_network_geometry(net, *, axis, pore_mask=None)

Summarize geometry and connectivity for a network or pore-induced subset.

Source code in src/voids/benchmarks/crosscheck.py
def summarize_network_geometry(
    net: Network,
    *,
    axis: str,
    pore_mask: np.ndarray | None = None,
) -> NetworkGeometrySummary:
    """Summarize geometry and connectivity for a network or pore-induced subset."""

    subnet = _subset_network_for_geometry(net, pore_mask=pore_mask)
    connectivity = connectivity_metrics(subnet)
    inlet_label = f"inlet_{axis}min"
    outlet_label = f"outlet_{axis}max"
    inlet_mask = np.asarray(
        subnet.pore_labels.get(inlet_label, np.zeros(subnet.Np, dtype=bool)), dtype=bool
    )
    outlet_mask = np.asarray(
        subnet.pore_labels.get(outlet_label, np.zeros(subnet.Np, dtype=bool)), dtype=bool
    )
    boundary_mask = np.asarray(
        subnet.pore_labels.get("boundary", np.zeros(subnet.Np, dtype=bool)), dtype=bool
    )

    pore_volume = _get_numeric_field(subnet, entity="pore", name="volume")
    throat_volume = _get_numeric_field(subnet, entity="throat", name="volume")
    pore_radius = _get_numeric_field(subnet, entity="pore", name="radius_inscribed")
    throat_radius = _get_numeric_field(subnet, entity="throat", name="radius_inscribed")
    throat_area = _get_numeric_field(subnet, entity="throat", name="area")
    throat_length = _get_numeric_field(subnet, entity="throat", name="length")
    throat_core_length = _get_numeric_field(subnet, entity="throat", name="core_length")
    pore_shape_factor = _get_numeric_field(subnet, entity="pore", name="shape_factor")
    throat_shape_factor = _get_numeric_field(subnet, entity="throat", name="shape_factor")
    throat_face_count = _get_numeric_field(subnet, entity="throat", name="face_count")
    support_side1 = _get_numeric_field(subnet, entity="throat", name="supporting_radius_side1")
    support_side2 = _get_numeric_field(subnet, entity="throat", name="supporting_radius_side2")
    throat_support_radius = None
    if support_side1 is not None and support_side2 is not None:
        throat_support_radius = np.nanmax(np.column_stack([support_side1, support_side2]), axis=1)

    return NetworkGeometrySummary(
        axis=axis,
        n_pores=int(subnet.Np),
        n_throats=int(subnet.Nt),
        n_components=int(connectivity.n_components),
        giant_component_fraction=float(connectivity.giant_component_fraction),
        isolated_pore_fraction=float(connectivity.isolated_pore_fraction),
        dead_end_fraction=float(connectivity.dead_end_fraction),
        mean_coordination=float(connectivity.mean_coordination),
        inlet_pore_count=int(np.count_nonzero(inlet_mask)),
        outlet_pore_count=int(np.count_nonzero(outlet_mask)),
        overlapping_boundary_count=int(np.count_nonzero(inlet_mask & outlet_mask)),
        boundary_pore_count=int(np.count_nonzero(boundary_mask)),
        pore_volume_total=float(np.nansum(pore_volume) if pore_volume is not None else np.nan),
        throat_volume_total=float(
            np.nansum(throat_volume) if throat_volume is not None else np.nan
        ),
        pore_radius_mean=_finite_statistic_mean(pore_radius),
        pore_radius_median=_finite_statistic_median(pore_radius),
        throat_radius_mean=_finite_statistic_mean(throat_radius),
        throat_radius_median=_finite_statistic_median(throat_radius),
        throat_area_mean=_finite_statistic_mean(throat_area),
        throat_area_median=_finite_statistic_median(throat_area),
        throat_length_mean=_finite_statistic_mean(throat_length),
        throat_length_median=_finite_statistic_median(throat_length),
        throat_core_length_mean=_finite_statistic_mean(throat_core_length),
        throat_core_length_median=_finite_statistic_median(throat_core_length),
        pore_shape_factor_mean=_finite_statistic_mean(pore_shape_factor),
        pore_shape_factor_median=_finite_statistic_median(pore_shape_factor),
        throat_shape_factor_mean=_finite_statistic_mean(throat_shape_factor),
        throat_shape_factor_median=_finite_statistic_median(throat_shape_factor),
        throat_face_count_mean=_finite_statistic_mean(throat_face_count),
        throat_face_count_median=_finite_statistic_median(throat_face_count),
        throat_support_radius_mean=_finite_statistic_mean(throat_support_radius),
        throat_support_radius_median=_finite_statistic_median(throat_support_radius),
    )

compare_network_geometry

compare_network_geometry(
    reference_net,
    candidate_net,
    *,
    axis,
    reference_pore_mask=None,
    candidate_pore_mask=None,
    reference_name="reference",
    candidate_name="candidate",
)

Compare geometry and connectivity between two networks or pore subsets.

Source code in src/voids/benchmarks/crosscheck.py
def compare_network_geometry(
    reference_net: Network,
    candidate_net: Network,
    *,
    axis: str,
    reference_pore_mask: np.ndarray | None = None,
    candidate_pore_mask: np.ndarray | None = None,
    reference_name: str = "reference",
    candidate_name: str = "candidate",
) -> NetworkGeometryComparison:
    """Compare geometry and connectivity between two networks or pore subsets."""

    reference_subnet = _subset_network_for_geometry(reference_net, pore_mask=reference_pore_mask)
    candidate_subnet = _subset_network_for_geometry(candidate_net, pore_mask=candidate_pore_mask)

    reference_summary = summarize_network_geometry(reference_subnet, axis=axis)
    candidate_summary = summarize_network_geometry(candidate_subnet, axis=axis)

    reference_coordination = coordination_numbers(reference_subnet)
    candidate_coordination = coordination_numbers(candidate_subnet)

    return NetworkGeometryComparison(
        reference_name=reference_name,
        candidate_name=candidate_name,
        axis=axis,
        reference_summary=reference_summary,
        candidate_summary=candidate_summary,
        pore_count_rel_diff=_rel_diff(reference_summary.n_pores, candidate_summary.n_pores),
        throat_count_rel_diff=_rel_diff(reference_summary.n_throats, candidate_summary.n_throats),
        inlet_count_rel_diff=_rel_diff(
            reference_summary.inlet_pore_count,
            candidate_summary.inlet_pore_count,
        ),
        outlet_count_rel_diff=_rel_diff(
            reference_summary.outlet_pore_count,
            candidate_summary.outlet_pore_count,
        ),
        mean_coordination_rel_diff=_rel_diff(
            reference_summary.mean_coordination,
            candidate_summary.mean_coordination,
        ),
        pore_radius_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="pore", name="radius_inscribed"),
            _get_numeric_field(candidate_subnet, entity="pore", name="radius_inscribed"),
        ),
        throat_radius_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="radius_inscribed"),
            _get_numeric_field(candidate_subnet, entity="throat", name="radius_inscribed"),
        ),
        throat_area_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="area"),
            _get_numeric_field(candidate_subnet, entity="throat", name="area"),
        ),
        throat_length_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="length"),
            _get_numeric_field(candidate_subnet, entity="throat", name="length"),
        ),
        throat_core_length_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="core_length"),
            _get_numeric_field(candidate_subnet, entity="throat", name="core_length"),
        ),
        pore_shape_factor_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="pore", name="shape_factor"),
            _get_numeric_field(candidate_subnet, entity="pore", name="shape_factor"),
        ),
        throat_shape_factor_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="shape_factor"),
            _get_numeric_field(candidate_subnet, entity="throat", name="shape_factor"),
        ),
        coordination_ks=_distribution_ks_statistic(
            reference_coordination.astype(float),
            candidate_coordination.astype(float),
        ),
        throat_face_count_ks=_distribution_ks_statistic(
            _get_numeric_field(reference_subnet, entity="throat", name="face_count"),
            _get_numeric_field(candidate_subnet, entity="throat", name="face_count"),
        ),
    )

audit_singlephase_conduit_conductance

audit_singlephase_conduit_conductance(
    net,
    viscosity,
    *,
    model="valvatne_blunt",
    pore_viscosity=None,
    throat_viscosity=None,
)

Return a per-throat conduit conductance breakdown for voids.

Parameters:

Name Type Description Default
net Network

Network with conduit lengths and pore/throat geometry.

required
viscosity float | ndarray | None

Scalar or array viscosity passed to the Valvatne-Blunt closure.

required
model str

Conductance model. Currently only the conduit-based Imperial-style variants "valvatne_blunt" and "valvatne_blunt_baseline" are supported.

'valvatne_blunt'
pore_viscosity float | ndarray | None

Optional separate viscosities for pore and throat segments.

None
throat_viscosity float | ndarray | None

Optional separate viscosities for pore and throat segments.

None

Returns:

Type Description
ConduitConductanceAudit

Per-throat geometric and conductance decomposition.

Source code in src/voids/benchmarks/crosscheck.py
def audit_singlephase_conduit_conductance(
    net: Network,
    viscosity: float | np.ndarray | None,
    *,
    model: str = "valvatne_blunt",
    pore_viscosity: float | np.ndarray | None = None,
    throat_viscosity: float | np.ndarray | None = None,
) -> ConduitConductanceAudit:
    """Return a per-throat conduit conductance breakdown for `voids`.

    Parameters
    ----------
    net :
        Network with conduit lengths and pore/throat geometry.
    viscosity :
        Scalar or array viscosity passed to the Valvatne-Blunt closure.
    model :
        Conductance model. Currently only the conduit-based Imperial-style
        variants ``"valvatne_blunt"`` and ``"valvatne_blunt_baseline"`` are
        supported.
    pore_viscosity, throat_viscosity :
        Optional separate viscosities for pore and throat segments.

    Returns
    -------
    ConduitConductanceAudit
        Per-throat geometric and conductance decomposition.
    """

    if model not in {"valvatne_blunt", "valvatne_blunt_baseline"}:
        raise ValueError(
            "audit_singlephase_conduit_conductance currently supports only "
            "'valvatne_blunt' and 'valvatne_blunt_baseline'"
        )
    if not _conduit_lengths_available(net):
        raise KeyError("Missing conduit lengths (pore1_length, core_length, pore2_length)")

    mu_p, mu_t = _resolve_pore_throat_viscosities(
        net,
        viscosity,
        pore_viscosity=pore_viscosity,
        throat_viscosity=throat_viscosity,
    )
    conns = np.asarray(net.throat_conns, dtype=np.int64)
    p1_idx = conns[:, 0]
    p2_idx = conns[:, 1]

    pore_area = _get_entity_area(net, "pore")
    throat_area = _get_entity_area(net, "throat")
    pore_shape = _get_entity_shape_factor(net, "pore", area=pore_area)
    throat_shape = _get_entity_shape_factor(net, "throat", area=throat_area)

    pore1_length = np.asarray(net.throat["pore1_length"], dtype=float)
    throat_length = np.asarray(net.throat["core_length"], dtype=float)
    pore2_length = np.asarray(net.throat["pore2_length"], dtype=float)

    unit_length = np.ones(net.Nt, dtype=float)
    cond1 = _segment_conductance_valvatne_blunt(
        pore_area[p1_idx],
        pore_shape[p1_idx],
        unit_length,
        mu_p[p1_idx],
    )
    condt = _segment_conductance_valvatne_blunt(
        throat_area,
        throat_shape,
        unit_length,
        mu_t,
    )
    cond2 = _segment_conductance_valvatne_blunt(
        pore_area[p2_idx],
        pore_shape[p2_idx],
        unit_length,
        mu_p[p2_idx],
    )
    resistance_terms = []
    for length_arr, cond_arr in (
        (pore1_length, cond1),
        (throat_length, condt),
        (pore2_length, cond2),
    ):
        r = np.zeros(net.Nt, dtype=float)
        positive = cond_arr > 0.0
        r[positive] = length_arr[positive] / cond_arr[positive]
        resistance_terms.append(r)
    total_resistance = resistance_terms[0] + resistance_terms[1] + resistance_terms[2]
    geq = np.zeros(net.Nt, dtype=float)
    positive_total = total_resistance > 0.0
    geq[positive_total] = 1.0 / total_resistance[positive_total]

    pore_boundary = np.asarray(
        net.pore_labels.get("boundary", np.zeros(net.Np, dtype=bool)),
        dtype=bool,
    )
    pore_radius = np.asarray(net.pore["radius_inscribed"], dtype=float)
    throat_radius = np.asarray(net.throat["radius_inscribed"], dtype=float)

    return ConduitConductanceAudit(
        model=model,
        throat_index=np.arange(net.Nt, dtype=np.int64),
        pore1_index=p1_idx.copy(),
        pore2_index=p2_idx.copy(),
        pore1_is_boundary=pore_boundary[p1_idx],
        pore2_is_boundary=pore_boundary[p2_idx],
        pore1_shape_factor=np.asarray(pore_shape[p1_idx], dtype=float),
        throat_shape_factor=np.asarray(throat_shape, dtype=float),
        pore2_shape_factor=np.asarray(pore_shape[p2_idx], dtype=float),
        pore1_area=np.asarray(pore_area[p1_idx], dtype=float),
        throat_area=np.asarray(throat_area, dtype=float),
        pore2_area=np.asarray(pore_area[p2_idx], dtype=float),
        pore1_radius=np.asarray(pore_radius[p1_idx], dtype=float),
        throat_radius=np.asarray(throat_radius, dtype=float),
        pore2_radius=np.asarray(pore_radius[p2_idx], dtype=float),
        pore1_length=pore1_length.copy(),
        throat_length=throat_length.copy(),
        pore2_length=pore2_length.copy(),
        pore1_conductance=cond1,
        throat_conductance=condt,
        pore2_conductance=cond2,
        equivalent_conductance=geq,
    )

crosscheck_singlephase_roundtrip_openpnm_dict

crosscheck_singlephase_roundtrip_openpnm_dict(
    net, fluid, bc, *, axis, options=None
)

Cross-check voids after a dict roundtrip through OpenPNM-style keys.

Parameters:

Name Type Description Default
net Network

Network to solve and round-trip.

required
fluid FluidSinglePhase

Fluid properties.

required
bc PressureBC

Pressure boundary conditions.

required
axis str

Flow axis used in the permeability calculation.

required
options SinglePhaseOptions | None

Optional solver configuration.

None

Returns:

Type Description
SinglePhaseCrosscheckSummary

Comparison between the original voids solve and the round-tripped solve.

Notes

This path does not require OpenPNM itself. It checks whether exporting to the flat OpenPNM/PoreSpy naming convention and importing back into voids changes any transport-relevant fields.

Source code in src/voids/benchmarks/crosscheck.py
def crosscheck_singlephase_roundtrip_openpnm_dict(
    net: Network,
    fluid: FluidSinglePhase,
    bc: PressureBC,
    *,
    axis: str,
    options: SinglePhaseOptions | None = None,
) -> SinglePhaseCrosscheckSummary:
    """Cross-check ``voids`` after a dict roundtrip through OpenPNM-style keys.

    Parameters
    ----------
    net :
        Network to solve and round-trip.
    fluid :
        Fluid properties.
    bc :
        Pressure boundary conditions.
    axis :
        Flow axis used in the permeability calculation.
    options :
        Optional solver configuration.

    Returns
    -------
    SinglePhaseCrosscheckSummary
        Comparison between the original ``voids`` solve and the round-tripped solve.

    Notes
    -----
    This path does not require OpenPNM itself. It checks whether exporting to the
    flat OpenPNM/PoreSpy naming convention and importing back into ``voids`` changes
    any transport-relevant fields.
    """

    options = options or SinglePhaseOptions()
    r0 = solve(net, fluid=fluid, bc=bc, axis=axis, options=options)
    op_dict = to_openpnm_dict(net)
    net_rt = from_porespy(op_dict, sample=net.sample, provenance=net.provenance)
    r1 = solve(net_rt, fluid=fluid, bc=bc, axis=axis, options=options)
    return _summary_from_results("openpnm_dict_roundtrip", axis, r0, r1)

crosscheck_singlephase_with_openpnm

crosscheck_singlephase_with_openpnm(
    net, fluid, bc, *, axis, options=None
)

Cross-check voids against OpenPNM StokesFlow.

Parameters:

Name Type Description Default
net Network

Network to simulate.

required
fluid FluidSinglePhase

Fluid properties.

required
bc PressureBC

Pressure boundary conditions.

required
axis str

Flow axis used for apparent permeability.

required
options SinglePhaseOptions | None

Optional solver configuration.

None

Returns:

Type Description
SinglePhaseCrosscheckSummary

Comparison between voids and OpenPNM.

Raises:

Type Description
ImportError

If OpenPNM is not installed.

RuntimeError

If the installed OpenPNM API is incompatible with the adapter.

ValueError

If the imposed pressure drop is zero.

Notes

The comparison injects the voids-computed throat.hydraulic_conductance into OpenPNM. That means the crosscheck isolates differences in system assembly, boundary-condition handling, sign conventions, and linear-solver behavior, rather than differences in geometric conductance modeling.

Source code in src/voids/benchmarks/crosscheck.py
def crosscheck_singlephase_with_openpnm(
    net: Network,
    fluid: FluidSinglePhase,
    bc: PressureBC,
    *,
    axis: str,
    options: SinglePhaseOptions | None = None,
) -> SinglePhaseCrosscheckSummary:
    """Cross-check ``voids`` against OpenPNM StokesFlow.

    Parameters
    ----------
    net :
        Network to simulate.
    fluid :
        Fluid properties.
    bc :
        Pressure boundary conditions.
    axis :
        Flow axis used for apparent permeability.
    options :
        Optional solver configuration.

    Returns
    -------
    SinglePhaseCrosscheckSummary
        Comparison between ``voids`` and OpenPNM.

    Raises
    ------
    ImportError
        If OpenPNM is not installed.
    RuntimeError
        If the installed OpenPNM API is incompatible with the adapter.
    ValueError
        If the imposed pressure drop is zero.

    Notes
    -----
    The comparison injects the ``voids``-computed ``throat.hydraulic_conductance``
    into OpenPNM. That means the crosscheck isolates differences in system assembly,
    boundary-condition handling, sign conventions, and linear-solver behavior,
    rather than differences in geometric conductance modeling.
    """

    try:
        import openpnm as op
    except Exception as exc:  # pragma: no cover - depends on optional env
        raise ImportError(
            "OpenPNM is not installed. Use the 'test' pixi environment or install openpnm."
        ) from exc

    options = options or SinglePhaseOptions()
    r_voids = solve(net, fluid=fluid, bc=bc, axis=axis, options=options)
    g = np.asarray(r_voids.throat_conductance, dtype=float)

    pn = to_openpnm_network(net, copy_properties=False, copy_labels=True)
    phase = _openpnm_phase_factory(op, pn)
    phase["throat.hydraulic_conductance"] = g

    sf = op.algorithms.StokesFlow(network=pn, phase=phase)
    inlet_mask = np.asarray(net.pore_labels[bc.inlet_label], dtype=bool)
    outlet_mask = np.asarray(net.pore_labels[bc.outlet_label], dtype=bool)
    inlet = np.where(inlet_mask)[0]
    outlet = np.where(outlet_mask)[0]

    if hasattr(sf, "set_value_BC"):
        sf.set_value_BC(pores=inlet, values=float(bc.pin))
        sf.set_value_BC(pores=outlet, values=float(bc.pout))
    elif hasattr(sf, "set_BC"):
        sf.set_BC(pores=inlet, bctype="value", bcvalues=float(bc.pin))
        sf.set_BC(pores=outlet, bctype="value", bcvalues=float(bc.pout))
    else:  # pragma: no cover
        raise RuntimeError("OpenPNM StokesFlow object does not expose a recognizable BC API")

    sf.run()
    p_ref = _get_openpnm_pressure(sf)

    q_rate = np.asarray(sf.rate(pores=inlet), dtype=float)
    q_ref_raw = float(q_rate.sum())
    q_ref = q_ref_raw
    if np.isfinite(q_ref) and np.isfinite(r_voids.total_flow_rate):
        if np.isclose(abs(q_ref), abs(r_voids.total_flow_rate), rtol=1e-8, atol=1e-14):
            q_ref = float(np.copysign(abs(q_ref), r_voids.total_flow_rate))

    dP = float(bc.pin - bc.pout)
    if abs(dP) == 0.0:
        raise ValueError("Pressure drop pin-pout must be nonzero")
    L = net.sample.length_for_axis(axis)
    Axs = net.sample.area_for_axis(axis)
    mu_ref = fluid.reference_viscosity(pin=bc.pin, pout=bc.pout)
    k_ref = abs(q_ref_raw) * mu_ref * L / (Axs * abs(dP))
    k_voids = float((r_voids.permeability or {}).get(axis, np.nan))

    return _summary_from_values(
        reference="openpnm_stokesflow",
        axis=axis,
        k_voids=k_voids,
        k_ref=float(k_ref),
        q_voids=float(r_voids.total_flow_rate),
        q_ref=float(q_ref),
        details={
            "openpnm_version": getattr(op, "__version__", "unknown"),
            "q_ref_raw": q_ref_raw,
            "n_inlet_pores": int(inlet.size),
            "n_outlet_pores": int(outlet.size),
            "conductance_model": options.conductance_model,
            "solver_voids": options.solver,
            "p_ref_min": float(np.min(p_ref)) if p_ref.size else np.nan,
            "p_ref_max": float(np.max(p_ref)) if p_ref.size else np.nan,
        },
    )

Segmented Volume Benchmarks

voids.benchmarks.segmented_volume

SegmentedVolumeCrosscheckResult dataclass

Store extraction, porosity, and solver cross-check outputs.

Attributes:

Name Type Description
extract NetworkExtractionResult

Result of importing the segmented volume into a voids network and pruning it to the requested spanning axis.

fluid FluidSinglePhase

Fluid properties used in the permeability solve.

bc PressureBC

Pressure boundary conditions imposed on the extracted network.

options SinglePhaseOptions

Solver and conductance options used for the comparison.

image_porosity float

Void fraction of the segmented binary image.

absolute_porosity, effective_porosity

Porosity diagnostics computed from the pruned extracted network.

summary SinglePhaseCrosscheckSummary

Comparison summary between voids and OpenPNM.

Notes

This high-level benchmark follows the same public pressure-BC convention as benchmark_segmented_volume_with_xlb: the preferred user input is delta_p, while optional pin / pout values can still be used to preserve an absolute pressure gauge. The applied physical pressures and pressure drop are recorded explicitly in :meth:SegmentedVolumeCrosscheckResult.to_record.

Source code in src/voids/benchmarks/segmented_volume.py
@dataclass(slots=True)
class SegmentedVolumeCrosscheckResult:
    """Store extraction, porosity, and solver cross-check outputs.

    Attributes
    ----------
    extract :
        Result of importing the segmented volume into a `voids` network and
        pruning it to the requested spanning axis.
    fluid :
        Fluid properties used in the permeability solve.
    bc :
        Pressure boundary conditions imposed on the extracted network.
    options :
        Solver and conductance options used for the comparison.
    image_porosity :
        Void fraction of the segmented binary image.
    absolute_porosity, effective_porosity :
        Porosity diagnostics computed from the pruned extracted network.
    summary :
        Comparison summary between `voids` and OpenPNM.

    Notes
    -----
    This high-level benchmark follows the same public pressure-BC convention as
    `benchmark_segmented_volume_with_xlb`: the preferred user input is
    ``delta_p``, while optional ``pin`` / ``pout`` values can still be used to
    preserve an absolute pressure gauge. The applied physical pressures and
    pressure drop are recorded explicitly in
    :meth:`SegmentedVolumeCrosscheckResult.to_record`.
    """

    extract: NetworkExtractionResult
    fluid: FluidSinglePhase
    bc: PressureBC
    options: SinglePhaseOptions
    image_porosity: float
    absolute_porosity: float
    effective_porosity: float
    summary: SinglePhaseCrosscheckSummary

    def to_record(self) -> dict[str, Any]:
        """Return scalar diagnostics suitable for tabulation."""

        details = dict(self.summary.details)
        return {
            "flow_axis": self.summary.axis,
            "phi_image": float(self.image_porosity),
            "phi_abs": float(self.absolute_porosity),
            "phi_eff": float(self.effective_porosity),
            "Np": int(self.extract.net.Np),
            "Nt": int(self.extract.net.Nt),
            "k_voids": float(details["k_voids"]),
            "k_openpnm": float(details["k_ref"]),
            "k_abs_diff": float(self.summary.permeability_abs_diff),
            "k_rel_diff": float(self.summary.permeability_rel_diff),
            "Q_voids": float(details["Q_voids"]),
            "Q_openpnm": float(details["Q_ref"]),
            "Q_abs_diff": float(self.summary.total_flow_abs_diff),
            "Q_rel_diff": float(self.summary.total_flow_rel_diff),
            "n_inlet_pores": int(details["n_inlet_pores"]),
            "n_outlet_pores": int(details["n_outlet_pores"]),
            "conductance_model": str(
                details.get("conductance_model", self.options.conductance_model)
            ),
            "solver_voids": str(details.get("solver_voids", self.options.solver)),
            "p_inlet_physical": float(self.bc.pin),
            "p_outlet_physical": float(self.bc.pout),
            "dp_physical": float(self.bc.pin - self.bc.pout),
            "backend": str(self.extract.backend),
            "backend_version": self.extract.backend_version,
            "openpnm_version": details.get("openpnm_version"),
        }

to_record

to_record()

Return scalar diagnostics suitable for tabulation.

Source code in src/voids/benchmarks/segmented_volume.py
def to_record(self) -> dict[str, Any]:
    """Return scalar diagnostics suitable for tabulation."""

    details = dict(self.summary.details)
    return {
        "flow_axis": self.summary.axis,
        "phi_image": float(self.image_porosity),
        "phi_abs": float(self.absolute_porosity),
        "phi_eff": float(self.effective_porosity),
        "Np": int(self.extract.net.Np),
        "Nt": int(self.extract.net.Nt),
        "k_voids": float(details["k_voids"]),
        "k_openpnm": float(details["k_ref"]),
        "k_abs_diff": float(self.summary.permeability_abs_diff),
        "k_rel_diff": float(self.summary.permeability_rel_diff),
        "Q_voids": float(details["Q_voids"]),
        "Q_openpnm": float(details["Q_ref"]),
        "Q_abs_diff": float(self.summary.total_flow_abs_diff),
        "Q_rel_diff": float(self.summary.total_flow_rel_diff),
        "n_inlet_pores": int(details["n_inlet_pores"]),
        "n_outlet_pores": int(details["n_outlet_pores"]),
        "conductance_model": str(
            details.get("conductance_model", self.options.conductance_model)
        ),
        "solver_voids": str(details.get("solver_voids", self.options.solver)),
        "p_inlet_physical": float(self.bc.pin),
        "p_outlet_physical": float(self.bc.pout),
        "dp_physical": float(self.bc.pin - self.bc.pout),
        "backend": str(self.extract.backend),
        "backend_version": self.extract.backend_version,
        "openpnm_version": details.get("openpnm_version"),
    }

benchmark_segmented_volume_with_openpnm

benchmark_segmented_volume_with_openpnm(
    phases,
    *,
    voxel_size,
    extraction_backend="porespy",
    flow_axis=None,
    fluid=None,
    delta_p=None,
    pin=None,
    pout=None,
    options=None,
    length_unit="m",
    pressure_unit="Pa",
    extraction_kwargs=None,
    provenance_notes=None,
    strict=True,
)

Benchmark an extracted segmented volume against OpenPNM.

Parameters:

Name Type Description Default
phases ndarray

Binary segmented image encoded as void=1 and solid=0.

required
voxel_size float

Edge length of one voxel in the declared length unit.

required
extraction_backend str

Image-to-network extraction backend forwarded to :func:voids.image.extract_spanning_pore_network.

'porespy'
flow_axis str | None

Requested transport axis. When omitted, the longest image axis is used.

None
fluid FluidSinglePhase | None

Fluid properties. Defaults to water-like viscosity 1e-3 Pa s.

None
delta_p float | None

Preferred physical pressure drop for the benchmark, typically in Pa. When provided alone, the wrapper uses pout = 0 and pin = delta_p as a gauge choice. When combined with one of pin or pout, the missing value is inferred. When combined with both, consistency with pin - pout is enforced.

None
pin float | None

Optional absolute physical inlet and outlet pressures. They are kept for backward compatibility and for cases where the user wants to preserve a particular pressure reference level. For the current incompressible benchmark, only the pressure drop pin - pout affects the reported permeability.

None
pout float | None

Optional absolute physical inlet and outlet pressures. They are kept for backward compatibility and for cases where the user wants to preserve a particular pressure reference level. For the current incompressible benchmark, only the pressure drop pin - pout affects the reported permeability.

None
options SinglePhaseOptions | None

Solver controls. Defaults to the image-workflow baseline valvatne_blunt with the direct linear solver.

None
length_unit str

Units attached to the extracted sample geometry.

'm'
pressure_unit str

Units attached to the extracted sample geometry.

'm'
extraction_kwargs dict[str, object] | None

Extra keyword arguments forwarded to porespy.networks.snow2.

None
provenance_notes dict[str, object] | None

Optional metadata attached to the extracted network provenance.

None
strict bool

Forwarded to :func:voids.image.extract_spanning_pore_network.

True

Returns:

Type Description
SegmentedVolumeCrosscheckResult

Extraction metadata, porosity diagnostics, and the OpenPNM comparison.

Raises:

Type Description
ValueError

If the image is invalid, the pressure specification is inconsistent, or the implied pressure drop is not positive.

Notes

This helper uses :func:voids.benchmarks.crosscheck_singlephase_with_openpnm, which injects the voids throat hydraulic conductances into OpenPNM. The resulting comparison isolates extraction consistency, boundary-condition handling, and linear-solver agreement; it does not benchmark independent conductance models between packages.

Unlike the XLB high-level benchmark, no fluid-density-based unit conversion is needed here because both sides solve the same extracted pore network directly under the same physical pressure BC.

The absolute pressure offset is numerically immaterial for this current incompressible benchmark. For example, delta_p=1, pin=1/pout=0, and delta_p=1 with pin=101326/pout=101325 all impose the same permeability-driving pressure drop.

Source code in src/voids/benchmarks/segmented_volume.py
def benchmark_segmented_volume_with_openpnm(
    phases: np.ndarray,
    *,
    voxel_size: float,
    extraction_backend: str = "porespy",
    flow_axis: str | None = None,
    fluid: FluidSinglePhase | None = None,
    delta_p: float | None = None,
    pin: float | None = None,
    pout: float | None = None,
    options: SinglePhaseOptions | None = None,
    length_unit: str = "m",
    pressure_unit: str = "Pa",
    extraction_kwargs: dict[str, object] | None = None,
    provenance_notes: dict[str, object] | None = None,
    strict: bool = True,
) -> SegmentedVolumeCrosscheckResult:
    """Benchmark an extracted segmented volume against OpenPNM.

    Parameters
    ----------
    phases :
        Binary segmented image encoded as ``void=1`` and ``solid=0``.
    voxel_size :
        Edge length of one voxel in the declared length unit.
    extraction_backend :
        Image-to-network extraction backend forwarded to
        :func:`voids.image.extract_spanning_pore_network`.
    flow_axis :
        Requested transport axis. When omitted, the longest image axis is used.
    fluid :
        Fluid properties. Defaults to water-like viscosity `1e-3 Pa s`.
    delta_p :
        Preferred physical pressure drop for the benchmark, typically in Pa.
        When provided alone, the wrapper uses `pout = 0` and `pin = delta_p` as
        a gauge choice. When combined with one of `pin` or `pout`, the missing
        value is inferred. When combined with both, consistency with
        ``pin - pout`` is enforced.
    pin, pout :
        Optional absolute physical inlet and outlet pressures. They are kept for
        backward compatibility and for cases where the user wants to preserve a
        particular pressure reference level. For the current incompressible
        benchmark, only the pressure drop ``pin - pout`` affects the reported
        permeability.
    options :
        Solver controls. Defaults to the image-workflow baseline
        ``valvatne_blunt`` with the direct linear solver.
    length_unit, pressure_unit :
        Units attached to the extracted sample geometry.
    extraction_kwargs :
        Extra keyword arguments forwarded to `porespy.networks.snow2`.
    provenance_notes :
        Optional metadata attached to the extracted network provenance.
    strict :
        Forwarded to :func:`voids.image.extract_spanning_pore_network`.

    Returns
    -------
    SegmentedVolumeCrosscheckResult
        Extraction metadata, porosity diagnostics, and the OpenPNM comparison.

    Raises
    ------
    ValueError
        If the image is invalid, the pressure specification is inconsistent, or
        the implied pressure drop is not positive.

    Notes
    -----
    This helper uses :func:`voids.benchmarks.crosscheck_singlephase_with_openpnm`,
    which injects the `voids` throat hydraulic conductances into OpenPNM. The
    resulting comparison isolates extraction consistency, boundary-condition
    handling, and linear-solver agreement; it does not benchmark independent
    conductance models between packages.

    Unlike the XLB high-level benchmark, no fluid-density-based unit conversion
    is needed here because both sides solve the same extracted pore network
    directly under the same physical pressure BC.

    The absolute pressure offset is numerically immaterial for this current
    incompressible benchmark. For example, ``delta_p=1``, ``pin=1``/``pout=0``,
    and ``delta_p=1`` with ``pin=101326``/``pout=101325`` all impose the same
    permeability-driving pressure drop.
    """

    arr = _as_binary_volume(phases)
    image_phi = float(arr.mean())
    pin_used, pout_used, _ = resolve_benchmark_pressures(
        delta_p=delta_p,
        pin=pin,
        pout=pout,
    )

    notes = dict(provenance_notes or {})
    notes.setdefault("benchmark_kind", "segmented_volume_openpnm")

    extract = extract_spanning_pore_network(
        arr,
        voxel_size=voxel_size,
        backend=extraction_backend,
        flow_axis=flow_axis,
        length_unit=length_unit,
        pressure_unit=pressure_unit,
        extraction_kwargs=extraction_kwargs,
        provenance_notes=notes,
        strict=strict,
    )

    fluid_used = fluid or FluidSinglePhase(viscosity=1.0e-3)
    options_used = options or SinglePhaseOptions(
        conductance_model="valvatne_blunt",
        solver="direct",
    )
    axis = extract.flow_axis
    bc = make_benchmark_pressure_bc(axis, pin=pin_used, pout=pout_used)
    summary = crosscheck_singlephase_with_openpnm(
        extract.net,
        fluid=fluid_used,
        bc=bc,
        axis=axis,
        options=options_used,
    )

    return SegmentedVolumeCrosscheckResult(
        extract=extract,
        fluid=fluid_used,
        bc=bc,
        options=options_used,
        image_porosity=image_phi,
        absolute_porosity=float(absolute_porosity(extract.net)),
        effective_porosity=float(effective_porosity(extract.net, axis=axis)),
        summary=summary,
    )

voids.benchmarks.xlb

Segmented-volume benchmarks that consume the XLB LBM backend.

The direct-image XLB adapter lives in :mod:voids.lbm.singlephase.xlb. This module composes that backend with voids network extraction and single-phase PNM solves for benchmark comparisons. Low-level XLB symbols are re-exported here for backward compatibility with older notebooks.

XLBConvergenceWarning

Bases: RuntimeWarning

Warn that an XLB solve reached max_steps before steady convergence.

Source code in src/voids/lbm/singlephase/xlb.py
class XLBConvergenceWarning(RuntimeWarning):
    """Warn that an XLB solve reached ``max_steps`` before steady convergence."""

XLBDirectSimulationResult dataclass

Store direct-image LBM outputs from an XLB run.

Attributes:

Name Type Description
lattice_pressure_inlet, lattice_pressure_outlet, lattice_pressure_drop

Resolved inlet, outlet, and differential pressure in lattice units.

lattice_density_inlet, lattice_density_outlet

Equivalent lattice densities associated with the pressure BCs through p_lu = c_s^2 rho.

permeability float

Apparent permeability mapped back to physical units.

max_mach_lattice, reynolds_voxel_max

Low-inertia diagnostics useful when interpreting a run as a creeping-flow reference.

Source code in src/voids/lbm/singlephase/xlb.py
@dataclass(slots=True)
class XLBDirectSimulationResult:
    """Store direct-image LBM outputs from an XLB run.

    Attributes
    ----------
    lattice_pressure_inlet, lattice_pressure_outlet, lattice_pressure_drop :
        Resolved inlet, outlet, and differential pressure in lattice units.
    lattice_density_inlet, lattice_density_outlet :
        Equivalent lattice densities associated with the pressure BCs through
        ``p_lu = c_s^2 rho``.
    permeability :
        Apparent permeability mapped back to physical units.
    max_mach_lattice, reynolds_voxel_max :
        Low-inertia diagnostics useful when interpreting a run as a creeping-flow
        reference.
    """

    flow_axis: str
    voxel_size: float
    image_porosity: float
    sample_lengths: dict[str, float]
    sample_cross_sections: dict[str, float]
    lattice_viscosity: float
    lattice_pressure_inlet: float
    lattice_pressure_outlet: float
    lattice_density_inlet: float
    lattice_density_outlet: float
    lattice_pressure_drop: float
    inlet_outlet_buffer_cells: int
    omega: float
    superficial_velocity_lattice: float
    superficial_velocity_profile_lattice: np.ndarray
    velocity_lattice: np.ndarray
    axial_velocity_lattice: np.ndarray
    converged: bool
    n_steps: int
    convergence_metric: float
    permeability: float
    backend: str
    backend_version: str | None
    formulation: str
    velocity_set: str
    collision_model: str
    streaming_scheme: str
    max_speed_lattice: float
    max_mach_lattice: float
    reynolds_voxel_max: float

XLBOptions dataclass

Numerical controls for the direct-image XLB solver.

Attributes:

Name Type Description
formulation str

Either "incompressible_navier_stokes" or "steady_stokes_limit".

backend str

XLB compute backend. The current voids adapter supports only "jax".

precision_policy str

XLB precision policy name, for example "FP32FP32".

collision_model str

XLB collision operator label passed to the stepper.

streaming_scheme str

XLB streaming scheme label passed to the stepper.

lattice_viscosity float

Kinematic viscosity in lattice units.

pressure_inlet_lattice, pressure_outlet_lattice

Optional inlet and outlet lattice pressures. If both are provided they define the pressure BC directly.

pressure_drop_lattice float | None

Optional lattice pressure drop. When set without explicit inlet/outlet pressures, it is applied relative to reference_density_lattice.

reference_density_lattice float

Reference lattice density used to construct a baseline outlet pressure when only pressure_drop_lattice is provided.

rho_inlet, rho_outlet

Legacy density-based BC inputs retained for backward compatibility. They are converted internally to lattice pressure using p_lu = c_s^2 rho.

inlet_outlet_buffer_cells int

Number of fluid reservoir layers inserted ahead of and behind the sample.

max_steps, min_steps, check_interval, steady_rtol

Iteration and convergence controls for the steady-state solve.

Notes

The current voids adapter uses XLB's JAX backend only. This keeps the dependency path compatible with CPU-only macOS and Linux environments.

The currently exposed XLB operator is the incompressible Navier-Stokes lattice-Boltzmann stepper. Setting formulation="steady_stokes_limit" does not switch to a different PDE solver; it selects conservative forcing and convergence defaults so the converged solution can be interpreted in the steady creeping-flow limit.

In this isothermal LBM setting, lattice pressure satisfies p_lu = c_s^2 rho. The preferred public inputs are therefore the lattice pressure fields pressure_inlet_lattice / pressure_outlet_lattice or the pressure drop pressure_drop_lattice. The legacy fields rho_inlet and rho_outlet remain supported for backward compatibility and are converted internally to pressure.

Source code in src/voids/lbm/singlephase/xlb.py
@dataclass(slots=True)
class XLBOptions:
    """Numerical controls for the direct-image XLB solver.

    Attributes
    ----------
    formulation :
        Either ``"incompressible_navier_stokes"`` or
        ``"steady_stokes_limit"``.
    backend :
        XLB compute backend. The current `voids` adapter supports only
        ``"jax"``.
    precision_policy :
        XLB precision policy name, for example ``"FP32FP32"``.
    collision_model :
        XLB collision operator label passed to the stepper.
    streaming_scheme :
        XLB streaming scheme label passed to the stepper.
    lattice_viscosity :
        Kinematic viscosity in lattice units.
    pressure_inlet_lattice, pressure_outlet_lattice :
        Optional inlet and outlet lattice pressures. If both are provided they
        define the pressure BC directly.
    pressure_drop_lattice :
        Optional lattice pressure drop. When set without explicit inlet/outlet
        pressures, it is applied relative to ``reference_density_lattice``.
    reference_density_lattice :
        Reference lattice density used to construct a baseline outlet pressure
        when only ``pressure_drop_lattice`` is provided.
    rho_inlet, rho_outlet :
        Legacy density-based BC inputs retained for backward compatibility.
        They are converted internally to lattice pressure using
        ``p_lu = c_s^2 rho``.
    inlet_outlet_buffer_cells :
        Number of fluid reservoir layers inserted ahead of and behind the
        sample.
    max_steps, min_steps, check_interval, steady_rtol :
        Iteration and convergence controls for the steady-state solve.

    Notes
    -----
    The current `voids` adapter uses XLB's JAX backend only. This keeps the
    dependency path compatible with CPU-only macOS and Linux environments.

    The currently exposed XLB operator is the incompressible Navier-Stokes
    lattice-Boltzmann stepper. Setting ``formulation="steady_stokes_limit"``
    does not switch to a different PDE solver; it selects conservative forcing
    and convergence defaults so the converged solution can be interpreted in the
    steady creeping-flow limit.

    In this isothermal LBM setting, lattice pressure satisfies
    ``p_lu = c_s^2 rho``. The preferred public inputs are therefore the lattice
    pressure fields ``pressure_inlet_lattice`` / ``pressure_outlet_lattice`` or
    the pressure drop ``pressure_drop_lattice``. The legacy fields ``rho_inlet``
    and ``rho_outlet`` remain supported for backward compatibility and are
    converted internally to pressure.
    """

    formulation: str = "incompressible_navier_stokes"
    backend: str = "jax"
    precision_policy: str = "FP32FP32"
    collision_model: str = "BGK"
    streaming_scheme: str = "pull"
    lattice_viscosity: float = 0.10
    pressure_inlet_lattice: float | None = None
    pressure_outlet_lattice: float | None = None
    pressure_drop_lattice: float | None = DEFAULT_PRESSURE_DROP_LATTICE
    reference_density_lattice: float = DEFAULT_REFERENCE_DENSITY_LATTICE
    rho_inlet: float | None = None
    rho_outlet: float | None = None
    inlet_outlet_buffer_cells: int = 6
    max_steps: int = 2000
    min_steps: int = 200
    check_interval: int = 100
    steady_rtol: float = 1.0e-3

    @classmethod
    def steady_stokes_defaults(cls, **overrides: float | int | str) -> "XLBOptions":
        """Return a conservative preset for the steady creeping-flow limit.

        Notes
        -----
        XLB does not currently expose a separate Stokes-only stepper in the
        installed package used by `voids`. This preset therefore still uses the
        incompressible Navier-Stokes LBM operator, but with a smaller lattice
        pressure drop and tighter steady-state controls so the converged solution is
        interpreted in the low-Reynolds, low-Mach limit.

        The buffer and convergence controls are intentionally stricter than the
        generic :class:`XLBOptions` defaults. They were selected from same-ROI
        DRP-317 sensitivity runs as a conservative direct-image permeability
        preset, not as a fit to experimental permeability.
        """

        values: dict[str, Any] = {
            "formulation": "steady_stokes_limit",
            "lattice_viscosity": 0.10,
            "pressure_drop_lattice": DEFAULT_STOKES_PRESSURE_DROP_LATTICE,
            "inlet_outlet_buffer_cells": 12,
            "max_steps": 8000,
            "min_steps": 1200,
            "check_interval": 100,
            "steady_rtol": 1.0e-4,
        }
        values.update(overrides)
        return cls(**values)

steady_stokes_defaults classmethod

steady_stokes_defaults(**overrides)

Return a conservative preset for the steady creeping-flow limit.

Notes

XLB does not currently expose a separate Stokes-only stepper in the installed package used by voids. This preset therefore still uses the incompressible Navier-Stokes LBM operator, but with a smaller lattice pressure drop and tighter steady-state controls so the converged solution is interpreted in the low-Reynolds, low-Mach limit.

The buffer and convergence controls are intentionally stricter than the generic :class:XLBOptions defaults. They were selected from same-ROI DRP-317 sensitivity runs as a conservative direct-image permeability preset, not as a fit to experimental permeability.

Source code in src/voids/lbm/singlephase/xlb.py
@classmethod
def steady_stokes_defaults(cls, **overrides: float | int | str) -> "XLBOptions":
    """Return a conservative preset for the steady creeping-flow limit.

    Notes
    -----
    XLB does not currently expose a separate Stokes-only stepper in the
    installed package used by `voids`. This preset therefore still uses the
    incompressible Navier-Stokes LBM operator, but with a smaller lattice
    pressure drop and tighter steady-state controls so the converged solution is
    interpreted in the low-Reynolds, low-Mach limit.

    The buffer and convergence controls are intentionally stricter than the
    generic :class:`XLBOptions` defaults. They were selected from same-ROI
    DRP-317 sensitivity runs as a conservative direct-image permeability
    preset, not as a fit to experimental permeability.
    """

    values: dict[str, Any] = {
        "formulation": "steady_stokes_limit",
        "lattice_viscosity": 0.10,
        "pressure_drop_lattice": DEFAULT_STOKES_PRESSURE_DROP_LATTICE,
        "inlet_outlet_buffer_cells": 12,
        "max_steps": 8000,
        "min_steps": 1200,
        "check_interval": 100,
        "steady_rtol": 1.0e-4,
    }
    values.update(overrides)
    return cls(**values)

SegmentedVolumeXLBResult dataclass

Store extraction, porosity, and direct-image XLB benchmark outputs.

Attributes:

Name Type Description
bc PressureBC

Physical pressure BC used on the extracted-network voids solve.

xlb_options XLBOptions

XLB options actually used for the direct-image solve. For the high-level benchmark wrapper these are pressure-coupled so they match the resolved physical pressure drop used on the voids side.

xlb_result XLBDirectSimulationResult

Direct-image XLB result, including resolved lattice pressure diagnostics.

Source code in src/voids/benchmarks/xlb.py
@dataclass(slots=True)
class SegmentedVolumeXLBResult:
    """Store extraction, porosity, and direct-image XLB benchmark outputs.

    Attributes
    ----------
    bc :
        Physical pressure BC used on the extracted-network `voids` solve.
    xlb_options :
        XLB options actually used for the direct-image solve. For the high-level
        benchmark wrapper these are pressure-coupled so they match the resolved
        physical pressure drop used on the `voids` side.
    xlb_result :
        Direct-image XLB result, including resolved lattice pressure diagnostics.
    """

    extract: NetworkExtractionResult
    fluid: FluidSinglePhase
    bc: PressureBC
    options: SinglePhaseOptions
    xlb_options: XLBOptions
    image_porosity: float
    absolute_porosity: float
    effective_porosity: float
    voids_result: SinglePhaseResult
    xlb_result: XLBDirectSimulationResult
    permeability_abs_diff: float
    permeability_rel_diff: float

    def to_record(self) -> dict[str, float | int | str | bool | None]:
        """Return scalar diagnostics suitable for tabulation."""

        k_voids = float((self.voids_result.permeability or {}).get(self.extract.flow_axis, np.nan))
        return {
            "flow_axis": self.extract.flow_axis,
            "phi_image": float(self.image_porosity),
            "phi_abs": float(self.absolute_porosity),
            "phi_eff": float(self.effective_porosity),
            "Np": int(self.extract.net.Np),
            "Nt": int(self.extract.net.Nt),
            "k_voids": k_voids,
            "k_xlb": float(self.xlb_result.permeability),
            "k_abs_diff": float(self.permeability_abs_diff),
            "k_rel_diff": float(self.permeability_rel_diff),
            "voids_mass_balance_error": float(self.voids_result.mass_balance_error),
            "conductance_model": str(self.options.conductance_model),
            "solver_voids": str(self.options.solver),
            "p_inlet_physical": float(self.bc.pin),
            "p_outlet_physical": float(self.bc.pout),
            "dp_physical": float(self.bc.pin - self.bc.pout),
            "extract_backend": str(self.extract.backend),
            "extract_backend_version": self.extract.backend_version,
            "xlb_backend": str(self.xlb_result.backend),
            "xlb_backend_version": self.xlb_result.backend_version,
            "xlb_formulation": str(self.xlb_result.formulation),
            "xlb_velocity_set": str(self.xlb_result.velocity_set),
            "xlb_collision_model": str(self.xlb_result.collision_model),
            "xlb_streaming_scheme": str(self.xlb_result.streaming_scheme),
            "xlb_steps": int(self.xlb_result.n_steps),
            "xlb_converged": bool(self.xlb_result.converged),
            "xlb_convergence_metric": float(self.xlb_result.convergence_metric),
            "xlb_lattice_viscosity": float(self.xlb_result.lattice_viscosity),
            "xlb_p_inlet": float(self.xlb_result.lattice_pressure_inlet),
            "xlb_p_outlet": float(self.xlb_result.lattice_pressure_outlet),
            "xlb_rho_inlet": float(self.xlb_result.lattice_density_inlet),
            "xlb_rho_outlet": float(self.xlb_result.lattice_density_outlet),
            "xlb_dp_lattice": float(self.xlb_result.lattice_pressure_drop),
            "xlb_buffer_cells": int(self.xlb_result.inlet_outlet_buffer_cells),
            "xlb_u_superficial_lattice": float(self.xlb_result.superficial_velocity_lattice),
            "xlb_u_max_lattice": float(self.xlb_result.max_speed_lattice),
            "xlb_mach_max": float(self.xlb_result.max_mach_lattice),
            "xlb_re_voxel_max": float(self.xlb_result.reynolds_voxel_max),
        }

to_record

to_record()

Return scalar diagnostics suitable for tabulation.

Source code in src/voids/benchmarks/xlb.py
def to_record(self) -> dict[str, float | int | str | bool | None]:
    """Return scalar diagnostics suitable for tabulation."""

    k_voids = float((self.voids_result.permeability or {}).get(self.extract.flow_axis, np.nan))
    return {
        "flow_axis": self.extract.flow_axis,
        "phi_image": float(self.image_porosity),
        "phi_abs": float(self.absolute_porosity),
        "phi_eff": float(self.effective_porosity),
        "Np": int(self.extract.net.Np),
        "Nt": int(self.extract.net.Nt),
        "k_voids": k_voids,
        "k_xlb": float(self.xlb_result.permeability),
        "k_abs_diff": float(self.permeability_abs_diff),
        "k_rel_diff": float(self.permeability_rel_diff),
        "voids_mass_balance_error": float(self.voids_result.mass_balance_error),
        "conductance_model": str(self.options.conductance_model),
        "solver_voids": str(self.options.solver),
        "p_inlet_physical": float(self.bc.pin),
        "p_outlet_physical": float(self.bc.pout),
        "dp_physical": float(self.bc.pin - self.bc.pout),
        "extract_backend": str(self.extract.backend),
        "extract_backend_version": self.extract.backend_version,
        "xlb_backend": str(self.xlb_result.backend),
        "xlb_backend_version": self.xlb_result.backend_version,
        "xlb_formulation": str(self.xlb_result.formulation),
        "xlb_velocity_set": str(self.xlb_result.velocity_set),
        "xlb_collision_model": str(self.xlb_result.collision_model),
        "xlb_streaming_scheme": str(self.xlb_result.streaming_scheme),
        "xlb_steps": int(self.xlb_result.n_steps),
        "xlb_converged": bool(self.xlb_result.converged),
        "xlb_convergence_metric": float(self.xlb_result.convergence_metric),
        "xlb_lattice_viscosity": float(self.xlb_result.lattice_viscosity),
        "xlb_p_inlet": float(self.xlb_result.lattice_pressure_inlet),
        "xlb_p_outlet": float(self.xlb_result.lattice_pressure_outlet),
        "xlb_rho_inlet": float(self.xlb_result.lattice_density_inlet),
        "xlb_rho_outlet": float(self.xlb_result.lattice_density_outlet),
        "xlb_dp_lattice": float(self.xlb_result.lattice_pressure_drop),
        "xlb_buffer_cells": int(self.xlb_result.inlet_outlet_buffer_cells),
        "xlb_u_superficial_lattice": float(self.xlb_result.superficial_velocity_lattice),
        "xlb_u_max_lattice": float(self.xlb_result.max_speed_lattice),
        "xlb_mach_max": float(self.xlb_result.max_mach_lattice),
        "xlb_re_voxel_max": float(self.xlb_result.reynolds_voxel_max),
    }

solve_binary_volume_with_xlb

solve_binary_volume_with_xlb(
    phases, *, voxel_size, flow_axis=None, options=None
)

Backward-compatible wrapper for the LBM XLB direct-image solver.

Source code in src/voids/benchmarks/xlb.py
def solve_binary_volume_with_xlb(
    phases: np.ndarray,
    *,
    voxel_size: float,
    flow_axis: str | None = None,
    options: XLBOptions | None = None,
) -> XLBDirectSimulationResult:
    """Backward-compatible wrapper for the LBM XLB direct-image solver."""

    _xlb_backend._import_xlb = _import_xlb
    return _xlb_backend.solve_binary_volume_with_xlb(
        phases,
        voxel_size=voxel_size,
        flow_axis=flow_axis,
        options=options,
    )

benchmark_segmented_volume_with_xlb

benchmark_segmented_volume_with_xlb(
    phases,
    *,
    voxel_size,
    flow_axis=None,
    fluid=None,
    delta_p=None,
    pin=None,
    pout=None,
    options=None,
    xlb_options=None,
    length_unit="m",
    pressure_unit="Pa",
    extraction_kwargs=None,
    provenance_notes=None,
    strict=True,
)

Benchmark a segmented volume against a direct-image XLB solve.

The voids side solves on the extracted pore network. The XLB side solves directly on the binary segmented image through :func:voids.lbm.singlephase.xlb.solve_binary_volume_with_xlb. The wrapper enforces a shared physical pressure drop before comparing permeability.

Source code in src/voids/benchmarks/xlb.py
def benchmark_segmented_volume_with_xlb(
    phases: np.ndarray,
    *,
    voxel_size: float,
    flow_axis: str | None = None,
    fluid: FluidSinglePhase | None = None,
    delta_p: float | None = None,
    pin: float | None = None,
    pout: float | None = None,
    options: SinglePhaseOptions | None = None,
    xlb_options: XLBOptions | None = None,
    length_unit: str = "m",
    pressure_unit: str = "Pa",
    extraction_kwargs: dict[str, object] | None = None,
    provenance_notes: dict[str, object] | None = None,
    strict: bool = True,
) -> SegmentedVolumeXLBResult:
    """Benchmark a segmented volume against a direct-image XLB solve.

    The `voids` side solves on the extracted pore network. The XLB side solves
    directly on the binary segmented image through
    :func:`voids.lbm.singlephase.xlb.solve_binary_volume_with_xlb`. The wrapper
    enforces a shared physical pressure drop before comparing permeability.
    """

    arr = _as_binary_volume(phases)
    image_phi = float(arr.mean())
    pin_used, pout_used, delta_p_physical = resolve_benchmark_pressures(
        delta_p=delta_p,
        pin=pin,
        pout=pout,
    )

    notes = dict(provenance_notes or {})
    notes.setdefault("benchmark_kind", "segmented_volume_xlb")

    extract = extract_spanning_pore_network(
        arr,
        voxel_size=voxel_size,
        flow_axis=flow_axis,
        length_unit=length_unit,
        pressure_unit=pressure_unit,
        extraction_kwargs=extraction_kwargs,
        provenance_notes=notes,
        strict=strict,
    )

    fluid_used = fluid or FluidSinglePhase(viscosity=1.0e-3, density=1.0e3)
    options_used = options or SinglePhaseOptions(
        conductance_model="valvatne_blunt",
        solver="direct",
    )
    xlb_options_used = xlb_options or XLBOptions()

    axis = extract.flow_axis
    inlet_count = int(
        np.asarray(extract.net.pore_labels.get(f"inlet_{axis}min", []), dtype=bool).sum()
    )
    outlet_count = int(
        np.asarray(extract.net.pore_labels.get(f"outlet_{axis}max", []), dtype=bool).sum()
    )
    if extract.net.Np == 0 or inlet_count == 0 or outlet_count == 0:
        raise ValueError(
            "The extracted spanning network is empty or lacks non-empty inlet/outlet pore labels "
            f"for axis '{axis}', so the XLB benchmark cannot be compared against `voids` on this case."
        )

    if fluid_used.density is None or fluid_used.density <= 0.0:
        raise ValueError(
            "benchmark_segmented_volume_with_xlb requires `fluid.density` to map the shared "
            "physical pressure drop into lattice pressure units"
        )
    xlb_options_coupled = _couple_xlb_options_to_physical_pressure_drop(
        xlb_options_used,
        delta_p_physical=delta_p_physical,
        voxel_size=voxel_size,
        fluid=fluid_used,
    )

    bc = make_benchmark_pressure_bc(axis, pin=pin_used, pout=pout_used)
    voids_result = solve(
        extract.net,
        fluid=fluid_used,
        bc=bc,
        axis=axis,
        options=options_used,
    )
    xlb_result = solve_binary_volume_with_xlb(
        arr,
        voxel_size=voxel_size,
        flow_axis=axis,
        options=xlb_options_coupled,
    )

    k_voids = float((voids_result.permeability or {}).get(axis, np.nan))
    k_xlb = float(xlb_result.permeability)

    return SegmentedVolumeXLBResult(
        extract=extract,
        fluid=fluid_used,
        bc=bc,
        options=options_used,
        xlb_options=xlb_options_coupled,
        image_porosity=image_phi,
        absolute_porosity=float(absolute_porosity(extract.net)),
        effective_porosity=float(effective_porosity(extract.net, axis=axis)),
        voids_result=voids_result,
        xlb_result=xlb_result,
        permeability_abs_diff=abs(k_voids - k_xlb),
        permeability_rel_diff=_rel_diff(k_voids, k_xlb),
    )