Skip to content

feat: add gstools.mps — Multiple Point Statistics via Direct Sampling - #416

Draft
n0228a wants to merge 33 commits into
GeoStat-Framework:mainfrom
n0228a:mps-direct-sampling
Draft

n0228a wants to merge 33 commits into
GeoStat-Framework:mainfrom
n0228a:mps-direct-sampling

Conversation

@n0228a

@n0228a n0228a commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a new gstools.mps subpackage implementing Multiple Point Statistics (MPS)
simulation via the Direct Sampling (DS) algorithm (Mariethoz et al. 2010) with
the DSBC parametrization (Juda et al. 2022). MPS is a fundamentally different
paradigm from two-point geostatistics: instead of a parametric covariance model,
spatial structure is learned directly from a training image (TI), enabling
reproduction of curvilinear, connected patterns — channels, fractures, categorical
facies — that variogram-based methods cannot capture.

The implementation is pure Python + NumPy. Unlike field/krige/variogram,
there is no gstools_core (Rust) or Cython path for mps; the per-node TI scan is
vectorised over candidate blocks and parallelised across nodes with threads.
Node-level parallelism is a dependency DAG rather than a barrier — a node is
dispatched as soon as the neighbours it reads are settled — and the same machinery
drives the main path and every post-processing pass, so output is bit-identical for
any num_threads.

Supersedes #409.


Public API

Five classes exported from gstools.mps. DirectSampling and MPSModel are
also available at the top-level gstools namespace, like SRF and CovModel;
TrainingImage, Variable, and Zone live in gstools.mps.

Variable

Holds one variable's data and its per-variable configuration:

  • data — n-D NumPy array; NaN marks undefined cells, ±inf is rejected
  • categorical — whether to use weighted mismatch distance (Mariethoz2010 Eq. 3);
    a continuous distance requires categorical=False
  • distance — continuous metric: "l1" (Juda2022 Eq. 7, default), "l2",
    "l<p>" (arbitrary Minkowski), "variation" or "variation<p>" (Mariethoz2010
    Eq. 9, with automatic mean-shift correction)
  • n_neighbors — data-event size (maximum neighbours, default 32)
  • max_radius — optional Euclidean cap on neighbour selection, in grid cells (>= 1)
  • weight — relative weight in the multivariate joint distance; None for equal
    share; weights are automatically normalized and do not need to sum to 1
  • penalty_matrix — optional (C, C) per-category mismatch cost T[u, v]
    replacing the flat binary mismatch for categorical variables, so that
    "sand mistaken for silt" can cost less than "sand mistaken for shale":
    Σ w_i · T[Z(x_i), Z(y_i)] / Σ w_i. Asymmetric matrices are allowed. Validated
    to be 2-D square with an all-zero diagonal and off-diagonal entries in (0, 1],
    and the variable's data must hold integer-valued category codes < C — these
    constraints are what keep the distance in [0, 1] (there is no d_max
    normalization on this path)
  • distance_power — spatial-decay exponent δ for this variable's neighbour
    weighting (Mariethoz2010 Eq. 5); 0.0 (default) gives uniform weights
  • cond_weight — weight multiplier for this variable's conditioning nodes in
    the distance (Meerschman2013 §6); default 1.0

replace(**overrides) returns a new Variable with the given search-config
fields overridden, sharing the original's data buffer — no copy. This is the
cheap way to sweep search parameters, or to build a post_processing_variables
override (below) without re-copying a large training image.

TrainingImage

The MPS analogue of CovModel. Three construction modes:

# Univariate — plain array + keyword args
ti = gs.mps.TrainingImage(ti_array, categorical=True, n_neighbors=30)

# Univariate — pre-configured Variable
ti = gs.mps.TrainingImage(gs.mps.Variable("z", ti_array, categorical=True, n_neighbors=30))

# Multivariate — list of Variable objects
ti = gs.mps.TrainingImage([
    gs.mps.Variable("facies",   facies_arr, categorical=True,  weight=0.6, n_neighbors=30),
    gs.mps.Variable("porosity", por_arr,    categorical=False, weight=0.4,
                    distance="l2", n_neighbors=15),
])

window(slices) returns a sub-region view of a TI, carrying over every search
parameter and the distance kernel — useful for cutting several zone TIs out of one
large image.

NaN masking — undefined TI cells (NaN) are excluded from distance
comparisons and never pasted into the simulation grid.

MPSModel

Configuration object bundling a TrainingImage with the search parameters and
every optional feature. Only ti is positional; all options are keyword-only:

model = gs.MPSModel(ti, scan_fraction=0.2, threshold=None)
Argument Purpose
scan_fraction, threshold, boundary core DS search parameters
scan_path TI scan order: "sequential" (default) or "random"
rotation, scale geometric non-stationarity (see below)
zones list of Zone objects for zonated simulation
post_processing, post_processing_factor, post_processing_path, post_processing_variables, post_processing_mask re-simulation passes (see below)

Geometric non-stationarity — rotation= / scale= accept a scalar, a vector,
a full per-node array, or a callable evaluated on the grid coordinates, and apply
the lag transform of Mariethoz2010 §6.2. Rotation angles follow gstools.tools.set_angles. A stationary TI can therefore produce
patterns that rotate and stretch across the grid without modifying the TI itself.
rotation is rejected on 1-D grids (no rotation planes exist); scale stays
meaningful.

Post-processing (post_processing=p, Meerschman et al. 2013 §4) runs p
passes after the main path, each re-simulating every non-conditioning node against
a fully informed neighbourhood to remove simulation noise. Me13 §7 advises at least
p=1 for categorical simulations. post_processing_factor (p_f > 0) divides
scan_fraction and n_neighbors during the passes. p_f > 1 trades fidelity for
speed — Me13's documented intent, which finds little effect and recommends p_f=1.
p_f < 1 does the reverse, spending more search effort per post-pass than the main
pass; this direction has no literature precedent and is offered for experimentation.
Passes are Gauss–Seidel, but that ordering is carried by the same dependency DAG the
main path uses, so they honour num_threads while staying bit-identical to serial
execution at any thread count. Each pass re-visits essentially the whole grid, so
p > 0 dominates the cost of a run — budget accordingly. post_processing=0
(default) is bit-identical to omitting the parameter and consumes no extra RNG draws.

post_processing_path controls the post-pass visit order and is an
implementation extension — Me13 §4 does not prescribe one. None (default)
inherits the main-path mode; "random", "sequential", "same" (reuse the main
pass's order), or an explicit (N, dim) array are accepted.

post_processing_variables (a list of Variable overrides, matched by name
against the primary TI's variables) gives post-processing passes their own search
settings — build one via variable.replace(...). An override keeps the primary
variable's data and categorical kind; its distance, penalty_matrix,
distance_power, cond_weight, n_neighbors, max_radius, and weight all apply
in the passes, and fields not set on the override inherit the primary's value.
Weights must be set on every variable or on none. A distance="variation" variable
needs at least 2 post-pass neighbours, checked at construction. Variables without an
override reuse the primary's search config; default [] is bit-identical to omitting
the parameter.
post_processing_mask (a boolean, simulation-grid-shaped array) restricts which
nodes are eligible for re-simulation during post-processing; None (default)
re-simulates every non-conditioning node. Conditioning nodes are never
re-simulated regardless of the mask.

Zone

Zone(ti, where) binds a training image to a region of the simulation grid for
zonated (non-stationary) simulation, Mariethoz2010 para [40]. Nodes inside the
region scan this zone's TI instead of the primary one, while the data event is
still built from simulation-grid neighbours regardless of their zone — which keeps
zone boundaries coherent instead of cutting the pattern at the seam.

  • where is a boolean array of the grid shape, or a callable
    f(x, [y, z, ...]) -> bool mask. Non-boolean arrays raise; there is no silent
    != 0 coercion, so an integer zonation raster is written
    Zone(ti_k, where=(labels == k)).
  • A zone contributes only its data, shape, and continuous range (d_max).
    Every search hyper-parameter and the distance kernel — n_neighbors,
    max_radius, distance, weight, penalty_matrix — always come from the
    primary TI, so setting them on a zone TI has no effect (during post-processing
    passes, the kernel instead comes from any post_processing_variables
    override — main-pass zone behaviour is unaffected).
  • Zoning does not perturb RNG consumption or the DAG, so output stays
    deterministic and thread-count invariant.

DirectSampling

Subclasses gstools.field.base.Field and follows the same call interface as
SRF: ds(pos, seed, *, ...), with every further option keyword-only. Takes an
MPSModel.

All search parameters (scan_fraction, threshold, boundary, n_neighbors,
max_radius, cond_weight) are read-only on DirectSampling — they
reflect the underlying MPSModel and Variable objects. n_neighbors,
max_radius, and cond_weight are per-Variable and collapse to a scalar
when all variables share the same value, otherwise return a {name: value}
dict. To change them, mutate the MPSModel or Variable directly.

Key capabilities:

  • n-dimensional structured grids — fully dimension-agnostic
  • Randomised simulation path (Mariethoz2010 §3 ¶13); also accepts
    "sequential" or an explicit (N, dim) visit-order array via the path
    argument
  • scan_fraction caps the per-node TI scan (Juda2022 §2)
  • threshold=None — DSBC mode: full scan, best-candidate selection
  • boundary="strict" (default) or "partial" — partial drops lags that exceed
    the TI extent and searches with the reduced template (Mariethoz2010 §6.2)
  • set_condition(cond_pos, cond_val) — hard-data conditioning with nearest-node
    snapping (points outside the grid warn) and per-variable collision resolution; cond_val accepts a
    {variable: array} dict for multivariate TIs, with np.nan to leave a
    variable unconditioned at a point; conditioning weight is set per Variable
    (cond_weight=)
  • set_mv_transforms() — per-variable mean / normalizer / trend post-processing.
    Note this is the Field pipeline and is unrelated to MPSModel's DS
    post_processing passes
  • DAG-based parallelism via num_threads (or gstools.config.NUM_THREADS); output
    is identical for any thread count, speed-ups are modest because the engine is pure Python
  • Dual RNG seeds (path_seed, node_seed) for independent control of visit
    order vs. TI scan randomness; distinct explicit seeds give distinct streams
  • Multivariate return value: {variable: ndarray} dict; each variable also
    stored as a named field accessible via ds["facies"] / ds.all_fields

Usage

import numpy as np
import gstools as gs

ti = gs.mps.TrainingImage(ti_array, categorical=True, n_neighbors=30)
model = gs.MPSModel(ti, scan_fraction=0.2, threshold=None)
ds = gs.DirectSampling(model)
ds.set_condition([cond_x, cond_y], cond_val)
field = ds([np.arange(100, dtype=float)] * 2, seed=42)

Multivariate TIs are built from a list of Variable objects and return a
{variable: ndarray} dict; rotation/scale, zones, and post_processing are
all MPSModel arguments layered on the same call. See the examples below for each.


Module layout

File Responsibility
training_image.py Variable, TrainingImage — data and distance functions
data_event.py DataEvent — internal value object bundling per-variable data-event arrays
distance.py Stateless vectorised distance helpers
model.py MPSModel — validated search-parameter config
zone.py Zone — a training image bound to a region of the grid
neighbors.py Neighbour selection, geometric lag transform, search-window bounds
scan.py TI scanning loop
runner.py Simulation path and DAG thread executor
nonstationary.py Rotation / scale map resolution
simulate.py _DirectSamplingEngine + ds_simulate entry point
direct_sampling.py DirectSampling — public Field subclass

Examples (examples/13_mps/)

examples/13_mps/ is wired into docs/source/tutorials.rst and
sphinx_gallery_conf, so these run at documentation build time. Several fetch
their training images from public sources at first run and cache them next to the
script.

File What it shows
00_simple_unconditional.py Minimal unconditional simulation on a synthetic channel TI
01_conditional.py Hard-data conditioning with set_condition()
02_continuous.py Continuous variable simulation
03_channel_strebelle.py Strebelle (2002) fluvial TI, conditional
04_channel_strebelle_nonstat.py Non-stationary simulation via MPSModel(rotation=..., scale=...)
05_channel_strebelle_radial.py Radial rotation and affinity maps; reproduces Mariethoz2010 Fig. 7
06_channel_multivariate_fig8.py Multivariate co-simulation; reproduces Mariethoz2010 Fig. 8
07_variation_distance_fig6.py Variation-distance metric; reproduces Mariethoz2010 Fig. 6
08_bivariate_joint_fig4.py Bivariate joint simulation; reproduces Mariethoz2010 Fig. 4
09_continuous_stone_fig3.py Continuous simulation on the GAIA stone TI; reproduces Mariethoz2010 Fig. 3

References

  • Mariethoz, G., Renard, P., Straubhaar, J. (2010). The Direct Sampling method
    to perform multiple-point geostatistical simulations. Water Resources Research.
    https://doi.org/10.1029/2008WR007621
  • Meerschman, E., Pirot, G., Mariethoz, G., Straubhaar, J., Van Meirvenne, M.,
    Renard, P. (2013). A practical guide to performing multiple-point statistical
    simulations with the Direct Sampling algorithm. Computers & Geosciences.
    https://doi.org/10.1016/j.cageo.2012.09.019
  • Juda, P., Renard, P., Straubhaar, J. (2022). A parsimonious parametrization of
    the Direct Sampling algorithm for multiple-point statistical simulations.
    Applied Computing and Geosciences.
    https://doi.org/10.1016/j.acags.2022.100091
  • Strebelle, S. (2002). Conditional simulation of complex geological structures
    using multiple-point statistics. Mathematical Geology.

@LSchueler LSchueler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really impressive work, thank you so much!

When the issues below together with the other ones I marked directly in the code are fixed, I think this is ready to be merged.

  • Every postprocessing-pass rebuilds the full dependency graph, even when the visit order is the same each pass ("same", "sequential", and explicit arrays). I'm not sure how slow this is and if it's worth optimising. What do you say?

  • Don't use ipynb files for the mps_overview example.

  • I don't think TrainingImage should be exported to the top level, I would keep it next to Variable e.g. or does it make the mps-usage more ergonomic?

  • Remove references to your internal docs from the code, a few examples:
    "DS_Feature_Checklist §3.3", "spec §3", "bug B5"

  • I found a few edge cases, I think it would be the most effective when I just paste code snippets for you to reproduce them.

univariate TI built from a named Variable crashes:

import numpy as np, gstools as gs
ti = gs.TrainingImage(gs.mps.Variable("f", np.zeros((9, 9))))
gs.DirectSampling(gs.MPSModel(ti))([range(3)] * 2)

default max_radius=None fails on a 100^3 grid:

import numpy as np, gstools as gs
ti = gs.TrainingImage(np.zeros((9, 9, 9)))
gs.DirectSampling(gs.MPSModel(ti))([range(100)] * 3)

inconsistent rotation:

import gstools as gs
from gstools.mps.nonstationary import resolve_spec
print(resolve_spec(0.5, (1, 1, 1), 3, None, "rotation")[0][0, 0, 0])
print(gs.tools.set_angles(3, 0.5))

a nan-threshold is accepted:

import numpy as np, gstools as gs
print(gs.MPSModel(gs.TrainingImage(np.zeros((9, 9))), threshold=np.nan).threshold)

This exception should be caught earlier not only during the simulation:

import numpy as np, gstools as gs
from gstools.mps import Variable
a = Variable("a", np.zeros((9, 9)))
ti = gs.TrainingImage([a, Variable("b", np.zeros((9, 9)))])
m = gs.MPSModel(ti, post_processing=1, post_processing_variables=[a.replace(weight=2.0)])
gs.DirectSampling(m)([range(3)] * 2)

a categorical override on a cont. var. is accepted without a warning

import numpy as np, gstools as gs
from gstools.mps import Variable
ti = gs.TrainingImage([Variable("c", np.random.rand(9, 9), categorical=False)])
m = gs.MPSModel(ti, post_processing=1,
                post_processing_variables=[Variable("c", np.random.rand(9, 9), categorical=True)])
print(gs.DirectSampling(m, seed=1)([range(3)] * 2)["c"])

the new penalty_matrix is not checked against the conditioning values, the exception should be thrown much earlier

import numpy as np, gstools as gs
ti = gs.TrainingImage(np.eye(9))
pv = ti.variable().replace(penalty_matrix=[[0, 1], [1, 0]])
ds = gs.DirectSampling(gs.MPSModel(ti, post_processing=1, post_processing_variables=[pv]))
ds.set_condition([[0], [0]], [2])
ds([range(3)] * 2)

the pp-pass applies the primary TI's mean-shift instead of the new ones distance (output should be 1, not 0)

import numpy as np, gstools as gs
data = np.random.default_rng(0).random((20, 20))
ti = gs.TrainingImage(data, categorical=False, distance="variation")
pv = ti.variable().replace(distance="l1")
f = gs.DirectSampling(gs.MPSModel(ti, post_processing=1, post_processing_variables=[pv]), seed=1)([range(10)] * 2)
print(np.isin(f, data).mean())

The pp-pass drops variation to 1 neighbour, which makes every candidate distance 0

import numpy as np, gstools as gs
from gstools.mps.distance import vec_variation_dist
ti = gs.TrainingImage(np.random.rand(9, 9), categorical=False, distance="variation", n_neighbors=2)
gs.MPSModel(ti, post_processing=1, post_processing_factor=2)  # post-pass: round(2/2) = 1 neighbour
print(vec_variation_dist(np.array([5.0]), np.array([[0.0], [0.7]]), np.ones(1), 1.0))

Comment thread src/gstools/__init__.py Outdated
Comment on lines +239 to +240
__all__ += ["covmodel", "field", "variogram", "krige", "random", "tools"]
__all__ += ["transform", "normalizer", "config", "mps"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete these lines and only add "mps" to the list on line 250

Comment on lines +410 to +414
def _snap_to_nearest(self, axes, k):
"""Return (grid_index_tuple, dist_sq) for the k-th conditioning point."""
idx = tuple(
int(np.argmin(np.abs(axes[d] - self._cond_pos[d][k])))
for d in range(self.dim)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if the out-of-grid conditioning points snap to the edge of the grid, at least a warning should be printed.

Comment thread examples/13_mps/README.rst Outdated
surroundings look similar enough, and copies that cell's value over.
"Similar enough" is decided by a **distance** between the two surroundings,
controlled by three parameters — the number of neighbours ``n``, the scan
fraction ``f``, and the acceptance threshold ``t`` (with ``t = 0`` giving the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the threshold part seems outdated, it's threshold=None, threshold=0 gets rejected

Comment thread src/gstools/mps/direct_sampling.py Outdated
----------
model : MPSModel
Algorithm configuration: training image, scan fraction, threshold,
conditioning weight, and boundary strategy. Neighbour count and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conditioning weight lives in Variable

Comment thread examples/13_mps/README.rst Outdated
patterns (the analogue of a :any:`CovModel`). Multivariate training images
are built from a list of :any:`Variable` objects, one per property.
* :any:`MPSModel` — the algorithm configuration: a training image together
with the scan fraction, threshold, conditioning weight, and the optional

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conditioning weight lives in Variable

@LSchueler LSchueler added this to the 2.0 milestone Oct 7, 2026
n0228a added a commit to n0228a/GSTools that referenced this pull request Oct 14, 2026
- R1: NumPy CSR dependency graph; post passes reuse neighbour lists
- R2/R3/R4/R14: drop notebook, TrainingImage only in gs.mps, remove
  internal refs, fix __all__
- R5-R8, R11, R15: named univariate variables, per-axis offset bounds,
  set_angles rotation, non-finite/penalty-code validation, out-of-grid warning
- R9/R10/R12/R13: coherent post-processing overrides (unset fields inherit,
  per-pass kernel and mean-shift, variation < 2 post neighbours raises)
- NaN/inf TI handling, categorical-distance and max_radius checks, explicit
  path/node seeds no longer collide, set_condition keeps state on error
- examples 05/06/08 run again; README/docstring fixes; regression tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@n0228a
n0228a force-pushed the mps-direct-sampling branch from 41472d4 to c6f7ee2 Compare October 14, 2026 13:18
- R1: NumPy CSR dependency graph; post passes reuse neighbour lists
- R2/R3/R4/R14: drop notebook, TrainingImage only in gs.mps, remove
  internal refs, fix __all__
- R5-R8, R11, R15: named univariate variables, per-axis offset bounds,
  set_angles rotation, non-finite/penalty-code validation, out-of-grid warning
- R9/R10/R12/R13: coherent post-processing overrides (unset fields inherit,
  per-pass kernel and mean-shift, variation < 2 post neighbours raises)
- NaN/inf TI handling, categorical-distance and max_radius checks, explicit
  path/node seeds no longer collide, set_condition keeps state on error
- examples 05/06/08 run again; README/docstring fixes; regression tests
@n0228a

n0228a commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

All points are addressed, plus several issues found while fixing them:

Review points

  • R1: the dependency graph is now NumPy CSR arrays instead of Python sets, and post-processing passes reuse neighbour lists across passes.
  • R2: the notebook is removed. R3: TrainingImage now lives only in gs.mps. R4: internal references are removed. R14: duplicate __all__ entries are removed.
  • R5: named univariate Variables work.
  • R6: the offset table is bounded per axis, so max_radius=None works on large grids.
  • R7: 3-D rotation follows set_angles.
  • R8, R11: NaN/inf and out-of-range penalty codes are rejected, for conditioning and for zones.
  • R9, R10, R12, R13: post-processing overrides were redesigned.
    • An override keeps the primary's data and kind; every search setting takes effect.
    • Unset fields inherit the primary's value.
    • The mean-shift follows the pass's own distance.
    • A variation variable with fewer than 2 post-pass neighbours raises at construction.
  • R15: conditioning points outside the grid now warn.
  • R16–R18: docs fixed.

Additional fixes

  • NaN/inf handling: NaN training images with zero weights no longer give random draws. ±inf cells are rejected. Variation uses a common support.
  • Silent mistakes now raise: setting distance on categorical data, max_radius < 1, and non-finite conditioning positions.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

OpenMP Benchmark Results

Base: a31e5fc5 → Head: bfd795de · Full HTML Report ↗

⚠️ 1 benchmark(s) regressed · Mann-Whitney U · 5% threshold

Full ASV comparison

Benchmarks that have improved:

| Change   | Before [a31e5fc5]    | After [bfd795de]    |   Ratio | Benchmark (Parameter)                                                                                |
|----------|----------------------|---------------------|---------|------------------------------------------------------------------------------------------------------|
| -        | 37.0±0.4ms           | 29.2±2ms            |    0.79 | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'large_120x2000', 2) |

Benchmarks that have stayed the same:

| Change   | Before [a31e5fc5]    | After [bfd795de]    | Ratio   | Benchmark (Parameter)                                                                                                      |
|----------|----------------------|---------------------|---------|----------------------------------------------------------------------------------------------------------------------------|
|          | 222M                 | 223M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'extra_large_360x6000', 1)              |
|          | 222M                 | 224M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'extra_large_360x6000', 2)              |
|          | 222M                 | 223M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'extra_large_360x6000', 4)              |
|          | 222M                 | 223M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'extra_large_360x6000', 8)              |
|          | 144M                 | 145M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'large_120x2000', 1)                    |
|          | 144M                 | 145M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'large_120x2000', 2)                    |
|          | 145M                 | 145M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'large_120x2000', 4)                    |
|          | 144M                 | 145M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'large_120x2000', 8)                    |
|          | 133M                 | 133M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'small_30x500', 1)                      |
|          | 132M                 | 134M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'small_30x500', 2)                      |
|          | 132M                 | 133M                | 1.01    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'small_30x500', 4)                      |
|          | 133M                 | 133M                | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.peakmem_global_krige('rust_core', 'small_30x500', 8)                      |
|          | 872±1ms              | 871±1ms             | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'extra_large_360x6000', 1)                 |
|          | 480±5ms              | 479±2ms             | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'extra_large_360x6000', 2)                 |
|          | 561±7ms              | 563±0.5ms           | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'extra_large_360x6000', 4)                 |
|          | 559±5ms              | 558±4ms             | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'extra_large_360x6000', 8)                 |
|          | 36.8±0.1ms           | 36.9±0.5ms          | 1.00    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'large_120x2000', 1)                       |
|          | 35.0±3ms             | 34.1±6ms            | 0.97    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'large_120x2000', 4)                       |
|          | 36.6±2ms             | 41.8±3ms            | ~1.14   | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'large_120x2000', 8)                       |
|          | 736±20μs             | 722±20μs            | 0.98    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'small_30x500', 2)                         |
|          | 879±10μs             | 860±30μs            | 0.98    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'small_30x500', 4)                         |
|          | 1.05±0.1ms           | 1.03±0.02ms         | 0.98    | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'small_30x500', 8)                         |
|          | 135M                 | 135M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'condsrf_unstructured', 1)      |
|          | 135M                 | 135M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'condsrf_unstructured', 2)      |
|          | 135M                 | 135M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'condsrf_unstructured', 4)      |
|          | 135M                 | 136M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'condsrf_unstructured', 8)      |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_fourier', 1)    |
|          | 130M                 | 131M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_fourier', 2)    |
|          | 130M                 | 131M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_fourier', 4)    |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_fourier', 8)    |
|          | 130M                 | 131M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_randmeth', 1)   |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_randmeth', 2)   |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_randmeth', 4)   |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_structured_randmeth', 8)   |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_unstructured_randmeth', 1) |
|          | 130M                 | 131M                | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_unstructured_randmeth', 2) |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_unstructured_randmeth', 4) |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.peakmem_field_generation('rust_core', 'srf_unstructured_randmeth', 8) |
|          | 26.9±0.3ms           | 27.0±0.1ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'condsrf_unstructured', 1)         |
|          | 25.4±3ms             | 30.5±7ms            | ~1.20   | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'condsrf_unstructured', 2)         |
|          | 28.6±3ms             | 24.4±2ms            | ~0.85   | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'condsrf_unstructured', 4)         |
|          | 26.3±3ms             | 28.3±2ms            | ~1.08   | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'condsrf_unstructured', 8)         |
|          | 88.4±0.1ms           | 88.3±0.1ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_fourier', 1)       |
|          | 45.2±0.06ms          | 45.1±0.1ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_fourier', 2)       |
|          | 36.4±0.5ms           | 36.2±0.3ms          | 0.99    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_fourier', 4)       |
|          | 36.1±0.1ms           | 36.0±0.1ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_fourier', 8)       |
|          | 64.9±0.1ms           | 65.1±0.4ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_randmeth', 1)      |
|          | 33.5±0.08ms          | 33.8±0.1ms          | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_randmeth', 2)      |
|          | 22.3±0.6ms           | 22.3±0.9ms          | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_randmeth', 4)      |
|          | 21.7±0.1ms           | 21.7±0.06ms         | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_structured_randmeth', 8)      |
|          | 33.4±0.1ms           | 33.7±0.4ms          | 1.01    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_unstructured_randmeth', 1)    |
|          | 17.6±0.03ms          | 17.7±0.04ms         | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_unstructured_randmeth', 2)    |
|          | 12.0±0.6ms           | 11.9±0.2ms          | 0.99    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_unstructured_randmeth', 4)    |
|          | 11.9±0.1ms           | 11.9±0.06ms         | 1.00    | benchmark_two_point_statistics.RandomFieldBenchmarks.time_field_generation('rust_core', 'srf_unstructured_randmeth', 8)    |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'full_900', 1)                  |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'full_900', 2)                  |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'full_900', 4)                  |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'full_900', 8)                  |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_15000_to_4500', 1)     |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_15000_to_4500', 2)     |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_15000_to_4500', 4)     |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_15000_to_4500', 8)     |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_5000_to_1500', 1)      |
|          | 130M                 | 131M                | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_5000_to_1500', 2)      |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_5000_to_1500', 4)      |
|          | 130M                 | 131M                | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.peakmem_variogram_estimate('rust_core', 'sampled_5000_to_1500', 8)      |
|          | 57.0±0.6ms           | 56.3±0.08ms         | 0.99    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'full_900', 1)                     |
|          | 29.0±0.08ms          | 28.9±0.09ms         | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'full_900', 2)                     |
|          | 21.6±0.3ms           | 21.3±0.3ms          | 0.99    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'full_900', 4)                     |
|          | 20.4±0.3ms           | 20.6±0.3ms          | 1.01    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'full_900', 8)                     |
|          | 1.48±0s              | 1.48±0s             | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_15000_to_4500', 1)        |
|          | 774±2ms              | 776±2ms             | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_15000_to_4500', 2)        |
|          | 511±20ms             | 523±20ms            | 1.02    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_15000_to_4500', 4)        |
|          | 482±8ms              | 478±10ms            | 0.99    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_15000_to_4500', 8)        |
|          | 166±0.07ms           | 166±0.1ms           | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_5000_to_1500', 1)         |
|          | 86.7±0.3ms           | 86.4±0.1ms          | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_5000_to_1500', 2)         |
|          | 57.8±1ms             | 57.9±1ms            | 1.00    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_5000_to_1500', 4)         |
|          | 55.9±1ms             | 54.9±3ms            | 0.98    | benchmark_two_point_statistics.VariogramBenchmarks.time_variogram_estimate('rust_core', 'sampled_5000_to_1500', 8)         |

Benchmarks that have got worse:

| Change   | Before [a31e5fc5]    | After [bfd795de]    |   Ratio | Benchmark (Parameter)                                                                              |
|----------|----------------------|---------------------|---------|----------------------------------------------------------------------------------------------------|
| +        | 792±10μs             | 841±10μs            |    1.06 | benchmark_two_point_statistics.KrigingBenchmarks.time_global_krige('rust_core', 'small_30x500', 1) |
ASV comparison exit status: 0

Comment thread src/gstools/mps/training_image.py Outdated
array is constant or entirely undefined (avoids a zero/NaN normalizer).
"""
finite = arr[np.isfinite(arr)]
dmax = float(finite.max() - finite.min()) if finite.size else 0.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential integer overflow in _data_range():

When the training image uses a signed integer dtype, the subtraction is performed before the conversion to float, so it can overflow.

For example:

import numpy as np

a = np.array([-128, 127], dtype=np.int8)

print(float(a.max() - a.min()))          # -1.0 (overflow)
print(float(a.max()) - float(a.min()))   # 255.0 (correct)

one solution could be:

dmax = float(finite.max()) - float(finite.min())

You could also change the type in src/gstools/mps/training_image.py, around lines 272–273 by adding if not categorical: and inside the if data_arr = data_arr.astype(np.float64), but then that could lead to memory allocations and maybe something else. so I prefer the small change in the solution I gave

Comment thread src/gstools/mps/direct_sampling.py Outdated
"DirectSampling: cond_pos and cond_val length mismatch."
)
_check_finite_positions(cond_pos_arr)
self._cond_pos = cond_pos_arr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_condition() modifies the object's stored conditioning positions before it has finished validating and converting the new conditioning values

self._cond_pos is immediately replaced with the new positions. Then, Python attempts to convert all the new values to float64; If one variable contains an invalid value, np.asarray(..., dtype=np.double) raises an exception. However, the update to self._cond_val never completes, so it still contains the old values.

Simply change the order:

new_cond_val = {
    v: np.asarray(a, dtype=np.double) for v, a in cond_val.items()
}

self._cond_pos = cond_pos_arr
self._cond_val = new_cond_val

Comment thread src/gstools/mps/distance.py Outdated
float64, matching the naive expression's own promotion; only an
integer-coded TI needs the explicit widen.
"""
diff = np.subtract(data_event_sim, all_de_ti)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is the same ordering problem I commented before for the direct_sampling.py file: integer subtraction happens before the conversion to float64.

I noticed that In training_image.py lines 1002–1007 already converts continuous data events to float64 before calling the distance functions, so the regular simulation path should be safe.

Still, would it make sense to move the integer-to-float conversion before np.subtract() here as well, to make the low-level function robust when called directly or reused elsewhere?

Comment thread src/gstools/mps/direct_sampling.py Outdated
cond_pos_arr = np.asarray(cond_pos, dtype=np.double).reshape(
self.dim, -1
)
n_cond = len(next(iter(cond_val.values())))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

len() only checks the size of the first dimension. should each variable have one scalar conditioning value per position?

If this makes sense to you then you can do something like

n_cond = cond_pos_arr.shape[1]

for v, arr in cond_val.items():
    arr = np.asarray(arr, dtype=np.double)
    if arr.shape != (n_cond,):
        raise ValueError(
            f"Conditioning values for {v!r} must have shape "
            f"({n_cond},), got {arr.shape}."
        )

"Either specify weight on every Variable or on none."
)
else:
total = sum(raw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here normalising very large variables weights can lead to numerical issues. A numerically safer approach is to scale the weights by their maximum before summing

def _snap_to_nearest(self, axes, k):
"""Return (grid_index_tuple, dist_sq) for the k-th conditioning point."""
idx = tuple(
int(np.argmin(np.abs(axes[d] - self._cond_pos[d][k])))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, np.argmin() can select an invalid grid coordinate if an axis contains NaN.

For example, with axes[d] = [0.0, np.nan, 2.0] and a conditioning position of 0.0, np.argmin(np.abs(axes[d] - 0.0)) returns index 1 instead of 0.

Although set_condition() already validates that conditioning positions are finite, I couldn't find an equivalent check for the simulation-grid axes. This could result in hard-conditioning values being assigned to the wrong grid nodes.

Would it make sense to validate that all simulation-grid coordinates are finite at the beginning of DirectSampling.__call__(), before snapping the conditioning points?

Avoids 16-bit seed collisions in path/node streams. Default RNG unchanged.
Widen ints before subtracting, validate cond_val shape and assign atomically, reject non-finite grid axes, rescale overflowing weights.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants