import json
…
(click to toggle)
import json
import os
import platform
from pathlib import Path
import numpy as np
import ogs
import ogstools as ot
import porepy as pp
import pyvista as pv
from IPython.display import Markdown, display
from numpy.random import default_rng
from scipy.spatial import cKDTree
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
ot.plot.setup.show_Region_bounds = False
…
(click to toggle)
ot.plot.setup.show_Region_bounds = False
out_dir = Path(os.environ.get("OGS_TESTRUNNER_OUT_DIR", "_out"))
if not out_dir.exists():
out_dir.mkdir(parents=True)
Protecting the environment over geological timescales requires isolating hazardous substances (e.g., radionuclides and industrial chemicals) from the biosphere. Accurate prediction of solute transport is essential for the safe design of deep geological repositories and for long-term groundwater contamination protection.
Natural retention processes — matrix diffusion and sorption — serve as critical barriers that delay contaminant migration, helping to prevent their spread through the environment.
Matrix Diffusion: The movement of solutes from regions of high to low concentration within a solid matrix, governed by concentration gradients and diffusion coefficients.
Sorption: The process by which solutes are retained on or in a solid, encompassing adsorption (solutes attaching to the surface) and absorption (solutes entering the solid matrix). This process is influenced by surface chemistry, solute properties, and environmental conditions, and is quantified by the distribution coefficient.
Tracer tests are a primary method for investigating subsurface solute transport and retention. In these experiments, a known tracer is injected into the formation, and its concentration is monitored over time at a recovery location.
Different tracers are used to isolate transport mechanisms:
In crystalline rock formations, groundwater flow occurs primarily through discrete fracture networks (DFN), as the rock matrix itself exhibits low permeability. DFN models simulate flow and solute migration by treating fractures as discrete hydraulic features.
To investigate solute transport and retention in fractured rocks, a DFN is generated using PorePy
with stochastic fracture placement algorithms.
The resulting DFN geometry is processed through OGSTools
to create OpenGeoSys (OGS)-compatible input files, enabling the setup and execution of a Hydro-Component (HC) simulation for flow and solute transport, and retention in realistic fractured media.
PorePy is an open-source Python library developed by the University of Bergen, designed for simulating multiphysics processes in fractured and porous media. It emphasizes discrete fracture network (DFN) modeling through a mixed-dimensional approach.
Install PorePy within the same virtual environment as OGS:
pip install porepy
For full installation instructions, see the PorePy Installation Guide.
domain_size = 100.0
…
(click to toggle)
domain_size = 100.0
mesh_size_boundary = 0.1 * domain_size
mesh_size_fracture = 0.05 * domain_size
mesh_size_min = 0.01 * domain_size
Defines the minimum and maximum coordinates of a 3D cubic domain.
mins = np.array([0.0, 0.0, 0.0])
…
(click to toggle)
mins = np.array([0.0, 0.0, 0.0])
maxs = np.array([domain_size, domain_size, domain_size])
bounding_box = {
"xmin": mins[0],
"xmax": maxs[0],
"ymin": mins[1],
"ymax": maxs[1],
"zmin": mins[2],
"zmax": maxs[2],
}
domain = pp.Domain(bounding_box=bounding_box)
n_fractures
times, with each iteration generating a single fracture.random center
is generated within the domain by scaling a uniformly distributed random vector.random radius
is selected within the radius_\text{R}ange
, setting its size between the allowed minimum and maximum values.major axis
and minor axis
are set equal to the radius—while PorePy supports ellipses, we’re using circular fractures here.Strike and dip angles
are randomly drawn from the interval $[-\frac{\pi}{2}, \frac{\pi}{2}]$.
use_saved_fractures = True
…
(click to toggle)
use_saved_fractures = True
fracture_params_file = Path("fracture_params.json")
fracture_params_path = Path(out_dir, "fracture_params.json")
radius_range = np.array([50, 80])
n_fractures = 10
# borehole
borehole_height = 60
borehole_radius = maxs[0] * 0.005 # radius in x/y
z_center = maxs[2]
y_center = 0.5 * (mins[1] + maxs[1])
x_left = 0.2 * maxs[0]
x_right = 0.8 * maxs[0]
fractures = []
…
(click to toggle)
fractures = []
if use_saved_fractures and fracture_params_file.exists():
fracture_params = json.loads(fracture_params_file.read_text())
print("Loaded existing fracture parameters.")
else:
rng = default_rng(12345)
fracture_params = []
for _ in range(n_fractures):
center = rng.random(3) * (maxs - mins) + mins
radius = rng.random() * (radius_range[1] - radius_range[0]) + radius_range[0]
major_axis = minor_axis = radius
major_axis_angle = rng.uniform(0, 2 * np.pi)
strike_angle = rng.uniform(-0.5 * np.pi, 0.5 * np.pi)
dip_angle = rng.uniform(-0.5 * np.pi, 0.5 * np.pi)
fracture_params.append(
{
"center": center.tolist(),
"major_axis": major_axis,
"minor_axis": minor_axis,
"major_axis_angle": major_axis_angle,
"strike_angle": strike_angle,
"dip_angle": dip_angle,
}
)
fracture_params_file.write_text(json.dumps(fracture_params, indent=4))
print("New fracture parameters generated and saved explicitly.")
for p in fracture_params:
frac = pp.create_elliptic_fracture(
center=np.array(p["center"]),
major_axis=p["major_axis"],
minor_axis=p["minor_axis"],
major_axis_angle=p["major_axis_angle"],
strike_angle=p["strike_angle"],
dip_angle=p["dip_angle"],
)
fractures.append(frac)
# vertical boreholes as long, thin fractures
center_left = np.array([x_left, y_center, z_center])
borehole_left = pp.create_elliptic_fracture(
center=center_left,
major_axis=borehole_height,
minor_axis=borehole_radius,
major_axis_angle=np.pi / 2,
strike_angle=0.0,
dip_angle=-0.5 * np.pi,
)
fractures.append(borehole_left)
center_right = np.array([x_right, y_center, z_center])
borehole_right = pp.create_elliptic_fracture(
center=center_right,
major_axis=borehole_height,
minor_axis=borehole_radius,
major_axis_angle=np.pi / 2,
strike_angle=0.0,
dip_angle=-0.5 * np.pi,
)
fractures.append(borehole_right)
Loaded existing fracture parameters.
Once all fractures are generated, they’re passed as a list to create_fracture_network()
, along with the domain’s bounding box. This creates a 3D Fracture Network object, representing the full 3D fracture system. The object takes care of intersection detection, applies the orientation from strike/dip angles, and prepares the geometry for meshing and simulation (see FractureNetwork3d documentation).
The system consists of two vertical fractures representing inlet and outlet boreholes, modeled as long, thin elliptic fractures. The minor axis is set to a small value (borehole_Radius = maxs[0] * 0.005
) to reflect the narrow borehole dimensions.
[x_Left, y_Center, zmax]
, with a major axis of borehole_height
and a minor axis of borehole_Radius
, oriented vertically (major_axis_angle =
$\frac{\pi}{2}$, dip_angle =
$\frac{\pi}{2}$).[x_Right, y_Center, zmax]
, with identical dimensions and orientation as the left borehole.Both fractures are added to the fractures
list.
network = pp.create_fracture_network(fractures=fractures, domain=domain)
We generate the computational mesh using PorePy’s mixed-dimensional approach:
cell_size_boundary
: maximum cell size near the domain boundaries.cell_size_fracture
: target cell size along the fracture surfaces.cell_size_min
: minimum allowed cell size anywhere in the mesh.simplex
: enables triangular (2D) or tetrahedral (3D) elements.This produces a mixed-dimensional grid (mdg
) with 3D rock matrix cells, 2D fracture surfaces, and lower-dimensional intersections.
For fracture-only simulations, the 3D matrix is removed, leaving just the 2D fractures and their intersections.
mesh_args = {
…
(click to toggle)
mesh_args = {
"cell_size_boundary": mesh_size_boundary,
"cell_size_fracture": mesh_size_fracture,
"cell_size_min": mesh_size_min,
"export": True,
"filename": "mdg2d",
# "folder_name": out_dir,
}
mdg = pp.create_mdg("simplex", mesh_args, network)
# --- Remove the 3D matrix cells to keep only fractures + intersections ---
mdg2d = mdg.copy()
for sd in list(mdg2d.subdomains()):
if sd.dim == 3:
mdg2d.remove_subdomain(sd)
# --- Clean up empty 1D subdomains & interfaces (avoid IndexError on export) ---
for g in list(mdg2d.subdomains(dim=1)):
cn = g.cell_nodes()
if g.num_cells == 0 or cn.indices.size == 0 or cn.indptr.size <= 1:
mdg2d.remove_subdomain(g)
Creates a VTK file of the 2D mixed-dimensional grid (mdg2d) using Exporter
(see link). The output is compatible with visualization tools like ParaView, and serves as input for OpenGeoSys.
exporter = pp.Exporter(mdg2d, "mixed_dimensional_grid", folder_name=out_dir).write_vtu()
.vtu
) for OpenGeosys simulationWe generate s clean, standardized .vtu
mesh containing only MaterialIDs
as cell data — fully tailored for OpenGeoSys simulations in fractured media.
.vtu
file into a mesh object for processing.MaterialIDs
are missing, they’re generated from subdomain_id
, starting at 0 — OGS uses them to identify physical regions..prj
setup.DFN_2D = ot.Mesh(f"{out_dir}/mixed_dimensional_grid_2.vtu")
…
(click to toggle)
DFN_2D = ot.Mesh(f"{out_dir}/mixed_dimensional_grid_2.vtu")
if "MaterialIDs" not in DFN_2D.cell_data:
DFN_2D["MaterialIDs"] = (
DFN_2D["subdomain_id"] - DFN_2D["subdomain_id"].min()
).astype(np.int32)
DFN_2D.save(f"{out_dir}/mixed_dimensional_grid_2.vtu")
DFN_2D = ot.Mesh(f"{out_dir}/mixed_dimensional_grid_2.vtu")
if "MaterialIDs" not in DFN_2D.cell_data:
DFN_2D["MaterialIDs"] = (
DFN_2D["subdomain_id"] - DFN_2D["subdomain_id"].min()
).astype(np.int32)
for key in list(DFN_2D.cell_data):
if key != "MaterialIDs":
DFN_2D.cell_data.remove(key)
for key in list(DFN_2D.point_data):
DFN_2D.point_data.remove(key)
DFN_2D.save(f"{out_dir}/mixed_dimensional_grid_2.vtu")
orig_dir = Path.cwd()
…
(click to toggle)
orig_dir = Path.cwd()
%cd {out_dir}
# remove duplicated nodes using PyVista
mesh_c = pv.read("mixed_dimensional_grid_2.vtu")
clean_mesh = mesh_c.clean(tolerance=1e-12)
clean_mesh.save("mixed_dimensional_grid_2.vtu")
print(f"Original points: {mesh_c.n_points}, Cleaned points: {clean_mesh.n_points}")
/var/lib/gitlab-runner/builds/vZ6vnZiU/1/ogs/build/release-all/Tests/Data/Parabolic/ComponentTransport/DFN_PorePy/DFNbyPorePy_to_OGS
Original points: 12110, Cleaned points: 10340
ogs.cli.reviseMesh(i="mixed_dimensional_grid_2.vtu", o="temp.vtu")
…
(click to toggle)
ogs.cli.reviseMesh(i="mixed_dimensional_grid_2.vtu", o="temp.vtu")
ogs.cli.NodeReordering(
i="mixed_dimensional_grid_2.vtu", o="mixed_dimensional_grid_2.vtu"
)
ogs.cli.checkMesh("mixed_dimensional_grid_2.vtu", v=True)
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:31.828] [ogs] [info] Mesh read: 10340 nodes, 20662 elements.
[2025-09-18 13:54:31.828] [ogs] [info] Simplifying the mesh...
[2025-09-18 13:54:31.836] [ogs] [warning] Property MaterialIDs exists but does not have the requested mesh item type node.
[2025-09-18 13:54:31.836] [ogs] [warning] Property MaterialIDs exists but does not have the requested type f.
[2025-09-18 13:54:31.836] [ogs] [warning] Property MaterialIDs exists but does not have the requested type d.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type i.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type f.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type d.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type i.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type f.
[2025-09-18 13:54:31.836] [ogs] [warning] Property PointMergeMap exists but does not have the requested type d.
[2025-09-18 13:54:31.836] [ogs] [warning] PropertyVector PointMergeMap not being converted.
[2025-09-18 13:54:31.855] [ogs] [info] Revised mesh: 10340 nodes, 20662 elements.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:31.971] [ogs] [info] Reordering nodes...
[2025-09-18 13:54:31.972] [ogs] [info] Corrected 10360 elements.
[2025-09-18 13:54:31.983] [ogs] [info] VTU file written.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.107] [ogs] [info] Memory size: 4 MiB
[2025-09-18 13:54:32.107] [ogs] [info] Time for reading: 0.0294004 s
[2025-09-18 13:54:32.107] [ogs] [info] Axis aligned bounding box: x [-1.77636e-15, 100) (extent 100)
y [0, 100) (extent 100)
z [0, 100) (extent 100)
[2025-09-18 13:54:32.107] [ogs] [info] Min/max edge lengths: [0.0336252, 6.85976]
[2025-09-18 13:54:32.108] [ogs] [info] Number of elements in the mesh:
[2025-09-18 13:54:32.108] [ogs] [info] Triangles: 20662
[2025-09-18 13:54:32.108] [ogs] [info] Mesh Quality Control:
[2025-09-18 13:54:32.108] [ogs] [info] Looking for unused nodes...
[2025-09-18 13:54:32.112] [ogs] [info] Found 0 potentially collapsible nodes.
[2025-09-18 13:54:32.117] [ogs] [info] Testing mesh element geometry:
[2025-09-18 13:54:32.118] [ogs] [info] 218 elements found with wrong node order.
[2025-09-18 13:54:32.118] [ogs] [info] No elements found with zero volume.
[2025-09-18 13:54:32.118] [ogs] [info] No elements found with non coplanar nodes.
[2025-09-18 13:54:32.118] [ogs] [info] No elements found with non-convex geometry.
[2025-09-18 13:54:32.118] [ogs] [info] 218 elements found with wrong node order.
ElementIDs: 20340, 20341, 20342, 20343, 20344, 20345, 20346, 20351, 20352, 20353, 20354, 20355, 20358, 20359, 20360, 20361, 20362, 20363, 20364, 20365, 20366, 20369, 20370, 20371, 20372, 20373, 20374, 20375, 20376, 20377, 20378, 20379, 20380, 20381, 20382, 20383, 20384, 20385, 20386, 20387, 20388, 20389, 20390, 20391, 20392, 20393, 20394, 20395, 20396, 20397, 20398, 20399, 20400, 20401, 20402, 20403, 20404, 20405, 20406, 20407, 20408, 20409, 20410, 20411, 20412, 20413, 20414, 20415, 20416, 20417, 20418, 20419, 20420, 20421, 20422, 20423, 20424, 20425, 20426, 20456, 20457, 20458, 20459, 20460, 20461, 20462, 20463, 20464, 20465, 20466, 20467, 20468, 20475, 20476, 20477, 20478, 20479, 20480, 20481, 20482, 20483, 20484, 20485, 20486, 20487, 20488, 20489, 20490, 20491, 20492, 20503, 20504, 20505, 20506, 20507, 20508, 20509, 20510, 20512, 20513, 20514, 20515, 20516, 20517, 20518, 20519, 20520, 20521, 20522, 20523, 20524, 20525, 20526, 20529, 20530, 20531, 20532, 20533, 20534, 20536, 20537, 20538, 20539, 20540, 20541, 20542, 20543, 20544, 20545, 20546, 20547, 20548, 20549, 20550, 20551, 20552, 20553, 20554, 20555, 20556, 20557, 20558, 20559, 20560, 20561, 20562, 20563, 20564, 20565, 20566, 20567, 20568, 20569, 20570, 20571, 20572, 20575, 20576, 20577, 20578, 20579, 20580, 20581, 20582, 20583, 20584, 20585, 20586, 20587, 20588, 20589, 20590, 20591, 20592, 20593, 20594, 20595, 20596, 20597, 20598, 20599, 20600, 20601, 20602, 20603, 20604, 20605, 20606, 20607, 20608, 20609, 20610, 20656, 20657, 20658, 20659, 20660, 20661,
[2025-09-18 13:54:32.118] [ogs] [warning] Skipping property vector 'PointMergeMap' - not defined on cells or nodes.
[2025-09-18 13:54:32.119] [ogs] [info] 1 property vectors copied, 1 vectors skipped.
[2025-09-18 13:54:32.119] [ogs] [info] 15 hole(s) detected within the mesh
0
ogs.cli.reviseMesh(i="mixed_dimensional_grid_2.vtu", o="temp.vtu")
…
(click to toggle)
ogs.cli.reviseMesh(i="mixed_dimensional_grid_2.vtu", o="temp.vtu")
ogs.cli.NodeReordering(
i="mixed_dimensional_grid_2.vtu", o="mixed_dimensional_grid_2.vtu"
)
ogs.cli.checkMesh("mixed_dimensional_grid_2.vtu", v=True)
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.242] [ogs] [info] Mesh read: 10340 nodes, 20662 elements.
[2025-09-18 13:54:32.242] [ogs] [info] Simplifying the mesh...
[2025-09-18 13:54:32.250] [ogs] [warning] Property MaterialIDs exists but does not have the requested mesh item type node.
[2025-09-18 13:54:32.250] [ogs] [warning] Property MaterialIDs exists but does not have the requested type f.
[2025-09-18 13:54:32.250] [ogs] [warning] Property MaterialIDs exists but does not have the requested type d.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type i.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type f.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type d.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type i.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type f.
[2025-09-18 13:54:32.250] [ogs] [warning] Property PointMergeMap exists but does not have the requested type d.
[2025-09-18 13:54:32.250] [ogs] [warning] PropertyVector PointMergeMap not being converted.
[2025-09-18 13:54:32.270] [ogs] [info] Revised mesh: 10340 nodes, 20662 elements.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.388] [ogs] [info] Reordering nodes...
[2025-09-18 13:54:32.388] [ogs] [info] Corrected 218 elements.
[2025-09-18 13:54:32.399] [ogs] [info] VTU file written.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.526] [ogs] [info] Memory size: 4 MiB
[2025-09-18 13:54:32.526] [ogs] [info] Time for reading: 0.0286488 s
[2025-09-18 13:54:32.526] [ogs] [info] Axis aligned bounding box: x [-1.77636e-15, 100) (extent 100)
y [0, 100) (extent 100)
z [0, 100) (extent 100)
[2025-09-18 13:54:32.526] [ogs] [info] Min/max edge lengths: [0.0336252, 6.85976]
[2025-09-18 13:54:32.526] [ogs] [info] Number of elements in the mesh:
[2025-09-18 13:54:32.526] [ogs] [info] Triangles: 20662
[2025-09-18 13:54:32.526] [ogs] [info] Mesh Quality Control:
[2025-09-18 13:54:32.526] [ogs] [info] Looking for unused nodes...
[2025-09-18 13:54:32.531] [ogs] [info] Found 0 potentially collapsible nodes.
[2025-09-18 13:54:32.536] [ogs] [info] Testing mesh element geometry:
[2025-09-18 13:54:32.536] [ogs] [info] 218 elements found with wrong node order.
[2025-09-18 13:54:32.536] [ogs] [info] No elements found with zero volume.
[2025-09-18 13:54:32.536] [ogs] [info] No elements found with non coplanar nodes.
[2025-09-18 13:54:32.536] [ogs] [info] No elements found with non-convex geometry.
[2025-09-18 13:54:32.536] [ogs] [info] 218 elements found with wrong node order.
ElementIDs: 20340, 20341, 20342, 20343, 20344, 20345, 20346, 20351, 20352, 20353, 20354, 20355, 20358, 20359, 20360, 20361, 20362, 20363, 20364, 20365, 20366, 20369, 20370, 20371, 20372, 20373, 20374, 20375, 20376, 20377, 20378, 20379, 20380, 20381, 20382, 20383, 20384, 20385, 20386, 20387, 20388, 20389, 20390, 20391, 20392, 20393, 20394, 20395, 20396, 20397, 20398, 20399, 20400, 20401, 20402, 20403, 20404, 20405, 20406, 20407, 20408, 20409, 20410, 20411, 20412, 20413, 20414, 20415, 20416, 20417, 20418, 20419, 20420, 20421, 20422, 20423, 20424, 20425, 20426, 20456, 20457, 20458, 20459, 20460, 20461, 20462, 20463, 20464, 20465, 20466, 20467, 20468, 20475, 20476, 20477, 20478, 20479, 20480, 20481, 20482, 20483, 20484, 20485, 20486, 20487, 20488, 20489, 20490, 20491, 20492, 20503, 20504, 20505, 20506, 20507, 20508, 20509, 20510, 20512, 20513, 20514, 20515, 20516, 20517, 20518, 20519, 20520, 20521, 20522, 20523, 20524, 20525, 20526, 20529, 20530, 20531, 20532, 20533, 20534, 20536, 20537, 20538, 20539, 20540, 20541, 20542, 20543, 20544, 20545, 20546, 20547, 20548, 20549, 20550, 20551, 20552, 20553, 20554, 20555, 20556, 20557, 20558, 20559, 20560, 20561, 20562, 20563, 20564, 20565, 20566, 20567, 20568, 20569, 20570, 20571, 20572, 20575, 20576, 20577, 20578, 20579, 20580, 20581, 20582, 20583, 20584, 20585, 20586, 20587, 20588, 20589, 20590, 20591, 20592, 20593, 20594, 20595, 20596, 20597, 20598, 20599, 20600, 20601, 20602, 20603, 20604, 20605, 20606, 20607, 20608, 20609, 20610, 20656, 20657, 20658, 20659, 20660, 20661,
[2025-09-18 13:54:32.537] [ogs] [warning] Skipping property vector 'PointMergeMap' - not defined on cells or nodes.
[2025-09-18 13:54:32.537] [ogs] [info] 1 property vectors copied, 1 vectors skipped.
[2025-09-18 13:54:32.537] [ogs] [info] 15 hole(s) detected within the mesh
0
ExtractBoundary
Extracts all outer surface faces from a 3D mesh and creates a boundary-only mesh. Useful for defining where boundary conditions will be applied.
RemoveMeshElements
Removes elements based on coordinate filters (e.g., x-min, x-max). Used to isolate specific boundary patches for inflow/outflow or other localized conditions.
ExtractMaterials
Extracts submeshes from a mesh by selecting elements with specific material IDs, enabling features such as boreholes, fractures, or layers to be used for boundary conditions.
boundary_tol_fraction = 1e-6 # Tolerance for boundary selection
…
(click to toggle)
boundary_tol_fraction = 1e-6 # Tolerance for boundary selection
boundary_tol = boundary_tol_fraction * domain_size
x_outlet = maxs[0] - boundary_tol
x_inlet = mins[0] + boundary_tol
ogs.cli.ExtractBoundary(i="mixed_dimensional_grid_2.vtu", o="boundaries.vtu")
…
(click to toggle)
ogs.cli.ExtractBoundary(i="mixed_dimensional_grid_2.vtu", o="boundaries.vtu")
ogs.cli.removeMeshElements(i="boundaries.vtu", o="x_inlet.vtu", **{"x-min": x_inlet})
ogs.cli.removeMeshElements(
i="boundaries.vtu", o="x_outlet.vtu", **{"x-min": x_outlet, "invert": True}
)
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.707] [ogs] [info] Mesh read: 10340 nodes, 20662 elements.
[2025-09-18 13:54:32.707] [ogs] [warning] Skipping property vector 'PointMergeMap' - not defined on cells or nodes.
[2025-09-18 13:54:32.707] [ogs] [info] 1 property vectors copied, 1 vectors skipped.
[2025-09-18 13:54:32.707] [ogs] [info] Created surface mesh: 1132 nodes, 1154 elements.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.804] [ogs] [info] Mesh read: 1132 nodes, 1154 elements.
[2025-09-18 13:54:32.804] [ogs] [info] Bounding box of "boundaries" is
x = [-0.000000,100.000000]
y = [0.000000,100.000000]
z = [0.000000,100.000000]
[2025-09-18 13:54:32.804] [ogs] [info] 988 elements found.
[2025-09-18 13:54:32.804] [ogs] [info] Removing total 988 elements...
[2025-09-18 13:54:32.804] [ogs] [info] 166 elements remain in mesh.
[2025-09-18 13:54:32.804] [ogs] [info] Removing total 967 nodes...
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:32.911] [ogs] [info] Mesh read: 1132 nodes, 1154 elements.
[2025-09-18 13:54:32.911] [ogs] [info] Bounding box of "boundaries" is
x = [-0.000000,100.000000]
y = [0.000000,100.000000]
z = [0.000000,100.000000]
[2025-09-18 13:54:32.911] [ogs] [info] 1129 elements found.
[2025-09-18 13:54:32.911] [ogs] [info] Removing total 1129 elements...
[2025-09-18 13:54:32.911] [ogs] [info] 25 elements remain in mesh.
[2025-09-18 13:54:32.911] [ogs] [info] Removing total 1105 nodes...
0
ogs.cli.ExtractMaterials(
…
(click to toggle)
ogs.cli.ExtractMaterials(
i="mixed_dimensional_grid_2.vtu", o="borehole_right.vtu", m=n_fractures
)
ogs.cli.ExtractMaterials(
i="mixed_dimensional_grid_2.vtu", o="borehole_left.vtu", m=n_fractures + 1
)
ogs.cli.identifySubdomains(
"-m",
"mixed_dimensional_grid_2.vtu",
"-s",
str(1e-10),
"--",
f"borehole_left_Layer{n_fractures+1}.vtu",
f"borehole_right_Layer{n_fractures}.vtu",
)
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:33.001] [ogs] [info] Extracting material group 10...
[2025-09-18 13:54:33.002] [ogs] [info] Removing total 20490 elements...
[2025-09-18 13:54:33.002] [ogs] [info] 172 elements remain in mesh.
[2025-09-18 13:54:33.002] [ogs] [info] Removing total 10192 nodes...
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
Warning: OGS_BIN_DIR does not exist, falling back to possible build directory location.
[2025-09-18 13:54:33.113] [ogs] [info] Extracting material group 11...
[2025-09-18 13:54:33.113] [ogs] [info] Removing total 20512 elements...
[2025-09-18 13:54:33.113] [ogs] [info] 150 elements remain in mesh.
[2025-09-18 13:54:33.114] [ogs] [info] Removing total 10206 nodes...
[2025-09-18 13:54:33.201] [ogs] [info] Mesh reading time: 0.0114138 s
[2025-09-18 13:54:33.201] [ogs] [info] MeshNodeSearcher construction time: 0.000521128 s
[2025-09-18 13:54:33.201] [ogs] [info] identifySubdomainMesh(): identifySubdomainMeshNodes took 2.4251e-05 s
[2025-09-18 13:54:33.202] [ogs] [info] identifySubdomainMesh(): identifySubdomainMeshElements took 0.000751476 s
[2025-09-18 13:54:33.202] [ogs] [info] identifySubdomainMesh(): identifySubdomainMeshNodes took 2.2521e-05 s
[2025-09-18 13:54:33.203] [ogs] [info] identifySubdomainMesh(): identifySubdomainMeshElements took 0.000458815 s
[2025-09-18 13:54:33.203] [ogs] [info] identifySubdomains time: 0.0013996 s
[2025-09-18 13:54:33.204] [ogs] [info] writing time: 0.00162616 s
[2025-09-18 13:54:33.204] [ogs] [info] Entire run time: 0.0152715 s
0
%cd $orig_dir
…
(click to toggle)
%cd $orig_dir
try:
pv.set_jupyter_backend("static")
except Exception as e:
print("PyVista backend not set:", e)
DFN_2D = pv.read(f"{out_dir}/mixed_dimensional_grid_2.vtu")
x_inlet = pv.read(f"{out_dir}/x_inlet.vtu")
x_outlet = pv.read(f"{out_dir}/x_outlet.vtu")
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(
DFN_2D,
scalars="MaterialIDs",
cmap="tab20",
opacity=0.4,
show_edges=True,
scalar_bar_args={"title": "MaterialIDs", "vertical": True},
)
plotter.add_mesh(DFN_2D.outline(), color="black", line_width=2)
plotter.add_mesh(
x_inlet,
color="blue",
opacity=1.0,
show_edges=True,
line_width=5,
render_lines_as_tubes=True,
)
plotter.add_mesh(
x_outlet,
color="red",
opacity=1.0,
show_edges=True,
line_width=5,
render_lines_as_tubes=True,
)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
# plotter.window_size = [500, 500]
output_path = out_dir.joinpath("Concentration.png")
plotter.screenshot(str(output_path))
plotter.show()
/var/lib/gitlab-runner/builds/vZ6vnZiU/1/ogs/ogs/Tests/Data/Parabolic/ComponentTransport/DFN_PorePy
[0m[33m2025-09-18 13:54:33.444 ( 28.519s) [ 77A5D3CFBB80]vtkXOpenGLRenderWindow.:1416 WARN| bad X server connection. DISPLAY=[0m
/var/lib/gitlab-runner/builds/vZ6vnZiU/1/ogs/build/release-all/.venv/lib/python3.12/site-packages/pyvista/plotting/plotter.py:3487: UserWarning: `show_edges=True` not supported when `render_lines_as_tubes=True`. Ignoring `show_edges`.
warnings.warn(
We assign random fracture properties (e.g., width and permeability) by calculating each mean value, $\mu$, from one of three user-defined distributions: Uniform, clipped Gaussian, or truncated Gaussian, constrained within specified bounds. The standard deviation is then calculated as $\sigma = \mathrm{rel_{std}} \times \mu$, with $\mathrm{rel_{std}}$ fixed at 30%, and cell values are subsequently sampled from a Gaussian distribution $\bigl(\mu, \sigma\bigr)$ and mapped to the mesh. Permeability can be configured as either isotropic or anisotropic.
Remark: Random fields with spatial correlation would be a more suitable representation of fracture heterogeneity. In the context of OGS see [8,9].
def sample_truncnorm(a, b, mu, sigma, rng, size=None):
…
(click to toggle)
def sample_truncnorm(a, b, mu, sigma, rng, size=None):
if size is None:
# scalar
while True:
x = rng.normal(mu, sigma)
if a <= x <= b:
return x
size = int(size)
out = np.empty(size)
filled = 0
batch = max(1024, size)
while filled < size:
x = rng.normal(mu, sigma, batch)
x = x[(x >= a) & (x <= b)]
take = min(size - filled, x.size)
if take:
out[filled : filled + take] = x[:take]
filled += take
return out
def sample_mean(a, b, rng, method="uniform"):
if method == "uniform":
return rng.uniform(a, b)
if method == "constant":
return 0.5 * (a + b)
mu = 0.5 * (a + b)
sigma = (b - a) / 6.0
if method == "gaussian":
return np.clip(rng.normal(mu, sigma), a, b)
if method == "truncated":
return sample_truncnorm(a, b, mu, sigma, rng)
msg = "Unknown sampling method"
raise ValueError(msg)
def generate_random_fracture_stats(
fracture_ids,
width_range=(5e-6, 1e-5),
k_range=(1e-14, 1e-12),
rel_std=0.3,
mean_dist="uniform",
rng=None,
):
rng = rng or default_rng()
stats = {}
for mat_id in fracture_ids:
w_min, w_max = width_range
width_mean = sample_mean(w_min, w_max, rng, method=mean_dist)
k_min, k_max = k_range
k_mean = sample_mean(k_min, k_max, rng, method=mean_dist)
stats[mat_id] = {
"width_mean": width_mean,
"width_std": rel_std * width_mean,
"width_range": (w_min, w_max),
"k_mean": k_mean,
"k_std": rel_std * k_mean,
"k_range": (k_min, k_max),
"method": mean_dist,
}
return stats
def assign_cellwise_random_props(mesh, stats_dict, clip_min=1e-20, seed=42):
rng = default_rng(seed)
MaterialIDs = mesh.cell_data["MaterialIDs"]
n_cells = len(MaterialIDs)
width_ic = np.zeros(n_cells)
permeability = np.zeros(n_cells)
for mat_id, stats in stats_dict.items():
idx = np.where(MaterialIDs == mat_id)[0]
n = len(idx)
method = stats.get("method", "uniform")
w_min, w_max = stats["width_range"]
w_mean, w_std = stats["width_mean"], stats["width_std"]
k_min, k_max = stats["k_range"]
k_mean, k_std = stats["k_mean"], stats["k_std"]
if method == "uniform":
width_vals = rng.uniform(w_min, w_max, n)
k_vals = rng.uniform(k_min, k_max, n)
elif method == "constant":
width_vals = np.full(n, w_mean)
k_vals = np.full(n, k_mean)
elif method == "truncated":
width_vals = sample_truncnorm(w_min, w_max, w_mean, w_std, rng, size=n)
k_vals = sample_truncnorm(k_min, k_max, k_mean, k_std, rng, size=n)
elif method == "gaussian":
width_vals = np.clip(rng.normal(w_mean, w_std, n), w_min, w_max)
k_vals = np.clip(rng.normal(k_mean, k_std, n), k_min, k_max)
else:
msg = "Unknown sampling method"
raise ValueError(msg)
width_ic[idx] = np.clip(width_vals, clip_min, None)
permeability[idx] = k_vals
mesh.cell_data["width_ic"] = width_ic
mesh.cell_data["permeability_ic"] = permeability
input_mesh = "mixed_dimensional_grid_2.vtu"
…
(click to toggle)
input_mesh = "mixed_dimensional_grid_2.vtu"
output_mesh = "mixed_dimensional_grid_2_updated.vtu"
use_isotropic_perm = False
mesh = pv.read(Path(out_dir, input_mesh))
material_ids = np.unique(mesh.cell_data["MaterialIDs"])
fracture_ids = list(material_ids)
SEED = 20250818
…
(click to toggle)
SEED = 20250818
rng = default_rng(SEED)
fracture_stats = generate_random_fracture_stats(
fracture_ids,
width_range=(1e-6, 1e-5),
k_range=(1e-16, 1e-14),
rel_std=0.3, # standard deviation is 30 % of mean value.
mean_dist="gaussian", # constant, uniform, gaussian, or truncated
rng=rng,
)
assign_cellwise_random_props(mesh, fracture_stats, seed=SEED)
mesh.save(Path(out_dir, output_mesh), binary=False)
base_scalar_bar_args = {
…
(click to toggle)
base_scalar_bar_args = {
"vertical": True,
"position_x": 0.9,
"position_y": 0.2,
"width": 0.03,
"height": 0.6,
"title_font_size": 20,
"label_font_size": 16,
"n_labels": 4,
"color": "black",
"fmt": "%.1f",
}
try:
…
(click to toggle)
try:
pv.set_jupyter_backend("static")
except Exception as e:
print("PyVista backend not set:", e)
DFN_2D = pv.read(Path(out_dir, output_mesh))
x_inlet = pv.read(Path(out_dir, "x_inlet.vtu"))
x_outlet = pv.read(Path(out_dir, "x_outlet.vtu"))
def plot_scalar_field(mesh, inlet, outlet, scalar_data, title, cmap, filename):
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = title
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args.update(
{
"fmt": "%.1e", # Scientific notation
"n_labels": 5, # More labels to show range
}
)
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(
mesh,
scalars=scalar_data,
cmap=cmap,
show_edges=False,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.add_mesh(inlet, color="blue", line_width=5, render_lines_as_tubes=True)
plotter.add_mesh(outlet, color="red", line_width=5, render_lines_as_tubes=True)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
plotter.screenshot(Path(out_dir, filename))
plotter.show()
def plot_isotropic_permeability(
mesh,
inlet,
outlet,
out_dir,
cmap="viridis",
image_name="permeability_isotropic.png",
):
k_iso = mesh.cell_data["permeability_ic"] # [:, 0]
k_log = np.log10(np.clip(k_iso, 1e-20, None))
mesh.cell_data["log_k_iso"] = k_log
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = "log₁₀(Permeability) [log₁₀(m²)]"
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(
mesh,
scalars="log_k_iso",
cmap=cmap,
show_edges=False,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.add_mesh(inlet, color="blue", line_width=6, render_lines_as_tubes=True)
plotter.add_mesh(outlet, color="red", line_width=6, render_lines_as_tubes=True)
plotter.view_isometric()
plotter.enable_parallel_projection()
plotter.show_axes()
plotter.screenshot(Path(out_dir, image_name))
plotter.show()
plot_isotropic_permeability(
mesh=DFN_2D,
inlet=x_inlet,
outlet=x_outlet,
out_dir=out_dir,
)
plot_scalar_field(
mesh=DFN_2D,
inlet=x_inlet,
outlet=x_outlet,
scalar_data="width_ic",
title="Fracture Width (m)",
cmap="plasma",
filename="width_ic.png",
)
def add_component_to_model(
…
(click to toggle)
def add_component_to_model(
model, name, pore_diff="1e-9", retardation="1.0", decay="0.0"
):
# Add <component>
model.add_element(".//media/medium/phases/phase/components", "component")
component_xpath = ".//media/medium/phases/phase/components/component[last()]"
model.add_element(component_xpath, "name", name)
model.add_element(component_xpath, "properties")
for prop, param in {
"pore_diffusion": f"pore_diff_{name}",
"retardation_factor": f"retard_{name}",
"decay_rate": f"decay_{name}",
}.items():
model.add_element(f"{component_xpath}/properties", "property")
prop_xpath = f"{component_xpath}/properties/property[last()]"
model.add_element(prop_xpath, "name", prop)
model.add_element(prop_xpath, "type", "Parameter")
model.add_element(prop_xpath, "parameter_name", param)
# Add <process_variable>
model.add_element("./process_variables", "process_variable")
pv_xpath = "./process_variables/process_variable[last()]"
model.add_element(pv_xpath, "name", name)
model.add_element(pv_xpath, "components", "1")
model.add_element(pv_xpath, "order", "1")
model.add_element(pv_xpath, "initial_condition", "c0")
model.add_element(pv_xpath, "boundary_conditions")
model.add_element(f"{pv_xpath}/boundary_conditions", "boundary_condition")
bc_xpath = f"{pv_xpath}/boundary_conditions/boundary_condition[last()]"
model.add_element(bc_xpath, "type", "Dirichlet")
mesh_name_bc = f"borehole_right_Layer{n_fractures}"
model.add_element(bc_xpath, "mesh", mesh_name_bc)
model.add_element(bc_xpath, "parameter", "c_bottom")
# Add <concentration> in <process_variables>
model.add_element(".//processes/process/process_variables", "concentration", name)
# Add <parameter> entries
for pname, val in {
f"pore_diff_{name}": pore_diff,
f"retard_{name}": retardation,
f"decay_{name}": decay,
}.items():
model.add_element(".//parameters", "parameter")
p_xpath = ".//parameters/parameter[last()]"
model.add_element(p_xpath, "name", pname)
model.add_element(p_xpath, "type", "Constant")
model.add_element(p_xpath, "value", val)
def update_reltols_for_components(model, total_components, tol="1e-12"):
reltols_xpath = ".//time_loop/processes/process/convergence_criterion/reltols"
reltol_values = " ".join([tol] * total_components)
model.replace_text(reltol_values, xpath=reltols_xpath)
def update_project_parameters(project: ot.Project, params: dict):
update_map = {
"prefix": ".//time_loop/output/prefix",
"initial_pressure_expression": ".//parameters/parameter[name='p0']/expression",
"inlet_pressure_expression": ".//parameters/parameter[name='pinlet']/expression",
"outlet_pressure_expression": ".//parameters/parameter[name='poutlet']/expression",
"inlet_concentration_value": ".//parameters/parameter[name='c_bottom']/value",
"porosity_value": ".//parameters/parameter[name='porosity_parameter']/value",
"fracture_thickness_value": ".//parameters/parameter[name='fracture_thickness_const']/value",
"decay_Si_value": ".//parameters/parameter[name='decay']/value",
"t_end": ".//time_loop/processes/process/time_stepping/t_end",
}
for key, xpath in update_map.items():
if key in params:
try:
project.replace_text(params[key], xpath=xpath)
display(
Markdown(
f"**Success:** Parameter `{key}` updated to **{params[key]}**"
)
)
except Exception as e:
msg = f"Failed to update parameter `{key}`: {e}"
display(Markdown(f"**Error:** {msg}"))
raise RuntimeError(msg) from e
if "fracture_permeability_values" in params:
try:
project.replace_text(
params["fracture_permeability_values"].strip(),
xpath=".//parameters/parameter[name='kappa1_frac']/values",
)
display(
Markdown(
"**Success:** Fracture permeability values updated successfully."
)
)
except Exception as e:
msg = f"Failed to update fracture permeability: {e}"
display(Markdown(f"**Error:** {msg}"))
raise RuntimeError(msg) from e
main_proc_xpath = "processes/process[1]"
project.remove_element(f"{main_proc_xpath}/numerical_stabilization")
if "numerical_stabilization" in params:
stab = params["numerical_stabilization"]
stype = stab.get("type")
if not stype:
error_msg = "'type' is required in numerical_stabilization"
raise ValueError(error_msg)
project.add_element(main_proc_xpath, "numerical_stabilization")
ns_xpath = f"{main_proc_xpath}/numerical_stabilization[last()]"
project.add_element(ns_xpath, "type", stype)
if stype == "FullUpwind":
cutoff = stab.get("cutoff_velocity", "0.0")
project.add_element(ns_xpath, "cutoff_velocity", cutoff)
elif stype == "IsotropicDiffusion":
tuning = stab.get("tuning_parameter")
cutoff = stab.get("cutoff_velocity")
if tuning is None or cutoff is None:
error_msg = "'tuning_parameter' and 'cutoff_velocity' required for IsotropicDiffusion"
raise ValueError(error_msg)
project.add_element(ns_xpath, "tuning_parameter", tuning)
project.add_element(ns_xpath, "cutoff_velocity", cutoff)
elif stype == "FluxCorrectedTransport":
pass
else:
error_msg = f"Unsupported stabilization type: {stype}"
raise ValueError(error_msg)
display(
Markdown(
f"**Success:** Configured numerical stabilization in main processes with type `{stype}`"
)
)
n = params["n_fractures"]
target_var_name = "Si"
bc_mesh_xpath = f".//process_variables/process_variable[name='{target_var_name}']/boundary_conditions/boundary_condition/mesh"
project.replace_text(f"borehole_right_Layer{n}", xpath=bc_mesh_xpath)
display(
Markdown(
f"**Success:** Updated BC mesh for `{target_var_name}` to `borehole_right_Layer{n}`"
)
)
left_mesh = f"borehole_left_Layer{n + 1}.vtu"
right_mesh = f"borehole_right_Layer{n}.vtu"
mesh_base_xpath = ".//meshes/mesh"
project.replace_text(left_mesh, xpath=f"{mesh_base_xpath}[last()-1]")
project.replace_text(right_mesh, xpath=f"{mesh_base_xpath}[last()]")
display(
Markdown(
f"**Success:** Updated borehole meshes: `{left_mesh}`, `{right_mesh}`"
)
)
user_parameters = {
…
(click to toggle)
user_parameters = {
"prefix": "DFN_HC",
"initial_pressure_expression": "1000*9.81*(500-z)",
"inlet_pressure_expression": "1000*9.81*(500-z)+2.943e5",
"outlet_pressure_expression": "1000*9.81*(500-z)",
"inlet_concentration_value": "1",
"porosity_value": "0.05",
"decay_Si_value": "1e-12",
"t_end": "1e11",
"maximum_dt": "5e9",
"numerical_stabilization": {
"type": "FluxCorrectedTransport",
},
"n_fractures": n_fractures,
}
project_file = Path("DFN_HC.prj")
…
(click to toggle)
project_file = Path("DFN_HC.prj")
project = ot.Project(
input_file=project_file, output_file=Path(f"{out_dir}/DFN_HC_final.prj")
)
add_component_to_model(
project, "Cs", pore_diff="1e-9", retardation="1.", decay="1.e-10"
)
update_reltols_for_components(
project, total_components=3
) # total = 1 pressure + 2 components
update_project_parameters(project, user_parameters)
project.write_input()
Success: Parameter prefix
updated to DFN_HC
Success: Parameter initial_pressure_expression
updated to 10009.81(500-z)
Success: Parameter inlet_pressure_expression
updated to 10009.81(500-z)+2.943e5
Success: Parameter outlet_pressure_expression
updated to 10009.81(500-z)
Success: Parameter inlet_concentration_value
updated to 1
Success: Parameter porosity_value
updated to 0.05
Success: Parameter decay_Si_value
updated to 1e-12
Success: Parameter t_end
updated to 1e11
Success: Configured numerical stabilization in main processes with type FluxCorrectedTransport
Success: Updated BC mesh for Si
to borehole_right_Layer10
Success: Updated borehole meshes: borehole_left_Layer11.vtu
, borehole_right_Layer10.vtu
project.run_model(args=f"-o {out_dir} -m {out_dir}", logfile=Path(out_dir, "run.log"))
Project file written to output.
Simulation: /var/lib/gitlab-runner/builds/vZ6vnZiU/1/ogs/build/release-all/Tests/Data/Parabolic/ComponentTransport/DFN_PorePy/DFNbyPorePy_to_OGS/DFN_HC_final.prj
Status: finished successfully.
Execution took 96.78064942359924 s
ms = ot.MeshSeries(f'{out_dir}/{user_parameters["prefix"]}.pvd')
mesh = ms[-1]
plotter = pv.Plotter(off_screen=True)
…
(click to toggle)
plotter = pv.Plotter(off_screen=True)
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = "Pressure [Pa]"
plotter.add_mesh(
mesh,
scalars="pressure",
cmap="Blues",
show_edges=True,
opacity=1.0,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
# plotter.window_size = [500, 500]
output_path = out_dir.joinpath("pressure.png")
plotter.screenshot(str(output_path))
plotter.show()
magnitude = np.linalg.norm(mesh["darcy_velocity"], axis=1)
…
(click to toggle)
magnitude = np.linalg.norm(mesh["darcy_velocity"], axis=1)
magnitude[magnitude <= 0] = 1e-12
log_magnitude = np.log10(magnitude)
mesh["darcy_velocity_magnitude"] = magnitude
mesh["log_darcy_velocity_magnitude"] = log_magnitude
mesh.set_active_vectors("darcy_velocity")
vmax = magnitude.max()
domain_size = max(mesh.bounds[1::2]) - min(mesh.bounds[::2])
scale_factor = domain_size * 0.1 / vmax if vmax > 0 else 1.0
arrows = mesh.glyph(
scale="darcy_velocity_magnitude", orient="darcy_velocity", factor=scale_factor
)
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = "log₁₀(Darcy Velocity) [m/s]"
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(
mesh,
scalars="log_darcy_velocity_magnitude",
cmap="viridis",
opacity=1.0,
show_edges=False,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(arrows, color="black", label="Velocity Vectors")
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
output_path = out_dir.joinpath("full_mesh_velocity_log_arrows.png")
plotter.screenshot(str(output_path))
plotter.show()
plotter = pv.Plotter(off_screen=True)
…
(click to toggle)
plotter = pv.Plotter(off_screen=True)
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = "Si Concentration [mol/m³]"
plotter.add_mesh(
mesh,
scalars="Si",
cmap="plasma",
clim=(0, 1),
show_edges=False,
opacity=1.0,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
# plotter.window_size = [500, 500]
output_path = out_dir.joinpath("concentration.png")
plotter.screenshot(str(output_path))
plotter.show()
plotter = pv.Plotter(off_screen=True)
…
(click to toggle)
plotter = pv.Plotter(off_screen=True)
scalar_bar_args = base_scalar_bar_args.copy()
scalar_bar_args["title"] = "Cs Concentration [mol/m³]"
plotter.add_mesh(
mesh,
scalars="Cs",
clim=(0, 1),
cmap="cividis",
show_edges=False,
opacity=1.0,
scalar_bar_args=scalar_bar_args,
)
plotter.add_mesh(mesh.outline(), color="black", line_width=2)
plotter.show_axes()
plotter.enable_parallel_projection()
plotter.view_isometric()
# plotter.window_size = [500, 500]
output_path = out_dir.joinpath("concentration_Cs.png")
plotter.screenshot(str(output_path))
plotter.show()
the temporal evolution of tracer concentration at a downstream monitoring point in a flow-through porous medium,
mesh_series = ot.MeshSeries(f'{out_dir}/{user_parameters["prefix"]}.pvd')
…
(click to toggle)
mesh_series = ot.MeshSeries(f'{out_dir}/{user_parameters["prefix"]}.pvd')
print("Scalar fields available in mesh:", mesh.array_names)
observation_points = np.array(
[
[80, 50, 90],
[80, 50, 80],
]
)
labels = [f"Point {i}: {pt}" for i, pt in enumerate(observation_points)]
ms_days = mesh_series.scale(time=("s", "d"))
var = ot.variables.Scalar("Si")
fig_si = ms_days.plot_probe(points=observation_points, variable=var, labels=labels)
fig_si.axes[0].set_xscale("log")
fig_cs = ms_days.plot_probe(points=observation_points, variable="Cs", labels=labels)
fig_cs.axes[0].set_xscale("log")
Scalar fields available in mesh: ['Cs', 'OGS_VERSION', 'PointMergeMap', 'CsFlowRate', 'LiquidMassFlowRate', 'Si', 'SiFlowRate', 'darcy_velocity', 'pressure', 'darcy_velocity_magnitude', 'log_darcy_velocity_magnitude', 'MaterialIDs', 'permeability_ic', 'porosity_avg', 'velocity', 'width_ic']
/tmp/ipykernel_423991/2640689875.py:16: DeprecationWarning: Call to a deprecated function plot_probe.
Please use the following code instead:
ms_pts = ot.MeshSeries.extract_probe(mesh_series, pts)
fig = ot.plot.line(ms_pts, "time", variable_of_interest)
fig_si = ms_days.plot_probe(points=observation_points, variable=var, labels=labels)
/tmp/ipykernel_423991/2640689875.py:19: DeprecationWarning: Call to a deprecated function plot_probe.
Please use the following code instead:
ms_pts = ot.MeshSeries.extract_probe(mesh_series, pts)
fig = ot.plot.line(ms_pts, "time", variable_of_interest)
fig_cs = ms_days.plot_probe(points=observation_points, variable="Cs", labels=labels)
def _ensure_point_pressure(m):
…
(click to toggle)
def _ensure_point_pressure(m):
if "pressure" in m.point_data:
return m
if "pressure" in m.cell_data:
return m.cell_data_to_point_data()
msg = "pressure not found in point_data or cell_data"
raise KeyError(msg)
def _nn_fill(masked_vals, valid_mask, tgt_pts, src_pts, src_vals):
nan_idx = ~valid_mask
if not np.any(nan_idx):
return masked_vals
tree = cKDTree(src_pts)
_, j = tree.query(tgt_pts[nan_idx], k=1)
masked_vals[nan_idx] = src_vals[j]
return masked_vals
last_mesh = mesh_series[-1]
pressure_last = np.asarray(last_mesh.point_data["pressure"])
system_name = platform.system()
if system_name == "Darwin":
ref_mesh_path = "DFN_HC_ts_39_t_100000000000.000000_mac.vtu"
elif system_name == "Linux":
ref_mesh_path = "DFN_HC_ts_39_t_100000000000.000000_linux.vtu"
else:
msg = f"Unsupported OS: {system_name}"
raise RuntimeError(msg)
ref_mesh = _ensure_point_pressure(pv.read(ref_mesh_path))
print(f"Original points: {mesh_c.n_points}, Cleaned points: {clean_mesh.n_points}")
print("pressure_last shape:", pressure_last.shape, "size:", pressure_last.size)
print(
"pressure_ref shape:",
ref_mesh.point_data["pressure"].shape,
"size:",
ref_mesh.point_data["pressure"].size,
)
sampled = last_mesh.sample(ref_mesh)
pressure_ref_on_last = np.asarray(sampled.point_data["pressure"])
mask = sampled.point_data.get("vtkValidPointMask", None)
if mask is not None:
mask = np.asarray(mask).astype(bool)
if not mask.all():
pressure_ref_on_last = _nn_fill(
pressure_ref_on_last,
mask,
last_mesh.points,
ref_mesh.points,
np.asarray(ref_mesh.point_data["pressure"]),
)
abs_diff = np.abs(pressure_last - pressure_ref_on_last)
print(f"max|delta_p|={abs_diff.max():.6g}, mean|delta_p|={abs_diff.mean():.6g}")
np.testing.assert_allclose(
actual=pressure_last,
desired=pressure_ref_on_last,
rtol=5e-6,
atol=1e-8,
equal_nan=False,
)
print("Pressure fields match")
Original points: 12110, Cleaned points: 10340
pressure_last shape: (10340,) size: 10340
pressure_ref shape: (10340,) size: 10340
max|delta_p|=3.44589e-08, mean|delta_p|=7.02986e-09
Pressure fields match
NOTE: PorePy produces slightly different number of mesh nodes on macOS vs. Linux. This causes small variations in the output arrays. To avoid flaky test failures, we accept either platform’s expected results until the root cause is resolved.
PorePy – An open-source simulation tool for fractured and porous media. Available at: https://github.com/pmgbergen/porepy
Keilegavlen, E., Berre, I., Fumagalli, A., Stefansson, I., and Edwards, M.G. (2021). PorePy: An open-source simulation tool for flow and transport in deformable fractured rocks. Computational Geosciences, 25, 165–187.
Buchwald, J., Kolditz, O., & Nagel, T. (2021). ogs6py and VTUinterface: streamlining OpenGeoSys workflows in Python. Journal of Open Source Software, 6(67), 3673.
Cvetkovic, V., & Frampton, A. (2010). Transport and retention from single to multiple fractures in crystalline rock at Äspö (Sweden): Fracture network simulations and generic retention model. Water Resources Research, 46, W05506.
Watanabe, N., & Kolditz, O. (2015). Numerical stability analysis of two-dimensional solute transport along a discrete fracture in a porous rock matrix. Water Resources Research, 51(7), 5855-5868.
Chen, C., Binder, M., Oppelt, L., Hu, Y., Engelmann, C., Arab, A., Xu, W., Scheytt, T., & Nagel, T. (2024). Modeling of heat and solute transport in a fracture-matrix mine thermal energy storage system and energy storage performance evaluation. Journal of Hydrology, 636(May), 131335.
Chen, R., Xu, W., Chen, Y., Nagel, T., Chen, C., Hu, Y., Li, J., & Zhuang, D. (2024). Influence of heterogeneity on dissolved CO2 migration in a fractured reservoir. Environmental Earth Sciences, 83(16), 463.
Chaudhry, A. A., Zhang, C., Ernst, O. G., & Nagel, T. (2025). Effects of inhomogeneity and statistical and material anisotropy on THM simulations. Reliability Engineering & System Safety, 110921.
Zheng, Z., Wang, X., Flügge, J., & Nagel, T. (2025). A stochastic modeling framework for radionuclide migration from deep geological repositories considering spatial variability. Advances in Water Resources, 203, 105003.
This article was written by Mostafa Mollaali, Thomas Nagel. If you are missing something or you find an error please let us know.
Generated with Hugo 0.147.9
in CI job 615369
|
Last revision: April 28, 2025