Network states¶
A pore network rarely sits still. When a transport or phase-change simulation runs on it, every pore and throat carries field values – a concentration, a saturation, a temperature – that evolve from one time step to the next. A single state is a snapshot of those fields at one simulation step. PoreScene can load a whole series of states from a MATLAB file and render each one on the same stick-and-ball geometry, so an evolving simulation turns into a comparable sequence of images.
This example loads a pore network together with several of its states, configures the
state field concentration on both the pores and the throats, and renders every
selected step on one fixed color scale. A simple diffusion problem based on a regular pore network is visualized in this example; the accompanying state data lives in
PoreScene’s repository on GitHub:
data/pnm-states.mat
State 0© Felix Faber / OVGU, Transport in Porous Media¶
State 500© Felix Faber / OVGU, Transport in Porous Media¶
State 600© Felix Faber / OVGU, Transport in Porous Media¶
State 700© Felix Faber / OVGU, Transport in Porous Media¶
State 800© Felix Faber / OVGU, Transport in Porous Media¶
State 930© Felix Faber / OVGU, Transport in Porous Media¶
The rendered result, one slide per state: the pore spheres and throat cylinders colored by
concentration on a fixed [0, 50] mol/l scale, with on-scale axes and the matching
colorbar. The same geometry is recolored for every state, so the frames can be compared
directly.
Step-by-step guide¶
1. Import required modules¶
Make sure to have porescene installed (see Installation). At first, required modules need to be imported:
import json
from pathlib import Path
import numpy as np
from porescene import worker
from porescene.color.palette import Colormap, Palette
from porescene.config import PropertyConfiguration
from porescene.model import PoreNetwork, StateVariableMap
from porescene.scene import Scene
from porescene.utility import CompassDirection, Orientation
PoreNetwork holds the pore and throat data together with the list
of states, while Scene sets up the rendering stage. Module
worker provides the high-level helpers that build the stick-and-ball
geometry and render every state. StateVariableMap tells the
importer which .mat variables hold each state field, and
PropertyConfiguration describes how the field is colored and
labelled – with Orientation and
CompassDirection placing the colorbar.
Palette and Colormap
supply the colors. File paths are handled with the built-in pathlib module,
json reads the variable mapping, and numpy is on hand to generate the list
of simulation steps.
2. Set utility variables¶
After that, the directory holding the input data and the directory receiving the rendered images are specified:
# data directory
pth_data = Path.cwd() / "data"
# frames directory
pth_frames = pth_data / "frames"
pth_data holds the input files – the pore network and its states – while
pth_frames collects the rendered states together with their colorbars. Keeping the
frames in their own subdirectory pays off as soon as a series grows: one file per state
piles up quickly, and they stay separated from the input data.
3. Load the pore network and its states¶
The geometry and the state fields share one MATLAB file. As with the other examples, the
network geometry is mapped through map_vars.json (see
network coordination number):
# load variable mapping for variable import from .mat file
with open(pth_data / "map_vars.json") as f:
map_vars = json.load(f)
The state fields need their own mapping. A StateVariableMap ties
a property name – the name the coloring is configured under later – to the .mat
variables that store its per-pore (sphere) and per-throat (cylinder) values. Here the
concentration is available for both, so both variables are set:
# map the concentration state field to its per-pore and per-throat .mat variables
concentration = StateVariableMap("concentration")
concentration.variable_sphere = "C_p_storage"
concentration.variable_cylinder = "C_t_storage"
vars_state = [concentration]
Note
A variable left unset is simply skipped, so a field may carry pore data, throat data,
or both. Further fields – a saturation or a temperature, say – are added by appending
more StateVariableMap instances to vars_state; every one
of them is then rendered as its own image series.
Each state field is stored as a two-dimensional array in the .mat file: the first
dimension runs over the pores (or throats) and the second over the simulation steps. Only
a handful of those steps usually need to be visualized, so the wanted step indices are
listed explicitly and wrapped into PoreNetworkState instances by
the importer:
# simulation steps to visualize
no_states = (0, 500, 600, 700, 800, 930)
# load the pore network together with the selected states from the MAT file
pn = PoreNetwork.from_mat(
pth_data / "pnm-states.mat",
map_vars["data_network"],
vars_state,
no_states,
)
The steps are picked by hand here, because the interesting part of this simulation happens late: one step at the very beginning, then four steps bunched around the transition, and the final step 930.
Tip
For an evenly sampled series, the step indices can just as well be generated, e.g. with
numpy.linspace() – np.linspace(0, 930, 5, dtype=int) yields five steps spread
across the whole simulation. The example keeps that variant as a comment next to the
explicit list.
The loaded PoreNetwork now carries the geometry and an ordered
list of states, each holding the concentration values at one simulation step.
Note
The state fields can also be attached to an already-loaded network with
load_states_from_mat(), which takes the same
vars_state and no_states arguments. This is handy when the geometry and the
simulation results live in separate .mat files.
4. Scene setup¶
The scene is created directly from the physical extent of the network, which sizes it
and calibrates the axes to the real dimensions of the sample (see Concepts & Conventions):
# initialize scene
sc = Scene(pn.extent)
Tip
A series of states is a good reason to pin the camera down: with
Scene.from_json() the same camera, lighting
and axis configuration is reused on every run, so frames rendered at different times
still line up (see Scene configuration).
The concentration field is registered on the scene with a
PropertyConfiguration. Its first argument is the property name
and has to match the name given to the StateVariableMap above –
that is how the coloring finds its data. The remaining arguments shape the colorbar and,
most importantly for a series, its bounds:
# settings for concentration visualizations
sc.config_scene.add_property(
PropertyConfiguration(
"concentration", # key that should match with the StateVariableMap
Palette.load(Colormap.MATTER).all(), # colormap
heading="Concentration [mol/l]", # colorbar label
orientation=Orientation.VERTICAL, # colorbar orientation
align=CompassDirection.WEST, # colorbar position around the rendering
precision=3, # precision of colorbar ticks
use_global_boundaries=True,
min=0,
max=50,
)
)
Palette.load(Colormap.MATTER).all() hands
the full MATTER colormap to a smooth gradient, heading sets the colorbar title, and
orientation together with align stands it vertically on the west (left) side.
use_global_boundaries=True combined with min and max fixes the color scale at
[0, 50] mol/l for all states, so every frame gets the same colorbar and equal
concentrations map to equal colors from step to step.
Tip
Set use_global_boundaries=True (with an explicit min and max) whenever the
states of a series should be comparable. Leave it off for a field whose range is
unknown or changes drastically, and each state auto-fits the gradient to its own value
range instead – which makes a single frame easy to read, but the frames no longer
comparable to one another.
build_structure() then builds the stick-and-ball geometry, and
calibrated axes are added around it:
# add cylinders and spheres to the scene
worker.build_structure(sc, pn)
# add axes around the scene
sc.create_axes()
Both layers stay enabled, so each state is drawn with its pore spheres and its throat cylinders colored – which is exactly why the concentration was mapped for both in the step before.
5. Render each state¶
make_state() walks every loaded state and, for each state, every
configured field. It fits the gradient (here once, from the global bounds), colors the
enabled layers, renders the scene, and composites the matching colorbar:
# render every state, coloring the pore spheres and throat cylinders by concentration
sc, pth_img = worker.make_state(pth_frames, pn, sc)
Each render is named after the layers it shows, the field they are colored by, and the
state index, so the frames of the series end up next to each other in pth_frames as
cylinder-concentration+sphere-concentration+axes+state-0.png,
...+state-500.png, and so on – the sequence shown in the carousel at the top of this
page, where the concentration front moves through the network on one and the same color
scale.
Tip
The rendered states are a ready-made frame sequence: passing them (in order) to
frames2mp4() or frames2gif() turns the
series into a video, the same way the solid animation example
does it.
Full script¶
The complete example, also available on GitHub: example/network_state.py.
1import json
2from pathlib import Path
3
4from porescene import worker
5from porescene.color.palette import Colormap, Palette
6from porescene.config import PropertyConfiguration
7from porescene.model import PoreNetwork, StateVariableMap
8from porescene.scene import Scene
9from porescene.utility import CompassDirection, Orientation
10
11# =============================================================================
12# Import Parameters
13
14# data directory
15pth_data = Path.cwd() / "data"
16
17# frames directory
18pth_frames = pth_data / "frames"
19
20
21# =============================================================================
22# Data Import
23
24# load variable mapping for variable import from .mat file
25with open(pth_data / "map_vars.json") as f:
26 map_vars = json.load(f)
27
28# map the concentration state field to its per-pore .mat variable
29concentration = StateVariableMap("concentration")
30concentration.variable_sphere = "C_p_storage"
31concentration.variable_cylinder = "C_t_storage"
32
33vars_state = [concentration]
34
35# distinct simulation steps to visualize
36no_states = (0, 500, 600, 700, 800, 930)
37
38# load the pore network together with the selected states from the MAT file
39pn = PoreNetwork.from_mat(
40 pth_data / "pnm-states.mat",
41 map_vars["data_network"],
42 vars_state,
43 no_states,
44)
45
46
47# =============================================================================
48# Scene configuration
49
50# initialize scene
51sc = Scene(pn.extent)
52
53# settings for concentration visualizations
54sc.config_scene.add_property(
55 PropertyConfiguration(
56 "concentration", # key that should match with the StateVariableMap
57 Palette.load(Colormap.MATTER).all(), # colormap
58 heading="Concentration [mol/l]", # colorbar label
59 orientation=Orientation.VERTICAL, # colorbar orientation
60 align=CompassDirection.WEST, # colorbar position around the rendering
61 precision=3, # precision of colorbar ticks
62 use_global_boundaries=True, # clamp colorbar imits to series minimum/maximum
63 min=0,
64 max=50,
65 )
66)
67
68# add cylinders and spheres to the scene
69worker.build_structure(sc, pn)
70
71# add axes around the scene
72sc.create_axes()
73
74
75# =============================================================================
76# Render each state
77
78# render every state, coloring the pore spheres by concentration and by saturation
79sc, pth_img = worker.make_state(pth_frames, pn, sc)