"""
Post-processing: build a 3-D **wedge volume** of the goSPL stratigraphy for one
or more output steps, ready to open in ParaView.
goSPL records the stratigraphic pile in the distributed ``stratal.<step>.p<rank>
.h5`` files (per-layer ``stratZ``/``stratH``/``phiS`` and, optionally,
``stratHf``/``phiF`` for dual lithology and ``stratP`` for provenance), with the
triangular surface mesh in ``topology.p<rank>.h5``. This tool stacks that
triangular mesh across the recorded layers into a **triangular-prism (wedge)
volume**: each vertex of the surface mesh is replicated at every layer's
deposition elevation ``stratZ``, and each surface triangle becomes a column of
wedge cells between consecutive layers. The layer properties are written as
**cell data** on the wedges, so colouring a slab shows that layer's composition.
Three field modes (``--field``):
* ``basic`` (default) — just the always-recorded per-layer ``thickness``,
``elevation``, ``layer`` index and ``porosity``. Works for **any** run (a
plain stratigraphy run needs neither dual lithology nor provenance).
* ``lithology`` — coarse / fine thickness, fine fraction and the per-fraction
porosities (needs the dual-lithology ``stratHf``/``phiF`` fields).
* ``provenance`` — per-source-class volume fraction, the dominant source and
the per-layer ``porosity`` (plus ``phiFine`` if the run is also dual
lithology); needs the provenance ``stratP`` field.
(``thickness``, ``elevation`` and ``layer`` are attached in every mode.)
The basal **bedrock sentinel** layer (the ~1e6 m infinite reservoir goSPL keeps
under the recorded deposits) is auto-detected and skipped, so the volume spans
only the real recorded layers (override with ``--first-layer``).
Output mirrors goSPL's own layout — one HDF5 per input partition per step
(``<outdir>/<out>.<step>.p<p>.h5``) plus a single self-contained
``<outdir>/<out>.xdmf`` temporal collection referencing them. It runs serially
(looping over all partitions) or under ``mpirun`` (the partitions are split
across ranks — independent of the simulation's processor count); the output is
identical either way, so the result is trivially parallel and time-evolving.
Options
-------
``--h5dir DIR``
goSPL output ``h5`` directory (**required**).
``--outdir DIR``
output directory, created if needed (default ``strata``).
``--out PREFIX``
output file prefix (default ``strata``); writes ``<outdir>/<out>.xdmf`` plus
``<outdir>/<out>.<step>.p<p>.h5``.
``--field {basic,lithology,provenance}``
cell-field set (default ``basic``). ``basic`` = thickness/elevation/layer/
porosity only (works for any run); ``lithology`` = coarse/fine thickness,
fine fraction and per-fraction porosity (needs the dual-lithology
``stratHf``/``phiF`` fields); ``provenance`` = per-source-class volume
fraction + dominant source + per-layer ``porosity`` (needs ``stratP``).
``--steps S0,S1,...``
comma-separated output steps to convert (default: all found). Steps with
fewer than 2 recorded layers are skipped.
``--tout YR`` (with optional ``--tstart YR``)
set the ParaView ``Time`` to the simulation year ``tstart + step*tout``
instead of the bare step index (default: step index).
``--first-layer N``
first layer index to include (default: auto-skip the basal bedrock-sentinel
layer, detected as thickness >= 1e5 m).
``--mesh-base BASE``
mesh-output file base providing the current surface ``elev`` (default
``gospl``). Layer interfaces are placed at the current surface minus the
cumulative compacted thickness, so eroded layers collapse instead of
overhanging; falls back to the recorded ``stratZ`` if the mesh file is
absent.
``--file-base BASE``
stratal-file base name (default ``stratal``).
Runnable as the installed command ``gospl-strata-volume`` or, with no install
step, as ``python -m gospl.analyse.stratamesh``. Examples::
# defaults -> writes ./strata/strata.xdmf (+ per-partition .h5)
gospl-strata-volume --h5dir myrun/h5 --field lithology --steps 0,5,10
# provenance source mix, ParaView time in years, run in parallel
mpirun -np 4 gospl-strata-volume --h5dir myrun/h5 --outdir vol \\
--field provenance --tout 100000
"""
import os
import glob
import argparse
import numpy as np
try:
import h5py
except ImportError: # pragma: no cover - h5py is required at runtime
h5py = None
try:
from mpi4py import MPI
_COMM = MPI.COMM_WORLD
_RANK = _COMM.Get_rank()
_SIZE = _COMM.Get_size()
except ImportError: # pragma: no cover - serial fallback
_COMM = None
_RANK = 0
_SIZE = 1
# Bedrock-sentinel thickness goSPL uses for the infinite basal reservoir; any
# layer thicker than this cut-off is treated as basement and excluded.
_SENTINEL_CUTOFF = 1.0e5
[docs]
def discover_steps(h5dir, file_base="stratal"):
"""Return ``(sorted step list, partition count)`` found in ``h5dir``."""
steps = set()
for f in glob.glob(os.path.join(h5dir, "%s.*.p*.h5" % file_base)):
name = os.path.basename(f)
try:
steps.add(int(name.split(".")[1]))
except (IndexError, ValueError):
continue
if not steps:
raise FileNotFoundError(
"no %s.<step>.p*.h5 files in %s" % (file_base, h5dir)
)
nparts = len(
glob.glob(os.path.join(h5dir, "%s.%d.p*.h5" % (file_base, min(steps))))
)
return sorted(steps), nparts
[docs]
def detect_first_layer(stratal_path):
"""
Index of the first non-basement layer: skip leading layers whose thickness
exceeds the sentinel cut-off (the ~1e6 m infinite-bedrock reservoir). The
layer structure is global, so reading one partition is representative.
"""
with h5py.File(stratal_path, "r") as f:
H = np.asarray(f["stratH"]) # (nodes, nlayers)
nlay = H.shape[1]
lo = 0
while lo < nlay and float(H[:, lo].max()) >= _SENTINEL_CUTOFF:
lo += 1
return lo
[docs]
def build_partition(stratal_path, topology_path, lo, field, mesh_path=None):
"""
Build the wedge volume for one partition / step.
:arg stratal_path: ``stratal.<step>.p<p>.h5`` (per-layer fields).
:arg topology_path: ``topology.p<p>.h5`` (``coords``, ``cells``).
:arg lo: first layer to include (basement layers below are dropped).
:arg field: ``"lithology"`` or ``"provenance"``.
:arg mesh_path: optional ``<mesh>.<step>.p<p>.h5`` providing the current
surface ``elev``. When given, the layer-interface elevations are
reconstructed from the **current** surface minus the cumulative
(compacted) thickness above each layer — so eroded layers (``stratH``
``= 0``) collapse to zero-thickness wedges instead of being drawn at
their stale deposition elevation (which produced spurious cells above
the eroded surface). Without it, the recorded ``stratZ`` is used.
:return: dict with ``vertices`` (Nv, 3), ``wedges`` (Nw, 6), ``cells`` cell
fields (each (Nw,)), ``frac`` (Nw, C) for provenance, and ``n_classes``.
Returns ``None`` when the step has too few recorded layers to form a
wedge.
"""
with h5py.File(topology_path, "r") as f:
coords = np.asarray(f["coords"], dtype=np.float64) # (n, 3)
tris = np.asarray(f["cells"], dtype=np.int64) - 1 # 0-indexed (n, 3)
with h5py.File(stratal_path, "r") as f:
stratZ = np.asarray(f["stratZ"], dtype=np.float64) # (n, L)
stratH = np.asarray(f["stratH"], dtype=np.float64)
nlay = stratZ.shape[1]
avail = set(f.keys())
litho = field == "lithology"
prov = field == "provenance"
basic = not (litho or prov) # thickness/elevation/porosity only
# Per-layer porosity (phiS) is recorded for ANY stratigraphy run, so it is
# read here and attached for every field mode (including "basic", which
# needs neither dual lithology nor provenance).
phiS = np.asarray(f["phiS"], dtype=np.float64) if "phiS" in avail else None
if litho:
if "stratHf" not in avail or "phiF" not in avail:
raise ValueError(
"field 'lithology' needs the dual-lithology 'stratHf'/'phiF' "
"fields, absent from %s (was the run dual lithology? use "
"--field basic for a single-lithology run)" % stratal_path
)
stratHf = np.asarray(f["stratHf"], dtype=np.float64)
phiF = np.asarray(f["phiF"], dtype=np.float64)
if prov:
if "stratP" not in avail:
raise ValueError(
"field 'provenance' needs the 'stratP' field, absent from %s "
"(was the run provenance-enabled? use --field basic for a run "
"without provenance)" % stratal_path
)
stratP = np.asarray(f["stratP"], dtype=np.float64) # (n, L, C)
# For a dual-lithology run the fine-fraction porosity phiF is also
# present; attach it alongside phiS.
phiF = (
np.asarray(f["phiF"], dtype=np.float64)
if "phiF" in avail else None
)
n = coords.shape[0]
m = tris.shape[0]
nsurf = nlay - lo # selected surfaces
if nsurf < 2:
return None # no slab to draw
nint = nsurf - 1 # wedge intervals
# --- layer-interface elevations -------------------------------------------
# Prefer the physically-current geometry: anchor at the present surface
# (`elev`) and subtract the cumulative compacted thickness above each layer.
# `erodeStrat` never updates `stratZ`, so an eroded layer keeps a stale
# (higher) deposition elevation; using it would draw cells above the current
# surface. The cumulative reconstruction collapses such layers (stratH == 0)
# and stays monotonic. Falls back to the recorded `stratZ` if no mesh file.
if mesh_path is not None and os.path.exists(mesh_path):
with h5py.File(mesh_path, "r") as mf:
topZ = np.asarray(mf["elev"], dtype=np.float64).reshape(-1) # (n,)
# above[:, l] = sum of thickness of the layers strictly above layer l.
above = np.cumsum(stratH[:, ::-1], axis=1)[:, ::-1] - stratH
surfZ = topZ[:, None] - above # top of layer l
else:
surfZ = stratZ
# --- vertices: node replicated at each selected surface elevation ---------
X = np.tile(coords[:, 0], nsurf)
Y = np.tile(coords[:, 1], nsurf)
Z = surfZ[:, lo:nlay].T.reshape(-1) # surface-major
vertices = np.column_stack([X, Y, Z]).astype(np.float32)
# --- wedges: triangle prism between consecutive surfaces ------------------
si = np.arange(nint)
bottom = tris[None, :, :] + (si[:, None, None] * n) # (nint, m, 3)
top = tris[None, :, :] + ((si + 1)[:, None, None] * n)
wedges = np.concatenate([bottom, top], axis=2).reshape(-1, 6).astype(np.int32)
# --- per-wedge (cell) fields = the slab's recorded layer property ---------
# Interval i is the slab below surface i+1 == original layer (lo+i+1).
lay = slice(lo + 1, nlay) # layers lo+1..L-1
def _cell(per_node_layer):
# (n, nint) per-node-per-layer -> (Nw,) per-wedge, interval-major.
return per_node_layer[tris].mean(axis=1).T.reshape(-1).astype(np.float32)
out = {
"vertices": vertices,
"wedges": wedges,
"cells": {},
"frac": None,
"n_classes": 0,
}
out["cells"]["thickness"] = _cell(stratH[:, lay])
# Per-cell elevation = mid-height of the wedge (mean of its bottom/top
# interface elevations). Interval i sits between selected surfaces i and
# i+1, i.e. recorded surfaces lo+i and lo+i+1.
midZ = 0.5 * (surfZ[:, lo : nlay - 1] + surfZ[:, lo + 1 : nlay]) # (n, nint)
out["cells"]["elevation"] = _cell(midZ)
# Per-cell recorded-layer index (interval i is the slab of original layer
# lo+1+i). Interval-major to match the `wedges` ordering used by `_cell`.
out["cells"]["layer"] = np.repeat(
np.arange(lo + 1, nlay, dtype=np.int32), m
)
# Basic mode: no lithology / provenance — just attach the recorded porosity
# (the surface + thickness + layer cells above are written for every mode).
if basic and phiS is not None:
out["cells"]["porosity"] = _cell(phiS[:, lay])
if litho:
Hl = stratH[:, lay]
Hf = stratHf[:, lay]
with np.errstate(divide="ignore", invalid="ignore"):
ff = np.where(Hl > 0.0, Hf / Hl, 0.0)
out["cells"]["coarseThick"] = _cell(Hl - Hf)
out["cells"]["fineThick"] = _cell(Hf)
out["cells"]["fineFrac"] = _cell(ff)
out["cells"]["phiCoarse"] = _cell(phiS[:, lay])
out["cells"]["phiFine"] = _cell(phiF[:, lay])
if prov:
C = stratP.shape[2]
P = stratP[:, lay, :] # (n, nint, C)
Hl = stratH[:, lay]
with np.errstate(divide="ignore", invalid="ignore"):
fnode = np.where(Hl[..., None] > 0.0, P / Hl[..., None], 0.0)
# Per-wedge per-class fraction (interval-major to match `wedges`).
fcell = fnode[tris].mean(axis=1) # (m, nint, C)
# Cells with no source composition (Σ_c == 0) are eroded / pinched-out
# layers (stratH == 0). Give each the composition of the cell directly
# below it in the same triangular column (the next lower interval), so a
# collapsed layer carries the underlying source rather than showing as a
# no-source cell (dominant == -1). Interval 0 is the deepest, so filling
# in ascending order lets a stack of empty layers cascade the nearest
# defined underlying composition upward. Only interval 0 can stay empty
# (nothing beneath it within the selected layers).
ctot = fcell.sum(axis=2) # (m, nint)
for i in range(1, fcell.shape[1]):
empty = ctot[:, i] <= 0.0
fcell[empty, i, :] = fcell[empty, i - 1, :]
ctot[empty, i] = ctot[empty, i - 1]
frac = np.transpose(fcell, (1, 0, 2)).reshape(-1, C).astype(np.float32)
tot = frac.sum(axis=1)
dominant = np.where(tot > 0.0, frac.argmax(axis=1), -1).astype(np.int32)
out["frac"] = frac
out["n_classes"] = C
out["cells"]["dominant"] = dominant
# Per-layer porosity alongside the provenance composition.
out["cells"]["porosity"] = _cell(phiS[:, lay])
if phiF is not None:
out["cells"]["phiFine"] = _cell(phiF[:, lay])
return out
[docs]
def write_partition_h5(path, data):
"""Write one partition's wedge volume to HDF5."""
opts = {"compression": "gzip"}
with h5py.File(path, "w") as f:
f.create_dataset("vertices", data=data["vertices"], **opts)
f.create_dataset("wedges", data=data["wedges"], **opts)
for name, arr in data["cells"].items():
f.create_dataset(name, data=arr, **opts)
if data["frac"] is not None:
f.create_dataset("frac", data=data["frac"], **opts)
def _attr_cell(name, h5file, path, nw, dtype="Float", prec=4):
return (
' <Attribute Name="%s" AttributeType="Scalar" Center="Cell">\n'
' <DataItem Format="HDF" NumberType="%s" Precision="%d" '
'Dimensions="%d 1">%s:/%s</DataItem>\n'
" </Attribute>\n" % (name, dtype, prec, nw, h5file, path)
)
def _attr_frac_col(c, h5file, nw, ncl):
# HyperSlab column c of the (nw, ncl) /frac array.
return (
' <Attribute Name="src_class%d" AttributeType="Scalar" Center="Cell">\n'
' <DataItem ItemType="HyperSlab" Dimensions="%d 1">\n'
' <DataItem Dimensions="3 2" Format="XML">0 %d 1 1 %d 1</DataItem>\n'
' <DataItem Format="HDF" NumberType="Float" Precision="4" '
'Dimensions="%d %d">%s:/frac</DataItem>\n'
" </DataItem>\n"
" </Attribute>\n" % (c, nw, c, nw, nw, ncl, h5file)
)
[docs]
def write_xdmf(out_prefix, meta, field, times=None):
"""
Write a single self-contained ``<out>.xdmf`` (temporal collection): one time
per step, each a spatial collection of the per-partition wedge grids.
:arg meta: ``{step: [ {h5, nv, nw, cells:[names], ncl}, ... per partition ]}``.
:arg times: optional ``{step: value}`` for the ParaView ``Time`` (e.g. the
simulation year); defaults to the step index.
"""
times = times or {}
L = [
'<?xml version="1.0" ?>\n<!DOCTYPE Xdmf SYSTEM "Xdmf.dtd" []>\n',
'<Xdmf Version="2.0">\n <Domain>\n',
' <Grid Name="StrataVolume" GridType="Collection" CollectionType="Temporal">\n',
]
for step in sorted(meta):
L.append(' <Grid Name="step_%d" GridType="Collection" '
'CollectionType="Spatial">\n' % step)
L.append(' <Time Value="%g"/>\n' % times.get(step, step))
for blk in meta[step]:
h5 = os.path.basename(blk["h5"])
nv, nw = blk["nv"], blk["nw"]
L.append(' <Grid Name="%s" GridType="Uniform">\n'
% blk["h5"].rsplit(".", 1)[0].rsplit(".", 1)[-1])
L.append(
' <Topology TopologyType="Wedge" NumberOfElements="%d">\n'
' <DataItem Format="HDF" DataType="Int" Dimensions="%d 6">'
"%s:/wedges</DataItem>\n"
" </Topology>\n" % (nw, nw, h5)
)
L.append(
' <Geometry GeometryType="XYZ">\n'
' <DataItem Format="HDF" NumberType="Float" Precision="4" '
'Dimensions="%d 3">%s:/vertices</DataItem>\n'
" </Geometry>\n" % (nv, h5)
)
for name in blk["cells"]:
dt = "Int" if name in ("dominant", "layer") else "Float"
L.append(" " + _attr_cell(name, h5, name, nw, dtype=dt))
if field == "provenance":
for c in range(blk["ncl"]):
L.append(" " + _attr_frac_col(c, h5, nw, blk["ncl"]))
L.append(" </Grid>\n")
L.append(" </Grid>\n")
L.append(" </Grid>\n </Domain>\n</Xdmf>\n")
with open(out_prefix + ".xdmf", "w") as f:
f.writelines(L)
def main(argv=None):
p = argparse.ArgumentParser(
description="Build a 3-D wedge volume of the goSPL stratigraphy for "
"ParaView (basic, lithology or provenance)."
)
p.add_argument("--h5dir", required=True, help="goSPL output 'h5' directory")
p.add_argument("--outdir", default="strata",
help="output directory, created if needed (default: strata)")
p.add_argument("--out", default="strata",
help="output file prefix (default: strata)")
p.add_argument("--field", default="basic",
choices=["basic", "lithology", "provenance"],
help="cell field set to attach: 'basic' (thickness, "
"elevation, layer, porosity — works for any run), "
"'lithology' (dual-lithology coarse/fine + porosities), "
"or 'provenance' (per-source fractions). Default: basic")
p.add_argument("--steps", default=None,
help="comma-separated steps (default: all found)")
p.add_argument("--tout", type=float, default=None,
help="output interval in years; sets ParaView Time = "
"tstart + step*tout (default: use the step index)")
p.add_argument("--tstart", type=float, default=0.0,
help="simulation start time in years for the --tout mapping")
p.add_argument("--first-layer", type=int, default=None,
help="first layer index to include (default: auto-skip the "
"bedrock sentinel)")
p.add_argument("--file-base", default="stratal",
help="stratal file base name (default: stratal)")
p.add_argument("--mesh-base", default="gospl",
help="mesh-output file base providing the current surface "
"'elev' (default: gospl); used to place layer interfaces "
"by cumulative thickness so eroded layers don't overhang")
args = p.parse_args(argv)
if h5py is None:
raise ImportError("h5py is required (pip install gospl[analysis])")
# Rank 0 creates the output directory before any rank writes into it.
if _RANK == 0:
os.makedirs(args.outdir, exist_ok=True)
if _COMM is not None and _SIZE > 1:
_COMM.Barrier()
all_steps, nparts = discover_steps(args.h5dir, args.file_base)
steps = (
[int(s) for s in args.steps.split(",")] if args.steps else all_steps
)
def sp(step, part):
return os.path.join(args.h5dir, "%s.%d.p%d.h5" % (args.file_base, step, part))
def tp(part):
return os.path.join(args.h5dir, "topology.p%d.h5" % part)
def mp(step, part):
return os.path.join(
args.h5dir, "%s.%d.p%d.h5" % (args.mesh_base, step, part)
)
local_meta = {} # step -> list of block dicts (this rank)
for step in steps:
lo = (
args.first_layer
if args.first_layer is not None
else detect_first_layer(sp(step, 0))
)
blocks = []
for part in range(nparts):
if part % _SIZE != _RANK: # split partitions across ranks
continue
data = build_partition(
sp(step, part), tp(part), lo, args.field, mesh_path=mp(step, part)
)
if data is None:
continue
out_h5 = os.path.join(
args.outdir, "%s.%d.p%d.h5" % (args.out, step, part)
)
write_partition_h5(out_h5, data)
blocks.append({
"h5": out_h5,
"nv": len(data["vertices"]),
"nw": len(data["wedges"]),
"cells": list(data["cells"].keys()),
"ncl": data["n_classes"],
})
if blocks:
local_meta[step] = blocks
# Gather metadata to rank 0 (which writes the single XDMF).
if _COMM is not None and _SIZE > 1:
gathered = _COMM.gather(local_meta, root=0)
else:
gathered = [local_meta]
if _RANK == 0:
meta = {}
for part_meta in gathered:
for step, blocks in part_meta.items():
meta.setdefault(step, []).extend(blocks)
for step in meta: # stable partition order in the XDMF
meta[step].sort(key=lambda b: b["h5"])
# ParaView Time: the simulation year when --tout is given, else step.
times = (
{s: args.tstart + s * args.tout for s in meta}
if args.tout is not None
else None
)
out_prefix = os.path.join(args.outdir, args.out)
write_xdmf(out_prefix, meta, args.field, times=times)
print("wrote %s.xdmf (%d step(s), %d partition(s)) — open in ParaView"
% (out_prefix, len(meta), nparts))
skipped = [s for s in steps if s not in meta]
if skipped:
print(" (skipped step(s) %s — fewer than 2 recorded layers, so no "
"slab to draw)" % ", ".join(str(s) for s in skipped))
return 0
if __name__ == "__main__":
raise SystemExit(main())