Skip to content

Commit b8beda1

Browse files
authored
Merge pull request #4 from PMEAL/dev
Merging dev into main and setting version to 0.1.0, #feature
2 parents f698fe2 + 413fe3f commit b8beda1

21 files changed

Lines changed: 946 additions & 410 deletions

CLAUDE.md renamed to .claude/CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
### Source layout
88
- `kabs/_single_phase_solver.py``SinglePhaseSolver` class: D3Q19 MRT-LBM Taichi kernels (collision, streaming, BCs)
9-
- `kabs/_solve_flow.py``solve_flow()` entry point and `FlowResult` container class; sets BCs, runs loop, handles convergence/VTK export; returns a `FlowResult`
9+
- `kabs/_solve_flow.py``solve_flow()` entry point and `FlowResult` container class; sets BCs, runs loop, handles convergence; returns a `FlowResult`
1010
- `kabs/_compute_permeability.py``compute_permeability()`: accepts a `FlowResult`, applies Darcy's law
1111
- `kabs/_compute_hydraulic_conductance.py``compute_hydraulic_conductance()`: accepts a `FlowResult`, computes Q/ΔP conductance
1212
- `kabs/utils.py``read_flow_vtr()` / `write_flow_vtr()`: VTR file I/O (pure `numpy` + `struct`, no pyvista dependency); embeds `direction` and `nu` as an XML comment for round-trip fidelity
@@ -31,8 +31,8 @@
3131
- For the cylinders test image (`r=10`, `ν=1/6`): converges around 500–1000 steps
3232

3333
### VTR output
34-
- Written via `result.export_to_vtk(prefix)` or automatically when `export_vtk=True` (default)
35-
- Filename: `{prefix}-{final_step}-{axis}.vtr` (step count reflects actual converged step, not `n_steps`)
34+
- Written via `result.export_to_vtk(prefix)` on the `FlowResult`, or via `write_flow_vtr(prefix, result)` from `kabs.utils`
35+
- Filename: `{prefix}.vtr` (caller controls the full prefix, including any step/axis suffix if desired)
3636
- Flow VTR contains: `Solid` (int8), `rho` (float32), `velocity` (3-component float32)
3737
- `direction` and `nu` embedded as an XML comment (`<!-- kabs-meta ... -->`) for round-trip fidelity
3838
- Read back with `read_flow_vtr(path)` from `kabs.utils`; uses only `numpy` + `struct` (no pyvista dependency)

.codecov.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
codecov:
2+
branch: dev
3+
max_report_age: off
4+
5+
coverage:
6+
precision: 1
7+
round: down
8+
range: "50...100"
9+
10+
status:
11+
project:
12+
default:
13+
target: auto
14+
threshold: 2%
15+
branches: null
16+
carryforward: true
17+
18+
patch:
19+
default:
20+
target: auto
21+
threshold: 2%
22+
branches: null
23+
24+
comment:
25+
layout: "header, diff, changes, sunburst, uncovered"
26+
branches: null
27+
behavior: default

.github/workflows/bump-version.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ jobs:
1010
runs-on: ubuntu-latest
1111

1212
steps:
13-
- uses: actions/checkout@v6
13+
- uses: actions/checkout@v4
1414
with:
1515
token: ${{ secrets.PAT }}
1616
fetch-depth: 0
1717

18-
- uses: astral-sh/setup-uv@v7
18+
- uses: astral-sh/setup-uv@v5
1919
with:
2020
python-version: "3.13"
2121
enable-cache: false
@@ -29,6 +29,9 @@ jobs:
2929
if: github.ref == 'refs/heads/dev'
3030
run: |
3131
uvx bump-my-version bump pre_n --no-tag
32+
uv lock
33+
git add uv.lock
34+
git commit --amend --no-edit
3235
git push
3336
3437
- name: Bump version (release)
@@ -41,6 +44,9 @@ jobs:
4144
elif [[ "${{ github.event.head_commit.message }}" == *"#major"* ]]; then
4245
uvx bump-my-version bump major
4346
fi
47+
uv lock
48+
git add uv.lock
49+
git commit --amend --no-edit
4450
git push --follow-tags
4551
4652
- name: Merge release back into dev

.github/workflows/generate-docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929

3030
- name: Install dependencies
3131
run: |
32-
uv sync
32+
uv sync --group dev
3333
uv pip install "quartodoc" "griffe<1.0"
3434
3535
- name: Build the documentation

.github/workflows/tests.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,28 @@ jobs:
1010
test:
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v4
13+
- uses: actions/checkout@v6
1414

1515
- name: Install uv
16-
uses: astral-sh/setup-uv@v5
16+
uses: astral-sh/setup-uv@v7
1717
with:
1818
enable-cache: true
1919

2020
- name: Set up Python
21-
uses: actions/setup-python@v5
21+
uses: actions/setup-python@v6
2222
with:
2323
python-version: "3.12"
2424

2525
- name: Install dependencies
2626
run: uv sync --group dev
2727

2828
- name: Run tests
29-
run: uv run pytest tests/ -v
29+
run: uv run pytest tests/ -v --cov=kabs --cov-report=xml
30+
31+
- name: Upload coverage to Codecov
32+
uses: codecov/codecov-action@v5
33+
with:
34+
token: ${{ secrets.CODECOV_TOKEN }}
35+
files: ./coverage.xml
36+
flags: unittests
37+
name: codecov-umbrella

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
__pycache__
22
*.vtr
33
.DS_Store
4+
.coverage
5+
coverage.xml

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# `kabs`
22

3+
[![Tests](https://github.com/PMEAL/kabs/actions/workflows/tests.yml/badge.svg)](https://github.com/PMEAL/kabs/actions/workflows/tests.yml)
4+
[![codecov](https://codecov.io/gh/PMEAL/kabs/branch/dev/graph/badge.svg)](https://codecov.io/gh/PMEAL/kabs)
5+
36
`kabs` computes the absolute (Darcy) permeability of a porous material from its 3D tomographic image using the Lattice Boltzmann Method (LBM). Given a binary voxel image of the pore space, it solves single-phase incompressible creeping flow, returning results in lattice units or physical units.
47

58
![](taichi_lbm.png)
@@ -36,7 +39,7 @@ ti.init(arch=ti.cpu) # use ti.gpu for GPU acceleration
3639
im = ps.generators.cylinders([200, 200, 200], r=10, porosity=0.7).astype(int)
3740

3841
# Run the LBM simulation and get a FlowResult back
39-
result = solve_flow(im, direction="x", export_vtk=False)
42+
result = solve_flow(im, direction="x")
4043

4144
# Optionally save to a VTR file for later inspection
4245
result.export_to_vtk("sample")
@@ -93,14 +96,21 @@ compute_permeability(result)
9396
The convergence check fires every `log_every` steps (default 500), so the true stopping
9497
point is rounded to that interval.
9598

99+
To save the converged result to a VTR file, call `export_to_vtk` on the returned object:
100+
101+
```python
102+
result = solve_flow(im, direction="x", tol=1e-4)
103+
result.export_to_vtk("sample") # writes sample-<step>-x.vtr
104+
```
105+
96106
### Full permeability tensor
97107

98108
For anisotropic materials, run all three directions:
99109

100110
```python
101111
results = {}
102112
for ax in ("x", "y", "z"):
103-
result = solve_flow(im, direction=ax, export_vtk=False)
113+
result = solve_flow(im, direction=ax)
104114
results[ax] = compute_permeability(result, dx_m=2.85e-6)
105115

106116
print(f"Kx={results['x']['k_mD']:.2f} Ky={results['y']['k_mD']:.2f} Kz={results['z']['k_mD']:.2f} mD")
@@ -124,7 +134,7 @@ For images with a high solid fraction, enable sparse storage so only pore voxels
124134
allocated in GPU memory:
125135

126136
```python
127-
solver = solve_flow(im, direction="x", sparse=True)
137+
result = solve_flow(im, direction="x", sparse=True)
128138
```
129139

130140
## Return values (permeability)

docs/taichi_lbm.png

204 KB
Loading

kabs/_compute_hydraulic_conductance.py

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,18 @@
1919
dx_m = physical voxel size in metres
2020
"""
2121

22-
2322
import numpy as np
2423

2524

2625
__all__ = ["compute_hydraulic_conductance"]
2726

2827

29-
_RHO_IN = 1.00
28+
_RHO_IN = 1.00
3029
_RHO_OUT = 0.99
3130

3231

3332
def compute_hydraulic_conductance(
34-
source,
33+
soln,
3534
direction=None,
3635
nu=None,
3736
dx_m=None,
@@ -47,18 +46,14 @@ def compute_hydraulic_conductance(
4746
4847
Parameters
4948
----------
50-
source : FlowResult or str/path-like
51-
Either a ``FlowResult`` returned by ``solve_flow()``, or a path to a
52-
``.vtr`` file written by ``SinglePhaseSolver.export_VTK()``.
53-
When a ``FlowResult`` is given, ``direction`` and ``nu`` default to the
54-
values stored in the result.
49+
soln : FlowResult
50+
A ``FlowResult`` returned by ``solve_flow()``. ``direction`` and
51+
``nu`` default to the values stored in the result.
5552
direction : {'x', 'y', 'z'} or None
56-
Flow direction. If *None* and ``source`` is a ``FlowResult``, taken
57-
from ``source.direction``; otherwise defaults to ``'x'``.
53+
Flow direction. If *None*, taken from ``soln.direction``.
5854
nu : float or None
5955
Kinematic viscosity used in the LBM simulation (lattice units).
60-
If *None* and ``source`` is a ``FlowResult``, taken from ``source.nu``;
61-
otherwise defaults to 1/6.
56+
If *None*, taken from ``soln.nu``.
6257
dx_m : float or None
6358
Physical voxel size in metres. Required for physical-unit output.
6459
mu_phys : float or None
@@ -76,11 +71,12 @@ def compute_hydraulic_conductance(
7671
Q_m3s – volumetric flow rate in m^3/s (None if dx_m/mu_phys not given)
7772
dP_Pa – total pressure drop in Pa (None if dx_m/mu_phys not given)
7873
g_SI – conductance in m^3/(Pa·s) (None if dx_m/mu_phys not given)
74+
summary – human-readable result summary string (always populated)
7975
"""
80-
_dir = direction if direction is not None else source.direction
81-
_nu = nu if nu is not None else source.nu
82-
solid = source.solid
83-
velocity = source.velocity
76+
_dir = direction if direction is not None else soln.direction
77+
_nu = nu if nu is not None else soln.nu
78+
solid = soln.solid
79+
velocity = soln.velocity
8480

8581
_dir = _dir.lower()
8682
if _dir not in ("x", "y", "z"):
@@ -94,53 +90,68 @@ def compute_hydraulic_conductance(
9490
vz = velocity[..., 2]
9591
nx, ny, nz = solid.shape
9692

97-
v_flow = {"x": vx, "y": vy, "z": vz }[_dir]
98-
L_flow = {"x": nx, "y": ny, "z": nz }[_dir]
93+
v_flow = {"x": vx, "y": vy, "z": vz}[_dir]
94+
L_flow = {"x": nx, "y": ny, "z": nz}[_dir]
9995
A_cross = {"x": ny * nz, "y": nx * nz, "z": nx * ny}[_dir]
10096

10197
u_darcy = float(np.mean(v_flow))
102-
Q_lu = u_darcy * A_cross
103-
dP_lu = (_RHO_IN - _RHO_OUT) * cs2
104-
g_lu = Q_lu / dP_lu
98+
Q_lu = u_darcy * A_cross
99+
dP_lu = (_RHO_IN - _RHO_OUT) * cs2
100+
g_lu = Q_lu / dP_lu
105101

106102
Q_m3s = dP_Pa = g_SI = None
107103
can_convert = (dx_m is not None) and (mu_phys is not None)
108104
if can_convert:
109105
g_SI = g_lu * _nu * dx_m**3 / mu_phys
110106

107+
n_pore = int(pore_mask.sum())
108+
porosity = n_pore / pore_mask.size
109+
ax_idx = {"x": 0, "y": 1, "z": 2}[_dir]
110+
slices = np.array([np.sum(np.take(v_flow, i, axis=ax_idx)) for i in range(L_flow)])
111+
lines = [
112+
f"",
113+
f"Flow direction = {_dir}",
114+
f"Conduit length = {L_flow} [lu]",
115+
f"Cross-section area = {A_cross} [lu^2]",
116+
f"Pore voxels = {n_pore} (porosity = {porosity:.4f})",
117+
f"",
118+
f"Darcy velocity u_D = {u_darcy:.6e} [lu/ts]",
119+
f"Volumetric flow Q = {Q_lu:.6e} [lu^3/ts]",
120+
f"Pressure drop dP = {dP_lu:.6f} [lu pressure]",
121+
f"",
122+
f"Conductance g = {g_lu:.6e} [lu^3/ts / lu_pressure]",
123+
]
124+
if can_convert:
125+
lines += [
126+
f"",
127+
f"With dx = {dx_m:.4e} m and mu = {mu_phys:.4e} Pa·s:",
128+
f" g = {g_SI:.4e} m^3/(Pa·s)",
129+
]
130+
elif dx_m is None:
131+
lines.append(
132+
"To get physical units: pass dx_m (voxel size in metres) and mu_phys (dynamic viscosity in Pa·s)."
133+
)
134+
else:
135+
lines.append(
136+
"To get physical units: also pass mu_phys (dynamic viscosity in Pa·s)."
137+
)
138+
lines += [
139+
f"",
140+
f"--- Sanity check: per-slice Q (should be constant) ---",
141+
f" Q_slice min={slices.min():.4e} max={slices.max():.4e}"
142+
f" mean={slices.mean():.4e} std={slices.std():.4e}",
143+
]
144+
summary = "\n".join(lines)
145+
111146
if verbose:
112-
n_pore = int(pore_mask.sum())
113-
porosity = n_pore / pore_mask.size
114-
print(f"\nFlow direction = {_dir}")
115-
print(f"Conduit length = {L_flow} [lu]")
116-
print(f"Cross-section area = {A_cross} [lu^2]")
117-
print(f"Pore voxels = {n_pore} (porosity = {porosity:.4f})")
118-
print(f"\nDarcy velocity u_D = {u_darcy:.6e} [lu/ts]")
119-
print(f"Volumetric flow Q = {Q_lu:.6e} [lu^3/ts]")
120-
print(f"Pressure drop dP = {dP_lu:.6f} [lu pressure]")
121-
print(f"\nConductance g = {g_lu:.6e} [lu^3/ts / lu_pressure]")
122-
if can_convert:
123-
print(f"\nWith dx = {dx_m:.4e} m and mu = {mu_phys:.4e} Pa·s:")
124-
print(f" g = {g_SI:.4e} m^3/(Pa·s)")
125-
elif dx_m is None:
126-
print("\nTo get physical units: pass dx_m (voxel size in metres)"
127-
" and mu_phys (dynamic viscosity in Pa·s).")
128-
else:
129-
print("\nTo get physical units: also pass mu_phys (dynamic viscosity in Pa·s).")
130-
131-
ax_idx = {"x": 0, "y": 1, "z": 2}[_dir]
132-
slices = np.array([np.sum(np.take(v_flow, i, axis=ax_idx)) for i in range(L_flow)])
133-
print(f"\n--- Sanity check: per-slice Q (should be constant) ---")
134-
print(f" Q_slice min={slices.min():.4e} max={slices.max():.4e}"
135-
f" mean={slices.mean():.4e} std={slices.std():.4e}")
147+
print(summary)
136148

137149
return {
138-
"Q_lu": Q_lu,
150+
"Q_lu": Q_lu,
139151
"dP_lu": dP_lu,
140-
"g_lu": g_lu,
152+
"g_lu": g_lu,
141153
"Q_m3s": Q_m3s,
142154
"dP_Pa": dP_Pa,
143-
"g_SI": g_SI,
155+
"g_SI": g_SI,
156+
"summary": summary,
144157
}
145-
146-

0 commit comments

Comments
 (0)