Skip to content

Physics

The voids.physics sub-package provides petrophysics helpers and the single-phase flow solver.


Petrophysics

voids.physics.petrophysics

PorosityBreakdown dataclass

Minimal porosity bookkeeping container.

Attributes:

Name Type Description
void_volume float

Total connected or absolute void volume used in the calculation.

bulk_volume float

Reference bulk volume of the sample.

porosity float

Ratio void_volume / bulk_volume.

Source code in src/voids/physics/petrophysics.py
@dataclass(slots=True)
class PorosityBreakdown:
    """Minimal porosity bookkeeping container.

    Attributes
    ----------
    void_volume :
        Total connected or absolute void volume used in the calculation.
    bulk_volume :
        Reference bulk volume of the sample.
    porosity :
        Ratio ``void_volume / bulk_volume``.
    """

    void_volume: float
    bulk_volume: float
    porosity: float

absolute_porosity

absolute_porosity(net)

Compute absolute porosity of the networked sample.

Parameters:

Name Type Description Default
net Network

Network with pore and throat volumes plus sample bulk-volume metadata.

required

Returns:

Type Description
float

Absolute porosity defined as phi_abs = V_void / V_bulk.

Source code in src/voids/physics/petrophysics.py
def absolute_porosity(net: Network) -> float:
    """Compute absolute porosity of the networked sample.

    Parameters
    ----------
    net :
        Network with pore and throat volumes plus sample bulk-volume metadata.

    Returns
    -------
    float
        Absolute porosity defined as
        ``phi_abs = V_void / V_bulk``.
    """

    return _void_volume(net) / net.sample.resolved_bulk_volume()

effective_porosity

effective_porosity(net, axis=None, mode=None)

Compute an effective porosity based on connected void space.

Parameters:

Name Type Description Default
net Network

Network with pore and throat volumes.

required
axis str | None

Cartesian axis used for spanning connectivity. When provided, only pores in components spanning the requested inlet/outlet labels contribute.

None
mode str | None

Effective-porosity mode used when axis is omitted. Currently only "boundary_connected" is supported.

None

Returns:

Type Description
float

Effective porosity based on the selected connected subset.

Raises:

Type Description
ValueError

If an unsupported mode is requested.

Notes

Two selection rules are supported:

  • axis is not None: use components spanning inlet/outlet labels along that axis.
  • axis is None and mode == "boundary_connected": include any component touching a pore label whose name starts with inlet or outlet, or the generic boundary label.
Source code in src/voids/physics/petrophysics.py
def effective_porosity(net: Network, axis: str | None = None, mode: str | None = None) -> float:
    """Compute an effective porosity based on connected void space.

    Parameters
    ----------
    net :
        Network with pore and throat volumes.
    axis :
        Cartesian axis used for spanning connectivity. When provided, only pores in
        components spanning the requested inlet/outlet labels contribute.
    mode :
        Effective-porosity mode used when ``axis`` is omitted. Currently only
        ``"boundary_connected"`` is supported.

    Returns
    -------
    float
        Effective porosity based on the selected connected subset.

    Raises
    ------
    ValueError
        If an unsupported mode is requested.

    Notes
    -----
    Two selection rules are supported:

    - ``axis is not None``: use components spanning inlet/outlet labels along that axis.
    - ``axis is None`` and ``mode == "boundary_connected"``: include any component
      touching a pore label whose name starts with ``inlet`` or ``outlet``, or the
      generic ``boundary`` label.
    """

    _, comp_labels = connected_components(net)
    if axis is not None:
        pore_mask = spanning_component_mask(net, axis=axis, labels=comp_labels)
    else:
        if mode is None:
            mode = "boundary_connected"
        if mode != "boundary_connected":
            raise ValueError(f"Unsupported effective porosity mode '{mode}'")
        boundary_ids: list[int] = []
        for name, mask in net.pore_labels.items():
            lname = name.lower()
            if lname.startswith("inlet") or lname.startswith("outlet") or lname == "boundary":
                boundary_ids.extend(np.unique(comp_labels[np.asarray(mask, dtype=bool)]).tolist())
        pore_mask = (
            np.isin(comp_labels, np.unique(boundary_ids))
            if boundary_ids
            else np.zeros(net.Np, dtype=bool)
        )
    return _void_volume(net, pore_mask=pore_mask) / net.sample.resolved_bulk_volume()

connectivity_metrics

connectivity_metrics(net)

Return graph-level connectivity metrics for a network.

Parameters:

Name Type Description Default
net Network

Network to analyze.

required

Returns:

Type Description
ConnectivitySummary

Convenience wrapper around :func:voids.graph.metrics.connectivity_metrics.

Source code in src/voids/physics/petrophysics.py
def connectivity_metrics(net: Network) -> ConnectivitySummary:
    """Return graph-level connectivity metrics for a network.

    Parameters
    ----------
    net :
        Network to analyze.

    Returns
    -------
    ConnectivitySummary
        Convenience wrapper around :func:`voids.graph.metrics.connectivity_metrics`.
    """

    return _connectivity_metrics(net)

Single-Phase Flow

voids.physics.singlephase

FluidSinglePhase dataclass

Single-phase fluid properties used by the flow solver.

Attributes:

Name Type Description
viscosity float | None

Reference dynamic viscosity of the fluid. For constant-viscosity solves, this is the viscosity used everywhere. When viscosity_model is supplied, this scalar remains the reporting/reference viscosity used in permeability calculations unless left as None.

density float | None

Optional fluid density. It is stored for bookkeeping but is not used by the incompressible Darcy-scale solver in v0.1.

viscosity_model TabulatedWaterViscosityModel | None

Optional pressure-temperature viscosity model used to evaluate local pore and throat viscosities during conductance assembly.

Source code in src/voids/physics/singlephase.py
@dataclass(slots=True)
class FluidSinglePhase:
    """Single-phase fluid properties used by the flow solver.

    Attributes
    ----------
    viscosity :
        Reference dynamic viscosity of the fluid. For constant-viscosity solves,
        this is the viscosity used everywhere. When ``viscosity_model`` is
        supplied, this scalar remains the reporting/reference viscosity used in
        permeability calculations unless left as ``None``.
    density :
        Optional fluid density. It is stored for bookkeeping but is not used by the
        incompressible Darcy-scale solver in ``v0.1``.
    viscosity_model :
        Optional pressure-temperature viscosity model used to evaluate local pore
        and throat viscosities during conductance assembly.
    """

    viscosity: float | None = None
    density: float | None = None
    viscosity_model: TabulatedWaterViscosityModel | None = None

    def __post_init__(self) -> None:
        if self.viscosity is None and self.viscosity_model is None:
            raise ValueError("Provide either a constant viscosity or a viscosity_model")

    @property
    def has_variable_viscosity(self) -> bool:
        """Return whether a pressure-dependent viscosity model is active."""

        return self.viscosity_model is not None

    def reference_viscosity(
        self,
        *,
        pressure: float | None = None,
        pin: float | None = None,
        pout: float | None = None,
    ) -> float:
        """Return the scalar reference viscosity used for reporting.

        Notes
        -----
        If ``self.viscosity`` is specified explicitly it always takes precedence.
        Otherwise, when ``viscosity_model`` is active, the midpoint viscosity
        between ``pin`` and ``pout`` is used.
        """

        if self.viscosity is not None:
            return float(self.viscosity)
        if self.viscosity_model is None:
            raise ValueError("No viscosity or viscosity_model is available")
        if pressure is not None:
            return float(
                self.viscosity_model.evaluate(
                    np.asarray([pressure], dtype=float),
                    pin=float(pressure),
                    pout=float(pressure),
                )[0]
            )
        if pin is None or pout is None:
            raise ValueError(
                "Need explicit pressure or both pin and pout when no constant reference viscosity "
                "is stored on the fluid"
            )
        return float(self.viscosity_model.reference_viscosity(pin=float(pin), pout=float(pout)))

has_variable_viscosity property

has_variable_viscosity

Return whether a pressure-dependent viscosity model is active.

reference_viscosity

reference_viscosity(*, pressure=None, pin=None, pout=None)

Return the scalar reference viscosity used for reporting.

Notes

If self.viscosity is specified explicitly it always takes precedence. Otherwise, when viscosity_model is active, the midpoint viscosity between pin and pout is used.

Source code in src/voids/physics/singlephase.py
def reference_viscosity(
    self,
    *,
    pressure: float | None = None,
    pin: float | None = None,
    pout: float | None = None,
) -> float:
    """Return the scalar reference viscosity used for reporting.

    Notes
    -----
    If ``self.viscosity`` is specified explicitly it always takes precedence.
    Otherwise, when ``viscosity_model`` is active, the midpoint viscosity
    between ``pin`` and ``pout`` is used.
    """

    if self.viscosity is not None:
        return float(self.viscosity)
    if self.viscosity_model is None:
        raise ValueError("No viscosity or viscosity_model is available")
    if pressure is not None:
        return float(
            self.viscosity_model.evaluate(
                np.asarray([pressure], dtype=float),
                pin=float(pressure),
                pout=float(pressure),
            )[0]
        )
    if pin is None or pout is None:
        raise ValueError(
            "Need explicit pressure or both pin and pout when no constant reference viscosity "
            "is stored on the fluid"
        )
    return float(self.viscosity_model.reference_viscosity(pin=float(pin), pout=float(pout)))

PressureBC dataclass

Dirichlet pressure boundary conditions.

Attributes:

Name Type Description
inlet_label, outlet_label

Names of pore labels identifying fixed-pressure pores.

pin, pout

Pressure values imposed on inlet and outlet pores.

Notes

For the current incompressible single-phase formulation, the physically relevant quantity is the imposed pressure drop pin - pout. Adding the same constant offset to both values is therefore only a gauge choice.

Source code in src/voids/physics/singlephase.py
@dataclass(slots=True)
class PressureBC:
    """Dirichlet pressure boundary conditions.

    Attributes
    ----------
    inlet_label, outlet_label :
        Names of pore labels identifying fixed-pressure pores.
    pin, pout :
        Pressure values imposed on inlet and outlet pores.

    Notes
    -----
    For the current incompressible single-phase formulation, the physically
    relevant quantity is the imposed pressure drop ``pin - pout``. Adding the
    same constant offset to both values is therefore only a gauge choice.
    """

    inlet_label: str
    outlet_label: str
    pin: float
    pout: float

SinglePhaseOptions dataclass

Numerical and constitutive options for the single-phase solver.

Attributes:

Name Type Description
conductance_model str

Name of the hydraulic conductance model passed to :func:voids.geom.hydraulic.throat_conductance.

solver str

Linear solver backend name.

check_mass_balance bool

If True, compute a normalized divergence residual on free pores.

regularization float | None

Optional diagonal shift added to the matrix before Dirichlet elimination.

nonlinear_max_iterations int

Maximum number of Picard iterations used when viscosity depends on pressure.

nonlinear_pressure_tolerance float

Relative infinity-norm pressure-change tolerance for the Picard loop.

nonlinear_relaxation float

Under-relaxation factor applied to the Picard pressure update.

solver_parameters dict[str, Any]

Optional linear-solver configuration dictionary passed to the inner SciPy linear solves. For Krylov methods, this can include {"preconditioner": "pyamg"}.

nonlinear_solver str

Nonlinear strategy used when viscosity depends on pressure. Supported values are "picard" and "newton".

nonlinear_line_search_reduction float

Backtracking reduction factor used by the damped Newton update.

nonlinear_line_search_max_steps int

Maximum number of backtracking steps attempted by the damped Newton update.

Source code in src/voids/physics/singlephase.py
@dataclass(slots=True)
class SinglePhaseOptions:
    """Numerical and constitutive options for the single-phase solver.

    Attributes
    ----------
    conductance_model :
        Name of the hydraulic conductance model passed to
        :func:`voids.geom.hydraulic.throat_conductance`.
    solver :
        Linear solver backend name.
    check_mass_balance :
        If ``True``, compute a normalized divergence residual on free pores.
    regularization :
        Optional diagonal shift added to the matrix before Dirichlet elimination.
    nonlinear_max_iterations :
        Maximum number of Picard iterations used when viscosity depends on
        pressure.
    nonlinear_pressure_tolerance :
        Relative infinity-norm pressure-change tolerance for the Picard loop.
    nonlinear_relaxation :
        Under-relaxation factor applied to the Picard pressure update.
    solver_parameters :
        Optional linear-solver configuration dictionary passed to the inner
        SciPy linear solves. For Krylov methods, this can include
        ``{"preconditioner": "pyamg"}``.
    nonlinear_solver :
        Nonlinear strategy used when viscosity depends on pressure. Supported
        values are ``"picard"`` and ``"newton"``.
    nonlinear_line_search_reduction :
        Backtracking reduction factor used by the damped Newton update.
    nonlinear_line_search_max_steps :
        Maximum number of backtracking steps attempted by the damped Newton
        update.
    """

    conductance_model: str = "generic_poiseuille"
    solver: str = "direct"
    check_mass_balance: bool = True
    regularization: float | None = None
    nonlinear_max_iterations: int = 25
    nonlinear_pressure_tolerance: float = 1.0e-10
    nonlinear_relaxation: float = 1.0
    solver_parameters: dict[str, Any] = field(default_factory=dict)
    nonlinear_solver: str = "picard"
    nonlinear_line_search_reduction: float = 0.5
    nonlinear_line_search_max_steps: int = 8

SinglePhaseResult dataclass

Results returned by :func:solve.

Attributes:

Name Type Description
pore_pressure ndarray

Pressure solution at pores.

throat_flux ndarray

Volumetric flux on each throat, positive when flowing from throat_conns[:, 0] to throat_conns[:, 1].

throat_conductance ndarray

Throat conductance values used during assembly.

total_flow_rate float

Net inlet flow rate associated with the imposed pressure drop.

permeability dict[str, float] | None

Dictionary containing the apparent permeability for the simulated axis.

residual_norm float

Algebraic residual norm of the solved linear system.

mass_balance_error float

Normalized divergence residual on free pores.

pore_viscosity ndarray | None

Final pore-wise dynamic viscosity values used by the conductance model.

throat_viscosity ndarray | None

Final throat-wise dynamic viscosity values used by the conductance model.

reference_viscosity float | None

Scalar viscosity used when reporting apparent permeability.

solver_info dict[str, Any]

Backend-specific diagnostic information.

Source code in src/voids/physics/singlephase.py
@dataclass(slots=True)
class SinglePhaseResult:
    """Results returned by :func:`solve`.

    Attributes
    ----------
    pore_pressure :
        Pressure solution at pores.
    throat_flux :
        Volumetric flux on each throat, positive when flowing from
        ``throat_conns[:, 0]`` to ``throat_conns[:, 1]``.
    throat_conductance :
        Throat conductance values used during assembly.
    total_flow_rate :
        Net inlet flow rate associated with the imposed pressure drop.
    permeability :
        Dictionary containing the apparent permeability for the simulated axis.
    residual_norm :
        Algebraic residual norm of the solved linear system.
    mass_balance_error :
        Normalized divergence residual on free pores.
    pore_viscosity :
        Final pore-wise dynamic viscosity values used by the conductance model.
    throat_viscosity :
        Final throat-wise dynamic viscosity values used by the conductance model.
    reference_viscosity :
        Scalar viscosity used when reporting apparent permeability.
    solver_info :
        Backend-specific diagnostic information.
    """

    pore_pressure: np.ndarray
    throat_flux: np.ndarray
    throat_conductance: np.ndarray
    total_flow_rate: float
    permeability: dict[str, float] | None
    residual_norm: float
    mass_balance_error: float
    pore_viscosity: np.ndarray | None = None
    throat_viscosity: np.ndarray | None = None
    reference_viscosity: float | None = None
    solver_info: dict[str, Any] = field(default_factory=dict)

solve

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

Solve steady incompressible single-phase flow on a pore network.

Parameters:

Name Type Description Default
net Network

Network containing topology, geometry, and sample metadata.

required
fluid FluidSinglePhase

Fluid properties. Constant viscosity is supported directly; when fluid.viscosity_model is provided the solver performs a nonlinear outer iteration so conductances can depend on the evolving pressure field. Built-in nonlinear strategies are Picard iteration and a damped Newton method using the exact Jacobian of the tabulated viscosity model.

required
bc PressureBC

Pressure boundary conditions.

required
axis str

Macroscopic flow axis used when converting total flow to apparent permeability.

required
options SinglePhaseOptions | None

Optional solver and conductance settings.

None

Returns:

Type Description
SinglePhaseResult

Pressure, flux, conductance, and derived transport metrics.

Raises:

Type Description
ValueError

If the imposed pressure drop is zero, if the viscosity inputs are invalid, or if a thermodynamic viscosity model is used with non-positive boundary pressures.

Notes

The solver assembles a graph-Laplacian system

A p = b

with throat fluxes

q_t = g_t * (p_i - p_j)

where g_t is the hydraulic conductance of throat t. After solving for pore pressure, the apparent permeability is computed from Darcy's law:

K = |Q| * mu * L / (A * |delta_p|)

where Q is total inlet flow rate, mu is a scalar reference viscosity, L is the sample length along axis, and A is the corresponding cross-sectional area. If fluid.viscosity is provided explicitly that value is used as the reference viscosity. Otherwise, when fluid.viscosity_model is active, the midpoint viscosity between pin and pout is used.

Thermodynamic viscosity backends interpret pressure as absolute pressure in Pa. In that case, unlike the constant-viscosity solver, adding a constant offset to both boundary pressures changes the local viscosity field.

Connected components that do not touch any Dirichlet pore are excluded from the linear solve because they form floating pressure blocks. Returned pressures and fluxes on those excluded components are reported as nan.

Source code in src/voids/physics/singlephase.py
def solve(
    net: Network,
    fluid: FluidSinglePhase,
    bc: PressureBC,
    *,
    axis: str,
    options: SinglePhaseOptions | None = None,
) -> SinglePhaseResult:
    """Solve steady incompressible single-phase flow on a pore network.

    Parameters
    ----------
    net :
        Network containing topology, geometry, and sample metadata.
    fluid :
        Fluid properties. Constant viscosity is supported directly; when
        ``fluid.viscosity_model`` is provided the solver performs a nonlinear
        outer iteration so conductances can depend on the evolving pressure
        field. Built-in nonlinear strategies are Picard iteration and a damped
        Newton method using the exact Jacobian of the tabulated viscosity model.
    bc :
        Pressure boundary conditions.
    axis :
        Macroscopic flow axis used when converting total flow to apparent permeability.
    options :
        Optional solver and conductance settings.

    Returns
    -------
    SinglePhaseResult
        Pressure, flux, conductance, and derived transport metrics.

    Raises
    ------
    ValueError
        If the imposed pressure drop is zero, if the viscosity inputs are
        invalid, or if a thermodynamic viscosity model is used with non-positive
        boundary pressures.

    Notes
    -----
    The solver assembles a graph-Laplacian system

    ``A p = b``

    with throat fluxes

    ``q_t = g_t * (p_i - p_j)``

    where ``g_t`` is the hydraulic conductance of throat ``t``. After solving for
    pore pressure, the apparent permeability is computed from Darcy's law:

    ``K = |Q| * mu * L / (A * |delta_p|)``

    where ``Q`` is total inlet flow rate, ``mu`` is a scalar reference viscosity,
    ``L`` is the sample length along ``axis``, and ``A`` is the corresponding
    cross-sectional area. If ``fluid.viscosity`` is provided explicitly that
    value is used as the reference viscosity. Otherwise, when
    ``fluid.viscosity_model`` is active, the midpoint viscosity between
    ``pin`` and ``pout`` is used.

    Thermodynamic viscosity backends interpret pressure as absolute pressure in
    Pa. In that case, unlike the constant-viscosity solver, adding a constant
    offset to both boundary pressures changes the local viscosity field.

    Connected components that do not touch any Dirichlet pore are excluded from the
    linear solve because they form floating pressure blocks. Returned pressures and
    fluxes on those excluded components are reported as ``nan``.
    """

    options = options or SinglePhaseOptions()
    _validate_options(options)

    values, fixed_mask = _make_dirichlet_vector(net, bc)
    active_pores = _active_bc_component_mask(net, fixed_mask)
    active_net, active_idx, active_throats = induced_subnetwork(net, active_pores)
    active_values = values[active_idx]
    active_fixed_mask = fixed_mask[active_idx]
    if fluid.viscosity_model is not None and min(float(bc.pin), float(bc.pout)) <= 0.0:
        raise ValueError(
            "Thermodynamic viscosity models require positive absolute boundary pressures in Pa"
        )

    reference_viscosity = fluid.reference_viscosity(pin=bc.pin, pout=bc.pout)
    if reference_viscosity <= 0.0:
        raise ValueError("Fluid viscosity must be positive")
    if fluid.viscosity_model is None:
        g_active = _throat_conductance(
            active_net,
            viscosity=reference_viscosity,
            model=options.conductance_model,
        )
        p_active, solver_info, A_bc, b_bc = _solve_active_linear_system(
            active_net,
            g_active,
            active_values=active_values,
            active_fixed_mask=active_fixed_mask,
            options=options,
        )
        pore_mu_active = np.full(active_net.Np, reference_viscosity, dtype=float)
        throat_mu_active = np.full(active_net.Nt, reference_viscosity, dtype=float)
        solver_info = {
            **solver_info,
            "nonlinear_iterations": 0,
            "nonlinear_pressure_change": 0.0,
            "nonlinear_solver": "none",
            "viscosity_backend": "constant",
            "viscosity_temperature": np.nan,
        }
    else:
        nonlinear_solver = (
            _solve_with_variable_viscosity_newton
            if options.nonlinear_solver == "newton"
            else _solve_with_variable_viscosity
        )
        p_active, g_active, pore_mu_active, throat_mu_active, solver_info, A_bc, b_bc = (
            nonlinear_solver(
                active_net,
                fluid=fluid,
                bc=bc,
                active_values=active_values,
                active_fixed_mask=active_fixed_mask,
                options=options,
            )
        )

    p = np.full(net.Np, np.nan, dtype=float)
    p[active_idx] = p_active

    g = np.full(net.Nt, np.nan, dtype=float)
    g[active_throats] = g_active

    pore_mu = np.full(net.Np, np.nan, dtype=float)
    pore_mu[active_idx] = pore_mu_active

    throat_mu = np.full(net.Nt, np.nan, dtype=float)
    throat_mu[active_throats] = throat_mu_active

    q = np.full(net.Nt, np.nan, dtype=float)
    i_active = active_net.throat_conns[:, 0]
    j_active = active_net.throat_conns[:, 1]
    q_active = g_active * (p_active[i_active] - p_active[j_active])
    q[active_throats] = q_active

    inlet_mask = np.asarray(active_net.pore_labels[bc.inlet_label], dtype=bool)
    Q = _inlet_total_flow(active_net, q_active, inlet_mask)
    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)
    K = abs(Q) * reference_viscosity * L / (Axs * abs(dP))

    res = residual_norm(A_bc, p_active, b_bc)
    mbe = (
        _mass_balance_error(active_net, q_active, active_fixed_mask)
        if options.check_mass_balance
        else float("nan")
    )

    return SinglePhaseResult(
        pore_pressure=p,
        throat_flux=q,
        throat_conductance=g,
        total_flow_rate=Q,
        permeability={axis: float(K)},
        residual_norm=res,
        mass_balance_error=mbe,
        pore_viscosity=pore_mu,
        throat_viscosity=throat_mu,
        reference_viscosity=reference_viscosity,
        solver_info=solver_info,
    )

Transport Utilities

voids.physics.transport

Transport post-processing placeholders for future versions.