voids 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. voids/__init__.py +8 -0
  2. voids/_logging.py +5 -0
  3. voids/_testing.py +25 -0
  4. voids/benchmarks/__init__.py +29 -0
  5. voids/benchmarks/_shared.py +92 -0
  6. voids/benchmarks/crosscheck.py +353 -0
  7. voids/benchmarks/segmented_volume.py +261 -0
  8. voids/benchmarks/xlb.py +1045 -0
  9. voids/core/__init__.py +6 -0
  10. voids/core/network.py +176 -0
  11. voids/core/provenance.py +83 -0
  12. voids/core/sample.py +165 -0
  13. voids/core/validation.py +140 -0
  14. voids/examples/__init__.py +13 -0
  15. voids/examples/demo.py +117 -0
  16. voids/examples/manufactured.py +73 -0
  17. voids/examples/mesh.py +283 -0
  18. voids/generators/__init__.py +63 -0
  19. voids/generators/network.py +825 -0
  20. voids/generators/porous_image.py +717 -0
  21. voids/generators/vug_templates.py +373 -0
  22. voids/geom/__init__.py +14 -0
  23. voids/geom/characteristic.py +103 -0
  24. voids/geom/hydraulic.py +717 -0
  25. voids/graph/__init__.py +15 -0
  26. voids/graph/connectivity.py +244 -0
  27. voids/graph/incidence.py +34 -0
  28. voids/graph/metrics.py +96 -0
  29. voids/image/__init__.py +30 -0
  30. voids/image/_utils.py +76 -0
  31. voids/image/connectivity.py +97 -0
  32. voids/image/network_extraction.py +265 -0
  33. voids/image/segmentation.py +362 -0
  34. voids/io/__init__.py +13 -0
  35. voids/io/hdf5.py +160 -0
  36. voids/io/openpnm.py +147 -0
  37. voids/io/porespy.py +669 -0
  38. voids/linalg/__init__.py +0 -0
  39. voids/linalg/assemble.py +58 -0
  40. voids/linalg/backends.py +28 -0
  41. voids/linalg/bc.py +67 -0
  42. voids/linalg/diagnostics.py +26 -0
  43. voids/linalg/solve.py +48 -0
  44. voids/paths.py +110 -0
  45. voids/physics/__init__.py +0 -0
  46. voids/physics/petrophysics.py +174 -0
  47. voids/physics/singlephase.py +350 -0
  48. voids/physics/transport.py +1 -0
  49. voids/py.typed +0 -0
  50. voids/simulators/__init__.py +5 -0
  51. voids/simulators/run_singlephase.py +38 -0
  52. voids/version.py +1 -0
  53. voids/visualization/__init__.py +6 -0
  54. voids/visualization/_sizing.py +88 -0
  55. voids/visualization/plotly.py +370 -0
  56. voids/visualization/pyvista.py +305 -0
  57. voids-0.1.4.dist-info/METADATA +266 -0
  58. voids-0.1.4.dist-info/RECORD +60 -0
  59. voids-0.1.4.dist-info/WHEEL +4 -0
  60. voids-0.1.4.dist-info/licenses/LICENSE +504 -0
voids/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """voids: pore network modeling scientific toolkit (v0.1.x)."""
2
+
3
+ from voids.version import __version__
4
+ from voids.core.network import Network
5
+ from voids.core.sample import SampleGeometry
6
+ from voids.core.provenance import Provenance
7
+
8
+ __all__ = ["__version__", "Network", "SampleGeometry", "Provenance"]
voids/_logging.py ADDED
@@ -0,0 +1,5 @@
1
+ """Internal logging configuration for voids package."""
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("voids")
voids/_testing.py ADDED
@@ -0,0 +1,25 @@
1
+ """Internal testing utilities for voids package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ import numpy as np
8
+
9
+
10
+ def set_seed(seed: int) -> None:
11
+ """Set deterministic seeds for the standard library and NumPy RNGs.
12
+
13
+ Parameters
14
+ ----------
15
+ seed :
16
+ Integer seed applied to :mod:`random` and :mod:`numpy.random`.
17
+
18
+ Notes
19
+ -----
20
+ The helper is intentionally narrow and does not configure any external or
21
+ accelerator-backed random-number generators.
22
+ """
23
+
24
+ random.seed(seed)
25
+ np.random.seed(seed)
@@ -0,0 +1,29 @@
1
+ from voids.benchmarks.crosscheck import (
2
+ SinglePhaseCrosscheckSummary,
3
+ crosscheck_singlephase_roundtrip_openpnm_dict,
4
+ crosscheck_singlephase_with_openpnm,
5
+ )
6
+ from voids.benchmarks.segmented_volume import (
7
+ SegmentedVolumeCrosscheckResult,
8
+ benchmark_segmented_volume_with_openpnm,
9
+ )
10
+ from voids.benchmarks.xlb import (
11
+ SegmentedVolumeXLBResult,
12
+ XLBDirectSimulationResult,
13
+ XLBOptions,
14
+ benchmark_segmented_volume_with_xlb,
15
+ solve_binary_volume_with_xlb,
16
+ )
17
+
18
+ __all__ = [
19
+ "SinglePhaseCrosscheckSummary",
20
+ "SegmentedVolumeCrosscheckResult",
21
+ "SegmentedVolumeXLBResult",
22
+ "XLBDirectSimulationResult",
23
+ "XLBOptions",
24
+ "benchmark_segmented_volume_with_openpnm",
25
+ "benchmark_segmented_volume_with_xlb",
26
+ "crosscheck_singlephase_roundtrip_openpnm_dict",
27
+ "crosscheck_singlephase_with_openpnm",
28
+ "solve_binary_volume_with_xlb",
29
+ ]
@@ -0,0 +1,92 @@
1
+ """Shared helpers for high-level segmented-volume benchmarks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from voids.physics.singlephase import PressureBC
8
+
9
+ DEFAULT_BENCHMARK_PRESSURE_DROP = 1.0
10
+ DEFAULT_BENCHMARK_REFERENCE_PRESSURE = 0.0
11
+
12
+
13
+ def resolve_benchmark_pressures(
14
+ *,
15
+ delta_p: float | None = None,
16
+ pin: float | None = None,
17
+ pout: float | None = None,
18
+ default_delta_p: float = DEFAULT_BENCHMARK_PRESSURE_DROP,
19
+ default_reference_pressure: float = DEFAULT_BENCHMARK_REFERENCE_PRESSURE,
20
+ ) -> tuple[float, float, float]:
21
+ """Resolve physical pressure inputs for a high-level segmented-volume benchmark.
22
+
23
+ Notes
24
+ -----
25
+ The current high-level segmented-volume benchmarks are formulated for
26
+ incompressible permeability estimation. In that setting, the physically
27
+ relevant input is the imposed pressure drop ``delta_p = pin - pout``.
28
+ Absolute pressure offsets are therefore treated as a gauge choice: they can
29
+ be accepted for clarity or provenance, but they do not alter the current
30
+ permeability estimate as long as ``delta_p`` is unchanged.
31
+ """
32
+
33
+ delta_p_input = None if delta_p is None else float(delta_p)
34
+ pin_input = None if pin is None else float(pin)
35
+ pout_input = None if pout is None else float(pout)
36
+
37
+ if delta_p_input is None and pin_input is None and pout_input is None:
38
+ delta_p_input = float(default_delta_p)
39
+ pout_input = float(default_reference_pressure)
40
+ pin_input = pout_input + delta_p_input
41
+ elif delta_p_input is None:
42
+ if pin_input is None or pout_input is None:
43
+ raise ValueError(
44
+ "Provide either `delta_p`, or both `pin` and `pout`, for a "
45
+ "high-level segmented-volume benchmark"
46
+ )
47
+ delta_p_input = pin_input - pout_input
48
+ else:
49
+ if pin_input is None and pout_input is None:
50
+ pout_input = float(default_reference_pressure)
51
+ pin_input = pout_input + delta_p_input
52
+ elif pin_input is None:
53
+ assert pout_input is not None
54
+ pin_input = pout_input + delta_p_input
55
+ elif pout_input is None:
56
+ pout_input = pin_input - delta_p_input
57
+ else:
58
+ if not math.isclose(
59
+ pin_input - pout_input,
60
+ delta_p_input,
61
+ rel_tol=1.0e-12,
62
+ abs_tol=1.0e-12,
63
+ ):
64
+ raise ValueError(
65
+ "Inconsistent pressure inputs: `delta_p` must equal `pin - pout` "
66
+ "when all three are provided"
67
+ )
68
+
69
+ assert delta_p_input is not None
70
+ assert pin_input is not None
71
+ assert pout_input is not None
72
+
73
+ if not all(math.isfinite(value) for value in (delta_p_input, pin_input, pout_input)):
74
+ raise ValueError("Benchmark pressure inputs must be finite")
75
+ if delta_p_input <= 0.0 or pin_input <= pout_input:
76
+ raise ValueError(
77
+ "High-level segmented-volume benchmarks require a positive physical "
78
+ "pressure drop (`delta_p > 0`, `pin > pout`)"
79
+ )
80
+ return pin_input, pout_input, delta_p_input
81
+
82
+
83
+ def make_benchmark_pressure_bc(axis: str, *, pin: float, pout: float) -> PressureBC:
84
+ """Return the standard inlet/outlet pressure BC for a benchmark axis."""
85
+
86
+ pin_float, pout_float, _ = resolve_benchmark_pressures(pin=pin, pout=pout)
87
+ return PressureBC(
88
+ f"inlet_{axis}min",
89
+ f"outlet_{axis}max",
90
+ pin=pin_float,
91
+ pout=pout_float,
92
+ )
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from voids.core.network import Network
9
+ from voids.io.openpnm import to_openpnm_dict, to_openpnm_network
10
+ from voids.io.porespy import from_porespy
11
+ from voids.physics.singlephase import (
12
+ FluidSinglePhase,
13
+ PressureBC,
14
+ SinglePhaseOptions,
15
+ SinglePhaseResult,
16
+ solve,
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SinglePhaseCrosscheckSummary:
22
+ """Summary of a solver-to-reference comparison.
23
+
24
+ Attributes
25
+ ----------
26
+ reference :
27
+ Name of the reference implementation or workflow.
28
+ axis :
29
+ Flow axis used in the comparison.
30
+ permeability_abs_diff, permeability_rel_diff :
31
+ Absolute and relative differences between apparent permeabilities.
32
+ total_flow_abs_diff, total_flow_rel_diff :
33
+ Absolute and relative differences between total flow rates.
34
+ details :
35
+ Auxiliary metadata useful for debugging and reporting.
36
+ """
37
+
38
+ reference: str
39
+ axis: str
40
+ permeability_abs_diff: float
41
+ permeability_rel_diff: float
42
+ total_flow_abs_diff: float
43
+ total_flow_rel_diff: float
44
+ details: dict[str, Any]
45
+
46
+
47
+ def _rel_diff(a: float, b: float) -> float:
48
+ """Compute a symmetric relative difference.
49
+
50
+ Parameters
51
+ ----------
52
+ a, b :
53
+ Values to compare.
54
+
55
+ Returns
56
+ -------
57
+ float
58
+ Relative difference ``abs(a - b) / max(abs(a), abs(b), 1e-30)``.
59
+ """
60
+
61
+ denom = max(abs(a), abs(b), 1e-30)
62
+ return abs(a - b) / denom
63
+
64
+
65
+ def _summary_from_values(
66
+ *,
67
+ reference: str,
68
+ axis: str,
69
+ k_voids: float,
70
+ k_ref: float,
71
+ q_voids: float,
72
+ q_ref: float,
73
+ details: dict[str, Any],
74
+ ) -> SinglePhaseCrosscheckSummary:
75
+ """Build a crosscheck summary from scalar transport metrics.
76
+
77
+ Parameters
78
+ ----------
79
+ reference :
80
+ Name of the reference implementation.
81
+ axis :
82
+ Flow axis.
83
+ k_voids, k_ref :
84
+ Apparent permeabilities from ``voids`` and the reference.
85
+ q_voids, q_ref :
86
+ Total flow rates from ``voids`` and the reference.
87
+ details :
88
+ Auxiliary metadata to attach to the summary.
89
+
90
+ Returns
91
+ -------
92
+ SinglePhaseCrosscheckSummary
93
+ Comparison summary.
94
+ """
95
+
96
+ return SinglePhaseCrosscheckSummary(
97
+ reference=reference,
98
+ axis=axis,
99
+ permeability_abs_diff=abs(k_voids - k_ref),
100
+ permeability_rel_diff=_rel_diff(k_voids, k_ref),
101
+ total_flow_abs_diff=abs(q_voids - q_ref),
102
+ total_flow_rel_diff=_rel_diff(q_voids, q_ref),
103
+ details={"k_voids": k_voids, "k_ref": k_ref, "Q_voids": q_voids, "Q_ref": q_ref, **details},
104
+ )
105
+
106
+
107
+ def _summary_from_results(
108
+ reference: str, axis: str, r0: SinglePhaseResult, r1: SinglePhaseResult
109
+ ) -> SinglePhaseCrosscheckSummary:
110
+ """Build a summary from two single-phase solver results.
111
+
112
+ Parameters
113
+ ----------
114
+ reference :
115
+ Name of the reference workflow.
116
+ axis :
117
+ Flow axis used for extracting permeability.
118
+ r0, r1 :
119
+ Solver results to compare.
120
+
121
+ Returns
122
+ -------
123
+ SinglePhaseCrosscheckSummary
124
+ Comparison summary.
125
+ """
126
+
127
+ k0 = float((r0.permeability or {}).get(axis, np.nan))
128
+ k1 = float((r1.permeability or {}).get(axis, np.nan))
129
+ q0 = float(r0.total_flow_rate)
130
+ q1 = float(r1.total_flow_rate)
131
+ return _summary_from_values(
132
+ reference=reference, axis=axis, k_voids=k0, k_ref=k1, q_voids=q0, q_ref=q1, details={}
133
+ )
134
+
135
+
136
+ def crosscheck_singlephase_roundtrip_openpnm_dict(
137
+ net: Network,
138
+ fluid: FluidSinglePhase,
139
+ bc: PressureBC,
140
+ *,
141
+ axis: str,
142
+ options: SinglePhaseOptions | None = None,
143
+ ) -> SinglePhaseCrosscheckSummary:
144
+ """Cross-check ``voids`` after a dict roundtrip through OpenPNM-style keys.
145
+
146
+ Parameters
147
+ ----------
148
+ net :
149
+ Network to solve and round-trip.
150
+ fluid :
151
+ Fluid properties.
152
+ bc :
153
+ Pressure boundary conditions.
154
+ axis :
155
+ Flow axis used in the permeability calculation.
156
+ options :
157
+ Optional solver configuration.
158
+
159
+ Returns
160
+ -------
161
+ SinglePhaseCrosscheckSummary
162
+ Comparison between the original ``voids`` solve and the round-tripped solve.
163
+
164
+ Notes
165
+ -----
166
+ This path does not require OpenPNM itself. It checks whether exporting to the
167
+ flat OpenPNM/PoreSpy naming convention and importing back into ``voids`` changes
168
+ any transport-relevant fields.
169
+ """
170
+
171
+ options = options or SinglePhaseOptions()
172
+ r0 = solve(net, fluid=fluid, bc=bc, axis=axis, options=options)
173
+ op_dict = to_openpnm_dict(net)
174
+ net_rt = from_porespy(op_dict, sample=net.sample, provenance=net.provenance)
175
+ r1 = solve(net_rt, fluid=fluid, bc=bc, axis=axis, options=options)
176
+ return _summary_from_results("openpnm_dict_roundtrip", axis, r0, r1)
177
+
178
+
179
+ def _openpnm_phase_factory(op, pn):
180
+ """Construct a compatible OpenPNM phase object.
181
+
182
+ Parameters
183
+ ----------
184
+ op :
185
+ Imported OpenPNM module.
186
+ pn :
187
+ OpenPNM network object.
188
+
189
+ Returns
190
+ -------
191
+ Any
192
+ Phase object compatible with the installed OpenPNM version.
193
+
194
+ Raises
195
+ ------
196
+ RuntimeError
197
+ If no known phase constructor works.
198
+ """
199
+
200
+ for factory in (
201
+ lambda: op.phase.Phase(network=pn),
202
+ lambda: op.phases.GenericPhase(network=pn),
203
+ ):
204
+ try:
205
+ return factory()
206
+ except Exception:
207
+ continue
208
+ raise RuntimeError("Unable to construct OpenPNM phase object")
209
+
210
+
211
+ def _get_openpnm_pressure(sf):
212
+ """Extract pore pressure from an OpenPNM StokesFlow result.
213
+
214
+ Parameters
215
+ ----------
216
+ sf :
217
+ OpenPNM StokesFlow algorithm object.
218
+
219
+ Returns
220
+ -------
221
+ numpy.ndarray
222
+ One-dimensional pore-pressure array.
223
+
224
+ Raises
225
+ ------
226
+ RuntimeError
227
+ If pressure cannot be retrieved from the current OpenPNM API.
228
+ """
229
+
230
+ for getter in (
231
+ lambda: sf["pore.pressure"],
232
+ lambda: sf.soln["pore.pressure"],
233
+ ):
234
+ try:
235
+ arr = np.asarray(getter(), dtype=float)
236
+ if arr.ndim == 1:
237
+ return arr
238
+ except Exception:
239
+ continue
240
+ raise RuntimeError("Unable to extract pore pressures from OpenPNM StokesFlow result")
241
+
242
+
243
+ def crosscheck_singlephase_with_openpnm(
244
+ net: Network,
245
+ fluid: FluidSinglePhase,
246
+ bc: PressureBC,
247
+ *,
248
+ axis: str,
249
+ options: SinglePhaseOptions | None = None,
250
+ ) -> SinglePhaseCrosscheckSummary:
251
+ """Cross-check ``voids`` against OpenPNM StokesFlow.
252
+
253
+ Parameters
254
+ ----------
255
+ net :
256
+ Network to simulate.
257
+ fluid :
258
+ Fluid properties.
259
+ bc :
260
+ Pressure boundary conditions.
261
+ axis :
262
+ Flow axis used for apparent permeability.
263
+ options :
264
+ Optional solver configuration.
265
+
266
+ Returns
267
+ -------
268
+ SinglePhaseCrosscheckSummary
269
+ Comparison between ``voids`` and OpenPNM.
270
+
271
+ Raises
272
+ ------
273
+ ImportError
274
+ If OpenPNM is not installed.
275
+ RuntimeError
276
+ If the installed OpenPNM API is incompatible with the adapter.
277
+ ValueError
278
+ If the imposed pressure drop is zero.
279
+
280
+ Notes
281
+ -----
282
+ The comparison injects the ``voids``-computed ``throat.hydraulic_conductance``
283
+ into OpenPNM. That means the crosscheck isolates differences in system assembly,
284
+ boundary-condition handling, sign conventions, and linear-solver behavior,
285
+ rather than differences in geometric conductance modeling.
286
+ """
287
+
288
+ try:
289
+ import openpnm as op
290
+ except Exception as exc: # pragma: no cover - depends on optional env
291
+ raise ImportError(
292
+ "OpenPNM is not installed. Use the 'test' pixi environment or install openpnm."
293
+ ) from exc
294
+
295
+ options = options or SinglePhaseOptions()
296
+ r_voids = solve(net, fluid=fluid, bc=bc, axis=axis, options=options)
297
+ g = np.asarray(r_voids.throat_conductance, dtype=float)
298
+
299
+ pn = to_openpnm_network(net, copy_properties=False, copy_labels=True)
300
+ phase = _openpnm_phase_factory(op, pn)
301
+ phase["throat.hydraulic_conductance"] = g
302
+
303
+ sf = op.algorithms.StokesFlow(network=pn, phase=phase)
304
+ inlet_mask = np.asarray(net.pore_labels[bc.inlet_label], dtype=bool)
305
+ outlet_mask = np.asarray(net.pore_labels[bc.outlet_label], dtype=bool)
306
+ inlet = np.where(inlet_mask)[0]
307
+ outlet = np.where(outlet_mask)[0]
308
+
309
+ if hasattr(sf, "set_value_BC"):
310
+ sf.set_value_BC(pores=inlet, values=float(bc.pin))
311
+ sf.set_value_BC(pores=outlet, values=float(bc.pout))
312
+ elif hasattr(sf, "set_BC"):
313
+ sf.set_BC(pores=inlet, bctype="value", bcvalues=float(bc.pin))
314
+ sf.set_BC(pores=outlet, bctype="value", bcvalues=float(bc.pout))
315
+ else: # pragma: no cover
316
+ raise RuntimeError("OpenPNM StokesFlow object does not expose a recognizable BC API")
317
+
318
+ sf.run()
319
+ p_ref = _get_openpnm_pressure(sf)
320
+
321
+ q_rate = np.asarray(sf.rate(pores=inlet), dtype=float)
322
+ q_ref_raw = float(q_rate.sum())
323
+ q_ref = q_ref_raw
324
+ if np.isfinite(q_ref) and np.isfinite(r_voids.total_flow_rate):
325
+ if np.isclose(abs(q_ref), abs(r_voids.total_flow_rate), rtol=1e-8, atol=1e-14):
326
+ q_ref = float(np.copysign(abs(q_ref), r_voids.total_flow_rate))
327
+
328
+ dP = float(bc.pin - bc.pout)
329
+ if abs(dP) == 0.0:
330
+ raise ValueError("Pressure drop pin-pout must be nonzero")
331
+ L = net.sample.length_for_axis(axis)
332
+ Axs = net.sample.area_for_axis(axis)
333
+ k_ref = abs(q_ref_raw) * fluid.viscosity * L / (Axs * abs(dP))
334
+ k_voids = float((r_voids.permeability or {}).get(axis, np.nan))
335
+
336
+ return _summary_from_values(
337
+ reference="openpnm_stokesflow",
338
+ axis=axis,
339
+ k_voids=k_voids,
340
+ k_ref=float(k_ref),
341
+ q_voids=float(r_voids.total_flow_rate),
342
+ q_ref=float(q_ref),
343
+ details={
344
+ "openpnm_version": getattr(op, "__version__", "unknown"),
345
+ "q_ref_raw": q_ref_raw,
346
+ "n_inlet_pores": int(inlet.size),
347
+ "n_outlet_pores": int(outlet.size),
348
+ "conductance_model": options.conductance_model,
349
+ "solver_voids": options.solver,
350
+ "p_ref_min": float(np.min(p_ref)) if p_ref.size else np.nan,
351
+ "p_ref_max": float(np.max(p_ref)) if p_ref.size else np.nan,
352
+ },
353
+ )