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

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.

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)

References