diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a68b91c..b7c58815 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ All notable changes to **GSTools** will be documented in this file. - add collocated cokriging [#396](https://github.com/GeoStat-Framework/GSTools/pull/396) - new `SimpleCollocated` (SCCK) and `IntrinsicCollocated` (ICCK) classes, subclassing `CollocatedCokriging` - uses a `Correlogram` to relate the secondary variable to the primary at the estimation location +- add Multiple Point Statistics with the Direct Sampling algorithm (new `gstools.mps` subpackage) + - `TrainingImage`/`Variable` hold the training data and the per-variable pattern distance, + `MPSModel` bundles the search parameters, `DirectSampling` generates the fields + - univariate and multivariate (joint) simulation, conditioning, masked (NaN) training images, + categorical and continuous distances including a per-category penalty matrix + - geometric non-stationarity via `MPSModel(rotation=..., scale=...)`, zonation via `Zone`, + and post-processing passes + - the main simulation path and the post-processing passes share one dependency-DAG runner, + so both honour `num_threads` and stay bit-identical to serial for any thread count +- new geometric helpers in `gstools.tools`: `set_scale`, `matrix_scale`, `matrix_transform` + and `matrix_detransform` (rotation ∘ scaling and its exact inverse, without a pinned axis); + `great_circle_to_chordal` is now exported alongside `chordal_to_great_circle` +- `matrix_isotropify` now raises `ValueError` for non-positive `anis` instead of returning an + infinite (degenerate) matrix, matching the three sibling `matrix_*` helpers; `CovModel` + already constrained `anis` to `(0, inf)`, so no model path changes ## [1.7.0] - Morphic Mint - 2025-04 diff --git a/docs/source/conf.py b/docs/source/conf.py index aaddc29f..ca414194 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -302,6 +302,7 @@ def setup(app): "../../examples/10_normalizer/", "../../examples/11_plurigaussian/", "../../examples/12_sum_model/", + "../../examples/13_mps/", ], # path where to save gallery generated examples "gallery_dirs": [ @@ -318,6 +319,7 @@ def setup(app): "examples/10_normalizer/", "examples/11_plurigaussian/", "examples/12_sum_model/", + "examples/13_mps/", ], # Pattern to search for example files "filename_pattern": r"\.py", diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index e3b20db1..655a2fce 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -24,6 +24,7 @@ explore its whole beauty and power. examples/10_normalizer/index examples/11_plurigaussian/index examples/12_sum_model/index + examples/13_mps/index examples/00_misc/index .. only:: html diff --git a/examples/13_mps/00_mps_overview.ipynb b/examples/13_mps/00_mps_overview.ipynb new file mode 100644 index 00000000..fe935d54 --- /dev/null +++ b/examples/13_mps/00_mps_overview.ipynb @@ -0,0 +1,658 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40757bdc", + "metadata": {}, + "source": [ + "# Multiple Point Statistics with GSTools\n", + "\n", + "Two-point geostatistics (covariance models, kriging, SRFs) describes spatial\n", + "structure through pairs of points and a variogram. This is powerful, but it\n", + "cannot reproduce *curvilinear* or *connected* features such as meandering\n", + "channels, fractures, or other patterns that depend on the joint configuration\n", + "of many points at once.\n", + "\n", + "**Multiple Point Statistics (MPS)** addresses this by learning patterns\n", + "directly from a **training image (TI)** — an example image deemed\n", + "representative of the spatial structure to simulate. Instead of fitting a\n", + "variogram, MPS borrows whole patterns from the TI.\n", + "\n", + "GSTools provides the **Direct Sampling (DS)** algorithm\n", + "([Mariethoz et al., 2010](https://doi.org/10.1029/2008WR007621)), together\n", + "with the **Direct Sampling Best Candidate (DSBC)** parametrization\n", + "([Juda et al., 2022](https://doi.org/10.1016/j.acags.2022.100091)), through\n", + "two classes:\n", + "\n", + "* `TrainingImage` — the MPS model: the training image plus the distance\n", + " used to compare patterns (the analogue of a `CovModel`).\n", + "* `DirectSampling` — the generator that produces realizations on a\n", + " structured grid (the analogue of `SRF`).\n", + "\n", + "The core idea: to fill each grid cell, DS looks at the values already present\n", + "around it (its *data event*), scans the training image for a location whose\n", + "surroundings look similar enough, and copies that cell's value over.\n", + "\"Similar enough\" is decided by a **distance** between the two surroundings,\n", + "controlled by three parameters — the number of neighbours `n`, the scan\n", + "fraction `f`, and the acceptance threshold `t` (with `t = 0` giving the\n", + "recommended DSBC mode).\n", + "\n", + "This notebook gathers the `examples/13_mps/` gallery into one place and\n", + "builds up from a minimal unconditional simulation to conditioning,\n", + "continuous variables, and a real, published training image:\n", + "\n", + "1. [A first Direct Sampling simulation](#1.-A-first-Direct-Sampling-simulation)\n", + "2. [Conditioning to hard data](#2.-Conditioning-to-hard-data)\n", + "3. [Continuous variables and distance metrics](#3.-Continuous-variables-and-distance-metrics)\n", + "4. [A real training image: the Strebelle channels](#4.-A-real-training-image:-the-Strebelle-channels)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f8e45cf7", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.ioff()\n", + "# turn off warnings for a cleaner notebook\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a656ab3f", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from matplotlib.colors import ListedColormap\n", + "\n", + "import gstools as gs" + ] + }, + { + "cell_type": "markdown", + "id": "5fc8896d", + "metadata": {}, + "source": [ + "## 1. A first Direct Sampling simulation\n", + "\n", + "This is the minimal Multiple Point Statistics example: build a training\n", + "image, wrap it in a `TrainingImage`, and generate one unconditional\n", + "realization with `DirectSampling`.\n", + "\n", + "We use a small, synthetic *channelized* training image generated with\n", + "NumPy, so the example is fast and needs no downloads." + ] + }, + { + "cell_type": "markdown", + "id": "13d2c57d", + "metadata": {}, + "source": [ + "Create a synthetic binary training image with curvilinear \"channels\".\n", + "The two facies (0 and 1) form connected, meandering bands — exactly the kind\n", + "of structure two-point statistics struggles to reproduce." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "46451d6d", + "metadata": {}, + "outputs": [], + "source": [ + "gx, gy = np.meshgrid(np.arange(60), np.arange(60), indexing=\"ij\")\n", + "ti_data = ((np.sin(gx / 5.0) + np.sin((gx + gy) / 8.0)) > 0).astype(float)" + ] + }, + { + "cell_type": "markdown", + "id": "0c28bb1a", + "metadata": {}, + "source": [ + "Wrap the array in a `TrainingImage`. For a categorical variable (facies\n", + "codes) the distance is the fraction of mismatching neighbours, so the\n", + "`distance` argument is ignored here." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "208d0ea1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TrainingImage(shape=(60, 60), categorical=True, distance='l1')\n" + ] + } + ], + "source": [ + "ti = gs.TrainingImage(ti_data, categorical=True)\n", + "print(ti)" + ] + }, + { + "cell_type": "markdown", + "id": "b11a879b", + "metadata": {}, + "source": [ + "Create the `DirectSampling` generator and simulate on a 40x40 grid.\n", + "\n", + "* `n_neighbors` — how many already-known cells define each data event.\n", + "* `scan_fraction` — fraction of the training image scanned per cell\n", + " (smaller is faster, slightly noisier).\n", + "* `threshold=0.0` — the recommended DSBC mode: always take the best match." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2651225f", + "metadata": {}, + "outputs": [], + "source": [ + "ds = gs.DirectSampling(\n", + " gs.MPSModel(ti, n_neighbors=12, scan_fraction=0.3, threshold=0.0)\n", + ")\n", + "field_simple = ds([np.arange(40, dtype=float)] * 2, seed=20250616)" + ] + }, + { + "cell_type": "markdown", + "id": "ce25b847", + "metadata": {}, + "source": [ + "Plot the training image next to the realization. The realization is not a\n", + "copy of the TI, but it reproduces the same channel patterns." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b3b6f2f8", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAICCAYAAADS0a7IAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAASrpJREFUeJzt3QeYVOXZOO6XJggCooKAoGDF3o2IFRRRRGML1tg+NYk1aqJEjSVfQvwM1iTG+MWSaOw/0Rh7YsVuYu+aKCAKKgIiImX+13Pyn/12l13qHnbKfV/XuXbnzJmZM2dmznuetzxvi0KhUEgAAABAk2vZ9E8JAAAACLoBAAAgR1q6AQAAICeCbgAAAMiJoBsAAAByIugGAACAnAi6AQAAICeCbgAAAMiJoBtKwJAhQ9If//jHZnv8kmru1weAanP77bdn5e/06dPnu6459wf4j9b//1+gnnfeeSedcMIJC3Vcfv7zn6fNN998sY/h/fffn7bYYotme/ySau7XB4D6Zs2alYYNG1Zzu1WrVqljx46pV69eaauttkp77LFHat++fYMH7oEHHkj33ntv+vDDD9Pyyy+f1l9//XTggQemHj16lMyB/te//pWVv/E+57euKd14443puuuuS6NHj07t2rVb4P4A/yHohkasvPLK6eSTT66z7rjjjktffvllVuDUttpqqy3RcYyCfY011mi2xy+p5n59AKhvzpw5WRA4cODA9KMf/ShbN3Xq1PTGG2+ks88+Ox177LHpoosuSkcccUTNY2bPnp322muv9PDDD6fvfe97afjw4WnGjBnp6quvTqeffnq67bbbsvtL1X777Zc22GCDtNxyy+Xy/O+99152TOM4Le3XhnIm6IZGdOrUKesmVVvUkEchXn/9klrS52vq/Sm31weAxkTrdP1y6swzz8yC7iOPPDJrsY1W7HD99dene+65J1111VXpv/7rv2q2P+yww9KFF16YPvvss5I+0H369MmWanttKHWCblgCMW5p3333zQrtAQMGpF//+tfptddey7ql77LLLumggw5Kn3/+ebZtmzZtUs+ePbPubLW7u4W4GIhtv/vd787zvNtvv3265JJL0ptvvpkVZtH6Xr9QW9LHh1tvvTXrLhbdwmLf42Ijnm+jjTaqaSFozPxev3///unSSy9N77//ftpyyy2z11922WXT2LFj02WX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Kb388svp/fffT2+++Wa6+eab5wnCP/vss9ShQ4d06aWXplNOOSWNHj260THdM2fOzJaiqVOn5v4eAKDcCboBoEK1adMmtW3bNvv/448/Th988EGd+1944YW09tprZ/8XCoW0//77p8MOOyy9/fbbDT7fyJEj03nnnbcU9hwAKkeLQpSyJSZqzjt37pxSnyEptWyTKkE5ZkeUwROgec7tU6fNTl3WfiFLgtZQN/HF8Yc//CF9//vfzwLvHj16NLjNfffdl3bbbbc0fvz41LNnz4Vq6e7du3dFlddAeV/ryl7O0rSw5bWWbprl5CmgB1i6dthhhzRr1qz01ltvNRp0xzRjYcaMGQ3eH63mxZZzAGDhmDIMACrM66+/nqZNmzZPwrTobt6vX7/s9htvvFFnTHYE3FdeeWXq06dPWn311Zf6PgNApdLSDQAVZsKECdn47E022SR17949vfbaa2nMmDHp8ssvz26HTz75JO2zzz5ps802S127dk0PP/xwllQtkq+1aNGiud8CAFQMQTcAVJhBgwZlQfYDDzyQxo0bl7baaqt0/fXXp5VWWqlmmx133DE9/fTT2TjuCNLPP//8tMsuu6T27ds3674DQKURdANABYr5tr/zne/Md5tIWjp8+PCltk9A5SqVfD2lktANajOmGwAAAHIi6AYAAICcCLoBAAAgJ8Z0NyFjSJr/WJXKeCKgcji3AwBLQks3AAAA5ETQDQAAADkRdAMAAEBOBN0AAACQE0E3AAAAlELQfdlll6VevXrVWdZff/15tnvwwQfTwIED0xprrJGGDBmSnnnmmabcZwAAAKi8KcOmTp2aunXrlu66666adS1b1o3bn3rqqTR06NB07rnnpmHDhqVrr702C8D/8Y9/pHXWWSeVM9PGVPZnZLoxAIDytijXc67tKdnu5csss0ydlu6ePXvWuX/kyJFZkP2Tn/wkbbjhhmnUqFGpb9++2V8AAACoJoscdL/xxhtp3XXXTZtuumk69thj00cffVTn/kcffTTtsssuddZFF/PHHntsyfcWAAAAKrV7+XLLLZfOOuusLIj+4osv0nnnnZc233zz9Oqrr6YVV1wxTZs2LeuC3r179zqPW3nlldP48eMbfd6ZM2dmS1E8BwAAAFRV0H3SSSelFi1a1NwePXp01nX8iiuuyILxuXPn/udJW9d92jZt2qQ5c+Y0+rzRJT0CeAAAAKja7uW1A+5iy/cGG2yQdTkPHTt2TG3btk2ffvppne3idteuXRt93hEjRqQpU6bULGPHjl20dwEAAACVNk93tGz/+9//TiussMJ/nqxly7TFFlukMWPG1Nnu8ccfT1tttVWjzxOBeqdOneosAAAAUFVBd3Qvf//997P/Z8yYkU477bQ0bty4dNhhh9Vsc9xxx6U77rgj/f3vf89u33rrremJJ55IP/jBD5p63wEAAKByxnRvu+222dzbkRTt66+/zrqW33///VnrdtGBBx6YtX5/+9vfzlrCoxU7xnzvtNNOeew/NJk852o0BziULvO0AgB5alEoFAqL+qDIUt6uXbssQVpjZs+enSZPnpx1PW/VqtUiPX9kL+/cuXNKfYak1LLx11jaXJixuATdULpK8dw+ddrs1GXtF7I8J6U85KpUy2uAcj3/U14WtrxepJbuokiYtiCRwXx+ydMAAACg0i1W0A0AAJA3rdGkas9eDgAAADRO0A0AAAA5EXQDAABATozprse4EUrteyXzOTTvbxAAYElo6QYAAICcCLoBAAAgJ4JuAAAAyImgGwAAAHIi6AYAAICcCLoBAAAgJ4JuAAAAyIl5uqFK5xc2/zfQVCY/MCl16tj0lxTOUwBUAi3dAAAAkBNBNwAAAORE0A0AAAA5EXQDAABATgTdAAAAkBNBNwAAAOSk6qYMy2v6JSg3ef4WTPPD0ubcDgCUKi3dAAAAkBNBNwAAAORE0A0AAAA5qbox3QBQLT7//PP0yiuvpI4dO6YNNtggLbPMMvNs89VXX6XnnnsutW7dOm255ZYNblPNY/XlqIDy+b1CqRJ0A0CFiUD6hBNOSPfee2/q169fGjt2bJo+fXq67rrr0i677FKz3cMPP5z233//1KNHjzRz5szscX/5y1/Spptu2qz7DwCVRPdyAKgwEWAPHjw4jRs3Lv39739P77zzTtpzzz3TYYcdVrNNBNgHHnhgti5aw99+++203XbbZesKhUKz7j8AVBJBNwBUmK5du6bhw4enli3/r5jfZJNN0hdffJHmzp2b3b7//vvTxIkT049+9KOabX784x+nt956Kz3zzDPNst8AUIkqsnu5MSVQvr9B4yeh6bzwwgtp/Pjx6f33308XXXRRGjVqVE0g/tJLL6WVV145de/evWb7jTfeOLs/7tt6663neb7ogh5L0dSpU31cAFCNQTcAkNKDDz6YHn300az1OsZtb7XVVjWHJVq9V1hhhTqHKQLuzp07Z/c1ZOTIkem8885zaAFgEeheDgAV6owzzsiSqb333ntp++23z8Z5F1unI0t5jOuub8aMGY1mMB8xYkSaMmVKzRIJ2gCA+RN0A0CFa9GiRTrmmGOyKcRefPHFbF3fvn3TJ598kmbNmlWz3aRJk9LXX3+d3deQtm3bpk6dOtVZAID5E3QDQIWJ4Lq+yFAeopt5iFbvCLDvu+++mm1uu+221K5du7Tjjjsuxb0FgMpmTDcAVJh77rknXXvttWmvvfbKEqW99tpr6bLLLktHH310WmuttbJt1lhjjXT88ceno446Kp1zzjlZAB5/zz777LT88ss391sAgIoh6AaACnPIIYektddeO910003p8ccfT6usskrWij1w4MA621166aVps802y1q7W7dunQXq++23X7PtNwBUohaFQqGQSkwkeYnsqanPkJRatlnkx5syDKjPVGTlr5rO7VOnzU5d1n4hS1ZWyuOmi+X15Lc3T506qsdfVM5LVOs5ulS++3ntczWVV9Vu6kKW18Z0AwAAQE4E3QAAAJATQTcAAADkRNANAAAAORF0AwAAQE4E3QAAAJATQTcAAADkpCwn1TT3HbA0zxulMp9opXNuBwAqkZZuAAAAyImgGwAAAHJSlt3LAQDKXakMqTCEBt8jyJeWbgAAAMiJoBsAAAByIugGAACAnBjTDdCM4y6NpQQAqGxaugEAACAngm4AAADIiaAbAAAAciLoBgAAgJwIugEAACAngm4AAADIiaAbAAAAqnGe7skPTEqdOpb0LgKU5BzgpTr/d55zngP5/y5L9dwCi2NRvs/KL5aElm4AAADIiaAbAAAAciLoBgAAgJwIugEAACAngm4AAAAotaD7mmuuScsvv3w67LDD5rnvzjvvTFtttVVaeeWV07bbbpseeeSRJd1PAAAAKDuLNR/Xm2++mX7605+m1VdfPU2fPr3OfY8++mjab7/90qhRo9KwYcOy4HzIkCHp+eefTxtssEFT7TcAOU1tYkogAIBmbOn++uuv0/Dhw9NFF12UevbsOc/9F1xwQdp1113TiSeemPr27ZvOP//8tO6662bbAwAAQDVZ5KD7lFNOSZtuumnaf//9G7z/iSeeSIMGDaqzbpdddsnWAwAAQDVZpO7ld9xxR7r//vvTiy++2OD9U6dOTdOmTcvGctfWrVu39NFHHzX6vDNnzsyW2s8DAAAAVdPSPW7cuHTsscem66+/PnXs2HH+T9qy7tO2bt06FQqFRrcfOXJk6ty5c83Su3fvhd0tAAAAKP+W7kiE9tlnn6XddtutZl0kUWvRokWWxfzll1/OguVll102ffrpp3UeO3HixKy1uzEjRozIuq3XbukWeAMAAFA1QffQoUOzoLu2Aw44ILVt2zZdd911qVOnTlkAvuWWW6bHH388HX/88XUymn/rW99q9LnjOWIBAACAquxe3qZNm6xFu/YS3caL64tdyk866aQ0evTodNddd6XZs2dnU4Y988wz6YQTTsjzfQAAAEBlzNM9P/vss0/61a9+lY466qg0efLk1L1796wlfMCAAU39UgDkwBzfAAAlEnTffPPNWZfy+qJVO5YZM2ZkY7wBAACgGi1R0N2hQ4f53i/gBgAAoJot9JhuAAAAYNEIugEAACAngm4AAADIiaAbAAAAymXKsKbUZXDXlFq2WarT3QAwf60GdGuWxzq3L55vvvkmLbPMMg3eN2XKlFQoFOZJgtq2bdvFfDXK0ZL8LgFYMC3dAFBhvvjii3Tuueemvn37po4dO6auXbumH/3oR+nrr7+us92KK66YevXqlfr06VOzXH311c223wBQiUq6pRsAWHT3339/atGiRXrkkUfSaqutll588cU0dOjQLOi+/PLL62x72223pSFDhjjMAJATQTcAVJjhw4fXub3JJpukY445Jv3hD3+YJ+gOEYy3a9duKe4hAFQP3csBoAp8+OGHaaWVVppn/be//e3UqVOn1KNHj/STn/xkni7oAMCSEXQDQIV78skn0x//+Md0/PHH11kft997770s0L7xxhvTNddck0466aRGn2fmzJlp6tSpdRYAYP4E3QBQwV5//fW01157pSOPPDJbarvkkkvSKqusklq2bJl23HHHdN5556X//d//bbS1e+TIkalz5841S+/evZfSuwCA8iXoBoAK9eabb6ZBgwalYcOGpd/97ncL3L5fv35p7ty56YMPPmjw/hEjRmTTjBWXsWPH5rDXAFBZKjKRWl7zTZojFqD5OLcvmrfeeivttNNOWWbyaL2ObOYL8vzzz6fWrVtn47sbEvN3m8MbABZNRQbdAFDN3n333TRw4MA0YMCANGrUqDpjr5dffvnsb2Qy/9e//pX23XffbB7vhx56KOte/oMf/CBLrAYANA1BNwBUmLvvvjtNnz49C6RXX331Ovd98cUX2d9DDz00/fa3v82mEpswYULq27dvuuiii9IRRxzRTHsNAJWpRaFQKKQSEzXykaAl9RmSUss2qVToXg6QXzfv5lKK5/ap02anLmu/kI2bLuVW50Utr0vxWFeiSvuNAv/HebQ8y2uJ1AAAACAngm4AAADIiaAbAAAAciKR2lIaI2X8BVAuqm08aJ7v17kfANDSDQAAADkRdAMAAEBOBN0AAAAg6AYAAIDyoqUbAAAAciLoBgAAgJwIugEAACAn5uleSswDC1B9FvvcP3dWKieTH5iUOnV0SVGu1xFA+cjrXDBnzMRcnpf/0NINAAAAORF0AwAAQE4E3QAAAJATQTcAAADkRNANAAAAORF0AwAAQE7M71EBTB0AlMp5AwCAurR0AwAAQE4E3QAAAJATQTcAAADkxJhuAGCJdBncNaWWbRa43ZwxEx3pxVTpx06eCSiv32Cln5OampZuAAAAyImgGwAAAHIi6AYAAICcGNNNLuOrjPOA5mV8JABAadDSDQAAADkRdAMAAEBOBN0AAACQE0E3AAAA5ETQDQAAADkRdAMAAEBOTBlG2U1XZDoygNIy+YFJqVNHlxSUf9luukWqVan8BkvFQp8L5s5aqM20dAMAAEBOBN0AAACQE0E3AAAA5ETQDQAAADkRdAMAAEBOBN0AAACQE0E3AAAA5MSkmpSdJZlD0xyEVBLzyQIAlD4t3QAAAJATQTcAAADkRPdyAACogmFohiVV13fU553/cZ46bXbqsvaCt9PSDQAV6L777ksHHnhg2nzzzdOwYcPS7bffPs82M2bMSGeffXbacsstU//+/dMFF1yQZs+e3Sz7CwCVSks3AFSYa665Jt1yyy3p8MMPT2uuuWZ67rnn0iGHHJLGjh2bTj755JrtDj300PTyyy+nyy+/PH399dfp2GOPTePGjctuAwBNY5Fauj/99NN0+umnp379+qWVVlopbbPNNum2226bZ7so6DfaaKPUuXPntMUWW6QHHnigiXYXAFiQgw8+ON17771p+PDhWUv39773vXTcccelK664omabCLaj9fsPf/hD2nXXXdNee+2VLr744mybjz/+2EEGgOZo6b7ooovSqquumv7617+mTp06pZtuuikr0COoHjRoULbNQw89lBX2v/nNb7LubFHbHn+fffbZtPHGGzfVfsNiyWtsS6WPAQPKyzLLLDPPupYt69azP/zww6ljx45pwIABNeuGDh2a5syZkx577LH0ne98Z6nsKwBUukVq6f7FL36R1ZSvscYaqWvXrumEE05Iq6yySnriiSdqtrnwwgvT7rvvno455pjUo0eP9JOf/CRtuOGGWcAOACx90a38qquuSvvvv3+ddd26dasTjEeF+rLLLpt1MW/IzJkz09SpU+ssAEBOidRmzZqVbr755qzL+ZAhQ7J1hUIhjRkzJu200051to1W8FgPACxdX3zxRdbjbP31108//elPa9ZHi3ZDLeJt27ZtNJnayJEjs6FjxaV379657jsAVGUitddffz1tttlm6ZtvvskK5hgL9q1vfSu7b9q0aWn69OlZzXltcXt+48Oi5jyWIjXnALDkpkyZko3XbteuXbrnnnvqBNkrrrhiVnFeWwTb8Zi4ryEjRoxIp5xySp3yWuANAE3c0r3uuutmteYfffRRNrXIEUcckSVrmd+4sbgdreCNUXMOAE0rAuIIuKP8vf/++7Ou47XFNGGTJk1K77//fs26p556Kts+kqA2JCrb43lqLwBAEwfdLVq0yGrMu3fvnk488cSsQP/1r3+d3RcJWdq3b58V4rVNnDgxrbzyyo0+Z9ScR816cYlxZgDA4omeZzH0KwLoBx98MOsKXt/AgQOz6cTOPPPMrIU7pgw799xz09Zbby3xKQCUwpjuoijQi63YEZBHV/NHH310ngyp/fv3b/Q51JwDQNO58sors1brCRMmZOVyTPVZXIratGmTRo8enV599dW0wgorZEsMEYt8LQBAM43pPuSQQ9LJJ5+cZSOPMdg33HBDNkbs+uuvr9nmhz/8Ydp3332zubr32GOPdN1116Xnn38+XXrppU242wBAY4488sisDF6QSK72yiuvZNnKW7dunfViAwCaMeg+/PDD06mnnpoF0a1atUrrrbdeViO+33771WwTGVKju3lsd8ABB6TVV189m8+7mGwNKlFe838Hc4BXrzy/V1S2Ysv1wurVq1eu+wNA03BtUAVB984775wt0Z08upI3JubojmXu3LnzJFUDAACAarFYEfH8Au46Ty7gBgAAoIpphgYAAICcCLoBAAAgJ4JuAAAAyImgGwAAAEohezlQXlNDmG6s9Jn6AwCgsmnpBgAAAEE3AAAAlBct3QAAAJATY7oBAKBMyQ1CqX+P5oyZmCr2eMydtVCbaekGAACAnAi6AQAAICe6l0MFy6urUDl2EwIAgOagpRsAAAByIugGAACAnAi6AQAAICeCbgAAAMiJoBsAAAByIugGAACAnAi6AQAAICfm6QaW6vzf1TbHd15zpQNAuVqUawHlaPlrtQjXQpV6nailGwAAAHIi6AYAAICcCLoBAAAgJ4JuAAAAyImgGwAAAHIi6AYAAICcmDIMWKrynPqjUqeZAACgfGnpBgAAgJwIugEAACAngm4AAADIiTHdAMAS6TK4a0ot2yxwO3kXqGZ55jQpN44FTfHdWNQypTm/d1q6AQAAICeCbgAAAMiJoBsAAAByYkw3UDHyGquzoDFDxqYBANAYLd0AAACQE0E3AAAA5ETQDQAAADkRdAMAAEBOBN0AAACQE0E3AAAA5MSUYQA5TilmOjEovd/DgqYBhHL6jpbK7wpK3Zxm/F0JugGgQj311FPphhtuSB06dEgXXHDBPPcfddRRac6cOXXWHXTQQWnw4MFLcS8BoLIJugGgAm2xxRapdevWWcD9wQcfNBh0X3fddemkk05KG264Yc26Xr16LeU9BYDKJugGgAp07bXXpg022CCde+65WdDdmF122SUNGTJkqe4bAFQTQTcAVKAIuBfGNddck0aPHp369u2bhg8fnvr06ZP7vgFANZG9HACqVNeuXdOqq66a1l9//Wz893rrrZfuvffeRrefOXNmmjp1ap0FAJg/Ld0AUKVeeuml1K3bfzK0nnDCCemYY45J//Vf/5XGjx/f4PYjR45M55133lLeSwAob1q6AaBKFQPuov333z999NFH6cMPP2xw+xEjRqQpU6bULGPHjl1KewoA5UtLN0CZzgdsblaa2ldffZX9LRQKDd7ftm3bbAEAFp6WbgCoQs8991ydluoIuEeNGpWN715ttdWadd8AoJJo6QaACvSLX/wivf322+nFF19MEydOTIcffni2/uKLL05dunRJLVq0SEOHDs3+j4RqkUgt/t58883NvesAUFEE3QBQgTbddNPUs2fPtOOOO9ZZX+wevsUWW6QXXnghPfvss2nChAnZeO14TMuWOsEBQFMSdANABdptt90WuE2bNm3SgAEDUrVZlHwIeeZloLrk9V2SOwSa73c1ddrs1GXtBW+nOhsAAAByIugGAACAnOheDlCmXUiXZJ9NNwYAsHRo6QYAAICcCLoBAAAgJ4JuAAAAyImgGwAAAHIi6AYAAICcCLoBAACgFILuZ599Nu27775ppZVWSp07d04777xzev755+fZ7pprrkmrr756at26derXr18aPXp0U+4zAAAAVN483T//+c/T4Ycfnn7/+9+nli1bphEjRmSB96uvvpp69eqVbXPPPfekY445Jl133XVp2LBhWQC+//77pyeffDJtueWWeb0PAEpgXnLzf1Np8vxO5/U7hKairGBpm1Oh58VFaum+88470957751WXHHF1KVLl3TZZZel6dOnpwcffLBmm1GjRqU999wzHXTQQaljx47pxBNPTJtttlm69NJL89h/AAAAqMwx3Z9++mmaM2dOWn755bPbhUIhPf3002mHHXaos93AgQOzlm4AAACoJovUvby+aMVeddVV06677prdnjZtWvrqq69S165d62zXrVu39MknnzT6PDNnzsyWoqlTpy7JbgEAAEB5t3SffvrpWbfy22+/PbVv336+286dOze1aNGi0ftHjhyZJWYrLr17917c3QIAAIDyDrrPOuusdMUVV6T77rsvbb755jXrYwx3hw4d0sSJdQfAT5o0KXXv3r3R54uEbFOmTKlZxo4duzi7BQAAAOUddP/0pz/NEqhFwN2/f/8690Vr9jbbbJMefvjhOuv/9re/Zesb07Zt29SpU6c6CwAAAFTVmO7zzjsvXXzxxemvf/1r2mqrrdLs2bOz9TF9WCzhtNNOS0OHDk1XX311NmXYtddem1566aVsmrFFNfmBSalTx3l30ZQ0wKKq1Ckoquk4O/cDABXf0n3hhRemGTNmZNnI27VrV7P893//d802gwcPzubojm379OmTbrjhhjR69Oi06aab5rH/AAAAUBkt3V9++eVCbRdzdMcCAAAA1WyJ5ukGAAAAcpqnGwAAoBzzfyzKfsgrsnS0yunza25augEAACAngm4AAADIiaAbAAAAclKWY7pLZRwIUFrKaWwPS/fzdW4HAJqLlm4AAADIiaAbAAAAciLoBgAAgJwIugEAACAngm4AAADIiaAbAAAAclKWU4aV45RDpqsBaD7O7QDVYVHO967Py1urUpjqee6shdpMSzcAAADkRNANAAAAORF0AwAAQE4E3QAAAJATQTcAAADkRNANAAAAORF0AwAAQE7M010Gc8SWxBx0UMFzLcPicm4HABZESzcAAADkRNANAAAAORF0AwAAQE6M6QaACjV16tR01113pVatWqUDDzywwW3Gjx+fHnnkkdS6des0cODA1LVr16W+n9Uqr5wt8l9QzeRCohQJugGgAh133HHpjjvuSB07dkxz5sxpMOi+9dZb0+GHH56233779PXXX6ejjz46jR49Ogu+AYCmoXs5AFSgzTffPL399tuNtnBPmTIlC7LPOuusdO+996aHH344HXrooVkQPnv27KW+vwBQqbR0l4E8u4npggNQeuf2qdNmpy5rL9nzH3nkkfO9PwLtL7/8Mh177LE160444YT029/+No0ZMybtsMMOS7YDAEBGSzcAVKHXXnstde/ePa2wwgo169ZZZ51s/Hfc15CZM2dm48RrLwDA/Am6AaAKRcC8/PLL11nXokWL1Llz50aD6ZEjR2b3F5fevXsvpb0FgPIl6AaAKrTsssumadOmzbM+1rVv377Bx4wYMSIbC15cxo4duxT2FADKmzHdAFCF1lprrfTJJ5+kGTNmZAF4GDduXJo1a1Zac801G3xM27ZtswUAWHhaugGgCg0ZMiTNnTs33X777TXrrr/++tSpUydJ1ACgCWnpBoAKdOedd6YJEyak559/Phuj/bvf/S5bH9OCdejQIa2yyirpnHPOSd///vezxGkxT3dkLv/Nb36T3Q8ANA1BNwBUoHfeeSe9++67qVevXtny4osvZusPOOCAmm3OPPPM9K1vfSvdd999WbfxRx55JPXv378Z9xoAKo+gu8otyRzg5vim3Oalh2py2mmnLdR2O++8c7ZQOfIsnxflHL0o++HcT3PI6/vM0jFnEa8Zm/MzNKYbAAAAciLoBgAAgJwIugEAACAngm4AAADIiaAbAAAAciLoBgAAgJyYMozFltf0HqZkqGymhQEAoJpo6QYAAICcCLoBAAAgJ4JuAAAAyIkx3QAALBR5V6ik3DG+z+Wt1YBuqVxo6QYAAICcCLoBAAAgJ4JuAAAAyIkx3VTVPM7lNPYDAAAof1q6AQAAICeCbgAAAMiJoBsAAAByIugGAACAnAi6AQAAICeCbgAAAMiJKcOoKksyHZnpxpbOcQYAKLXrFdeBLAkt3QAAAJATQTcAAADkRNANAAAAORF0AwAAQE4E3QAAAFBKQXehUEhffvllmj179ny3mzVr1uLuFwAAAFRX0D158uR08cUXp379+qWOHTumm266qcHtrrjiitSzZ8/Url271KdPn3TzzTc31f4CAABAZc7Tfcstt6QPPvgg3XnnnWnddddtcJu476STTsoC8qFDh6brrrsuHXzwwWnVVVdN/fv3b6r9hoqZe9q8jwAAULkWKeg+9thjF7jNJZdckvbaa6+0zz77ZLePOeaYdM0116TLLrtM0A0AAEBVadJEajHW+5lnnknbb799nfU77bRTevrpp5vypQAAAKCyWroXZNq0aWnGjBmpa9euddbH7YkTG++aO3PmzGwpmjp1alPuFgAAAJR/0F00d+7ceW63aNGi0e1HjhyZzjvvvDx2BQAAYKnl9pGvh1y7l0dG8+WWW26eVu243aNHj0YfN2LEiDRlypSaZezYsU25WwAAAFD+QXe0Zg8YMCD97W9/q7P+wQcfTNtuu22jj2vbtm3q1KlTnQUAAACqqnv5nDlzsjHbRTEO+8svv0xt2rTJAufw4x//OA0ePDj99re/TcOGDUvXXntteu2117K/QNNORdZc3Zfymj4NAACquqX78ccfT927d8+WDh06ZPNxx/+nnnpqzTYDBw5MN998c7rqqqvSJptskv76179my0YbbZTH/gMAAEBltHTvuOOOWcv2guy7777ZAgAAANWsScd0AwAAAP9H0A0AAAA5EXQDAABATgTdAAAAkBNBNwAAAJRC9nKgtJgvG1gSr732WioUCnXW9ejRI6244ooOLAA0EUE3AFSpjTfeOPXq1Sstt9xyNevOOOOMdMghhzTrfgFAJRF0A0AV+93vfpeGDBnS3LsBABXLmG4AqGLTpk1L7777bvrmm2+ae1cAoCIJugGgih166KFp0KBBWRfz7373u2ny5MnNvUsAUFEE3QBQpX75y1+mKVOmpA8++CC9+uqr6cknn0zHHHNMo9vPnDkzTZ06tc4CAMyfoBsAqtRpp52W2rZtm/2/9tprp3PPPTfdfvvt6csvv2xw+5EjR6bOnTvXLL17917KewwA5UciNaBBrQZ0y+XImOYMSldkMo8pxMaNG5f69es3z/0jRoxIp5xySs3taOkWeAPA/Am6AaAKReK0ZZZZps66Rx99NLVr1y6tuuqqDT4mWsWLLeMAwMIRdANAFfrTn/6UHn/88bTvvvumrl27poceeijrPn7WWWel9u3bN/fuAUDFEHQDQBU66qijsnHZV199dZowYULq27dvuuuuu9LgwYObe9cAoKIIugGgSu23337ZsqQmPzApderYumxyS7B0yOFBJXE+YknIXg4AAAA5EXQDAABATgTdAAAAkBNjuqFKNdfYpCV5XeMDAQAoN1q6AQAAICeCbgAAAMiJoBsAAAByIugGAACAnAi6AQAAICeCbgAAAMiJKcOAspHnNGemI4PSUwq/y+aaXrFUlcJnAqX+3XfeoD4t3QAAAJATQTcAAADkRNANAAAAORF0AwAAQE4E3QAAAJATQTcAAADkRNANAAAAOTFPN1Qw80QunWNl3loAABqjpRsAAAByIugGAACAnAi6AQAAICfGdAMAlHjOBjk6wG+Q0jv3T502O3VZe8HbaekGAACAnAi6AQAAICe6l0MZ092wsj+HUunWCgDA4tPSDQAAADkRdAMAAEBOBN0AAACQE0E3AAAA5ETQDQAAADkRdAMAAEBOBN0AAACQE/N0A1ThPOzmAIfy4jcL5VXOUt7n0VYL+92YO2uhNtPSDQAAADkRdAMAAEBOBN0AAACQE0E3AAAA5ETQDQAAADkRdAMAAEBOTBkGJc50FpTa98rURQAAC09LNwAAAORE0A0AAAA5EXQDAABATozpBgCoYnnlDpH/gab4zi3q90guHJbm927qtNmpy9oL3k5LNwBUqc8//zwdccQRqUePHql3797phBNOSF999VVz7xYAVJTcWroLhUL68ssvU8eOHfN6CQBgCey7775p+vTp6aGHHkpff/11OuCAA9Knn36abrzxRscVAJpILi3dF198cVpppZXSiiuumNWe//GPf8zjZQCAxfT000+nRx55JF1xxRVp/fXXT5tvvnkaNWpUuummm9K///1vxxUASrWl+7bbbktnnHFGuuOOO9Kuu+6abrjhhnTkkUemvn37pu22226RnqvL4K4ptWwzz3pjhACaj/GfleGJJ55InTt3zoLtop133jn7O2bMmNSnT59m3DsAqBxN3tJ9+eWXp7333jvtvvvuqVWrVum73/1u2nrrrdOvf/3rpn4pAGAxffTRR6lbt7pJZdq3b5+WW265NGHChAYfM3PmzDR16tQ6CwCwFIPuuXPnpmeffTZtu+22ddbvsMMO6ZlnnmnKlwIAllDLli0bXBd5WRoycuTIrHW8uETyNQBgKQbdkTgtErF07dq1zvq4PWnSpEYfp+YcAJauaOWuXzYXy+P6LeBFI0aMSFOmTKlZxo4du5T2FgDKV5MG3S1atMj+zpkzp8762bNnN1ibXqTmHACWrv79+2dThr322ms16yKxWohhYQ1p27Zt6tSpU50FAFiKQXdMDxbLJ598Umd93I4s5o1Rcw4AS1ckN91ss83SD3/4w2yasPHjx6ef/OQnaciQIWmdddbxcQBAqWYvj0I85vuMQrzogQcemG/m8qg5j6WoZizZ3NkNbj91WsProSLNndXcewBLhXN7rWPxZd0eY3mIHmijR49ORx99dFYxHreHDRuWrrzyyoV+jmJ5vTT2l/IrZ/ymaYrv3CJ/j1w30QQW9ntXLP8ay4VS1KKwoC0W0eOPP54GDhyYLrjggqzwvvbaa9NFF12UXnjhhbTeeust1HOMGzdOchYAql6Mm14aXbhjWFgMEZvfULCGKK8BIGU5Tnr16rX0gu5w9913p5///Ofpww8/TGuttVY6//zz0/bbb79IWdBjKpPoqj5t2rQsAI83YuzY/EXyG8dq4ThWC8+xcqzy4Hs1f8WiOcq9Yr6UUlS7vK69n5X++Xp/5c9nWN58fuVtagWVEVFeR7zas2fP+VZcN3n38rDHHntky+KKHS7WFBQLcQlbFp5j5VjlwffKsfK9Yn7ldTWeN7y/8uczLG8+v/LWqULKiJhCc6kmUgMAAAD+j6AbAAAAqjXojqzm55xzTp3s5jhWvld+g6XI+cqxojp+C95f+fMZljefX3lrW+FlxFJLpAYAAACUQUs3AAAAlCtBNwAAAORE0A0AAAA5yWWe7qYSw81feOGFbOL0fv36pXXXXbe5d6lkzJgxIz3wwANp2WWXTYMHD25wm08//TQ99dRTqV27dmnbbbfNtq028R165ZVX0gcffJD69OmTNtxwwwa3++STT9IzzzyTOnTokB2rakrsUNu7776bXn/99dS1a9e0xRZbpDZt2syzzYQJE9Jzzz2XOnbsmAYMGJCWWWaZVM3++te/pmnTpqUDDjhgnvvGjx+fnn/++Wz+xjhWDR3PSjZ58uR0//33z7N+l112SSuuuGKddR9++GH65z//mVZYYYXUv3//1Lp1SRdPzMdHH32UnSPie7/NNttU1DnijjvuSDNnzqyzboMNNsiWcv6d/u1vf8vmW996660b3Kacf5/ffPNNevDBB9PcuXPTsGHD5rn/1ltvTXPmzKmzbtNNN03rrLNOKpff24svvpjNdbzZZpul9u3bN3gMxowZk5VVW265ZerRo0cqF1OmTMligbie23jjjdNKK61U5/6XX345u26pLY7BnnvumcpBvK94D3Gd2rt37+y715C33347e5/x2W211VapRYsWqVy8//776bXXXsvOH/H9q10mxHfz//2//zfPY6LsWHXVVVNFKZSor776qrDLLrsUunXrVhg8eHChY8eOhaOPProwd+7cQjWbM2dO4eSTTy706NGj0KtXr8Lmm2/e4Ha33HJLoUOHDoUBAwYU1l9//ULPnj0LL730UqGaPPbYY4UNN9ywsNFGGxX22GOPwsorr1zYdtttC5999lmd7a677rpC+/btC9tvv32hX79+hdVWW63w1ltvFarJuHHjCjvvvHNh3XXXLey5556FtdZaK/t+Pffcc3W2+/3vf58dqx133DHbZvXVVy+89957hWp1ww03FJZZZplIRjnPfZdffnlh2WWXLey0006FNdZYo7D22msXPvjgg0I1ie9PHJt99tmnMHz48JrlnXfeqbPdBRdckB2rgQMHZr+/DTbYoDBhwoRm228W3xVXXFFzjlhzzTWz5V//+lfFHNIVV1yx0L9//zrf51tvvbVQjqIsPPzww7PriZVWWqlw8MEHN7hdOf8+zzrrrMIqq6yS7XcsDWnbtm1hu+22q/OZ/uUvfymUusmTJxf222+/Qu/evQu77bZbYeONN86ucx544IE620UZHWV1lNnxu4zfZ5Tl5eDUU0/Nrl/ju7fDDjtk+37JJZfU2eb000/P3nftz+8HP/hBoRw8++yzhU022SS7lh82bFihe/fuhU033bTw0Ucf1dkurvuXW265LC6KbeL7OnXq1EKp++STTwq77757dv2z5557ZtfYcb55/PHHa7aZNGlSdp2w66671vkMn3zyyUKlKdmg+5xzzsl+aB9//HF2OwLGuLi96aabCtXsm2++KVx00UVZYXnSSSc1GHRPnDgx+3FeeOGF2e2oqNh7772zH3Y1uf/++wuvvfZaze0pU6ZkhU5U3hSNHTs2K3DjQjHMnj07q+SJ4LyavPnmm4Wnnnqq5nZ8Z4YOHVrYZptt6hTcbdq0KVxzzTXZ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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10, 5))\n", + "ax0.imshow(ti_data, cmap=\"cividis\", origin=\"lower\")\n", + "ax0.set_title(\"Training image\")\n", + "ax1.imshow(field_simple, cmap=\"cividis\", origin=\"lower\")\n", + "ax1.set_title(\"DS realization\")\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5e7a8b30", + "metadata": {}, + "source": [ + "## 2. Conditioning to hard data\n", + "\n", + "Real simulations must honour measured data (boreholes, samples). Direct\n", + "Sampling supports this through `DirectSampling.set_condition`: conditioning\n", + "values are pinned into the grid before simulation and preserved exactly,\n", + "while the rest of the field is filled in around them.\n", + "\n", + "We reuse the synthetic channel training image from the first example." + ] + }, + { + "cell_type": "markdown", + "id": "4b4b9dcb", + "metadata": {}, + "source": [ + "Draw 40 random \"hard data\" points and read their facies from the TI. In a\n", + "real study these would be field measurements; here we sample the TI so the\n", + "conditioning data are consistent with the patterns." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7a754e7c", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(0)\n", + "cond_x = rng.integers(0, 40, 40).astype(float)\n", + "cond_y = rng.integers(0, 40, 40).astype(float)\n", + "cond_val = ti_data[cond_x.astype(int), cond_y.astype(int)]" + ] + }, + { + "cell_type": "markdown", + "id": "ed690ca0", + "metadata": {}, + "source": [ + "Set the conditioning data and simulate. `set_condition` snaps each point to\n", + "its nearest grid node, so the values are honoured exactly at those cells." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "bcb598a1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "conditioning honoured: 40/40\n" + ] + } + ], + "source": [ + "ds_cond = gs.DirectSampling(\n", + " gs.MPSModel(ti, n_neighbors=12, scan_fraction=0.3, threshold=0.0)\n", + ")\n", + "ds_cond.set_condition([cond_x, cond_y], cond_val)\n", + "field_cond = ds_cond([np.arange(40, dtype=float)] * 2, seed=7)\n", + "\n", + "honored = int(\n", + " (field_cond[cond_x.astype(int), cond_y.astype(int)] == cond_val).sum()\n", + ")\n", + "print(f\"conditioning honoured: {honored}/{cond_val.size}\")" + ] + }, + { + "cell_type": "markdown", + "id": "abbb1153", + "metadata": {}, + "source": [ + "Plot the realization with the conditioning points overlaid. Every marker sits\n", + "on a cell whose simulated facies matches the datum." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "640ef1df", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAdsAAAHqCAYAAABMXOQ8AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAXd9JREFUeJzt3Qd4VFXaB/D/nUnvBVKA0Kt0ELAgioCIiAUVFPksa0FBxY6suvbFvpZdRdEVu+IiKCgIKhaKIkUUBekQQiCk9zZzvuc9YUImBTIhN5ny/z3PiLlzZ+b2955z3nOuoZRSICIiItNYzPtqIiIiYrAlIiJqAizZEhERmYzBloiIyGQMtkRERCZjsCUiIjIZgy0REZHJGGyJiIhMxmBLRERkMgZbFzzwwAMwDAPl5eWV06655hq0adOm3t/h6vxmc7flaS5+fn647777jjutOZenKRUUFKB169Z49dVXm+T3Vq5cqc+txYsXe9Vvucvvz549W//mnj170Jzuu+8+vRzN9fkT9X//938499xzvSvYrl69GpMmTULbtm0RGBiI+Ph4DB48GI8++igOHDgAd3b55Zejffv28Ca33367Psgdr5CQEH0xPuecc/D8888jKyur1s/Nnz8fZ555JhISEhAWFoY+ffrgrrvuwrZt2+DL5IZNtqPcwLmjp556Cv7+/rjuuuuae1GIGsWll16Kzp07n9B3PPzww/jmm2+wZMkS7wi2skJDhw5FeHg4Fi1ahJycHPz555+488478e677+Jvf/sb3MXcuXOxf/9+0+Z3N8nJyZDhtLOzsytviKT00717d33HXtWDDz6oD/DTTz8da9euxeHDh/Hiiy/ixx9/xHnnnQdPCIhPPvmkz/22lGpfeuklTJkyBQEBAc2yDETuqFOnThg9ejT++c9/en6w/fDDD/HII4/oEuxrr72Gvn37IigoCLGxsbrE+Ouvv+Lss89u7sX0eXIRbteuna6GXr9+PWJiYnDBBRcgIyNDb5uSkhI8++yzGDFihD4wpYYiODgYw4cPx6pVq3SQJvck56Dc4E6ePLm5F4XI7ch5IQWLzZs3e3awlUCbmJhYZ3tVaGgo7r33XqdpCxcuxKmnnqqrNqWqUqotv/76a6d5LrroIl36Sk9Px2WXXaZLzS1atMDUqVN1YKjuzTff1PNLoJeAv3Tp0nq1eZ5yyin4+OOPsXfvXqdq1+Li4lrnN2sdHG0b8rJYLIiOjtZVvhLoGltERIS+OZKq5Ndff11PKyoq0ussVc3VSfWkzH88jvVNTU3VJeTIyEicddZZ+j0pXf/nP/9Bv379dBCX9yTYSw1IVbKtHdtBfleC/s0334zMzEyX203lZq/qPq36uummm+r9mzt27NDTxRNPPFE5r6xjXb/t2KZ///vf0bFjR32zI+eJ1PLI9nHIz8/X3/X444/r46d///76GO7WrZs+LutDapO6dOmCpKQkp+lVv/vLL7/EgAEDdBPPG2+8od8/ePCg3g6y/rJ8st5yrjqOfQep5Rg2bJjebzLvrFmz4Cozfuunn37CGWec4TRvXe2rTbmujclut+Ohhx7Sx45ca6SUtmvXLqd5vvvuuxpNRrKvpfBTWzuwHM/3338/WrVq5XStmzNnjj7u5Pjr168fli1b5tKy1vfzd999d41rnbSrrlmzpnKek08+WTdp7dy502ndHPk39fkOB0dhT84Tlyg3snv3bnncn7rqqqvq/Zk333xTf+bOO+9UKSkpau/eveq6665TFotFffrpp5XzXXjhhapTp05qwoQJ6rvvvlO5ublq3rx5yt/fXz300ENO3/nKK6/o75TpaWlpaufOnerSSy9VY8eO1dPLysoq57366qtV69atnT4/ceJE1a5du1qXt7b5zViHqmR5t2/frrdrWFiYXp9jLU9tpk+frpcxOTm51vezsrL0+6NHj66c1rNnTxUZGalWrVqlGsKxvuPGjVPffvutysjIUO+8845+75prrtHr8sYbb6j09HS9zSZNmqR/b8eOHbV+X35+vlqxYoXq0qWL03IKq9WqZsyYcdxp1d1+++16vV9++WWXflP2iXzu/vvvr/Vz1X/bZrOpkSNHqqioKDV//nyVk5Oj1qxZo7p27arat2+vt43Iy8vT33vxxRfrfbtr1y51+PBhde211+rjaevWrep4YmJi1BVXXFFjuuO7ZX/I+7Kdt23bppYvX65SU1NVUlKSGjBggFq9erWe94cfflAdO3ZU5557buV3bN68WYWEhKgxY8boz8qy/fOf/9TLK9+9aNGi4y6fGb/122+/qeDgYH2Oy7ki8z799NNq/PjxNeZtqnXdsmWLnq8+r8cee+yY3/Xqq6/q+eS8mTNnjsrMzFS//vqrXuYhQ4Yc87NyDXzxxRf1Mfnuu+/W+M7LL79czZ49W5+Hc+fOVcXFxeqll15ShmGoRx99VH9+x44der3PO+88/Znjaejn5bySbX3llVeqiIgItWfPnsr3LrnkEn09OZ5jfYeDXN+r7uv6cKtgKwFENuQDDzxQr/lLSkpUixYt1LBhw5ym2+121bt3b30Rqnrhlu+Wk6Kqyy67TCUmJjp9Z2xsrD7pqiosLFRxcXGNHmzNWIe6lJeXq9DQUPX4448fc/kbEmyFBD9ZZodNmzapbt266c917txZB0M5iaoG+2NxrO9XX33lNP3777+vNcDJtpTtLut0LP/73//056ueRA0JtvL78j0SyI6n+m+6Gmw///xzPf/rr7/uNN/GjRudvscREOUiWvU4leAcFBSk7rnnnmMup9wcOG78qnN8d6tWrVRpaanTezfccIMOLPv373ea/vXXX+vPSEB2HKtywyA3ilXJzWx9g60ZvyUX4ujoaL2OVUkgqT5vU62rGcH273//e603+nKuHs9FF12khg4dWuM777jjDqf5JNjKtpTzt/qxFRsbe9xge6KfF3J8ys3Tk08+6XKwPdZ3OJxyyimqR48eyhVuV43sit9++01XqY4fP95pulQFyDRJc5dqAwepdpVqoqp69eqlq+Gkik5s2rRJtztKlWRVUg0k1bCesA5Cqiwlg1ga9KWqT75PqiYl+UWqfcwgN29V0/Il81iqdaXq+sYbb9TVM4899hi6du2KGTNm1Os7pQqp+nZ3VN9UrXYVUp0n2+b77793qhocN24c4uLiYLVanaprT2Q7SLWibF9pk65evWbGb0oGpKh+nEj1muxjx/sOUj0o+7tqVb9UC1evMqxOEt8cx1ldxowZU1kNXnWfnHbaaTWaDaQ5ROZ17BNZTmm3r/790mRQlRz31avqP/roI1N+S6xYsULPK004VVW/Dpj1+7WRJpQjBaLjvuqb1T527Nga1w5R9biQ73v55ZcxcOBAvT0c21+aumo7fqtvow0bNugmperTQ0NDMWrUqOMuo6ufl+v19OnTna51ci2Q62F9zzdXv0POJ8e5Ul9uFWwl4Ubs27evXvM7knGkW0l1jmkSyByknaK2jSYkIaTqd0pXo+pqm3aizFgHx0klbRSSKXzo0CHdViMnkbTxlpWVNfp6yIEngVzabaqSACsXpnvuuUdnkktbtgSMp59+GgsWLDju91b/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DqeHkGrj6667TpdU6yJ3rO3atdNPgBJSzTd16lR89tlnusrS3cid9h133IGYmJjjzivVYrIe8txmd+45J9WrVWuApApfSgxSKnT4/vvv9fkkNRUO8v9y3sn5526k9F211CM1RFIqT01N1SW/quRBKI5zTGol3Nlll13mdD45ar0c54osv9Q23HTTTbqUKM4//3xdEpaSojuS/STNRw5JSUl6Hauf//L3okWLsGLFCmRkZMBbuXU1sjx0XnZW1bYLeYqQXLjlPXes5qqvxYsX6ypxuWDLBUNOGGnT9Bayf3r37u00Tf6Wqj15xrE8ftFTvf7667rq+Oeff0bPnj111bM7Vo3X5uuvv9bVr1X3k1y8O3bsWDmtbdu2us1W3hs7diw8YZ2kPVpeVX355Zc64P7xxx+6meC9995zqmp2N7KsUjUs7ZtStSpNFueee25lk4DclFfdd45zSvaTu5LgKU1H+fn5+jwZPHgwrrrqKqd5pNniP//5j26H3rJlC5566inccsst8DZuHWylbaK2koZMk3YaT/Xkk09i9OjROshK8Ln22mv1na0cdKGhofAGjnalqhztnJ6676T0K6U9aaMWkoAjbU3StvbFF1/A3b399tu6RC4Jesc7x2RfecJ+kv3x8ssv6xugqh5//PHKc0zyCq6//npMmDBBn2PummApbesLFy7EoUOHdOCdOHFi5RNlZD+J6vtK9tOePXvgruTGQdYpMzMTmzZt0vuhajKb3Mw98MADCAsL03+/9dZbutZMamW8qfAh3LoaWYbykjui6mRa9exDTyJ3q45HB8oFXIYtk0cKrl+/Ht6itn3n+NtT952skyPQOi58UvUsma/u3hQgQfbGG2/Eq6++qhO8vOEc++mnn3QV8r333qtvWOs6x6T6XM4xqRr/5Zdf4K4kkVCSiqQ6VapV77vvPnzyySf6PcewhrWdU+68n+SGW9Zp2bJlujr/tddew7/+9a/K96WmwRFohexHqWWRmj9v49bBVtLEpRqisLCwcprcbcuzEKtWe3k6xyOmDh8+DG8h+07uzqu3Uwtv23dScnLnUuDnn3+uS0kvvPCCDrjV95OUmhwlJ1FQUKDPO3feT1KFLyVXyQN44oknvO4ck2YWyaT+8ccf9d+OfVHbOeXO+6mq9u3b666bjnWqi9Q8eMp+8ppgK2nucncqSQ4OUu8vd3me2l4r1SrVkzU+/fRTXV0kfSC9xXnnnaf7DEq7dNV9J30DPaV9szopGVUn+07aOKWrkDuSEoJUn0q3kptvvrnG+3IeyflUtf+w/L8cj9W7PrkLKZ1KoJX1kSaZ6uTGp7ZzTK4l7pgrIMta/WZNql2leliSihzVxdJtyVHSdQReKd27a7u6JK1VVVJSotvPHetU2zySzyHzeEo3SK9ps5UkFOlDJnevcmBJP9tZs2bh4Ycfdurn6Ekk8/PKK6/E+PHjdbauZLbOmTNH95dz9w7qVclJL/tDqr+lGkuqiiTR5sILL9Tvy79yIZd+ddOmTdOJDxJspW+uu5Jlk4uetOvJTZGsk5B+3lIVuWTJEt2mJFmgMpiCJOBI8odjPncjgx/Iskv/Uqnyrrqcl1xyiW7CkPNIqlinT5+uL3zSz1bOMemnmpCQAHezc+dOfRMgpTm5cau6TlJ1LPtF2j6lJC/nmJxTGzdu1OeYrFP1QRXcgdQkSHWqZO9KFrKU6v773//qm7iqNRHPPvusPqdkWp8+fXSTgAxAIvvYHckN0fDhw3USV0FBgU4ClYAr1f4Ocn2Qde/bt6++mf33v/+t1+nqq6+Gt/GIp/5IA7skoMidqRyQcrHzZHIxeOedd3SGoXSjke4xnlaqlYzC0tJSp2lyoZs9e3bl3/K+XOSkJCJZ5ddcc40+qdzVPffco2+GaksscrSZyc2RlPzkgigXfNkOsg/dNUv3jTfeqPU9uZhX7WoiJWCpbpbLgZSUHF223I0k2cjNQG0ki1VuYIXcOMh+k3NMalLkuuHOpSWpxpcMZMkslipvWVZJmqw+4pr0XpB9J9X8cs2YMmWK2z6mTkrs7777rj7/g4KC9A2CFDQcXZeqzyPVx9JuLTeCjvZ2b+IRwZaIiMiTuXWbLRERkTdgsCUiIjIZgy0REZHJGGyJiIhMxmBLRERkMgZbIiIikzHYEhERmYzBloiIyGQMtkRERCZjsCUiIjIZgy0REZHJGGyJiIhgrv8HK+p7w1QZ5HsAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "ax.imshow(field_cond, cmap=\"cividis\", origin=\"lower\")\n", + "ax.scatter(\n", + " cond_y, cond_x, c=cond_val, cmap=\"cividis\", edgecolors=\"red\", s=30\n", + ")\n", + "ax.set_title(\"Conditional DS realization (red-edged = hard data)\")\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "d60992eb", + "metadata": {}, + "source": [ + "## 3. Continuous variables and distance metrics\n", + "\n", + "Direct Sampling is not limited to categorical facies: with\n", + "`categorical=False` it simulates continuous variables (permeability,\n", + "porosity, elevation, ...). The `distance` argument then selects how two\n", + "patterns are compared:\n", + "\n", + "* `\"l1\"` / `\"l2\"` — Manhattan / Euclidean distance on the raw values\n", + " (Mariethoz et al., 2010, Eq. 6 / Eq. 4).\n", + "* `\"variation\"` — compares only the *relative* variations within a pattern\n", + " (Eq. 9), tolerating a locally varying mean. Useful for non-stationary data.\n", + "\n", + "Here we use a smooth continuous training image and a small acceptance\n", + "`threshold` (standard DS mode) to allow approximate matches." + ] + }, + { + "cell_type": "markdown", + "id": "7aaef7f6", + "metadata": {}, + "source": [ + "A smooth, continuous synthetic training image." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "3fdcae36", + "metadata": {}, + "outputs": [], + "source": [ + "gx, gy = np.meshgrid(np.arange(60), np.arange(60), indexing=\"ij\")\n", + "ti_cont_data = np.sin(gx / 6.0) * np.cos(gy / 8.0)" + ] + }, + { + "cell_type": "markdown", + "id": "9ae54017", + "metadata": {}, + "source": [ + "Build a continuous training image with the Euclidean (`\"l2\"`) distance." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c6f47f49", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TrainingImage(shape=(60, 60), categorical=False, distance='l2')\n" + ] + } + ], + "source": [ + "ti_cont = gs.TrainingImage(ti_cont_data, categorical=False, distance=\"l2\")\n", + "print(ti_cont)" + ] + }, + { + "cell_type": "markdown", + "id": "d94fc2be", + "metadata": {}, + "source": [ + "Simulate. `threshold=0.03` accepts the first pattern within that distance\n", + "(standard DS), which is faster than the exhaustive best-candidate search for\n", + "continuous variables." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f316fb3f", + "metadata": {}, + "outputs": [], + "source": [ + "ds_cont = gs.DirectSampling(\n", + " gs.MPSModel(ti_cont, n_neighbors=12, scan_fraction=0.3, threshold=0.03)\n", + ")\n", + "field_cont = ds_cont([np.arange(32, dtype=float)] * 2, seed=3)" + ] + }, + { + "cell_type": "markdown", + "id": "d52a52ae", + "metadata": {}, + "source": [ + "Plot. The realization reproduces the smooth wavy structure of the TI without\n", + "copying it. A shared colour scale makes the comparison fair." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "105a2d5f", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAwQAAAFaCAYAAACg4JpMAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAk4lJREFUeJztnQWUXEXWx6tdpscyk4kRg+ASSCC4u8MuEFwXWFgWZyEsfDgsi+wCizuLL+7uLgkswS3uNt7+vnMr9Gy/qtv9qnus0/3/nfNO0jX1qqvr6a26/3tdlmVZAgAAAAAAAFCRuPu7AwAAAAAAAID+AwYBAAAAAAAAFQwMAgAAAAAAACoYGAQAAAAAAABUMDAIAAAAAAAAqGBgEAAAAAAAAFDBwCAAAAAAAACggoFBAAAAAAAAQAUDg6AXuO+++8QWW2wh4vF4v+zfXfr7+/uaF198Uey6664iFouJUqYSjsvNN98sjj766P7uBgAVx5///Gdx4oknOpb1Z38AAL2HV1QQ5557rnjnnXcc662xxhrijjvuKPp7ZsyYId5//32RTqf7Zf/u0t/f35ckEglxyimniP33318EAoH+7o64/fbbxb333ivefvtt4fF4Ku647LHHHuK0004Te++9t9wAKBXeeustcd5553V99vv9ora2Vqy66qpi6623FjvvvLPwevVHamtrq3yefPbZZ2LBggVi+PDhYuONNxYHH3ywqK6uFqXCV199JZLJpGNZT3L88cfLcbzhhhuM+gMA6D1clmVZokL49ttvxeLFi7s+f/DBB+Lss8+WG72IZKCb9NixY4v+npkzZ4rp06eLzTffXLhcrj7fv7v09/f3JXfeeac44YQTxOzZs8XAgQP7uzvyheOyyy6Thor6clEpx+Xwww8XX3zxhfjvf//b310BoIvHHntMThxceOGFYvvttxepVEo+Tz755BNx9913y+fGv//9b7HpppvanjnbbLONNBzI0B0zZoz4+eefxbXXXivmzp0rr+cBAwaUxChTP+kF/L333rO9lBPrrrtur3znJptsIoLBoDS2VHr7uwEAFbxCsOaaa9o+L1u2TP5LN2lyxegpaAaItv7av7v09/f3tYsKuQuVgjHgRKUcl8MOO0y+WNFqCBk/AJQSq6++uu158bvf/U6cddZZYscddxS77LKL+Pzzz+UzhTjnnHNEc3OzNHCHDBkiy6jeEUccIY488siSd//rz5dxGAIA9C3QECjceOONcqYk47O9zz77yBs+8dxzz8kHAW1bbrmlfJGk1YVff/3V0dc7u90HHnhA7LXXXmK33XaTS6WqC0h39yeoTyeddJLYYYcd5IPnyy+/lL7y1C65nuTD6ftpVp1WVH7/+9+L119/XZbRQhOVU7/23HNP8eijj2rtmo5fof3v7OwUN910k+zPdtttJ33Qs2e5ckHfQQ9vGkcOaveWW24RBxxwgHyI/+lPf+qatcqwcOFCcdFFF8k2dtppJ3HqqaeK77//3lbH9NhdcsklcuwJqp8Zq8z4dPe8oJlNmuHMdVxolaTQ31ZIm6TRIJeoAw88ULZ33HHHiVdeeUXbd9tttxWhUIg9hwAoRRoaGqRbUEtLi7j88su7yn/44QcxYsSILmMgA53fDz30kGhsbMzb7sMPPyyvIzIqnnzySbHffvt1Xe8EXV//93//J69RukedeeaZ2v2RrtHMvWSrrbYS++67r7jmmmtEW1tbwX78TzzxRFdb6kYre4V8J7kEfvPNN2LKlClddbPdBHNpCGjlMPNsoOcQjXdmci/DUUcdJceC7uF0XyV3LrpPvfTSS46/GYBKpaJWCEygly96mSTXjY6ODnHMMceIl19+Wf5to402En/729/k/+lla86cOeK2224Tt956q7xJ0Y0/l693pl1yB1m6dKn44x//KF9GaRmZ/ErpppWhu/vTMjXNrNIs1emnny7cbrf4y1/+ItZaay3ZLv2ufOT7fnr40LIy3aiff/55eaN94403xNNPPy1dXKhfdNOdOHGifOiRcZDBdPwK6T8t2dMLJD0QJk2aJEaPHi37Q2X0Mk/HLxfkp09MmDBB+xu9DFMbixYtkrN8tLr0yy+/SOOEZq+pL7T0T4ZNXV2d/O6qqir5W9Zff305HvTSW8ixozH76aef5Is/1c1oCAYNGtQj58V3330nPv30U+23zps3T7ZLD88Mpr+tkDYPPfRQ6aZ3wQUXiJVXXln+nn/+859i1qxZNiExnUcbbrihePPNN3MeOwBKjXHjxomRI0fK+2K2Ho0+04SG6oZK9zXa8kHXBl1H9DJN90t66X7mmWfk3+ha2n333eVMOrk9hsNhcdddd8lrlFxw1ltvPVnvoIMOki/PBE0mkFF/9dVXSxcouneoWqV8fvzkDpW5h2fXocmSbDdGk+/861//Ku93pCHItEn/z/XdGYOEJhTIACKjgO55dI+jiQYap6FDh8p6ZGQ0NTXJ1UYySEgnRhMmtB9NYtG9HQCgYFUwzz77LOknrNtvv72r7IwzzpBl5557bldZIpHI2UY8HreGDx9unXTSSV1ll1xyiWyjs7NTa/fiiy+27X/MMcdYkUjEikajPbb/zjvvbDU1NVltbW1dZfT3lVdeWbbx7bff5h2XfN9/2WWX2equt9561qqrrmr93//9n6183Lhx1pZbbmk5wY1fIf0/9NBDrerqamvGjBm2dv/6179a4XDYWrx4cc7vnjRpkmyPq3PggQdaoVDImjZtmq08mUxaHR0d8v+77babVVNTYy1atMj29/Hjx1tDhw61YrFYwceO+k11uXOuu+fFxIkTrZEjR2rt0vlPbfz4449dZaa/zbTN1tZWy+12W//85z+1uvQ3FTqugUBAKwegv/jPf/4jz+mHHnooZ50ddthB1mlvb5ef6V41aNAgy+/3W3vvvbd19dVXW2+88UbX9ePEVVddJduj6zkD3RvovjlixAhrwoQJViqVsu2z8cYbW5tvvnnedqdOnSrbfeqpp7rKtt56a20/riybOXPmyPv3wIEDrZ9++qng76S+0ndwqN9Nz4MBAwbI8nQ63VU+c+ZMKxgMynt2hrFjx1per9d65513usponOgZsuuuu+btJwCVClyGckA+nhmyxZ00u0CREWh5lmZQaaaBlnNp6dOE7GVVgtqgZVQnNx7T/Wn2/LXXXpNuLjSrm4Ei6FBZd6FZ3mwoWsaPP/5oG69M+ddff63t7zR+hfSfXFBoxoncZFTfeppForYyLk0cGYE5zYJnE41G5UwULc/TjF82NLNFKx/UNrm7kJsSuQtk//3YY4+Vs3kkNuzJY5+Pnmy7mN/mBAkHaQaTjhe5UWQTiUS0+iS0pONLEVoAWFHIzHBnQhjTCgHdH8m1j+5nFEGMBMl0XZGri2lMj+zrm55HFC2Prm1aGVBXGejeR7PlS5YskZ8pQAGJnimqEblUkmsOuesRps8tjvb2drlCQauptGq4yiqrdP2tN76TVnTpN1E72asRK620knQdopUTEnpnGDVqlLwPZqBx2myzzdjnEgAALkM5UV8EiSuuuEIuc5JvIy2R0ksL3WTIfYZujk5QXfXFNfPCRe4dFL6uu/tT5Aq6KXL9z7jkFAt9P918s8lEyFDbpnK6eVNfMkvSJuNXSP/pxZRe3mkJmvxqMw9X+jfjZ6/6xWdDL/aZh3fm/5l2af+MMJCDXGJoOZtclFQyZRQVqKeOfT56uu1Cf5sJ9BJDrlbk0kSiTHp5oGNGRl7G/Ug1Suihn31cACh16OXY5/PZJhko+tAf/vAHuWXuceTCQvdCMhLo/07Qy2025L5IXHfddeKee+6R97zM/Y+u+cx9rL6+XrrJTJ48WbpdkgsNGeD0wk6Giclzi4Pu0eTiSGLpRx55xBZZifrRG99J7lNErvsS3TPomZMJEME9Q+i+mBkfAIAdaAgY6AVWjUlPN0B6oaUZcroJZ0OzmDT76QS94OTyGTWZKTLZP9MPErepcGWFkO/7ufjb2f0yHb9C+p95WaQY4DRzzcE9PDJkhH70EM82NjIrE6pQjfturp+04pH9W3ri2OfDtG3qMxlQKqSTKPa3mbZJkECfNCWkOSAjjmYVSYNCep1srUNmf3qw5zqvACg16D5Gfu+klcoXFpjuO7RS8NRTT4nHH3/cyCBQny+Za5S0NxtssEHOe9+HH34oV1zp+7JXGUgj1B1OPvlkqY34+9//rgUV6K3vNLkvZU8gOD2TAAB28LQ1JOO+QGLIbGj5k2JJZ4Sf/Q09bIYNGyZvyiofffSRKPXxK6T/gwcPlrPfNFtWTNhYioFN0CxXtkFAfaF2yd2IHh7cw536Sasl7777rvY3KqMXdBLGFutyQAZUT78M028k44dm6LLdsdSxLuS3mbaZbWzTuNNGUUBI8EcvDqpBQLOL2bOOAJQ6//jHP6RxTKtgGf7zn/+wUbjoOqD7SrGJtyjoAl2HNCOe796XcYtU77sUsa1YSOBMUd3I9ZPCrXbnO+l+l+3mk49M8Ae6B9FEQga6R9MEAwV64NwPAQBmQENgCM3QUNQGmtHJzEbQjY9CMWb7TpYCZ5xxhpyhoagKGcgnftq0aSvE+BXS/yuvvFL6y9Lye8Zvl6AH5aWXXsrOVGegl9Kamhq5vwr591LkI3pppaXuzIOH+kSRMQiKPvTxxx+L66+/vms/MiIo/CAtk5NhUygZ1wA1vGdPQKH/KEIRPdAz0O8h1wIV099m2iYZfTSbmDn2mRUYck9S3SHIHYn8o7Mf+gCUKnSvoVUuCtFLUciydVZURitjpCPIQK4tFD2N3BIpGk8x0Ow/uVpSdB4KR5oNRf6iqGOZyEf00k1uRZnoZBSF7Nlnny3qe2lVg9yAKGQ06SI4CvlOuvZpQsfEMCJXQxrLf/3rX7YJB5pMoIklioYGACgeGAQFQDc4CqNIvokU2o2Waim8YzEvfr0JvWRTfH+KxUwPDprtfuGFF7pmrvrLDcN0/ArpP72Q0gORxKrkYkJt0j40W0QPXPLfzQW5hdHLLcX6VpeRSVBM5TTDR36n1C7FDKcQtBkfVdJBkEFCLwM0o04PLHpQUpsU8rQYaDaRvovEcGSwZOch6C405hQf/OKLL5ZjSoYYPagzYr9sTH+baZs0hvTiRHVoGz9+vGyXzgU6L7Kh+OxkQJIgEYBSIxNjnwSqdF1QqEsSvJIhTELa7BVFelmlex7dj0hgTNc2rWzSxAiF4SQ9VbFQyF66Pim0MrVJq3a0ukn3royRTfdWCu1MOT3oWqPwyTSzn+tl3sQgoJd80kGQqyaXh6CQ76TJH5rIoXq06pGdh4CDQjKT2yF9N7VLq5k0DtS2GvACAFAYLgo1JCoUimFMEQfoBSXjskKz0CREzZUhlWYyyBeSbmJ0g6eZEPIbpWHMxH2mGU6aEaU2Mg+HXO1m+kD70mx1T+yf/TeazaYXL7px0oz5+eefL2fmM2JgjkK+n+pRfXXZmmZ4aVPLTcav2P5TX+bPny8fjiSyzefHm4Fm7uhhTREq6IVXhfpFdcjdabXVVmMNDDI8KHcC/TY6l9TjUMyxo+8kVxx6+NKMG70g99R5QeNHs3Lk7kPnPY0ZfR+9UFA0oEJ+W6FtknsAGTgUAYmOUXYUI4J+Lx0PEhpnr04A0N/QOU7XQga6d9H1QBMQquZMhe53dN7TChnFx6cXdpP7E13btB9NDuSayKFriiJ3kdsetcslO6NVCapDbn2ZIAPkZkPXa8ZdMpN0MTtDsFpG1zRd2xzUdraeweQ7M/cYWtWgeywJsjOuQVx/slcXqS90b6HnCO2XDbmBUtnaa69tK6exJGOGjDkAgJ2KNggqDXrJopfx7oq7yq3/JJAjfUKhoTRBz3P//ffLWVMyBFVjAQAAAAC9A1yGyhCacaEl1IzvO0FuHq+++qp8+S11+rr/5G977bXXdoUqBf0HrSiQcQZjAAAAAOg7EGWoDKGlUhKlkusMLc3SsjO5aJC/6YpgEPR1/8kNqJgoRaDnoeV/AAAAAPQtcBkqYygEHvlZko8pvWip/uGlzorefwAAAACAFQEYBAAAAAAAAFQw0BAAAAAAAABQwcAgAAAAAAAAoIIpSVExxSKnTKck9jSJ1QwAAIVCEZcp9jkllnK7y29uBPdRAEBvU+730UqiJA0CMgYoaREAAPQ2lPCNkt+VG7iPAgD6inK9j1YSJWkQZLLBXvzk+yJYFclZz82sHvjcelnQa7da/R7dig37PFpZld8+PBG/2X4Br70PYeX7uT7Jfomk7bM71qbVccXatTJ3XC8T8U7bx3RHq1bFYsrS0Q57najethWP6WXJhNJQShSN2z6mLq89C6WEKXMHw/b9lM/L61RpZa6w/RxzhfRsxGm/vp8VYMr89u9MevTISJ3JNFNmzw8YZep0JPSy9rh9nNsS+rh3MGUxpf04830JiylLFZfH0ORa5a7dgFe/vkI++7UTUa5TosrgWm1vaxU7jF+bzT5dDmR+1ztH7SkifuYa+o260UMc2/KE80f4Cg51nsBJtSxxrOPyO0cScyvXazHtuMPOx9xd3+RYx2LuC3pnnGdNU5GBef8e84Yc21gStT8/OH5aYn8ucPy4mHmeKHw7uyXv3z3Mtazy1S/O50OsU3muMKSVeydHkrkHZjPn6ymObfhCfJb2QgkPGOxYx3J4frbN/9WxDZfH71gnybxPqKw0dqO8f0/FOsQPtx5VtvfRSqIkDYKMmxAZA6Gq6sJeMjzOBkHA1CAIqAaBx2i/oGoQKC8wRMjEIIjqv8Xl18vccebmG7O3n3brNxjLpZelPfabq+XWXwot5ffJsqSn/w2CUMjZIAjpZa5wVV4DIbdBEHE0EjiDwMu8fHsTlmMdN2MQiLj9nEkrBoKEeRh6lPbVz7IPaaasJw0C5VrljXlng6CKMQgiAbNrlShXt8TM7yJjoDqPQVAdDDi25Q3lrxOscn5hTSWDPWMQGHyXo0FQpd8HtDrVzi/7lj/SMwaBw8tUzOvc34TP2SCoijs/8oNR5/76w8y9qECDwBvUJ5ZUkmlng8Dlyd8XwmKef7Y2vM7XgNvXM2Gv3cqkUTEGgctrcJ14nQ0CV8r5nPEEnPtbzvfRSgIOXwAAAAAAAFQwJblCkGHsoBpRlWfmxMNYpJymxacUepnZC2bRQKvHzWBybfmVmU/1s+wna0zbO2EZzFoQKWYmwOVTZsuZ5U53XVwr86SVGYOkPoNgJfX9hDqb3IMrBNxBdal15G+2j4Pl0WdFLbd+yqvjbDHjaTHLr9zx4dpX8XMHX+mqh/nNqisaUa24xjQyk2XJtOVYlmBWA5gikbKKWyEwuVa5Oj7mwlSHz/S6VOu5mBU/AAAAoBIpaYMAAAAAAACAQolGoyIeZyYwHfD7/SIY7BkXsRUJGAQAAAAAAKCsjIGGUER0iMK9FQYPHix+/fXXijMKYBAAAAAAAICygVYGyBg4XAwT/gLksnGRFvfNmy33h0EAAACg16AHzR133CHuv/9+MW3aNJlz5cgjjxQnnHCCrd7nn38uzj77bDF16lQ5Y3XaaaeJI444ouDvm/HWL6LKk1svEZw8y7GNupG1ef9eNXi6YxveKufZtrkf/+JYZ5U9xzvWCQ0ZlPfvi99737GNpj33dawj6s0isDhhebo/N5dgtEIqjWHnyDNDIs6RZ750+K65y6KiJ0gz0c9UWgxCqSaZ8NnZ+MP5z28i2rzQsU5KCffNkeY0eAUSa3UO2WqCUzQjYs7UL/O3keiZY91bhFwe4TeI9JXBY7mEKE4qt8JT0isEK9cHRHVNYUs2BtHOhKvIUIlcVC23QfgtRlNsJipmwpxxglfBxIvXwokxdViUei5TEalp+z0Fc4FbJmHPuBuDWsYIei2T/eS+Xsfzgxe128uYqJkyI6SKOurc4UozhSZH1eD9okfhrglXkSFNTa5VlxKat6+45557xPTp08V1110nE/m888474uijjxaJREKcfPLJss6sWbPE9ttvLw4//HBx2223iXfffVf84Q9/EFVVVWK//fbrl34DAMCKBj1XuHewnPWF4QOyDClpgwAAAMqN4447zvZ54sSJ4oEHHhBvvvlml0Fw0003yUQ/ZDTQBMPKK68s3nrrLXH55ZfDIAAAAEMoeh0XwS5nfVG5+RSQhwAAAPqRKVOmiA8//FDsuuuuXWW0IrDddtvZVht33HFH8cUXX4i2Nj2DOQAAAB1aHSh0q1SwQgAAAP1AY2OjaG9vF7FYTJx33nm2lYPZs2eLTTfd1Fa/qalJuo3NmzdPjBkzRmuP2qEtQ0tLSy//AgAAKG2wQmAODAIAAOgHfv75Z2kQkCvQH//4R9HQ0CBOOeWUrr+71YSKXm9OLQlxxRVXiIsuuqiXew0AACsOhc76e0TlUtIGQV1iiaiJJwrbyURNbigQ1USqpvtpESO4/ZiMucrnFLNfmimzGAVMyu0rSliqCklNNcVc+z0FJxrlUKuZilRNxONspl2ufWUYuBuRR5MCy9ATSjtcHZP9mOPAtWUiAi8BobhJGSsmN9gvnurf6Bi1tbVyO/jgg8U333wjrr766i6DgFYDFi1aZKu/YMEC+e/AgQPZ9iZNmiROP/102woBRTACAIBKBSsEZWIQAABAJUCz/xRlKMPGG28sXnrpJVudt99+W6y++uqirq6ObSMQCMgNAADAclwFimVdFTxwEBUDAEAfcs4550jRMPn7p1Ip8dprr4kbbrhBrhRkoJwElKOAVg0obwEZA/fee6/NpQgAAIDZCkEhW6WCFQIAAOhD9t9/fykifv/99+XL/rBhw8Rf/vIXccYZZ3TVWWONNcQTTzwhw5CSARGJRMRZZ52lJS8zIdGREHF3bvevdMrZ3c/jy+9Z27HIOSHT8K3XdKxTs1KNY5222c4JopyoXWWYY5106zLHOh6/QZ6cULVjFZdDcqeUz+4CymHitdkad05EtajD2U034M0/l9gec05uloileiTpmMW5UirEWpfm/Xu8o7lPEooRHYtn90iCMyfcXM6iIhKTObWT7mvX0gKBhqBMDALXzG+EK5InEySTQMrlZh5cXvvN1MWd4D69zPIoZepnmShMv1FbXvtDwvLqy/hc0rG0oivgMk/GU8WVJZm2uPYTyn4J5mar1pF9V55GBu8YOVH97jkNgY9xzvepIkzGyZ8r8ymnkd+jn1d+5vuMyrikcczD35WMKZ+ZFwTmgaTtl2LqMDf9dEx54DDH2eIegAYP36KvVebadTHXl/AHnK9Ltszelrujf8J3jh8/Xrz44osinU7LLSMWVtl9993l1tnZKYLBoJbwEAAAgIlBUEgegsqlpA0CAAAoVyiKkBpJiCMUCvVJfwAAoNzACoE5MAgAAAAAAEDZgShD5kBUDAAAAAAAQAWDFQIAAAAAAFB2uAtMTOYWlUtJGwTRbz8X/lDuuNouRvzpYsTBLiUShDtUpdcJ6uJld5U94oUrrEeLcIf1qBhpv3OEBYsRNlsuZ7FwZ1IXdXYm9HodCbuQtDmq96k1rpd1JuztR5O6IDWWchYax5k6pngU4a8qFiaCTJQLNfJFmImMEvHrZdV++2UQCeh1Qsz3pdUDJpcnVWGzVkW4UnoUD1eiw/bZHW3Vd+zUy9Id9rJUe4tWx4q26/t12susOCdi1vuZLjRRYAHXKicgdgX1a1W9ftlrt7peLwtH7J+VMQAAAFBewGWoTAwCAAAAAAAAigGiYnNgEAAAAAAAgLIDBoE5MAgAAKCM+XZxpwgqrmzZ+JncHCort9jzXaiEG51Do057dapjHQ/j0qfVCTonXOrw5U9EFRzU6NhGmnG1U/EyLqoqlkniJre3237NXI4VlTaDhGFcjheVDocEZ2nGtVUlqbi1cgwbM8Cxzo+ffC26S6LdOTEZm+OoiERfSYPzyuUQjtgk6ZgJ3qDdjZIj1rok798tJRdOqQGXIXNgEAAAAAAAgLKDzLhCRMWebiRVXdEpaYNgwRc/ik7/cqGhmxMlMla0J6Rbzr6wffbKX6OLEP3Vepmnzj474alt0OvU6yJLt6IzTjPWvCulz9SkXX7H7MKcgJgTDC/qsGeZXRrV+7mkUy9b3Gbfr1lpR/aBmR1SZ4zizAxRivk9qoCYKwsxs4Zhpqw2bB+/urAuUm1Q6hD1IXu9xjSTHVeps7yfjNhZ/Ync7CCXTTimiHzbl2l10ksX6k01L7bXabF/JmLLdDFyvMUuYk60d+ptR/V+phPMeWsgIPf49FuNWxFz+6r0WWZflZ7RO1Bnn9Xy1tTqbStia9mH+ibb53SbfQwAAACUF26Xq6BMxe4Kzghf0gYBAAAAAAAAfaIhcFXuOMMgAAAAAAAAZUfBGgJX5VoEMAgAAAAAAEDZgRWCMjEIFn01R3R6vbn1Aowfua9K9/X2R3x5fZCJUIOeYCzUZE/WFGQSNQkm8oBXSYTm8un6BBHQ/a4ty+6AnmB87qOMb/4SRh+woN3u/z23RU88NXeZ7je+QIkmskRph4gx2oOkoiFIKgnOCIv5PS5OQ+C1l3mZBGN+xqe/RilrqtGT2rXW6X7qiTrdT12FTY7GrC2mlWPIaQhcnIYgGc2rDSCSi+dqZfGFC2yfOxfo2oPoYj2CRmxZm/0zc37E2/XjnGL0IxaTQM/kWvWG7LcfP3ft1ujHK6hcq+GBuhYgxF2rCilGNwEAAKB8wApBmRgEAAAAAAAAFAOJhAsRCrvhMgQAAAAAAED54PK4WE+EnPVdhWsIYrGY+PDDD0VnZ6fYeOONxYABufNntLS0iFdeeYX924YbbihGjRol///aa6+JZcvsK/6jR48W48ePF70FVggAAKCMWRRLiYArt1vXwIBzwqXpjPtYNsMZd0AVLnS0VsfnXMc31zmJFBdaOpv4Et21TiXEhJlWSS6Y5VjHM2i46C4GXnlsmGqVSMD5kZ9u1d0HTcI+ZxMw+B6Pwfkwb5rzcXJ7mTDRCvFOu4tkMZgkJksndXdQFW+wqtvt+MJ6qGUVl8fTI7/JKWlbqScmc3tcwu3uvRWCb7/9Vuyyyy4iFAqJ+vp6MXXqVHHXXXeJ/fffn63f3NwsHn74YVvZzJkzxSeffCJeeumlLoPgzDPPFOl0Wqy22mpd9XbYYQcYBAAAAAAAABSEx+2Y+dmGq7DMZEcccYRYf/31xZNPPincbrf4+9//Lo466iixzTbbiIEDB2r1hw8fLh577DFb2XHHHSfmzp0rdtxxR1v54YcfLg2DvqKkVwgW/bhUdPxmwXIWHidU5ISJwXq7aDTcGDdLwqQkXOJOqqAiIJb7VdlFj66qeq0OJzZVS5LMtFAHk+69hUlMtlhJKDZriS68nLVEF1UuU8Slna36uESZZGXxmCIq5sSnaUZgy4nFvfYyHzODGWBExdGI3zGBGjeLpiZCCzAzV7XMjFcirZcZ3UqYpHRW1H580kxirdQyPYW8KiJum60nL+tY0KKVtS+wJ0KLtTDHuVmf+UkzYvGkck6aXqveoH38AjV60rhwoz7Tm2i3n6PpuD6eHGFlJjHd4TwTCgAAYMWF3IXIbci4vjCv+/3334tPP/1UvPnmm9IYIP70pz+JCy+8UBoI9KLvRHt7u1wxOP3007vayF45ePbZZ8WwYcPEOuusI/x+/RlZMQYBAAAAAAAARbsMFWAQuH8zCMjXP5tAICC3bP773//Kf9dbb72usqqqKrHyyit3/c2J//znP9IoOProo7W/PfHEE9Lo+Prrr+V333///WKTTTYRJWEQkBX08ssv28rIb+qCCy6wlbW1tYlHH31UTJ8+Xay66qrigAMO6HXLBgAAAAAAgGwvhEJchly/hQ4n155s6D2XZv5VPQBRV1dnK29oaNAEwbm48847xc477yxGjBhhK7/sssvErrvuKlcN4vG4OPLII6Uu4bvvvpNGR29QgGOVEO+//7548MEH5Y/PbLW1dnHL4sWLpVL6pptuEtFoVFx++eViiy22EB0dussKAAAAAAAAvblCUMiWcdehF/7MNmnSJKGSWTGg6ELqpHgw6JzfiGb/33vvPXHsscdqf9t99927XIhoQp2MkVmzZonJkyeLknEZGjp0qDjnnHNy/p2smlQqJd555x0RDofFX/7yF6mSvvHGG8VZZ53V3f4CAAAAAADQa9TU1MgtH+QaRMyYMUOsueaaXQlm6cX997//vdHqwODBg8Wee+7pWDcz+b5ggT0Zab8aBAsXLpQv/WT9TJgwQWy55Zaaz9PBBx8sjYHM0gn9WCov1CCY0ZkQQVe6K9ucir9TFxPWMOHx1LIks5+lCIgJt88+PL4qPWuqf0C73lbMbi26ucy0akZbeSI5h5rjRMWtjKhycVs8bwZiTkAs21KExh1KO0RU8a0jElF7WLdUTG/bSqeMwp65fXb3Mh8Tpi0eqXUUt1oGAmKiWhG3NlT5jcY9xRxD9StdXKZiZhw0UXF7i2N2YS4LMScgbp2r79c+3/59Lc368WpmBMRRZky5cVAJMULtoCJYr2fO0SQjmFczI3uU65TwVemzM2qG8lRnaYfLAwAA0AN5CHpJVDxhwgT5jktRg84//3xZ9tZbb8mXdnL3yfDqq6/K3ATZOQSSyaS47777pHbA67U/w8jdiN6hs13tn3rqKZkjYdy4caJkDAKymEhsQRYR+VTttdde4oEHHpAdpeQMpBsYM2aMbR/6TErpXNB+tGVQxRwAAAAAAAAUbhAUoCEQ+kRYLnw+n7j66qtlNKFEIiEaGxvFlVdeKf39N9hgg656p512mhQD33HHHV1lzz33nDQcjjnmGK1deo8+7LDDpGZg5MiR0k3olltuEeeee65MTlYSBgHN/P/1r3/tyuR2wgknSAtpt912E4ceemiXTkBdZqGlDlJR5+KKK64QF110UXG/AAAAQE5aU2mRby0kZDB7xq0WZTNUWWXjUMM4s31p0FdhVYK19kgfHNyKbzZGLwgGSZtcymomW8dgBc2V0ENAZ+MN5U+0RhQwCdrtxEzcSms2fiV0NAcXTroYApHcWWHzrU4X8veeJBXPf6yJQK0evz4bK+Xc35TiqcDhDUWc+1Kdf3ytZFQ4f9OKF2XIFHr5J0HwI488IqZNmyYuvfRSmZsgm5122smWYCyjUaAcA6ussorW5tixY8Xzzz8v7rnnHpmxmMKOvv322zILcm9SkEGQ8ZfKQMkYaPmC9AJkEJDymYyFjPI6e/kjEsl94pFYg2KwZq8QqApvAAAAAAAATKF3UspFYFw/Xbhlvd1228ktF9dee61W9uc//zlvm/QOnHFD6iu6nYeABMQUTYggfycyGkg5nQ19zgguOLj4rgAAAAAAABSL2+OWm3F9q6Dgm2VFQQYBhUeiEKIZPvjgAzFlyhRx0kkndZWRz9NDDz0kzjvvPOk6ROmYyVfq4osvLrhz86MpEfgtjTQrKmasvk6f21H0yFmL3pA+FIEau5tTsEFfGEsz4VStuCLQZISlJjD6TZFgCmNJvf1mJZtwc6cuto62O5dxAuJo8yKtLN7R7Lhcabos6wnY3QaSQX11Kc0KlBtsn70+fUm6I6T/5mUd9rK2mC5kjTIuCNzxMfAOYM8HKxHPfw4xGXqJeKv9/OtcqtfpWKQfC1VEvIjJ6sy5ibQx51qxouKI4lbAtdPI9F3NcOxjspMH6vTrMqFkJk5AVAwAAGVNwaJiq4d878rdILj++utlGFESSyxatEg8/fTTUiFN4ocMFJL0pZdekr5OW221lUxkRm5Fxx9/fG/0HwAAAAAAAA0YBL1kEFD2YVoR+Pjjj2VIJIoytNZaa2kCYvo7RRWiSEQUcpTCL3k8PSMgAgAAAAAAwAm4DPWihoBWB7LDKXGQlsAkKQMAAAAAAAC9QoEuQwIuQ6VJSzItMkHduOPJ+SVz9VStQUTxGc+VrCyhlKWiepKuVELfT6TTPaIhsAST+IpxXI8z/u2qriDJ+IgnGF/5uFIW72hx1AvIttqbHUOrmSYmSyf1cVZxe/VwfwlFa5CIeR2TlxGdythwmgxu3FNMWdpERMCdD+rYJPVzNJ1gzlvlnOQSeSUYrYiqD+D0As3MWPEaAuFI3CChGXuNM/0KqYkGmd/MXZdpJYGfxV27AAAAygYKo+suIMqQ2yDsbrnS7ShDAAAAzFm6dKm44YYbxIsvvigWL14s3S4p9HJ2jGmKPU2hnFU+/fTTgkMy13ndIuDKHTmDC86gMjCQ/1GxhJlwUKkxsBxjTLbqYvAE8+cHMIk6klq6wLGOd9AIxzppn3NuBcvjy/93g4mG4qaddLgAHoUSZyYOVELVzpEFAyHnCPdJxdDnCNYMLHiCSSXGBNMoNGZ/T+U88FXVOtbx+PVs7SqBSL1jnda5v+T9e7rEX6Ap50hBicnSiDIEAACgD6D405S9/R//+IdMZ3/bbbeJrbfeWkZty6Slp1DOlL+FEt1kM3Bg/hcbAAAA3UhMli5tA6c3wQoBAAD0Iffdd59wu/83C3X11VfLrJQPPvhgl0GQYfDgwTg2AADQV1GG0pVrEFTu2ggAAPQD2cZAhlgsJoMxZJNIJMTaa68tVl99dRmkgSK8AQAAKNxlqJCtUinpFQLyQkznSQLl4QS2rHgx/2fCYvZTyyxGvMuWmfgIFik05pI3ceJWtYwTxXJlliKItlIpo9+nlvF10kZlqtDYtA+qGDmdDut1uBPJSCwseheDcU+z5596jjJ955KqmZxXzG/mykzO5GKvS65faYPfzF2Xpcott9wiZs6cKSZOnNhV5vV6xZlnnikOPPBAaUDcdNNNYsKECTKks7qKkG1U0JahhUkqCAAAlQS9ThTmMiQqlpI2CAAAoJx59dVXxSmnnCKuvfZaMXbs2K7ynXfeWeyyyy5dn2+99VYxdepUcdlll4nHH3+cbeuKK64QF110UZ/0GwAAVgRcbpfcCqlfqVTu2ggAAPQjb7zxhth7773FxRdfLIXG2biYyB20QvDtt9/mbI8iFTU3N3dttOoAAACVDK2wZpKTGW3uyn0txgoBAAD0MW+++abM4n7++eeLs88+22ifn376STQ2Nub8eyAQkBsAAIAiRcUerBAAAADoA9555x2xxx57iPPOO0/O6nNccMEF4qOPPhKpVEokk0lx8803y0hExx57LI4RAAAYAlFxmawQUKoWnyt3spQQY8lxSXbUMs4A5PzGPD770pHbrw8Xp0jnsu8ylUQxcOPgcTuXcZn6PF4m07PX3ne3T0+Y42GSuKSUMhPhsWmSGLfPb5RIRi3jfh87Dur5wdQxdSssOsuhskzp8niMkimpsxnc7IbHr7flU7L7mlw3uco44a9JVnG1LdPv8/jdeT/nvC6VMlc/LQ2fc845orOzU1x33XVyy7DrrruKu+++W/5/t912k8bCZ599JuLxuBg5cqT497//LQ455JCi7hn5kk3NZLK0qwxgzqFsGh0SlxGBGufVi8gQe7ZxjuCAGsc6/pqqvH/31jgndnJXOX+PyyD5k+V2HhtXMn9CtrSv2rENv8HMZsJAbJ9gAj2orNyU/zi1MtnDVX6d2+pYp3Go8zEIG5xXHQ4J71Jx5wRokUGjHOvE2pY41ulcOt+xTrXDdyUN+muSdMzrcF0TA8esnvfvqViHcP7VYEWgpA0CAAAoN5577jn5kq8SDP7v5ZKyFpPGIBM1CK5AAABQODTxU8jkjwsaAgAAAH0BZSc2BYYAAAAUT0YsXEj9SgUrBAAAAAAAoPwoNNmYBwZBSVLj84jAb772nHsk55dcq/j9y3YUX/JgdcDIv9Ufsfuke4O63zpXJry+HtELuIT+o33MQAQVv38irPgGBhgfX1/A41jmD+u+tulEwlE3wfk4phNxI72FNxCyfw7q/qp+xr/XH7b78/rY36yXhZSxUsdO7seca5zWwAjufFDHQT2H6PuC+jnqq7L/Zn+Vfj76q/S2ahW/8YRh4jXOp19NOsZdq9x+EeW6VK9Toiqs9139jb4qs+vS7bMfe1fSTNMCAABgBXYZKuAl3wWXIQAAAAAAAMoHaAjMgcsQAAAAAAAo07CjngLqp0SlAoMAAAAAAACUbR6CQuoXyieffCIeffRRGU562223Ffvtt1/e+v/3f/8nZsyYYSvbeuutxVFHHdWtdrtL5aonAAAAAABA2eJ2uwveCuGRRx4Rm2++uUwiOXToUHHiiSeKP/3pT3n3eeaZZ0Q0GhXbbLNN17b66qt3u92yXiEYEvKIkGv5Ug+XWCdoIFQkqhTBcLgxpLdVryeY8VWHbZ+9Vfp+7pCeAMelCkKZxDSWQQIrzlD1MSdrkPnNtWG7qHKAIpAmOjt1cXA66awu5ZKCxTualXbiZmJkLgGXMn4+RlQciOjjHlZ+o/qZqGUEqHWKcLWKSUAX4JKccQnuTHTGzPngCtjPP3dQ/33+mrBeppyj4UZdzJ2KOy+B+pZGtbIqRh3cmdLPj4SSmIy7VjMJBvNdq5yAONygX3Pq9RusDxslplIF2Ez8gbJktYhPhPMkS+QE3yq1A/Tj4JQcTsXHiNv1dpyX9lMJ56RXqah+/8nGxVxfKu5InfP3NI52bqd9sWMdy5s/uVa+xHIZggYzm4Mizkm8ljLPBZXaQP5jWb1qo+gJUkrAAo5fYs7ngxMrrTHMsU5nW/5ziojUDXKss2zhCMc6Mcdkgc5Jx4LM/VQlEHKuE6nLn3wvGfWJH0RlrhAkk0lxyimnyGSTl1xyiSzbcMMNxS677CJOOOEEsc466+Tcl+odeeSRPd5u2RoEAAAAAAAAlJpB8Nlnn4n58+eLiRMndpXtuOOOorGxUSagzPfi/vLLL4sff/xRDBs2TOyxxx5i3LhxPdJud6iQOTIAAAAAAFBJuFzLMxUbb67lr8UtLS22LZM1PpuffvpJ/jtq1KiuMnI5Gj58uPj5559z9ikcDouVV15ZrLvuumL69Olik002Eddff3232+0uWCEAAAAAAABlR7ErBMOHD7eVX3DBBeLCCy+0lZHYl6iqsrsgVldXd/2N46mnnhJNTU1dn8eOHSvOOusssf/++4shQ4YU3W53wQoBAAAAAAAoW4OgkI2YOXOmaG5u7tomTZokVGpqlidIXbZsma18yZIlorZWT+qaIdsYIMgQiMfjYsqUKd1qt7uU9ArByPpQXjGcL6R33xv0OgqGQ4yAONykZ74NN9mFO6EGvY6byZjrUoTGFiMi5bLVqroxTkgW9unjEWGEeA2KoLYtqovJ4sm0VrZYERi6vS4jIVI8FnAUJ6cV8WkuPIqYlcsuzPUhqIgW62r04zyEEUg1KfWqmQzO3Lj7GDGmWmQxx5kTlLv8QcfzKlBXrZWFmuxi4LSB4JITb3KZukMt+hJpKq6fM+lUuuDv467fQI0u+A7W62LWqia7yDw0UBd/Brlrtdpez+3RhdQAAADKB7fHLbdC6mdeyjMv5rkglx/i66+/FltssYX8P83g//LLLzIqkCkdHR3yX+u3d6SeardQsEIAAAAAAADKDpfbVZiGwG0SKnA5a621lnT3yfb/v/322+WL/T777NNVdt5554m7775b/p80AF988UXX3yis6OWXXy6Nj8zLv2m7FbVCAAAAAAAAQCkmJrvnnntkONDx48eLhoYG8d5774mbb75ZagGyNQMkHKbEY4FAQBx++OEytOjIkSPFl19+KWf/H3vsMZs7kEm7PQ0MAgAAAAAAUHb0tkGw/vrry6hAb775pnyxp5UACiWazWWXXSYGDhwo/7/SSiuJ999/X+oFKOwo5Rugl/5gMFhwuxVlEAxYdYCI+JZ30c0kSXJzft2MrsBfE8qbzIkINuhCjXCT3efYO6DRKHmNO2z3O0t7mOQfjG+5WsKdl2HGF7uW0U3EUro/toqfSbYVUdpawiTyamcSwSSUshSjT7CYJDPc8pxH6ZeXOc4hRldQqyRiaWL84ofU6T7pTUqynvqgfryqmXH3Mn03WmxkEru5QpG8/u6yrLNdK6tK2ZOOcb6SXiUhFxGoa7N9jrcu92HMJskkx0lG9bK0kqyMu1a5m6yq9/Ez51qgjklKV1/tqBcINi2/+WbjqW2wf/b2XrSGUqKxISyqmASA+bQbJsc9m0Hr2kVyHCYJ8qqHDXCsox5/jsiw/ImxLCZxopMWjK3TttCxjvD4i0pWWChtBuMbZjRhKmsM1K85lZ+X6PeLbJZ0OuvFRjY6j68J1czzT6V1pfxCzOYO52RsrcwzR+sLcw8rJhlYzCE5HKerU5mwiv1+xzF9kf5MMXlPyCbeYaYN7O+wo4XUL5RIJCL23HPPnH/fe++9tbINNthAbt1pt6IMAgAAAAAAAIrB5fEId54JEa5+pQJRMQAAAAAAABUMVggAAAAAAEDZ0dsagnICBgEAAAAAACg7YBCUiUHQtP5wUR3ILdLx/CY4zsbt0wU7PkVUyYksfbW6MNFTXe8sIK7XxYsiEHYUkQpG5OJSElb5GdFqgBFsNoadhUw+5vuqGKGsmqRrGSO26ozrAsNORdAWY0TFpqhi3RDTz5BfP/Z1iqi4hhEHcwLsWkWgxY1nyOc2SkzmUcu4xGSMyNDy2c8Zd40uCPMoAmLZvHK+VzFCyOAA/dxWRcSJdj1JVyrKJCYzTHym9ZM5/zx+X97rlPCG9TJ/TZXzdcmIsj31duGrx+csqAMAALDikskvUEj9SqWkDQIAAAAAAACKASsEfWAQ/PDDD+Kmm26SsVKPPPJI29+am5vFAw88IKZPny5WXXVVccghh4hQSA/3CAAAAAAAQK9lKi5EQ+A2z1RcbhS1NhKLxcTEiRPlS/9zzz1n+9uCBQvEuHHjxP333y8TLdxwww1is802E+3tWJ4HAAAAAAB96zJUyFapFLVCcOaZZ4oJEyawWdMuvfRS4fV6ZXY1StF86qmnitVWW01cf/31YtKkST3RZwAAAIZsdukhoiace4X2u7ufdWwjMiR/YqfIMEZLpeANOWud3IwuTMUTNEi6OHzl/N/D6E60OnX5k5tJLGetVCpc3+12LMs5+VNd0Dl+ekfCuZ20cP5NqwzQk3tm06jouTgGGCToSqTTPZKYLO6gaUsySTOLQdXS8X1xruPE5J+XdDuhGLHGUF1fVuj4Rts94mFRurjcHrkVUr9SKdggePrpp8Wrr74qJk+eLA444AD274cddpg0Boj6+nqZae2pp54q2CBoWG8NUcOICrtgDpzLq99kXD77A8QV1G9mLr/+Pe6qmryfibQv5FhmefW2LS5TsbJS5WMExCHmIle0yL/ta//OiF8XgzYk9LHqSDiLg2MpvSyhZKtNGTzAcuFRfhA3DpxIOqCMTZgRAoeZrMcRRaAcZMY45NP74OeyZ6sFrKhYH3fLH3J8QfB4GRG9ck5a9fpKnIfJcOyL2susRMIsm2vSOaMnC3eTVY6hOxAyui7VMleVnrnWHWSyoCplLpeeyRoAAEAZQc+eQl7y3TAIjJg5c6Y4/vjjpZtQOBxmXYlmzJghVlllFVs5fSaDIBe0H20ZWlpazA8eAAAAAAAA3MRTIW5A7sp1GTL+5alUSoqDTznlFLHhhhuydTo6locyrK62z9jV1NR0/Y3jiiuuELW1tV3b8OHDzX8BAAAAAAAACi6Pp+CtUjE2CF5++WXx8ccfi7lz50pdAG3ffPON+PLLL+X/lyxZIqqqqmQs/WXLltn2Xbp0qWYkZEOuRBSZKLPRSgQAAAAAAADddhkqZKtQjDUEq6++urjyyittZRRK1OfziVGjRkkhsd/vF2PGjBHfffedrR59XmuttXK2TXqDjOYAAAAAAACAnnEZKkRD4K7YQTc2CEgHQCsB2bz22msytGh2OQmN7733XvHXv/5VCopptp80B+QWVCiBNcaLQIQRB+YTFTPLPZaqunXrP9tiyoSSUTbFCJa5rLPqfpyIlOuDKlHlMhULpikvcwKrASeqGIEtJ/xVoy1wwRe4spS2X/GiYrdyvLTsv8xYLa+nfGbU1owOWHiVQi4DsZo9OZeoWO8rMw5M5mpVQuzizg9VeEwE7dFfXCld9OtNM9mFlTJXWo98YTGZkQVTzwiDa1W7Tk2vVaZOmhNuK+OeSjtHqwEAALDigkzF5vS4KXTOOeeIoUOHio022kgcccQRYtNNN5V5CI477rie/ioAAAAAAAB4XAW6C7ngMlQU9JLvUWb5IpGIeP/996XmgCIOUQjS7bffXmoLAACg0mltbRU333yzePHFF8XixYulO+XZZ58tNthgA83V8rzzzhNTp04VgwcPliux++yzT8Hft+izL0UskHs1ZMR263U7P4DXIDeAb5Cet0YluWiucztDRzvW8TQMzvt3LoS0SqqqQfQVlj9/XP+AwfMz4DZZlXVuJ8SEZlaZ0cyEJM6iNuicY6DeIA9Bc5RZ3VRoqnI+9xa05+9vwCCTbXPMuS9q+G0OLhy2ypSZdh2myh+3G+PYhhqGm4NbDS+UjoBzroh+BWFHezcxWYa99tqLb9TrFbvvvnt3mgYAgLKEQjePGDFCXHDBBWLAgAHi9ttvF5tvvrn48MMPxdixY7syvm+55ZbyPvrggw+Kd999V+y3337imWeeEbvttlt//wQAAFghgMtQHxkEvU1y0OoiWZM7OhELkwiqp4QkXDIxo+8z3M9t4LfOlVleZz9/k0yXcj9RHN2QDBSFyYKT6Zmgrl6ZTppw1fR93cXpTrgZQ4OsqC6ujkG2TxaD7+tRTK/dHrpW0ynnGcre4L777pOTJhluuOEG8corr4j777+/yyC48cYbZZ0777xTrsKOGzdOfPLJJ+Liiy+GQQAAAKZghcCYypVTAwBAP5BtDGTnecl2v3zrrbfEDjvsYCvbZZddpFGQL6cLAAAAJsqQ8eau2OGr3F8OAAAlwF133SWmTZsm9t9//66yWbNmSd1ANvSZVvooFwwHZXunLO/ZGwAAVDJITGYODAIAAOgnaCXgT3/6k/jb3/4mxo8f31WeTqe1lQTK+ZJZTeBAxncAAADFAoMAAAD6gffee0/sscceMlTzmWeeaftbY2OjjECUzaJFi7r+xoGM7wAAwLkMFbgVQCKRkNHgKCnvsGHDxKGHHirmz5+fd5/nn39e3vuHDx8udWPnn3++aG9vt9WhoBJ1dXW27eSTTxYVKypekA6KzhSTjKm7YlOmjsdikl+5DPYzSH7F1XExIlxtP0sPc+bikkylko6CUC7xFCsaVcuYOqxwtTcFqIZiU01Iyu3HJaBT6llcVkMPkyDLoH2uToqxw7XEbkLvQyrNJZdT+sQmktMLLYNkc6UgFOeuOZdyqnGJ60yu1Y50/8WbptDMu+66qzjjjDNktCGVCRMmiLffflszIChBJEUm4kDGdwAA6FtR8Zlnnin+85//iIceekhO1px44oky8MOnn34q3Ixx8cEHH8iw01Rv3XXXFb/88os45phjxLfffisee+wxW3hqavukk06y3eN7E6wQAABAH0LhRckYOP3008VFF12UM8cL5SG444475Ocvv/xSZoA/4YQTcKwAAMAQl9tT8GbKsmXL5Mv95ZdfLrbeemux9tprS03Y5MmTxUsvvcTuQ8l6n3vuOWk00AoB7UfR45588knR1tZmqxsMBm0rBKFQYRPkZbVCAAAA5QatCtCNn17wacuw0047idtuu03+n5aRKQwpLRFTfRIMkzFw2mmnFfx9Qw84RNREqrrV59h3n+f9e2C9zURP4FtjI8c6qXnTnBuqG5S/jYDBeBiE63VKKCbreJzD28at/HNzfoOA0EmD+b2Ixzm5VtzgtWBEbf5kYHGDBF0JbllSYbVAwrHOtKTzMdh8eP5EdO0J5/Fd0O48dqsHOx3rfNrsfD6MHZy/v0OrfT3S36Yq52PdHOM1SxnaAs7f06+4CnQDcpnX/fjjj6XLECXfzUCruKNHj5YrulzOGC5JLz0PKKJcRieW4ZprrhFXXXWVdEXae++9ZQJLMhJ6CxgEAADQhzz++OPyBV8lHLa/2Bx44IEy8hAlKauvr+/VBwEAAJQjhc76u36rq0Zp41wyZ8+eLf8dNMg+AdHU1CTmzJlj9H1Lly6VKwx0v89uf8MNNxS///3vpVvRlClTxJ///GfxxRdfyJWE3gIGAQAA9CFDhgwxrkuzRoXUBwAAwOQhMMW9fIWA3HmyIa3XhRdeyO6izvpThDiTZLCdnZ1in332EdXV1TJBZTYZd1FipZVWksbCzjvvLL7++mvpmlRxBsH3iztEVSz3gXSzgl69zKeodX3M8lHAy5UpB5lRKvo9TJlSL8i0ze2nCYETUa2KK6nPLLqSTL1EzLlOSl+OTcfsS55WXN9PJPX9LDUUIidiNkW5eCmOsIZXXzJ1e+3L2C4/M6Pq05e6La+9nuXVhTuWj2nLoF7a7TdaKo8lLccl93hKX9aOK20lmZXvaFI/Fgmlfa5PCeb7OIGyCSbXqnqdml6r/H7O12prvBvnKAAAgNKn0MhB7uV1Z86cKWpqavIKemklgKCIcNl5Y2hVl7QCTsbAXnvtJaPHvfnmm6K2tjZvfVoxIL7//vteMwggKgYAAAAAAGVHsYnJampqbBtnEGy00UYyktC7777bVUauQj/99JPYeOONc/YpGo1KTQC5HL3xxhtdhkU+KMgEYVK3WGAQAAAAAACA8g07WshmCGkHJk6cKN2Jpk+fLnUHFPhh1KhRYs899+yqt9lmm3XlECD92L777iuNAVoZUPUHGbEyRR7KZKWfOnWqDFNKwSacVh66AwwCAAAAAABQfvSiQUDceuutYoMNNhCrrbaaaGhoELNmzRIvvPCCbUWBDIWOjg75/9dee02GJP3111/F6quvbgsrSrkICHrxp4hDm2yyiWxn2223lblpXn31Vakr6y1KWkMwZXaLCFblDgfm53zzmTLVhz/g0etEAvpQVPvtAx/x63VqmP3CPnv7biYLGadHEEoiMs7H3xWzx6mV7cda9aba7Qr5lPKZSLct0/frtGfLS0fb9ToxXVeQTtj7nooXH4rMpRwfj08fYzcTccUVtEdpcYf00ILusB7OzV1dZ/8c0X35rFREK0tzQV8UHQPncc/pA6KKv34HEwavjfF5b44mHeu0McciqogNOhL6fnFGkKAmUMtVpsIlD1Ov1aBXv9Fx+puIcl2Gffp+tUH9nKkN2nUnbfFeTKYHAACg33G53XIrpH4hkCD4gQceEPfdd59IpVLC7/ezuWdIaEyQMJgiC3FkNAsUUY4yz9MWj8fZNivOIAAAAAAAAKAoXAXO+ruKm4Gnmftcs/dkNGQgw4BWA0zpK2OAgEEAAABlTGLYOiKRFS1DxR3TVwG1OsPH5v8Og3644509ksRL1I9wrJJymOWzPD3zkO1MMyu9CiG38wqa5bTK5vw1wmuQvMxyOz/yA0xEOhWPEplNxW/wm9lVcoWEyB95hRgVbXasIzryr1jHAwMcm1ipxvncbBfOddZsFN0em2CUn2HOxl9j/tKZjzEt3+T9e0ur8/2jX6FodgUkGxNMRLxKAQYBAAAAAAAoP8gYKMggcItKBQYBAAAAAAAoOyyXW26F1K9UStog+OinxcIXihqLEomQIjgkqhWBYYQRHDZE9BizDVX2ZeX6kM8oUZPL5XNMkhTwMvupicmYpVt3nBH5turLh6mlC5TPC7U6yWVLtLLYMrtoOd6if1+iXe9XMhrPKzKW/WQSXakCYlmmLPd7g/ryvrdKX7L219hFxIE6XQjsq9VdJzwdDfbPcT3Or5tZUXYx7g2WL6Q0LoxEuJ0Je1lLVBf5LuqIO5Yt6dSdNxa36fs1K/u1KuJkoo0p44TGJnDXr3qtctduXdjneK3Wh/XzY0Dc5yikbm/XxwUAAEAZgRWC8jAIAAAAAAAAKF5DUIAuwAUNAQAAAAAAAOUDeRwUEkrUDZchAAAAAAAAygZoCMypXFMIAAAAAAAAUNoagvlzWoQnsFzY6GJEiR5GkOpVsgQTAUUMXK2IhYmBNbrAcKUBYUcBMS8YtvchxIifmaZ0kozokckcnGperJctnmffbYEuKm6fq+8XXWzPaBxbpn9frCWmlcXb7GLWFJP51mIy9Lo8zHFVMs96Q/ppGqjRReCBGrvQONigC4jDTR16WVIR4jJJTFxBPeuxy2c/PySKMJwLL87pchNKxSVRXRy8gBHBzm2xC7xnLdF/3wLmeC1V2oozYuR4TBcVJ5kMymoMde5adTN+mV6//brwK5mEiQhzrQ6I2H/PkDpdYJ5Qrt3lfbB/7mB+MwAAgDICouLyMAgAAAB0j3kxt2iP5l4MDnhyJy3L0NmZfwbDMpjhCPv0qF8qKX0eQcPPTCKoOOXFisec++uUK4zwuJwr6Sa6Tm3AIZGagdBRjVJXbB2Rdq7jiy3O//ceCt3oirU510k4J7xLTc+fXKtu6GjHNkyOQTpU71hHeLzFTQZm98WXPzEcEWm3TwpyuKKtzl2pGZz/7y7nNvoVGATGwCAAAAAAAADlBwwCY2AQAAAAAACAsoNWdgpLTOYSlUpJGwRL5y4Rbv9yH2kX49ft9ev+xb6Ax1FDEGN8hzsZn3eT5Ephxd+dqFV8oWsD+rIyp0dQ/c9dab1PaUZDkG5dppXFly511At0zNMTk7XNtWsIOhbpy7GdS/XEZIl2+5jG4nrfU8xv9jAXn09xCfAzfuSBGr0s3BjOmywtV3I0t89+GYQYvUA6Uqf3vVrPVsYdM5Nx6FDGq43x31/MJCZTNQOzlujHa+kyvaxDSVYWZfQJiZj+W5JxvV9pZXmbu1bdXl0f4FUSkQUYrUg0oh/nzljQMdGbhwkdF1S0PJ0xaAgAAKCswQpBeRgEAAAAAAAAFAUSkxkDgwAAAAAAAJQfWCEwBgYBAAAAAAAoO5CYzBwYBAAAAAAAoDxXCBhNWd76FUpJGwTtC2YI12/xdjmhoscf0sq8IV0QGqqxiz9TjLCUY6EiGK4O6sM1MKInyOpQBMqciJSNXq3GiE7rAk4rpgt6LUZoHFvWljfhGNG+QI/x3DrHXta+QI+ivYwRZbcrScc6mTGOp81ExSFFVFzFiFvrGREslwxN+z5FQEz4a+znTKCuxWiMRSrueAy5+OycCDaqjFcbI8pe1qGP+2JFHLxMSVQm21qml7UrycpibXos6XiHPg6pmC5QtgyE1G6v3/FaTUR0kXaKy+KmsJBJ/Mddqw2KQDka1a8vAAAAZQRchsrDIAAAANA9flrSKariuW/1Szu7bxjd+OoPjnXO22stxzpcRmunaFEcarZ4vQ19gknFJPhgwOtcq8rn3F9vOt7thGKWRze6Vdzt+ROKye+Kd3Q/uVbK+Zxyx3omoVW6eZFjHSuuT4pkk1q6wLmNRNy5TvIXxzpuZtKy0O9y+j3ye+qbHOukO5mJLgWPQzueNuc2+hUYBL1nEEydOlU899xzYtGiRWK11VYTBx10kKiurrbVWbx4sbj33nvF9OnTxaqrriqOPPJIEYk4Z6kEAAAAAACgR4BBYExBzlJ/+9vfxDHHHCPa29vFoEGDxD333CPWWGMNMXPmzK468+bNExtssIF45plnxODBg2WdTTbZRLS2lnh6awAAAAAAUHaJycw3l6hUCjII9t9/f/Hxxx+LSy65RJx11lnirbfeEvF4XDz44INddehvtBrwyiuviEmTJok33nhDLFiwQFx33XW90X8AAAAAAAByrxAUshXBd999J6ZMmSLfiXtyn2La7ROXoVVWWcX2mVYKYrGYaGr6n48ZrQwcddRRwv9bFuGamhqx1157yfLzzjuvoM5FWxYJlzeQW1Qc0EXFvrguelRxuRv0/QL6ULQp4tlWRoQYTeqCyoQiEGU0pEawfqPJhJEfYLLdPg7xVt0vNLrUOQsxJyBexAhemxNpR1GxojvOiSoqrmWyQXNC7aaldqGslxGWchmO4y3tjhmOvcy4u7hs0wZw3sDqORNlxLRtUb0PzYrQOKpkjM5VpoqIo4wfbryjWStLdrYVJSrmAgCo1yrXjss9SCvzKudDIKT/Pu5aVbNBR+NmwQUAAACsoPRyYrKZM2eKPffcU8yaNUu+77a1tYmHHnpIbL/99t3ap5h2u0vBptCcOXPEH//4R3HYYYeJjTbaSJxyyini8MMPl3+LRqOy86NHj7btQ59/+umnnG2SUdHS0mLbAAAAAAAAKNUVgsMPP1zU19fLd+NffvlFHH300dKbZtmyZd3ap5h2+9wgCIVCYv311xdrr722aGhoEC+++KKYP3++/Ftn5/IZP1VkTNZNR0fuyAVXXHGFqK2t7dqGDx9e+C8BAAAAAADgNwrTD7jlZsqvv/4qXefPOeecLq8Y+j/N5j/11FNF71NMu/1iEJDFQisE1Ln33ntPzu5fcMEF8m+kHXC73ZoFs3TpUmkU5IK0Bs3NzV1btkgZAAAAAACAvlohaFG8VuhdV4V8+4nx48d3ldXV1cnompm/FbNPMe32ex4Cn88n1ltvvS53IPpMHf7mm29s9egzrSjkIhAIyE0l0dkqXJ7lB8HFZJpLJ81EFqr/ciKoh0BNxHxaWVJJdBVn/Lq5soQiGijS1VxPVCbjHOu/2eL89RN2H+oU4y+dYOKPJxXfazXhGNHG+bcnnTUECcvMIk1Zzj58frdepyNm73sw6vz7iLQyVurn5TvqfupWivGdN4kXzoyDes6kmUqdjHYjrpyj6jmbqywRtWsBkspnWadd1xCkGI1OWhmbYq9VLnlZMsjoYxL2azXJjAs7Vso5quo2+hK6J95yyy3itddek2GZ//KXv9j+TrNDNPGi8vrrr4thw4YV9F0fT18mAlW548JzifJUmjvyH79xY3RdlspHM5yXuuvC+n3YJJmhSmM4f0z+wdX680Yl6HGeL3O5nPMZuFnVkJ1Wh7j+3P1OJdTuHI/fxSS7LCo/gNvh1cHge6x2Z9fg1MLZznWanXMrsIkls4jPdM4fwGnLVHzh5YlUu4vL4dwz6Ys3+LNjHe7dQcVTpyeMzCbW4ZwTof+jDJnrAqzf6qqeKjTxfeGFF9rKlixZIv8dMMA+RuQ9k/mbisk+xbTb5wbBCy+8IHbbbbeuz+QqRFGESE+Q4cADDxS33367OPfcc0VjY6Nc+nj++efF1Vdf3bM9BwCAFRB62T/xxBPFcccdJ1KplIzCpkJLw3Tv/PLLL23l2QEcAAAA9A4zZ860ebZwk9Y0CU7Q6gG502cg9/mMq08x+xTTbp+7DD322GNyReCQQw4Rv/vd72RiMlrSyI4eRDNdFI2IyidOnCg23XRTsd1228n8BQAAUOnQPZFWCE499VTbzV7F5XLJPC/ZW+ZBAQAAwBlabC90I8gYyN44g2DkyJHy39mz7StZJAQeMWKEVt90n2La7XOD4K677hJPPvmkDIVERgHlJKCsxdlWVDgcFm+++aasu8suu0gj4umnnxZeb7e8kwAAoCzgHiwciURCbL755mLjjTeWEyo//vhjr/cNAADKCXK/LXQzhe7NFESHwupn+PTTT+WL+4477thVRn7/Gdd6k31M2+1pCn5Lp9l/NR+Bisfj6dVYqQAAUM7Q6sAf/vAH6YJJgRpuvvlmMXbsWPHZZ5+JtdZai92HlpezhW8I3wwAqHTo9b4QGadVQF1a4SVdwfnnny+CwaB0kyePGcq9tdlmm3XVI7f6TTbZRNxxxx1G+5i229OU3bQ9J/Q0SZxkAie+MxHkcRSbrIzDSqcdxUKceMhiBMNppWNcAjAuwZhazzQJGSdpMtmX65fJ7zPBRGjV25j8PlPUY8qdM2nDa8TkWuLOR5O2uLb5MlW034MXU4lAq6u777571+etttpKTJgwQVx00UXikUceyRm+mf4OAABgOfS4KOR9K13g4+T0008XQ4YMEQ8//LD08afEvKeddpqtzrhx42TAnUL2ManT05SdQQAAACs6tMqqrhhsscUW4tVXX80bvpkeItkrBMjpAgCoZGjCqJBJI6uICaaDDjpIbrm47777Ct7HtE5PAoMAAABWAGbMmCETNxYavhkAACqV3l4hKCcKTkwGAACgd7nqqqvEd9991/X5oYcekhkqjzjiCAw9AAAUoSMw2SoZrBAAAEAfQpncSWBGTJs2Tca7pmht66yzjozKlvE5Pfjgg+XfksmknPm/7rrrZO6CQpk8banwhXInMooyietUgr78CbhMtFQhf4djHZN21hha7VhnQaueVTSb5pieaFBl5fqw6AkG1nd/1SYgnPvrShkk/+vUkw2qWD7n3+1qy58MLDl/hmMb0Q1/51jH98NNjnXaZzgnL0t25h+b9nnOyc3cBonq3D7nVyq337lOOp4/sVtk2EDHNuZ/8q1jHX+1wbH2/Jr3760xswSx/QVWCMrEIHB7fcLlWR532+X2GGU29Qb0uN6eQFBrV6vDXOzkt5uN36vX4cpMMmmyiSdNzFNmHLjMsOqNyePX9+PKvMqDP8hkfA159M7H02ofmKzOzMOeGyu1fS5LZ4g5Xmo9b1A/vd0+5/Fjb+rMGLP8lva8UNQh5cbFw4yDSynjzmMPd4767deEV8nmTaRinY5Zv7ksxKbXqke5Vrm22TLl93APam6s1DKT67Q3oHByNNuvkp2TgKK0TZ48WWalpORlAwc6vwAAAADoew1BuVDSBgEAAJSjYJiSjJmgpq4HAABgDk1NFhI3MF3BgwuDAAAAAAAAlB3Z2YdN61cqMAgAAAAAAEDZAQ2BOTAIAAAAAABA2QENQZkYBIGaRuHy5o7QwAkhfVV6nG5/2F4WCOk/2xfQy/yKKDXMiHA5UbFPES8WrV1kBKouRhDtDtoFooQ3aB83X5U+joEarswenaOWERVzCYBVgWZnSv/RpomDVVFxhBnjGqYsWG3/Pf6IPlZcmbfKPn4eJgqEixHFupTkUaZw54NPES0HmN8XYUTSVcp52xnQ+8Sd78l4bV5hcC6S8c6ishd7uAAAwYjts7+qRqvjD+vntj9kP4Z+5trlxkq9ft2J4o4fAACAFQNoCMrEIAAAAAAAAKAYZH6BQjQEonKBQQAAAAAAAMqOtGXJrZD6lQoMAgAAKGN+/nq+cPtzJyBabb3Bjm3MnZU/oVXSILkZlyNDJRTRXctU/pt0Dgw4bIDuTlpoArSAQX/rFfc1jgjjxqcyqCr/o9iVyp+oStZJ5E/GJom2O9dJtzhWScydlr+JZudEX9bDlznWWTxjvugJlnw7K+/fuZw8KrEWg/E1oHNp1LGOxyERoHeqczI2y8BHN51a4lineqjdtVOlI+6cNK8/KTQDsSUql5I2CEIDBgu3L5Qz2RHnl+wL1zj6UFfVBI0eRAOq7GW1Yb1OmLlwVf9vLgGSy0AzYLkZX3Ym8ZorqD/s/TX2skCdflGH2/UbXIrRDKj4mBtaJGF/SHem0obaA+GYYIzTEITq9GMYbrSPTahBHxduHPw1VfY+RfQ6LiWRF2Ex56RJYjLufPApA8GdV5Gg/vIxQDlv22P6i0OKORZan7z6S2Fc8fE31RCYXqtq0rFglX5uh6qZa075zXWKdkSWhfWxiihaA08SGgIAAChnEGWoTAwCAAAAAAAAiqLAPASigpcInKczAQAAAAAAAGULVggAAAAAAEDZkRaW3AqpX6nAIAAAAAAAAGUHuQsVFHbUEhVLSRsEtYOGCk8gd3QMLxMZwMuJfBVRcSiiixAH1uqi0SGKcLWJSeRVyyRFUgWhPsYxy81lp1JPRE5UzIhb3UxCp2CDPfFUMqonnrIYsalLEbd6maRWwXq9D5H2hKM4OW2Ymczjtw+YXxF350qqpvYr3KSPS7ipXisLNdjruRlhuitkFx4TlsfvKCp2MceZC16injMR5tweyJy3nco4c9FT5ikibe46CYZ1MXIsqv++FBPhxTKI2OJhhOFqH/zcucYI+WsVEbF6nRJNTOCAWiVZmT9Z0rc/AAAA3QSiYnPwRAQAAAAAAGUHVgjMgUEAAAAAAADKDmgIzIFBAAAAZUzaYc186kc/ObYRiFTn/Xsi6py0qY5x4VOJRZ0TcLUs63Ruh8nHUWhismrFxYxDzW1RrE8yl5vERto5l4gJ6bZljnWSCwySXjkkOIsvXerYRmxZm2Od6GLnJGkdC5zrzPpojuguvipfjyQDi7Y6Xyuc63M+116ORa26m7DKiFF212KOqEMitXaDpHn9CVYIzIFBAAAAAAAAyo60ZcmtkPqVSkkbBA1Dq4U3qIs5M3gYdSYnNK5V0svXMllMORGiKiIeyIhbVaEiUa3MGnkZa56dEHIrmYq9ej/dYT17rKe2QW8raRf5VqV0ka/Hp/fdW2Ufh0CdPouTbNdnDJLRnhQVK6JsRmzqDQcdszOrwmpOQCzbGtDoOJ7ucLWRqNhymunLMRuoZreuZbISxxhBb9qyZ/f1M+JdbqZzsXIutzEzs51c1mNOVGxwAzW5VrnZVu5abVAyFbPXLiPArlfGNJB0nvEDAACw4kKxU5j4KXnr9zY//fSTeOqpp0RnZ6fYdtttxRZbbJG3fjKZFC+99JL48ssvxYABA8Quu+wiRo8ebatz/fXXizlz7CthEyZMEL/73e+M+4XEZAAAAAAAoGxXCArZepOXX35ZrLPOOuLzzz8XCxYsELvttpu48MILc9b/+eefxdprry1uu+020dHRId555x2x1lpriQceeMBW76677hJfffWVqKur69pCIfuE4Qq9QgAAAAAAAEAx0At+qkRchtLptDj++OPldt1118myrbbaShx44IHi0EMPFWPGjNH2qaqqkkbEqFGjusr+7//+T5x44onioIMOEu4szxJabTjzzDOL7h9WCAAAAAAAQNmxPKZCISsEoteYPHmymD59ujjiiCO6yvbdd19RU1Mjnn76aXafwYMH24wBYty4caKlpUU0Nzfbyj/44ANxwQUXyNWEadOmFdy/kl4hWGtEnfD/5jPvYZIrcWUhv/6TQoqvMudTXcf4bKvJoeoVLQIxgCkLKJoBH9NPzhKzlKRWrI+6T0/U5q5hNAQqjB4hXKVHnAgqPvYJTi/QoZel4nZ/83RC9z+3GB2Dy6NrPlyKlsLNHFOfonWQZYquwMNERnFX1+llEXuZm9NkhBgNgU/3U1eTyTGHnj0fgso5w51XHD7FN59LaNakJPIimjvtmo8ORvPRqRxTIs5oCNSILabXqqp34K5d7lqtUcpqAz7HOsQA5RpvT0FDAAAA5UyxGoKWFnv0qkAgILfu8MMPP8h/s1cCvF6vGDlyZNffTLj//vul21B9/f8SrXo8HpFKpeRGxsWpp54qbr/9dnHIIYeUh0EAAAAAAABAX0YZGj58uK2cZt45X/9rr71WagFyQS4/559/vvx/e/vycL3V1fYJxtra2q6/OXHDDTfIF/7XX3/dVv7II4/YDI2LL75YuibtvPPOorHRHjglFzAIAAAAAABA2ZEqUEOQ+q3uzJkzpStPhlyrA/RyH4/nzvkQDv/PqyMSiXStPpARkGHZsmVizTXXdOzbvffeK8444wwpKCbtQTaq/uDoo4+WRgy5Ke20007CBBgEAABQxrTOmybcPt3FrhBirfkTTbl9zu5XbX7dBVLFxfnYKXgH5Q5FnSHeA4nJuDC/KkEmnK6KQRXnFxaPQQI0d/5kVqakluae7TRNGLb0h5k90pfW2c6J1Jqn2/2oi6HD4XwhFhkkFGtOOJ8zcYNzz+l8GGbgUmryPb/+6jy+jQ4J+jrSurtpKUFHpBBdQPq3f8kYyDYIcnHssccat5156f/+++9lSFCCjAny96cX+Hzcd9998rvo3wMOOMDxu8h1iIhG8yeWywaiYgAAAAAAUHaQ8V/o1luMHTtWrLrqqtK3P8NDDz0kw4nuvffeXWX//Oc/xRNPPGHTDGSMAYpIpEKrGSRWzuZf//qXDDu62WabGfevpFcINh49QIQYYWgGN5PgiRNsqsJLNQkUEWZShYd9iujRq9cJeDmBqH0/P5uYjM1MZvtkefUlKivAzI4pYmTZks8+G+fiEmvVd+hlsU7bZ1+UqZPUl8eshF2kKroza6DMdLHCY3/QscwVYOowie5cSrI3y6vvl2bGnatnKTN53FFmTj8RUs61/81T/A83k6SrWhERdzB1OhL6sYgqs5/cbGiCuTEWG5KNu1bVBG1cAj9uBla9VtlrlxFXa2L/ZEnf/gAAAHQTSpxZyHPL6sWwo/Ted8cdd4jdd99dzJ49W/r2P/744+Jvf/ubLZIQ1dlkk01kUjHKV3DkkUeKddddV3zxxRdyy3DaaaeJQYMGiUQiIaMVURskUCY3oa+//lrcfffdxvoBAk9EAAAAAABQdqSs5Vsh9XsT8v2niELPPfeczFRMeQPWW289Wx160R86dKj8PyUYu/TSS9m2KLIQsfLKK4vPPvtMvPbaa+LHH38UW2+9tdhmm21EQ4NBBMpiDQKynCh98ttvvy0tko022kj6MmUnRiDmz58v7rzzTrmEQcsjtNSRLaAAAAAAAACgFKMM9SZDhgzJqz045phjuv6/yiqriHPOOcexTZ/PJ3bddVe5FUtBGgKyOMgviSwWSpYwadIkseOOO4pk8n+CHFoG2WCDDaTRsMYaa0g/KBJPqAkUAAAAAAAAAP1PQSsENOufHdqI/Jvo84svvij23HPPrtintEzx/PPPy4QLxx13nKxDIgkKgQQAAAAAAEBvU6hQONWbqYrLySBQ45yOGDFCLlMsXLiwq4wMgT/84Q/SGMgkZSBj4dlnny3YIFh3ULWIVOcO+8TpcrmodS5F2smFgVMFjlw9j6GIWRWNcnUY/aS+YOPVw/RxQc1cTEZjoYhgXUHdZcvFCH9d6WTezxIr7VzG1TFFFUkzommuTM30rGYNlnU8PkchMJch2rQttV/c+ehnTkC3y34T8nJZuJkbVSJtb6su7TUKYadmbrQEJyDWikSxq6km16p6nXJ1uGuHy4LsNbnmNCE3AACAcqIUXYZKlW49EUnBnE6npYAhE++UXIay1dIEff75559zthOLxWSihuwNAADKFbof/uUvfxHjxo0T//jHP9g6pMGiyRWKNrHPPvuIN954o8/7CQAA5SAqLmSrVIqOMvTxxx+LU089VboIkeiBIMV0ZlVAzeSW+RvHFVdcIS666KJiuwIAACsM77zzjhSN0dbW1iYnUVSWLFkiNt98c6m/uvzyy8W7774rU9C//PLLYrvttivo+zqXzBEuJoRxIbiZ1cpsBq+zqWMbHcsWO9YJ1zlHxWhb5pxop2FI7nDVhJ+L/WsQulbFxy/12ogl++YNw2Uys8mEa1ax1OVDho6F+RPVpRPOib6W/Oh8PsTb4859WZT73SLD7Lb87XQa/GaTpGMGp4NoM0h458QXBtfAYIeEYkRVyLlOwuENmQtPXUpghaCXDQKKcUpK5uOPP16ce+65trTMFHGI0jCrD7d8UYZInHz66ad3faYVguHDhxfTNQAAKGkoOhuFnaOY1A8//DBb5+abb5Yrp4888oh0yyQj4KuvvhIXXnhhwQYBAABUKum0JbdC6lcqBRsEU6ZMETvssIM4/PDDtaVuenCtvvrqMiFCNlOnThXrrLNOzjYDgYDcVIbX+EVNjXO6eyef456i2KbN+6QmJmNmc7hkWCb++t3x6TehN9vnNASl0H6R+3Gu66rOJGAVd7b15K2sr++LPXntmjTlCjjPAPcGlD3Siddff11GcKN7aoY99thDriqQa2Yw6DzTCwAAlU66QDegdOXaA4VpCL788ssuY4CiBnEcdNBBclaLchEQlCThhRdeEAcffHDP9BgAAMocSkVPsaqzoUQ1pNmaM2cOuw+0WAAAwLsMFbJVKgWtEJCbUDwely5BlEo5AwneaCMo6xrlIKBcBLQ0/v7778uZrez6AAAAckOJH9VV08xn+hsHtFgAAKBH2eMi7eUiBYPAjCuvvFKkUnqoyuyoQrQc/uqrr4oPPvhAzJgxQ5x//vliww03xDkKAACGUC6XxYvtosvM51zp6KHFAgAAO9AQ9NIKwWGHHWZUj8RyFCGDNgAAAIUxfvx48cknn9jKPvzwQ5n7pbGxsSAtFgAAVCo0hV2IhiAlKpeiw472BeGl00Q4GSlIwGlxGZC0hFWGia7UZFRccio3I0xU6qmJr3K2pfSBi3JmkmRqeT21jj4u3EWiNp9mZKrcilracj4+FrMjGY89JTZVE8dxTXNh4dT2ucR1RomuuKRZXN+ZZG+upD0snpurwySS09pixN1scrm0cyI5ozCG3L5Fiq3Za5e7dpT2tYR0Oa4vtcwXbRWlCuUfuOOOO8Rjjz0m9ttvP5m34J577hGnnHJKf3cNAABWGBB2tEwMAgAAKDdIg7XNNtvI/3///fdSJPzaa6+JtddeWzzwwAOynPIP3HLLLeKoo46SIZkpSAMFZjjnnHMK/j5vKJI3D0F06TzHNjz+/JGR5n9jX83gCFQPcKzjC0ac2wnZ89wUQ1vUOU5+3CBefGvceT5xWI2r+5FNDCZFXIkO5++JO8evdxtEsHJxk2pZxFqcvyfWEnOs0zq3TfQEVQ4JAuZFnY913CD8jEkdEx/1docpbaffY5pbIWEw5+PU31L3uYeGwBwYBAAA0IdQokaa7VcJh8O2z8cdd5x005w2bZpoamrKqR0AAACQW0OQQh4CI2AQAABAH+LxeMT6669vVJeCNKy55pq93icAAChHUgUaBKkKTkQAgwAAAAAAAJQdMAjKxCBI/fpfkarK47vKCHpdnMjX67PXCeg+ki6/XuYOhh0zB1uMb67lU+r59N9g+e1tEyklT1ySsVSjjF9rjPE3jCtl6uflZXpbHQm7T2yC2S+hClKZet3xK1TFwW5G0Ktm9iWCXvv4BZTPy+t4HNvyM/6ZQa9extULKIpkj2DEugndv9aV6HSuk9R9bl1JpV7M3g5hMX7DapmVsIuaiXSSiXfPCZtNMLlWletU1vH5Ha9VD+NTbnn0tiyf/Zpzd/aMfzIAAIDShF5zClshEBVLSRsEAAAAAAAAFANWCMyBQQAAAAAAAMoOGATmwCAAAAAAAABlB6IMmVNcBiEAAAAAAABAWVDSKwRtU78UrmDuhDpuv959t08v8wbtwkQ3I0J0V9VoZa5wtb1OdZ1Wx1Ndr5Wl0/b90lwmVa8ulkwrmVQ5IXBnUi/rYFIat8TsiXealc+yDpOcRxUVq5+JNqatmCJ25kQ8psIev4E4OOT3OIqKI8z5EWH2qw3aBai1AX2/FJuJWS/zutXfyIiKU7pY1xW3JxVyx/QsulZ7i96v5sX2b+vQ66Tbmbai7fZ2orpgORnVhcYWo7jiylRcTPpn9VpVr1NZR4nNL8uCVY7XJXc9u2vscfxdHfYxKFdiLYuEy6OPbQZ/xDlhWDKaX4DtDeRPXCbrGCQUSysZu9l2fPqxVYm2M4L4bBoLvw8Vi0l8Bcd7Y09N3THCfRWXQR2LCSyRTbBev25Vwo3OycvaFzgnW1vWmTBI0pV/fA3yfBnVMcEp6RgRZIJmZFPrY4KnKJgE9mhmnu8qNQ7XgcHP6f/EZIWIiq0S/0G9CFYIAAAAAABA2WoICtl6E8uyxL/+9S+xxRZbiPHjx4szzzxTtLToE3nZ7LvvvmKNNdawbeeff363212hVggAAAAAAAAoB1HxRRddJK677jpxyy23iMbGRvni/vnnn4s333wz5z6//vqr2HvvvcVRRx3VVVZXV9ftdlVgEAAAAAAAgLKD8jl5CnjJT/aiQdDW1ib+/ve/i2uuuUZMnDhRlt13331ivfXWky/u2267bc59Bw4cKFcGerrdFcYgWPTfn0XUn9ufkdMLeBg/ZF+VPZGRv1r3ZQ3UR/SyAXYLzBMdqHeCSd7kHmD3/3P5AkY+mJbLfiImmBMzxvhrL4vqfZjfbvfFXdyh++YuatPLFrfZfcmXdehttzHag8643RcxziRQM/XN8yv+5pxeIBLUj31d2H6uDIjo495QpZ8fnYoGI8GMscett+XT9AJChNRuca6gKX3c3UpiMqt1qb7b4nl62dIFts/xpfp+sWW6/3e8xe4/n2jtMNIQpOPJgv2Jc12rapl6nRL+GuZarbNfq4E6u46C8NQ36WVK8rVUu7N/MgAAgBWXUloh+Pjjj0VnZ6fYZZddusrWXXddMXz4cMcX99tvv138+9//FsOGDZOrBccee6xwu93dbneFMQgAAAAAAADoy7CjLYr/fSAQkFt3mDlzpvx3yJAhtvLBgweLWbNm5dxvzJgxYvfdd5cv+VOmTBHnnnuuNALuuuuubrWrAoMAAAAAAACUHTLKUAGRg1K/1aXZ9WwuuOACceGFF2r1d9ttN/HLL7/kbK++vl58+OGHy9tOLfek8Pns3gxkaCST+up7hkceeUR4PMs9JTbccEOpEfjd734nJk2aJFZdddWi21WBQQAAAAAAAMqOYl2GZs6cKWpq/hfiONfqwI033ihiMT1sdwav93+v2Q0Ny0NfL168WDQ1/c+tddGiRfJFPxcZYyDDVlttJf/9+uuvpUFQbLtaX41rAgAAAAAAUOYGQU1Njc0gyMXo0aON2868nH/00Udir7326npp//HHH8V5551n3M6MGTPkv7W1tT3abkkbBAu+XijaPcu76GKygngYsamXEZsGauyWXaheT6ITatKTG6UUUWU4rSfxcDEJxlxK4iSXXxcsi6AuxEwbqN1VASyxhEnMooqIZyyxi1aJucv0snnL7Mli2hRxMhFnRMzxmH1s0oyoOOOb54RHSYTiC+jHORDSxebhsP1YDKzR+95WpwtX1eVELidMmEkEE2QS+GhLkxaTmCzNLOHFOvImHOMExER0wULb5/a5+n6dC5fp+y21f1+sRZ/hSDDHPtHJiIoNMtOYXKuBWn0GJlSvH6/gAPtNOtSkJwesYoThLmWWJd2un//liMvtkVsu/FXLHyr5cPtyJzaTbVQ7JzczEZ8HDR7APiZxoEookr+/JlQxiQ2Luaf5DTJamdRxwsqTfK6rTsw5GZjFBMpQ4QJzZLP0+zmiJwjUOP+mAT7ndEpTF+RPQjjQ4JzqNEjA2MY891RCBsfaKZFa3OC8G8y8CxXT3xWdUhIVr7TSSlILcOmll0qhb1VVlcwnQBGE9tlnn6569FI/duxYcckll0jNwKeffiqOPPJI4ff7xbx588TJJ58sVlllFbH55psX1K4TSEwGAAAAAADKjpSVFql0AZvVu0bS3XffLaqrq+XLOrn6vPrqq+Lpp5+WL/EZSJMwd+5c+X968SfXIHIFGjFihBg5cqTUJdB+ZCAU0u4KvUIAAAAAAABAX0YZ6i3ohf31118XCxculKFCSbzsctlXjZ599lkRCi33ZCG3JUo4du2114rZs2fLSEKqeNi0XSdgEAAAAAAAgLKDjAF3ibgMqS/whegSSFhMKwTdadcJGAQAAAAAAKDsIJmEq6BMxaJiKWmDYP7MVhHOI4bjxDl+JVstJ0xMtOuiqVQiWVS21VBYzwzrrq5zFpEa+KlxlmpHQhc2t8SSjlmIOQHxjMV6ptb2ZrvorJ0Rm8YZYWms0/596aQuSLUYUTaHWxFq+4K62DRYpR/nWMR+XOPMWHFj6vd6HAXEtUyG3notLbEQJvcdFyPas6J20ZvV0arV4bIQqyLi9tmL9DoL9EzFbfPt3xddyhxnRlQcZTNQC0f8jFI7qIwfJyCMNYS1sirl/OPEqi4l2zURCdvbSnU4CywBAACsuJTqCkEpUtIGAQAAAAAAAMUAg8AcGAQAAAAAAKDsgEFgDgwCAAAAAABQdpRalKFSpqQNghmdSRF0LfcP5nJ5hBg/4aq47jc+oMPus51ifMs5vMEWx2QsQcbXW03+4mY0BC41gRXtZzn7ZnM+3JyuYHGb3Sd8IaMF6GjVy9pUDUGzXifWpvuyJ9rtY5WKdxppCLiESaqGwBvSxz0R1RMhpZK6v7nKQiXpGVGn6E6alER2OX3nGRmIegxdnFaEKbPi9nFPd+qJdBKtuuYjtsyuD+hgdCGtc3QNQfsCe71lTHK7FuY3cwlz1CQ53LXKaQjUa3UAo01JxZ21Nh6/fky9VXpCs1CDfRxSnbnTzZcTtSPWFG6fnowxQ/uCmY5thOoH5/27T0nGyBGIOCdAq2U0I1o7jHZHZbBDOw0GicuYx0tRxExENg5YbuffbHl0XZWGQdIxK6Frh1S8wfzjF6wPF5WssBhaZtmfPRxrMPf0bNqZhJ8qJqcD906iMpu51zomuFQYpSTh5DDRxq5mcB0MWKk679/bU0khloiShYyBQkTFqQo2CJCYDAAAAAAAgAqmpFcIAAAAAAAAKAbLsoRVwKy/5bA6U87AIAAAAAAAAGUHaQIK0QWkK9hlCAYBAAAAAAAozxWCAmb9LawQlCaLYkkRcC2XOXhcuigx5GFEj4xoVMXLJGHyVzFJkVrsQs9Ehy6UTUX1trxqUi4uMZkBnLAoxgg9OcFrW9T+ne1M8rKoIraWZUrSNk5AHGvWk1/F25t7TVTsZ5Kccbi9dlGdP6Dbu3FlXIhWpUwdOyLBzBoki51JYM4HVcinioxlH9qjjkLjGCcCb9HHTxURL2LE+M2M0K6NTUxmFSW26/RajiI4zzL9N/vUhGa1umAwxIxVMhrP+xkAAEB5Qe5CBbkMpbFCAAAAAAAAQNkAl6FedBlatGiRuOuuu8TkyZPFSSedJLbYYgutzuzZs8Wtt94qpk+fLlZddVVx4oknigED9DCRAAAAAAAA9AYU5ZuL/p2vfqVSUNjRBx98UKy//vpiwYIF4pFHHhHTpk3T6syYMUOMGzdOTJkyRWyyySbitddeExtuuKFYsqSEA9UCAEAJ8cEHH8jJFnWbN29ef3cNAABWOA1BIVulUtAKAT2Qfv75ZxEIBMQ111zD1rn44ovFkCFDxFNPPSU8Ho848sgjxZgxY8Q//vEPcckll/RUvwEAoGyhCZRPP/1UvP7667by+vr6gttqGjVSeAK5E0XFhgxzbMPrkESqp/xuPV4mq52Cj9EGFcqqg/MnWyLqg86JvobV6AnwVAxkbcLNaOSycZno0H7T2+XDM9D5WLsXz3X+rsX5k4GFBzt7BHCJFlVcXJbDIgjW5z9OEUU7x1Hf5lwn3h7vkVnYnvjZEYMkaRGHcSFW2nxU3r+3xhNCfPm5KFXgMmROQXfWESNGONZ54YUXxB//+EdpDBChUEjsueee4vnnny/YIOhIWyIplj9o3L/9m03K0q8aTnwcVR5WMUZAmWSEpCmlXjqu10kz6Wotg2yQxa5LcQLOOCP0VMXHSeY3p5n9kspvTDHi1iQjGFZFxMmonh3XSuvf53K7HUXFnPDY49czr6p9TSZ0oXiSyeqsjh8nFk4aZhtNFzu7oIwNly00RTderSzleB5zZWrGYS4DMScg7mTO94SyK/cYShmc7yHmKdiZ0lsLKRmN1TFYXsZcq0qZlShO7N9XuFwu1iUTAACAGRAV91PY0c7OTjF37lzNcBg5cqR4+OGHc+4Xi8XklqGlxTkVOQAAlDPJZFLsvffe8t911llHnHbaaWLw4MH93S0AAFhxKDDKkKjgKEMFaQiciEaXz9BGIhFbOX3O/I3jiiuuELW1tV3b8OHDe7JbAACwwkHGwKGHHiqOPvpo8cUXX4g111xT/PLLLznr06QKTaZkbwAAUMnQqn2hW6XSowYBvfi73W5NQLx48WJRV1eXc79JkyaJ5ubmrm3mzJk92S0AAFih2H777cXjjz8u9t9/f/H73/9eulzSRMkFF1yQcx9MrAAAgB0pFE4XsFmVaxD0qMuQz+eTs1hTp061lX/11Vdi3XXXzbkfiZRp6y0M3b+LwmJ8qssJK5UySjDGlfUmff19KzKczqWY5GLL6xl8n1FLxbXNYfXmBd5PkPYqG6/XK7bddlvxxhtv5J1YOf3007s+0woBVlsBAJUMNAT9tEJAHHLIITIk6Zw5c+Tnb7/9Vrz44oty6RsAAEBxzJ8/X4TDuaMF0aRKTU2NbQMAgEqG4nVkIg2ZbaJiKWiFgGb+L7300q7PN954o3juuefEdtttJ4477jhZRjNU7733nhg7dqzMR/Dxxx/LZe/DDjus53sPAABlyG233SY1BIMGDZKfX3nlFfHEE0+Iv/3tb/3dNQAAWGEoNLeA1UcuQ0uXLpXaWgrTn494PC7ze3E0NTV1TfyQq312cB6CNLkDBw7sHYOAGt5nn33k/zP/Equssoptlor8XT///HP5IyhfAUXIAAAAYH6v3WyzzYTf75dRhih627nnnitOPfXUgoewYUi18Iaqcv491ukcJtnjENM8ZeCWZpI/YFCtc1z0kENOBKLJIT8AF6q5P4ONOLrsmcSlZ0I4q1hJgzj5Vc4rS1VjxuT/nvQPjm0k23MHGsngr869IpbBG9TDS6u0zGp2rOP8Pd4eyZswulEPmV2oG6TJ96Tizud43chaxzodC/IHJ+hMGIRZ70dKLVPx4sWLpSfNm2++Kd+Xhw4dKh544AExfvx4tv706dPFrrvuaitrb2+XSSofe+wxqTEjKLz/rFmzbHrdAw44QFx++eW9YxDQbNWBBx5oVJd+XK4fCAAAIDf77ruvnHT58ccfRSqVEiuvvHKv6qwAAKAcITcgVwHWfbqXZwIoatyiRYukCygF4jnhhBPkyzzd66uq9ImbVVddVfz000+2snPOOUfceuutYrfddtPKzzzzzNIQFfc0AZdLbgRnEIeYWSsuuVHQbS8LMDNMbp/elkep52K+z+PXh5BLpMVUEsXAJV7zM6kwA0qZh6njZsq8yu/xBPSZNi+TFCzNJNIyEQJzY6UmJvMG9O/zMGXqfm7ueDG/WR0/r3K+yLYMM0c6ZRzNvaO9Dy6fPuvl8fscz1GP3200s+WPpRyvpTh7Y3Sux12rfmYA1WuVu3a5/Xwh++/xKp9zXpfKb+SS4pVaYrLVVlutv7sBAAArLKUkKp49e7Z49tlnxZNPPtk1k08z+Hfffbd4+umnxcEHH+zYBq0Y33fffVKXqwafoL+R6xBN3tPqcqGU9hMRAAAAAACAIigo5Gi6wCRmBfLZZ59JjcKmm25qcw+lVQD6mwkvvPCCdCE99thjtb+RW+lGG20kVx5o1SGX9mCFXCEAAAAAAACgL2lREjvmCo9PL90k/M2Fx+MRo0ePlv8nVyGioaHBVqexsbHrb07ceeedYsKECWK99dazlR9++OFy1YCExhTlk4L5kOspBfahsNUmwCAAAAAAAABlB2UedhUQOSj9W101hwslhbzwwgu1+jRT//PPP+dsb8CAAeKTTz6R/6fEvUQikZCGQgYyKLI/54KExLRCcMstt2h/y85BQ0Ll66+/Xmy44YZiypQpctXABBgEAAAAAACg7ChWQzBz5kxbLpdcQR1efvll47YzRga92I8aNaqrnD7vtNNOjvvfe++9UjcwceJEx7qZ9ilKUVkYBI0Bjwj+Jr5lxbSM4LCGEY0OUISXgRpdbBGo0Q+2v8pez1fFiFuDzEniVcSf7uKGmfvNPk40zfzmiCIkjYR0QWqUEZvGFYFmMjLAqK+qoDcV79TqpA2zC6uiZW8ootUJROq1sqByvALMb+bKwsr5wYU1VEXasl+mSmMDQbkqInap55A81/Tz1hsOOp7HwVp9ObM+mnQMe8iJgzuZcHgmWY450XJEGVPu2q1ifo96/fqr9LHyVgUdx89dwSnqAQCgYvIQFGIQWMvr9kZyx4033lgEg0FpRBx//PGy7Ouvv5ZuR9tss01XPTJGqJ6aQ+Cuu+4SBx10kNQIqGJi1S2I8oERYxxCBK8wBgEAAAAAAADFQMZAIaFErV4UFVdXV8uwoCT+pZd90g6cdtppYsstt5QJfjNQ3oFNNtlE3HHHHV1l7777rvjhhx9kzgIV0gn8/e9/F3/4wx/EyJEjxeTJk8XZZ58tNQTrr7++cf9gEAAAQBmz/dpNIlhVnfPvyzqcEwstbMmfREpdkSyWlQY4J6Ja0BJzbqc+f/KnemalUCVikACNW51VqQ04t+O02mj1UEBAV9B5fD31TY51ErPscdFVwisNFj1Bx4KlPZKYLFCnrzJnkzBIkhZb1u5YZ2DQ+bxKRksnkVegLnfCwgzhpv8luuLwR51DjvcnpZap+KKLLpK6giuvvFJ0dnaKbbfdVpZRmOkMI0aMkOLgbChbPbkVkS5AZfPNNxcnnXSS1BZQPoNhw4aJSy65RBxzzDEF9Q0GAQAAAAAAKDtKKQ9BRlhMqwK05YKEwyr0gp+PHXfcUW7dAQYBAAAAAAAoO6S7UAllKi5lStogWCnkFSGXp6Dsp+GA/pOC9XZhYrhBXzaNDKpyXGb0V+v7uYL6fu6QvcxiRMUWIxhWi7iktwGvvvwc9ulltWH7MuqAiL6s2h6zC0uJVCpt7wMzxl6/vqScCNf2mqjYF9SX//1MdtpwxH6cQ8xvHlDFlCn7VTGuApxrAKOTZY+ZBnM+uPxBx/OKO/8C9fZzNNyhL32n4vZjyh1X71J9v9p2fWm7Uzk/ZPsG90/uWq1SsoNzguhwo37sQ8r1G2zQ3WH81fr4+artY+XzlPTtDwAAQDex0im5FVK/UsETEQAAAAAAlB0wCMyBQQAAAAAAAMoOK50ucIUgLSoVGAQAAAAAAKDssFIpuRVSv1IpaYNgyJCIqMqTztnLhLrzMb7lqm9yuFH3xQ4O0BNQhIc02Os06HXc1XpILs3/m0tMxiSnUvFxGgnF75qI+PX2Byp+8Z1x/SRPMeKZhYpjvJ/TZIR17UEiZv++VCpStHrfbdAHTkOgJiZrrNZ90lcaoPukNynnR23AZ6TT4EIFqkUWc5wtt96WWzlnuPPKP6BF75cS8s1ifPzdPuY6UZJ5Bev1RF4RRkOQVBKaEWnmO1U83Pgpx5DTEISYfqmagXBTvVGoPHfEXuZ26zoXAAAA5YNlFaghsGAQAAAAAAAAUDZAQ1AmKwQAAAC6x/DakAhHcifqGsyspKmMYiKzZdOoRDXjSBisJPm40F0Ko+vDPZJUzIkhBuPi58LfKXgMQo9xUbhsWM7jkg7kTj5XyMq02+N8LH3u/O2kFs52bKPK6/w9JgnOOmbNc25nsH21XyWd0Fc+VZZ8O92xTt2YYY51UnHn73I5XAcmydiSBgnD1NV4Dh8T3S6bVKdzosD+BAaBOTAIAAAAAABA2QGDwBwYBAAAAAAAoOxAlKEyMQga12gQEd9y8aObWZr1MMvCHmYpzRe2CxP9NYyouKHWscxT3+QoVCRcYbugNu3xGS3dug2Wmjlxa33IZ7A8r/9mP5Nsqy5sb2tZh75s3tzBJKxK2IU4qWTaSFTMJT5Ty4LMb44wgvIGJRGZmnCMExBz7g71jGCZE25zom/tmHGiYm6pXElMxomKPXE9eZjqCOJm+skl6QrUtdo+J9r1tpNskjNdcJVWMpNx16qLKVOXvX1VQaPl6mB9tWNAAO+AgVqZp9buNuDxdmh1AAAAlA8yIWoBouI0EpMBAAAAAABQPsBlqExWCAAAAAAAACgGGATmOEvMAQAAAAAAAGULVggAAAAAAED5QZmK3QUkG0shMVlJMnC9lUX1b8JDLi4vF0OXz8pqFyt6wrpQ0R3WYzi7q2ochZ7uGj2+seWzt2/5dLGkYOI4uxRBKhfjOsiUDWBExSoBRkBcHdDFuq019r62MzGTOxhhqZoJOZVOG2VG5vB7PY7i5zAjKI8oGY0jBnWIWqWMG08uQzR3fAxCjpMa3vGccdcMMGhICJcqRg7rAttAo57hON1hF9QmGAFxmjn2KSZeN5cdWcXDiJ3Va5UNCFAVcswEzl6XjNhfref2tTv0GgAAwIqMzDyMTMVGYIUAAADKmPWHVIvqat1QzNCRcDbojAxdB2JM5DGTSQoVy2BewSBfmCPeHko6xsxnaCiBuvQ20s7JrKyAPbodS6zVuR2DxGTu+vwJw+I//dexDf/K6zjWSbfrkxkqNQOHFTxxopJautCxjfDIkY51hNsgIZ7By6mV1CP5FQo3UaLVUSZXONLR/BMn8fZOUephRwsyCNLO96lyBQYBAAAAAAAoS1FxYQZBqlf7U8rAIAAAAAAAAGXH8hUC81l/CysEpUnteuuIGsaHON/ynMur+3+7lERQrkDQaElR9VVWk0cRlpcp84ecfcbdjE+1y3mpOcT4srtc+nqz1+3L6ydPdCjJxLhl/SjjH64nPRMioegD0oZ6AQ63MhDcsjyXFEzVSQQ57QGT5Ezbj3EV4DQYnIbAKDEZk6guHajKqych3Mx55Kmut7fTqS/vWsySrxWzawb8ybjZsnWxsyfctaqWcdcud10q1y+77B0IGVyr+d0IAAAArNhghcAcrBAAAAAAAICyAwaBOTAIAAAAAABA2ZFOp4QLGgIjYBAAAAAAAICyQ4bFdhUgKk4hyhAAAAAAAABlA/IQlMkKgW/M+sJXnTu2ssUINoVJGSf0ZESPlsfrKAQWTJkmGlXaydUHVUbq5xKvMQJiv0cvS6TsraWY0NIpS//NqnFsMUG/OftZrdYNTbEmruZCfXOhvVUhLjN8rEBZ1QZ7GMEyF5LcZ1KPO9cUkTsLIyBOMXHGXSlFDBxm4pVb+hFzq0uoTB22jMFlUI+9VrVOmV0nlnIM08z1xV6rypim0wbHoR9ZtGiRuPrqq8XUqVPF4MGDxYknnijGjRvX390CAIAVS0NQyApBGmFHAQAAlAhtbW1i0003FSNGjBDHH3+8ePfdd8Vmm20m3n77bbHxxhsX1FZjyCtqwt2b+0mZZAPrgSRe3ASE1g5jiKvMa8uf2KnBYDxM+ssEfSsKJ6M64XGOiOWLtznWSQeqnfviN0hwltQzm2fj33xf5+8xePHyOnyPaSI1p8kNtxKtjaW2ybGKyyCBnOh0Tg4nQvmPkysZEz2Byfngdhg7b6vzedefwCDo5xWCadOmiZtuuklMnz5drLrqquLkk08WTU3OFxMAAAAhbrnlFrlC8OWXX4pwOCwOOOAA8csvv4jzzjtPvPrqqxgiAAAwwEpEC5v1T3U/S/SKSo8bBL/++qvYcMMNxbbbbit233138dBDD8nPkydPFo2NjT39dQAAUHa88sorYscdd5TGQIZ9991XnHDCCSIejwu/v7TdnQAAoD+heyS5Ws775tGC9x08eHBF3mN73CC4+OKLxahRo8Sjjz4q3G63OPjgg8WYMWPEtddeKy6//PKe/joAACg7aJV1jz32sJWttNJKIplMitmzZ4vRo0dr+8RiMbllaGlp6ZO+AgBAqREMBuUENU2gFIrf75f7Vxo9bhC8+OKL4qSTTpLGABEIBMSee+4pyws1COJNq4l4TY2oFFR3VLehkFWXI1PFHusW6AasRzSXudrEDxb0KKmkLqovFeghFgrZsy1nVgtyPeCuuOIKcdFFF/VJ/wAAoNShl/pKfLEvlh6SRC2ns7NTzJ8/XwwfPtxWTp/JUssFzWrRbFb2BgAAlUp9fb1YsmSJrWzx4sVdf+OYNGmSaG5u7tpmzpzZJ30FAACw4tOjBkFmuTrb75WIRCIiGs0dLYBmtmpra7s21aAAAIBKYv3115e6q2zo85AhQ3IGaKDV2JqaGtsGAAAA9LnLEL34ezwesXTpUm1mK9esVmZm6/TTT+/6TLNbFG6vtdUgPBcAABRB5v5iEuqyrznyyCNlYIbXXntN7LDDDmLu3LnirrvukuWmZH5XT9xHV7Swo63t+SOF+JIrVtjRpMHcnS/e7vxFBjlDXCbHOpU/HKgrmeiRsKOuPgo7KtoMxs7d1jNhR6MG35XMf+65koX7xXOk465uj13Lb2NXivdR0I8GgdfrFWuttZYMlZfNF198IdZbb72c+9HMFm0ZMi5DFLIUAAB6E3phppXJUmLrrbcWl112mdRfrbnmmuKnn36SZeeff75xGxlDYK3VcR8FAFTefRQUhsvqYbPuqquuEn//+9/F559/Lmf5v/rqKzFhwgRx++23i0MPPdSojXQ6LebMmSMtTmqDfGFXtOVvMmrI9Ql9x7jjnClN6P5CD7GhQ4d2BUEoNRYuXCi+++47GQav0AmSzH20urpaZvFeke9JKwIYX4xvJZ7DK8J9FPRTlKFTTz1VfPTRR2Ls2LFyVYAMg8MOO0wccsghxm3QSUUh9jIrBSuyPyz6jnHHOVO6lPqM1sCBA+VWDJn7aDndk1YEML4Y30o7h0v9Pgr6ySDw+Xzi8ccfF19//bWYMWOGzEEA1x8AAAAAAAAqxCDIsPbaa8sNAAAAAAAAULqUtMMXCY0vuOACm+B4RQF9x7jjnAGlxIp8T1oRwPhifFd0cA5XNj0uKgYAAAAAAACsOJT0CgEAAAAAAACgd4FBAAAAAAAAQAUDgwAAAAAAAIAKpteiDHWXtrY28d5774lUKiU233xzUVdXJ0qVn3/+WXz22Wdiww03FKussgpbh7I3//LLL2LllVeWORpKAUomMnnyZBGLxWTOCEp+xDFlyhQxbdo0GUJ23XXXFaUA9Zn6tXjxYrHaaqvlDG1LeTAo/C3VKbWoV4sWLRKvvfaaGD16tNh4441tfyNpD51Ts2bNkplq11hjDdHf0Hh///33tjJKerX77rtrff/kk0/E3LlzZeZyGnvQvySTSfHhhx+KJUuWiA022EAmfATdez69+uqror6+XmyzzTZsnXnz5snrIBKJyGcYxNzmUNhyel5Skqz1118/5/2TzulgMCi22GILEQqFijyalcfs2bPFF198Ie/f48aNk+eoyrJly8T7778vPB6PHF+uDigzrBLkvffesxoaGqxx48ZZG2+8sVVbW2u9/PLLVqnx5ZdfWjvvvLO1yiqrWF6v17r55pu1OolEwtpvv/2s+vp6a6eddpL/0mcq708uvPBCa9iwYdbWW29tbb/99lYoFLIuueQSW51YLGbttdde8lhQ3+k4HHLIIVYqlbL6k8cff9waM2aMtcUWW1i77babVV1dbe29995WNBrtqtPZ2Wntsssu1sCBA2Xfa2pqrKOPPtpKp9NWKUD9oH75/X7riCOOsP2tvb3d2m677axBgwbJOvT7TjjhBKu/OeWUU6yhQ4daEydO7NqoLJuWlhZryy23tIYMGWLtuOOOVlVVlXXqqaf2W5+BZc2dO9daZ511rFGjRsnziq71K6+8EkNTBHRfOfHEE+X5TRvdOznuvPNOKxwOy/vraqutJsf+xx9/xJg78PHHH1sbbLCBtfbaa1t77LGHvN9stNFG1rx582z1Hn30UXlv2XzzzWVdqkfPY5AferYceOCB1ujRo60999zTWn/99eU7yZNPPmmrR+9b9Lyn9y86HvQOQO9loLwpOYOAXpRHjhxpHX/88V1lZ5xxhtXU1CRP5lLinXfesV544QX5gkw3J84guOGGG6y6ujrr119/lZ9//vln+XL6r3/9y+pPbr/9dqu1tbXrM/0Osg/ff//9rrKrrrrKamxstGbOnCk/f//99/Ihd8cdd1j9ybPPPmstWrSo6/OMGTOsYDBo3XbbbV1lZNwMHjzYmjNnjvw8depUKxAIWPfff79VCtAL2e677y5fGFSD4K9//au10korWQsWLJCfJ0+eLA3Oxx57zOpP6OWf+pwPulbpYbN48eKuB7zb7baee+65PuolUCHDbfz48fJllqCHv8vlkucVKIxly5bJe3dzc7O8bjmDYNq0adLQp3sskUwmZb1tttkGw+3AG2+8YX3xxRddn+mZv+6661oHHXRQVxndFyORiHw+ZSZX9t13X/lyC/KzZMkS65lnnrGVnX766fKFPzNZRmNOE2lnn312V51jjjlGGrX9PZEJKswgePPNN+WLKb18Zpg1a5Z8gD311FNWqZLLICAL+6ijjrKVHX744dYmm2xilRo0c3jjjTd2fR47dqw2M33AAQeU3IONHrhkuFxzzTVdZauvvrp12mmn2erRagetGvQ39JJMqzPz589nDQIyiCdNmmQro5WCffbZx+pvg4BWZZ5++ml5ndLDRYVWNS666CJb2VZbbSVnpUDfQw93ejmlGetsVl55ZevMM8/EIekGuQwCelGlWdfslyd6CaPnWmZyBZhz1llnWWuuuWbXZzK0aAKora3NNjlH40sTP6Awbr31Vvnsp+do9oQBrSxm+Oabb+T4vvXWWxjeMqbkRMVfffWV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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "vmin, vmax = ti_cont_data.min(), ti_cont_data.max()\n", + "fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10, 5))\n", + "im = ax0.imshow(\n", + " ti_cont_data, cmap=\"RdBu_r\", origin=\"lower\", vmin=vmin, vmax=vmax\n", + ")\n", + "ax0.set_title(\"Training image (continuous)\")\n", + "ax1.imshow(field_cont, cmap=\"RdBu_r\", origin=\"lower\", vmin=vmin, vmax=vmax)\n", + "ax1.set_title(\"DS realization\")\n", + "fig.colorbar(im, ax=(ax0, ax1), shrink=0.7)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "017d0c21", + "metadata": {}, + "source": [ + "## 4. A real training image: the Strebelle channels\n", + "\n", + "The previous examples used tiny synthetic training images. Here we use the\n", + "classic **Strebelle (2002) channelized fluvial training image**, the de-facto\n", + "benchmark for MPS, and condition the simulation on random hard data.\n", + "\n", + "> **Data source / license.** The training image is downloaded from the\n", + "> [GeoDataSets](https://github.com/GeostatsGuy/GeoDataSets) repository by\n", + "> Michael Pyrcz (GeostatsGuy), which is distributed under the **MIT license**\n", + "> (redistribution permitted with attribution). The underlying channel TI is\n", + "> due to Strebelle, S. (2002), *Conditional simulation of complex geological\n", + "> structures using multiple-point statistics*, Mathematical Geology, 34(1),\n", + "> 1-21. If the download is unavailable, the example falls back to a synthetic\n", + "> training image so it still runs offline." + ] + }, + { + "cell_type": "markdown", + "id": "a322ccd4", + "metadata": {}, + "source": [ + "Load the Strebelle training image, with a synthetic fallback for offline use." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "d3d46961", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TI (256, 256) (Strebelle (2002) via GeoDataSets (MIT)), sand fraction = 0.291\n" + ] + } + ], + "source": [ + "import os\n", + "import urllib.request\n", + "\n", + "TI_URL = (\n", + " \"https://raw.githubusercontent.com/GeostatsGuy/\"\n", + " \"GeoDataSets/master/MPS_Training_image_and_Realizations_500.npz\"\n", + ")\n", + "CACHE = \"mps_strebelle.npz\"\n", + "try:\n", + " if not os.path.exists(CACHE):\n", + " urllib.request.urlretrieve(TI_URL, CACHE)\n", + " ti_strebelle_arr = np.load(CACHE)[\"array1\"].astype(float)\n", + " source = \"Strebelle (2002) via GeoDataSets (MIT)\"\n", + "except Exception as err: # network fallback\n", + " print(f\"download failed ({err}); using a synthetic channel TI instead\")\n", + " gx, gy = np.meshgrid(np.arange(150), np.arange(150), indexing=\"ij\")\n", + " ti_strebelle_arr = (\n", + " (np.sin(gx / 6.0) + np.sin((gx + gy) / 10.0)) > 0\n", + " ).astype(float)\n", + " source = \"synthetic fallback\"\n", + "\n", + "ti_strebelle = gs.TrainingImage(ti_strebelle_arr, categorical=True)\n", + "print(f\"TI {ti_strebelle.shape} ({source}), sand fraction = {ti_strebelle_arr.mean():.3f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ea12c5fc", + "metadata": {}, + "source": [ + "Take 80 random conditioning points from the training image patterns." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "53752f84", + "metadata": {}, + "outputs": [], + "source": [ + "sg_size = 80\n", + "rng = np.random.default_rng(0)\n", + "cond_x = rng.integers(0, sg_size, 80).astype(float)\n", + "cond_y = rng.integers(0, sg_size, 80).astype(float)\n", + "cond_val = ti_strebelle_arr[cond_x.astype(int), cond_y.astype(int)]" + ] + }, + { + "cell_type": "markdown", + "id": "3db056f5", + "metadata": {}, + "source": [ + "Simulate with DSBC-style parameters (best-candidate + partial scan)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "513e273e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "conditioning honoured: 80/80\n" + ] + } + ], + "source": [ + "ds_strebelle = gs.DirectSampling(\n", + " gs.MPSModel(ti_strebelle, n_neighbors=30, scan_fraction=0.2, threshold=0.0)\n", + ")\n", + "ds_strebelle.set_condition([cond_x, cond_y], cond_val)\n", + "field_strebelle = ds_strebelle([np.arange(sg_size, dtype=float)] * 2, seed=42)\n", + "\n", + "honored = int(\n", + " (\n", + " field_strebelle[cond_x.astype(int), cond_y.astype(int)] == cond_val\n", + " ).sum()\n", + ")\n", + "print(f\"conditioning honoured: {honored}/{cond_val.size}\")" + ] + }, + { + "cell_type": "markdown", + "id": "5d64acde", + "metadata": {}, + "source": [ + "Plot the training image crop next to the conditional realization." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "f3ba3037", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "cmap = ListedColormap([\"#c9a96e\", \"#2b6cb0\"]) # shale / sand\n", + "fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(11, 5.5))\n", + "ax0.imshow(ti_strebelle_arr[:sg_size, :sg_size], cmap=cmap, origin=\"lower\")\n", + "ax0.set_title(\"Training image (crop)\")\n", + "ax1.imshow(field_strebelle, cmap=cmap, origin=\"lower\")\n", + "ax1.scatter(cond_y, cond_x, c=cond_val, cmap=cmap, edgecolors=\"k\", s=18)\n", + "ax1.set_title(\"Conditional DS realization\")\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "52495d79", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "* The standalone, sphinx-gallery versions of these four examples live in\n", + " this same folder (`00_simple_unconditional.py`, `01_conditional.py`,\n", + " `02_continuous.py`, `03_channel_strebelle.py`) and are built into the\n", + " online documentation gallery.\n", + "* For a deeper dive into the algorithm internals (the distance layer, the\n", + " parallel DAG engine, and an equation map to Mariethoz (2010) / Juda\n", + " (2022)), see `src/gstools/mps/CODE_GUIDE.md`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4895092d-349a-4948-9470-a1ee803e96c3", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (gstools)", + "language": "python", + "name": "gstools" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/13_mps/00_simple_unconditional.py b/examples/13_mps/00_simple_unconditional.py new file mode 100644 index 00000000..80981ec8 --- /dev/null +++ b/examples/13_mps/00_simple_unconditional.py @@ -0,0 +1,55 @@ +r""" +A first Direct Sampling simulation +---------------------------------- + +This is the minimal Multiple Point Statistics example: build a training image, +wrap it in a :any:`TrainingImage`, and generate one unconditional realization +with :any:`DirectSampling`. + +We use a small, synthetic *channelized* training image generated with NumPy, so +the example is fast and needs no downloads. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import gstools as gs + +############################################################################### +# Create a synthetic binary training image with curvilinear "channels". +# The two facies (0 and 1) form connected, meandering bands — exactly the kind +# of structure two-point statistics struggles to reproduce. + +gx, gy = np.meshgrid(np.arange(60), np.arange(60), indexing="ij") +ti_data = ((np.sin(gx / 5.0) + np.sin((gx + gy) / 8.0)) > 0).astype(float) + +############################################################################### +# Wrap the array in a :any:`TrainingImage`. For a categorical variable (facies +# codes) the distance is the fraction of mismatching neighbours, so the +# ``distance`` argument is ignored here. + +ti = gs.TrainingImage(ti_data, categorical=True, n_neighbors=12) +print(ti) + +############################################################################### +# Create the :any:`DirectSampling` generator and simulate on a 40x40 grid. +# +# * ``n_neighbors`` — how many already-known cells define each data event +# (set on the :any:`TrainingImage`). +# * ``scan_fraction`` — fraction of the training image scanned per cell +# (smaller is faster, slightly noisier). +# * ``threshold=0.0`` — the recommended DSBC mode: always take the best match. + +ds = gs.DirectSampling(gs.MPSModel(ti, scan_fraction=0.3, threshold=0.0)) +field = ds([np.arange(40, dtype=float)] * 2, seed=20250616) + +############################################################################### +# Plot the training image next to the realization. The realization is not a +# copy of the TI, but it reproduces the same channel patterns. + +fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10, 5)) +ax0.imshow(ti_data, cmap="cividis", origin="lower") +ax0.set_title("Training image") +ax1.imshow(field, cmap="cividis", origin="lower") +ax1.set_title("DS realization") +fig.tight_layout() diff --git a/examples/13_mps/01_conditional.py b/examples/13_mps/01_conditional.py new file mode 100644 index 00000000..7eeef91c --- /dev/null +++ b/examples/13_mps/01_conditional.py @@ -0,0 +1,56 @@ +r""" +Conditioning to hard data +------------------------- + +Real simulations must honour measured data (boreholes, samples). Direct +Sampling supports this through :meth:`~gstools.DirectSampling.set_condition`: +conditioning values are pinned into the grid before simulation and preserved +exactly, while the rest of the field is filled in around them. + +We reuse the synthetic channel training image from the first example. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import gstools as gs + +############################################################################### +# Same synthetic channelized training image as before. + +gx, gy = np.meshgrid(np.arange(60), np.arange(60), indexing="ij") +ti_data = ((np.sin(gx / 5.0) + np.sin((gx + gy) / 8.0)) > 0).astype(float) +ti = gs.TrainingImage(ti_data, categorical=True, n_neighbors=12) + +############################################################################### +# Draw 40 random "hard data" points and read their facies from the TI. In a +# real study these would be field measurements; here we sample the TI so the +# conditioning data are consistent with the patterns. + +rng = np.random.default_rng(0) +cond_x = rng.integers(0, 40, 40).astype(float) +cond_y = rng.integers(0, 40, 40).astype(float) +cond_val = ti_data[cond_x.astype(int), cond_y.astype(int)] + +############################################################################### +# Set the conditioning data and simulate. ``set_condition`` snaps each point to +# its nearest grid node, so the values are honoured exactly at those cells. + +ds = gs.DirectSampling(gs.MPSModel(ti, scan_fraction=0.3, threshold=0.0)) +ds.set_condition([cond_x, cond_y], cond_val) +field = ds([np.arange(40, dtype=float)] * 2, seed=7) + +honored = int( + (field[cond_x.astype(int), cond_y.astype(int)] == cond_val).sum() +) +print(f"conditioning honoured: {honored}/{cond_val.size}") + +############################################################################### +# Plot the realization with the conditioning points overlaid. Every marker sits +# on a cell whose simulated facies matches the datum. + +fig, ax = plt.subplots(figsize=(6, 5)) +ax.imshow(field, cmap="cividis", origin="lower") +ax.scatter(cond_y, cond_x, c=cond_val, cmap="cividis", edgecolors="red", s=30) +ax.set_title("Conditional DS realization (red-edged = hard data)") +fig.tight_layout() diff --git a/examples/13_mps/02_continuous.py b/examples/13_mps/02_continuous.py new file mode 100644 index 00000000..674e3302 --- /dev/null +++ b/examples/13_mps/02_continuous.py @@ -0,0 +1,62 @@ +r""" +Continuous variables and distance metrics +----------------------------------------- + +Direct Sampling is not limited to categorical facies: with ``categorical=False`` +it simulates continuous variables (permeability, porosity, elevation, ...). The +``distance`` argument then selects how two patterns are compared: + +* ``"l1"`` / ``"l2"`` — Manhattan / Euclidean distance on the raw values + (Mariethoz et al., 2010, Eq. 6 / Eq. 4). +* ``"variation"`` — compares only the *relative* variations within a pattern + (Eq. 9), tolerating a locally varying mean. Useful for non-stationary data. + +Here we use a smooth continuous training image and a small acceptance +``threshold`` (standard DS mode) to allow approximate matches. + +.. note:: + + The acceptance ``threshold`` is **not comparable across metrics**: the + ``"variation"`` distance is normalised by ``2·d_max``, so the same value is + stricter than it would be for ``"l1"``/``"l2"``. Re-tune it when you switch + the distance metric. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import gstools as gs + +############################################################################### +# A smooth, continuous synthetic training image. + +gx, gy = np.meshgrid(np.arange(60), np.arange(60), indexing="ij") +ti_data = np.sin(gx / 6.0) * np.cos(gy / 8.0) + +############################################################################### +# Build a continuous training image with the Euclidean (``"l2"``) distance. + +ti = gs.TrainingImage( + ti_data, categorical=False, distance="l2", n_neighbors=12 +) +print(ti) + +############################################################################### +# Simulate. ``threshold=0.03`` accepts the first pattern within that distance +# (standard DS), which is faster than the exhaustive best-candidate search for +# continuous variables. + +ds = gs.DirectSampling(gs.MPSModel(ti, scan_fraction=0.3, threshold=0.03)) +field = ds([np.arange(32, dtype=float)] * 2, seed=3) + +############################################################################### +# Plot. The realization reproduces the smooth wavy structure of the TI without +# copying it. A shared colour scale makes the comparison fair. + +vmin, vmax = ti_data.min(), ti_data.max() +fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(10, 5)) +im = ax0.imshow(ti_data, cmap="RdBu_r", origin="lower", vmin=vmin, vmax=vmax) +ax0.set_title("Training image (continuous)") +ax1.imshow(field, cmap="RdBu_r", origin="lower", vmin=vmin, vmax=vmax) +ax1.set_title("DS realization") +fig.colorbar(im, ax=(ax0, ax1), shrink=0.7) diff --git a/examples/13_mps/03_channel_strebelle.py b/examples/13_mps/03_channel_strebelle.py new file mode 100644 index 00000000..e637f295 --- /dev/null +++ b/examples/13_mps/03_channel_strebelle.py @@ -0,0 +1,83 @@ +r""" +A real training image: the Strebelle channels +---------------------------------------------- + +The previous examples used tiny synthetic training images. Here we use the +classic **Strebelle (2002) channelized fluvial training image**, the de-facto +benchmark for MPS, and condition the simulation on random hard data. + +.. note:: + + **Data source / license.** The training image is downloaded from the + `GeoDataSets `_ repository by + Michael Pyrcz (GeostatsGuy), which is distributed under the **MIT license** + (redistribution permitted with attribution). The underlying channel TI is + due to Strebelle, S. (2002), *Conditional simulation of complex geological + structures using multiple-point statistics*, Mathematical Geology, 34(1), + 1-21. If the download is unavailable, the example falls back to a synthetic + training image so it still runs offline. +""" + +import os +import urllib.request + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import ListedColormap + +import gstools as gs + +############################################################################### +# Load the Strebelle training image, with a synthetic fallback for offline use. + +TI_URL = ( + "https://raw.githubusercontent.com/GeostatsGuy/" + "GeoDataSets/master/MPS_Training_image_and_Realizations_500.npz" +) +CACHE = "mps_strebelle.npz" +try: + if not os.path.exists(CACHE): + urllib.request.urlretrieve(TI_URL, CACHE) + ti_arr = np.load(CACHE)["array1"].astype(float) + source = "Strebelle (2002) via GeoDataSets (MIT)" +except Exception as err: # pragma: no cover - network fallback + print(f"download failed ({err}); using a synthetic channel TI instead") + gx, gy = np.meshgrid(np.arange(150), np.arange(150), indexing="ij") + ti_arr = ((np.sin(gx / 6.0) + np.sin((gx + gy) / 10.0)) > 0).astype(float) + source = "synthetic fallback" + +ti = gs.TrainingImage(ti_arr, categorical=True, n_neighbors=30) +print(f"TI {ti.shape} ({source}), sand fraction = {ti_arr.mean():.3f}") + +############################################################################### +# Take 80 random conditioning points from the training image patterns. + +sg_size = 80 +rng = np.random.default_rng(0) +cond_x = rng.integers(0, sg_size, 80).astype(float) +cond_y = rng.integers(0, sg_size, 80).astype(float) +cond_val = ti_arr[cond_x.astype(int), cond_y.astype(int)] + +############################################################################### +# Simulate with DSBC-style parameters (best-candidate + partial scan). + +ds = gs.DirectSampling(gs.MPSModel(ti, scan_fraction=0.2, threshold=0.0)) +ds.set_condition([cond_x, cond_y], cond_val) +field = ds([np.arange(sg_size, dtype=float)] * 2, seed=42) + +honored = int( + (field[cond_x.astype(int), cond_y.astype(int)] == cond_val).sum() +) +print(f"conditioning honoured: {honored}/{cond_val.size}") + +############################################################################### +# Plot the training image crop next to the conditional realization. + +cmap = ListedColormap(["#c9a96e", "#2b6cb0"]) # shale / sand +fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(11, 5.5)) +ax0.imshow(ti_arr[:sg_size, :sg_size], cmap=cmap, origin="lower") +ax0.set_title("Training image (crop)") +ax1.imshow(field, cmap=cmap, origin="lower") +ax1.scatter(cond_y, cond_x, c=cond_val, cmap=cmap, edgecolors="k", s=18) +ax1.set_title("Conditional DS realization") +fig.tight_layout() diff --git a/examples/13_mps/04_channel_strebelle_nonstat.py b/examples/13_mps/04_channel_strebelle_nonstat.py new file mode 100644 index 00000000..e40a2c8e --- /dev/null +++ b/examples/13_mps/04_channel_strebelle_nonstat.py @@ -0,0 +1,77 @@ +r""" +A non-stationary real training image +------------------------------------ + +Here we take the Strebelle channelized fluvial training image and apply a +spatially varying rotation and scaling factor to show how Direct Sampling +can generate non-stationary features from a stationary training image. +""" + +import os +import urllib.request + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import ListedColormap + +import gstools as gs + +# Load the Strebelle training image +TI_URL = ( + "https://raw.githubusercontent.com/GeostatsGuy/" + "GeoDataSets/master/MPS_Training_image_and_Realizations_500.npz" +) +CACHE = "mps_strebelle.npz" +try: + if not os.path.exists(CACHE): + urllib.request.urlretrieve(TI_URL, CACHE) + ti_arr = np.load(CACHE)["array1"].astype(float) + source = "Strebelle (2002) via GeoDataSets (MIT)" +except Exception as err: + print(f"download failed ({err}); using a synthetic channel TI instead") + gx, gy = np.meshgrid(np.arange(150), np.arange(150), indexing="ij") + ti_arr = ((np.sin(gx / 6.0) + np.sin((gx + gy) / 10.0)) > 0).astype(float) + source = "synthetic fallback" + +ti = gs.TrainingImage(ti_arr, categorical=True, n_neighbors=30) +print(f"TI {ti.shape} ({source}), sand fraction = {ti_arr.mean():.3f}") + +# Grid size for simulation +sg_size = 80 +xs = np.arange(sg_size, dtype=float) +ys = np.arange(sg_size, dtype=float) +gx, gy = np.meshgrid(xs, ys, indexing="ij") + +# Create non-stationary rotation and anisotropy (scaling) maps. These are +# passed straight into MPSModel as model configuration (rotation=, scale=) +# rather than set on the engine after construction. +# Rotation varies smoothly along the Y-axis from 0 to 45 degrees +rotation = (gy / sg_size) * (np.pi / 4.0) + +# Anisotropy (channel width) varies along the X-axis +# > 1 means wider channels, < 1 means narrower +anis = 0.8 + (gx / sg_size) * 0.4 + +# scale is a full per-axis vector (no implicit axis-0 pin): channels keep +# their along-x size (1.0) while the transversal width follows the map. +scale = np.stack([np.ones_like(anis), anis], axis=-1) +ds = gs.DirectSampling( + gs.MPSModel( + ti, scan_fraction=0.1, threshold=0.0, rotation=rotation, scale=scale + ) +) + +print("Simulating non-stationary field...") +# Generate the field +field = ds([xs, ys], seed=42) + +# Plot the result +cmap = ListedColormap(["#c9a96e", "#2b6cb0"]) # shale / sand +fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(11, 5.5)) +ax0.imshow(ti_arr[:sg_size, :sg_size], cmap=cmap, origin="lower") +ax0.set_title("Training image (crop)") +ax1.imshow(field, cmap=cmap, origin="lower") +ax1.set_title("Non-stationary DS realization\n(varying rotation & scale)") +fig.tight_layout() +plt.savefig("nonstat_strebelle.png") +print("Saved nonstat_strebelle.png") diff --git a/examples/13_mps/05_channel_strebelle_radial.py b/examples/13_mps/05_channel_strebelle_radial.py new file mode 100644 index 00000000..39844c58 --- /dev/null +++ b/examples/13_mps/05_channel_strebelle_radial.py @@ -0,0 +1,119 @@ +r""" +Reproducing Mariethoz et al. (2010) Figure 7: radial non-stationarity +----------------------------------------------------------------------- + +This example reproduces Figure 7 from Mariethoz et al. (2010), demonstrating +geometric non-stationarity via Direct Sampling. We use the Strebelle channel +training image and apply a radial rotation map and a distance-based affinity +(scaling) map to create channels radiating outward and becoming thinner towards +the edges. +""" + +import os +import urllib.request + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import ListedColormap + +import gstools as gs + +# Load the Strebelle training image +TI_URL = ( + "https://raw.githubusercontent.com/GeostatsGuy/" + "GeoDataSets/master/MPS_Training_image_and_Realizations_500.npz" +) +CACHE = "mps_strebelle.npz" +try: + if not os.path.exists(CACHE): + urllib.request.urlretrieve(TI_URL, CACHE) + ti_arr = np.load(CACHE)["array1"].astype(float) + source = "Strebelle (2002) via GeoDataSets (MIT)" +except Exception as err: + print(f"download failed ({err}); using a synthetic channel TI instead") + gx, gy = np.meshgrid(np.arange(150), np.arange(150), indexing="ij") + ti_arr = ((np.sin(gx / 6.0) + np.sin((gx + gy) / 10.0)) > 0).astype(float) + source = "synthetic fallback" + +# GSTools' rotation/anisotropy convention always aligns the *primary* +# (axis-0, unscaled by `anis`) direction to the `rotation` angle and scales +# axis 1 by `anis`. The Strebelle TI's channels run along its axis 1, not +# axis 0 (verified via structure-tensor analysis of the raw TI) -- left +# as-is, this makes `rotation` point the channels' *short* axis radially +# (channels end up tangential, forming concentric circles) and makes `anis` +# compress the channels' *length* instead of their width (no visible +# thinning). Transposing the TI swaps which array axis carries the channel +# direction, so both the orientation and the thinning effect come out +# right with a plain radial `rotation` and `anis` map. +ti_arr = ti_arr.T + +ti = gs.TrainingImage(ti_arr, categorical=True, n_neighbors=32) +print(f"TI {ti.shape} ({source}), sand fraction = {ti_arr.mean():.3f}") + +# The paper used a 1000x1000 grid. To avoid grid crowding where the thick TI +# channels collide near the center, we increase the size to 500x500. +sg_size = 1000 +xs = np.arange(sg_size, dtype=float) +ys = np.arange(sg_size, dtype=float) +gx, gy = np.meshgrid(xs, ys, indexing="ij") + +center_x = sg_size / 2.0 +center_y = sg_size / 2.0 + +# 1. Rotation Map: Angle from the center, creating a radial pattern. +# Note: GSTools rotation is in radians. +# We measure the angle from index 0 (y) towards index 1 (x) to match GSTools' assumption. +rotation = np.arctan2(gy - center_y, gx - center_x) + +# 2. Affinity Map: Scales the channels based on distance from center. +# The paper goes from 1.0 at the center down to ~0.4 at the corners. +# In GSTools, anis < 1 correctly makes the feature thinner. +radius = np.sqrt((gx - center_x) ** 2 + (gy - center_y) ** 2) +max_radius = np.sqrt(center_x**2 + center_y**2) +anis = 1.0 - 0.65 * (radius / max_radius) + +# 3. Configure the MPS model. rotation/scale are model configuration passed +# straight into MPSModel; scale is a full per-axis vector, so axis 0 stays +# pinned at 1.0 while the affinity map scales axis 1. +scale = np.stack([np.ones_like(anis), anis], axis=-1) +ds = gs.DirectSampling( + gs.MPSModel( + ti, scan_fraction=0.35, threshold=0, rotation=rotation, scale=scale + ) +) + +print(f"Simulating non-stationary field ({sg_size}x{sg_size})...") +field = ds([xs, ys], seed=1, num_threads=8) + +# Plotting the reproduction of Mariethoz Figure 7 +fig, axes = plt.subplots(2, 2, figsize=(10, 10)) + +# All panels are transposed so x runs horizontally and y vertically: the +# arrays are indexed [x, y], which imshow would otherwise render sideways. + +# a) Rotation map +im_rot = axes[0, 0].imshow(np.rad2deg(rotation).T, cmap="gray", origin="lower") +axes[0, 0].set_title("a) Rotation (degrees)") +plt.colorbar(im_rot, ax=axes[0, 0], fraction=0.046, pad=0.04) + +# b) Affinity map +im_aff = axes[0, 1].imshow( + anis.T, cmap="gray", origin="lower", vmin=0.1, vmax=1.0 +) +axes[0, 1].set_title("b) Affinity ratio") +plt.colorbar(im_aff, ax=axes[0, 1], fraction=0.046, pad=0.04) + +# c) TI +cmap = ListedColormap( + ["#000000", "#FFFFFF"] +) # black background, white channels +axes[1, 0].imshow(ti_arr.T[:250, :250], cmap=cmap, origin="lower") +axes[1, 0].set_title("c) TI") + +# d) Simulation +axes[1, 1].imshow(field.T, cmap=cmap, origin="lower") +axes[1, 1].set_title("d) One simulation") + +fig.tight_layout() +plt.savefig("mariethoz_fig7_reproduction.png") +print("Saved mariethoz_fig7_reproduction.png") diff --git a/examples/13_mps/06_channel_multivariate_fig8.py b/examples/13_mps/06_channel_multivariate_fig8.py new file mode 100644 index 00000000..10cf7c16 --- /dev/null +++ b/examples/13_mps/06_channel_multivariate_fig8.py @@ -0,0 +1,110 @@ +r""" +Reproducing Mariethoz et al. (2010) Figure 8: non-stationary co-simulation +----------------------------------------------------------------------------- + +Figure 8 co-simulates a channel facies together with an auxiliary orientation +variable. Both the training image and the simulation grid carry the auxiliary +variable, so the joint scan is forced to match the local channel direction — +the auxiliary variable acts as the non-stationarity carrier instead of an +explicit rotation map. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import gstools as gs + +# 1. Create the Synthetic TI (250x250) +ti_size = 250 +gx_ti, gy_ti = np.meshgrid( + np.arange(ti_size), np.arange(ti_size), indexing="ij" +) + +# Primary variable: channels rotating as a function of X +# theta varies from 0 (horizontal) to pi/2 (vertical) along X +theta_ti = (gx_ti / float(ti_size - 1)) * (np.pi / 2.0) +# create a simple pattern that rotates +channels_ti = ( + (np.sin(gx_ti * np.cos(theta_ti) * 0.5 + gy_ti * np.sin(theta_ti) * 0.5)) + > 0 +).astype(float) + +# Secondary variable: X coordinate normalized +context_ti = gx_ti / float(ti_size - 1) + +# Assemble multivariate TI using Variable list. +# From paper: n1 = 30 for primary, n2 = 1 for secondary. +ti = gs.TrainingImage( + [ + gs.Variable( + "channels", + channels_ti, + categorical=True, + weight=0.5, + n_neighbors=30, + ), + gs.Variable( + "context", + context_ti, + categorical=False, + weight=0.5, + distance="l1", + n_neighbors=1, + ), + ] +) + +# 2. Setup the MPS Model +# We use scan_fraction=0.5 (half the TI) and a tight threshold=0.01 +# since we have a perfect synthetic correlation to match. +model = gs.MPSModel(ti, scan_fraction=0.5, threshold=0.01) + +# 3. Setup the Simulation Grid (Exhaustive Secondary Variable) +# The simulation grid is 250x250. +sg_size = 250 +xs_sg = np.arange(sg_size, dtype=float) +ys_sg = np.arange(sg_size, dtype=float) +gx_sg, gy_sg = np.meshgrid(xs_sg, ys_sg, indexing="ij") + +# Secondary variable in SG: normalized Y coordinate +# We want horizontal at context=0 (which is X=0 in TI), vertical at context=1 (X=250 in TI) +# The paper says: zeros are at the bottom, ones are on top. +context_sg = gy_sg / float(sg_size - 1) + +# To use this as an exhaustive secondary variable, we "condition" the simulation +# with the context variable at every single point. +cond_pos = [gx_sg.flatten(), gy_sg.flatten()] +cond_val = { + # np.nan means "unconditioned" for the channels variable + "channels": np.full(sg_size * sg_size, np.nan), + "context": context_sg.flatten(), +} + +ds = gs.DirectSampling(model) +ds.set_condition(cond_pos, cond_val) + +# 4. Run the simulation +print(f"Simulating multivariate non-stationary field ({sg_size}x{sg_size})...") +# Note: we use num_threads=1 because we found the inner loop thread overhead is high! +fields = ds([xs_sg, ys_sg], seed=42, num_threads=1) +channels_sim = fields["channels"] +context_sim = fields["context"] + +# 5. Plotting +fig, axes = plt.subplots(2, 2, figsize=(10, 10)) + +axes[0, 0].imshow(channels_ti, cmap="gray", origin="lower") +axes[0, 0].set_title("a) Training image, variable 1") + +axes[0, 1].imshow(context_ti, cmap="gray", origin="lower", vmin=0, vmax=1) +axes[0, 1].set_title("b) Training image, variable 2") + +axes[1, 0].imshow(channels_sim, cmap="gray", origin="lower") +axes[1, 0].set_title("c) Simulation, variable 1") + +axes[1, 1].imshow(context_sim, cmap="gray", origin="lower", vmin=0, vmax=1) +axes[1, 1].set_title("d) Simulation, variable 2") + +fig.tight_layout() +plt.savefig("mariethoz_fig8_reproduction.png") +print("Saved mariethoz_fig8_reproduction.png") diff --git a/examples/13_mps/07_variation_distance_fig6.py b/examples/13_mps/07_variation_distance_fig6.py new file mode 100644 index 00000000..97699119 --- /dev/null +++ b/examples/13_mps/07_variation_distance_fig6.py @@ -0,0 +1,102 @@ +r""" +Reproducing Mariethoz et al. (2010) Figure 6: the variation distance +----------------------------------------------------------------------- + +Figure 6 contrasts the plain :math:`L_2` pattern distance with Mariethoz's +"variation" distance (Eq. 9) on a multi-Gaussian training image. The variation +distance compares the *shape* of a pattern rather than its level, and the +matched value is mean-shifted on assignment, which lets a stationary TI +reproduce a field whose local mean drifts. +""" + +import matplotlib.pyplot as plt +import numpy as np + +import gstools as gs + +# 1. Create the Training Image (Multi-Gaussian) +# The paper uses a 250x250 multi-Gaussian field with zero mean and unit variance. +# Exponential variogram, range x = 35, range y = 25. +grid_size = 250 +x = y = np.arange(grid_size, dtype=float) + +# Note: GSTools len_scale is roughly range / 3 for exponential models, +# but we'll just set len_scale directly to match the spatial correlation visually. +cov_model = gs.Exponential(dim=2, var=1.0, len_scale=[35, 25], angles=0) +srf = gs.SRF(cov_model, mean=0, seed=123) +ti_data = srf((x, y), mesh_type="structured") + +# We create a continuous Training Image and specify distance="variation" +# This tells GSTools to use the variation-based distance (Mariethoz Eq 9) +ti = gs.TrainingImage( + ti_data, categorical=False, distance="variation", n_neighbors=15 +) + +# 2. Create the Non-Stationary Conditioning Data +# 100 points, values ranging from ~99 to ~111 with a spatial trend +np.random.seed(42) +n_cond = 100 +cond_x = np.random.uniform(0, grid_size, n_cond) +cond_y = np.random.uniform(0, grid_size, n_cond) + +# Create a trend that increases along the X axis (left to right) +# plus some random noise to simulate real data +cond_val = 100 + 10 * (cond_x / grid_size) + np.random.normal(0, 1.5, n_cond) + +# 3. Setup the MPS Model +# From the caption: n=15, t=0.01. We use scan_fraction=0.5 +model = gs.MPSModel(ti, scan_fraction=0.5, threshold=0.01) + +ds = gs.DirectSampling(model) +ds.set_condition([cond_x, cond_y], cond_val) + +# 4. Run the simulation +print( + f"Simulating variation-based field ({grid_size}x{grid_size}) with 100 cond points..." +) +field = ds([x, y], seed=42, num_threads=1) + +# 5. Plotting +fig, axes = plt.subplots(1, 3, figsize=(15, 5)) + +# a) TI +im_a = axes[0].imshow( + ti_data.T, + cmap="gray", + origin="lower", + extent=[0, 250, 0, 250], + vmin=-3, + vmax=3, +) +axes[0].set_title("a) Training image") +plt.colorbar(im_a, ax=axes[0], fraction=0.046, pad=0.04) + +# b) Conditioning data +im_b = axes[1].scatter( + cond_x, cond_y, c=cond_val, cmap="gray", vmin=98, vmax=112, s=20 +) +axes[1].set_xlim(0, 250) +axes[1].set_ylim(0, 250) +axes[1].set_aspect("equal") +axes[1].set_title("b) Conditioning data") +plt.colorbar(im_b, ax=axes[1], fraction=0.046, pad=0.04) + +# c) Simulation +im_c = axes[2].imshow( + field.T, + cmap="gray", + origin="lower", + extent=[0, 250, 0, 250], + vmin=98, + vmax=112, +) +# Overlay conditioning points as open circles +axes[2].scatter( + cond_x, cond_y, facecolors="none", edgecolors="k", s=40, linewidths=1 +) +axes[2].set_title("c) One simulation") +plt.colorbar(im_c, ax=axes[2], fraction=0.046, pad=0.04) + +fig.tight_layout() +plt.savefig("mariethoz_fig6_reproduction.png") +print("Saved mariethoz_fig6_reproduction.png") diff --git a/examples/13_mps/08_bivariate_joint_fig4.py b/examples/13_mps/08_bivariate_joint_fig4.py new file mode 100644 index 00000000..79b4f385 --- /dev/null +++ b/examples/13_mps/08_bivariate_joint_fig4.py @@ -0,0 +1,111 @@ +r""" +Reproducing Mariethoz et al. (2010) Figure 4: bivariate joint simulation +--------------------------------------------------------------------------- + +Figure 4 simulates two dependent variables at once: the Strebelle channel +facies and a smoothed, noise-perturbed derivative of it. Direct Sampling +copies both values from the *same* training image cell at every node, so the +cross-variable relationship is reproduced exactly rather than being imposed +by a coregionalization model. +""" + +import os +import urllib.request + +import matplotlib.pyplot as plt +import numpy as np +from scipy.ndimage import uniform_filter + +import gstools as gs + +# 1. Prepare the Training Image +# Load the Strebelle training image (Variable 1) +TI_URL = ( + "https://raw.githubusercontent.com/GeostatsGuy/" + "GeoDataSets/master/MPS_Training_image_and_Realizations_500.npz" +) +CACHE = "mps_strebelle.npz" +if not os.path.exists(CACHE): + urllib.request.urlretrieve(TI_URL, CACHE) + +ti_full = np.load(CACHE)["array1"].astype(float) +# The figure shows a 250x250 subset +ti_var1 = ti_full[:250, :250] + +# Generate Variable 2 (e.g. resistivity) +# Paper: "smoothing variable 1 using a moving average with a window made of +# the 500 closest nodes and then adding an uncorrelated white noise [0, 0.5]" +# A 23x23 square window contains 529 nodes, which is a good approximation. +smoothed = uniform_filter(ti_var1, size=23, mode="reflect") + +np.random.seed(42) +noise = np.random.uniform(0, 0.5, size=ti_var1.shape) +ti_var2 = smoothed + noise + +# 2. Assemble Multivariate TI using Variable list. +# Paper says Variable 2 uses distance (4), which is the weighted RMSE ("l2" in GSTools). +# n1 = 30, n2 = 30, t = 0.01, w1 = 0.5, w2 = 0.5 +ti = gs.TrainingImage( + [ + gs.Variable( + "facies", + ti_var1, + categorical=True, + weight=0.5, + distance="l1", + n_neighbors=30, + ), + gs.Variable( + "resistivity", + ti_var2, + categorical=False, + weight=0.5, + distance="l2", + n_neighbors=30, + ), + ] +) + +# 3. Setup the MPS Model +model = gs.MPSModel(ti, scan_fraction=0.5, threshold=0.01) + +# 4. Run unconditional simulation +ds = gs.DirectSampling(model) + +grid_size = 250 +x = y = np.arange(grid_size, dtype=float) + +print(f"Simulating unconditional bivariate field ({grid_size}x{grid_size})...") +fields = ds([x, y], seed=123, num_threads=1) + +sim_var1 = fields["facies"] +sim_var2 = fields["resistivity"] + +# 5. Plotting +fig, axes = plt.subplots(2, 2, figsize=(10, 10)) + +# a) TI var 1 +axes[0, 0].imshow(ti_var1, cmap="gray", origin="lower") +axes[0, 0].set_title("a) Training image, variable 1") + +# b) TI var 2 +im_b = axes[0, 1].imshow( + ti_var2, cmap="gray", origin="lower", vmin=0, vmax=1.5 +) +axes[0, 1].set_title("b) Training image, variable 2") +plt.colorbar(im_b, ax=axes[0, 1], fraction=0.046, pad=0.04) + +# c) Sim var 1 +axes[1, 0].imshow(sim_var1, cmap="gray", origin="lower") +axes[1, 0].set_title("c) Simulation, variable 1") + +# d) Sim var 2 +im_d = axes[1, 1].imshow( + sim_var2, cmap="gray", origin="lower", vmin=0, vmax=1.5 +) +axes[1, 1].set_title("d) Simulation, variable 2") +plt.colorbar(im_d, ax=axes[1, 1], fraction=0.046, pad=0.04) + +fig.tight_layout() +plt.savefig("mariethoz_fig4_reproduction.png") +print("Saved mariethoz_fig4_reproduction.png") diff --git a/examples/13_mps/09_continuous_stone_fig3.py b/examples/13_mps/09_continuous_stone_fig3.py new file mode 100644 index 00000000..81b758c8 --- /dev/null +++ b/examples/13_mps/09_continuous_stone_fig3.py @@ -0,0 +1,99 @@ +r""" +Reproducing Mariethoz et al. (2010) Figure 3: a continuous training image +-------------------------------------------------------------------------- + +Figure 3 of Mariethoz et al. (2010) simulates the continuous "stone" training +image conditioned on values drawn from the TI's own marginal distribution. +The paper's caption gives ``n = 80`` and ``t = 0.01``; the pattern distance is +their Distance 4 (Eq. 4, a normalized RMSE), which is ``distance="l2"`` in +GSTools' naming. + +Because the conditioning values are resampled from the TI, a correct +simulation must reproduce the TI histogram closely — panel c) is the +verification, not just decoration. +""" + +import os +import urllib.request + +import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +import gstools as gs + +# 1. Training image +TI_URL = ( + "https://raw.githubusercontent.com/GAIA-UNIL/TrainingImagesTIFF/" + "master/stone.tiff" +) +CACHE = "stone.tiff" +if not os.path.exists(CACHE): + urllib.request.urlretrieve(TI_URL, CACHE) +# The paper figure uses a 200x200 grid. +ti_data = np.array(Image.open(CACHE)).astype(float)[:200, :200] +grid_size = 200 + +ti = gs.TrainingImage( + ti_data, categorical=False, distance="l2", n_neighbors=80, distance_power=0.5 +) + +# 2. Conditioning data: paper takes 100 values from the TI's marginal +# distribution and places them at random positions in the simulation. +rng = np.random.default_rng(1) +n_cond = 200 +cond_x = rng.uniform(0, grid_size, n_cond) +cond_y = rng.uniform(0, grid_size, n_cond) +cond_val = ti_data[ + rng.integers(0, ti_data.shape[0], n_cond), + rng.integers(0, ti_data.shape[1], n_cond), +] + +# 3. Simulation +ds = gs.DirectSampling( + gs.MPSModel(ti, scan_fraction=0.5, threshold=0.01, post_processing=1) +) +ds.set_condition([cond_x, cond_y], cond_val) + +x = y = np.arange(grid_size, dtype=float) +print(f"Simulating continuous field ({grid_size}x{grid_size})...") +field = ds([x, y], seed=121, num_threads=8, progress=True, path="sequential") + +# 4. Verification: DS pastes TI cells, so every simulated value must be a TI +# value (CLAUDE.md MPS validity criterion). +assert set(np.unique(field).tolist()) <= set(np.unique(ti_data).tolist()) +print(f"mean -- TI {ti_data.mean():.4f} | simulation {field.mean():.4f}") + +# 5. Plotting: Figure 3's layout (two images on top, histogram below) +fig = plt.figure(figsize=(12, 10)) +ax1, ax2, ax3 = plt.subplot(221), plt.subplot(222), plt.subplot(212) + +ax1.imshow(ti_data.T, cmap="gray", origin="lower", vmin=0, vmax=1.0) +ax1.set_title("a) Training image") + +im_b = ax2.imshow(field.T, cmap="gray", origin="lower", vmin=0, vmax=1.0) +ax2.scatter( + cond_x, + cond_y, + c=cond_val, + cmap="gray", + vmin=0, + vmax=1.0, + edgecolors="k", + s=40, + linewidths=0.5, +) +ax2.set_title("b) Simulation (conditioning points overlaid)") +plt.colorbar(im_b, ax=ax2, fraction=0.046, pad=0.04) + +hist_ti, bins = np.histogram(ti_data.ravel(), bins=50, density=True) +hist_sim, _ = np.histogram(field.ravel(), bins=bins, density=True) +bin_centers = 0.5 * (bins[:-1] + bins[1:]) +ax3.plot(bin_centers, hist_sim, "k-", label="Simulation") +ax3.plot(bin_centers, hist_ti, "k--", label="Training image") +ax3.set_title("c) Comparison of histograms") +ax3.legend() + +fig.tight_layout() +plt.savefig("mariethoz_fig3_reproduction.png") +print("Saved mariethoz_fig3_reproduction.png") diff --git a/examples/13_mps/README.rst b/examples/13_mps/README.rst new file mode 100644 index 00000000..796f1ddf --- /dev/null +++ b/examples/13_mps/README.rst @@ -0,0 +1,52 @@ +Multiple Point Statistics +========================= + +Two-point geostatistics (covariance models, kriging, SRFs) describes spatial +structure through pairs of points and a variogram. This is powerful, but it +cannot reproduce *curvilinear* or *connected* features such as meandering +channels, fractures, or other patterns that depend on the joint configuration +of many points at once. + +**Multiple Point Statistics (MPS)** addresses this by learning patterns +directly from a **training image (TI)** — an example image deemed +representative of the spatial structure to simulate. Instead of fitting a +variogram, MPS borrows whole patterns from the TI. + +GSTools provides the **Direct Sampling (DS)** algorithm +(`Mariethoz et al., 2010 `_), together +with the **Direct Sampling Best Candidate (DSBC)** parametrization +(`Juda et al., 2022 `_). + +The three classes you always need: + +* :any:`TrainingImage` — the training data plus the distance used to compare + 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 + features below. +* :any:`DirectSampling` — the generator that produces realizations on a + structured grid (the analogue of :any:`SRF`). + +Optional, passed to :any:`MPSModel`: + +* ``rotation=`` / ``scale=`` — geometric non-stationarity, so a stationary + training image can produce patterns that rotate and stretch across the grid. +* :any:`Zone` — bind a different training image (or a + :meth:`~gstools.TrainingImage.window` view of one large image) to a region + of the simulation grid. +* ``post_processing=`` — re-simulation passes that remove simulation noise. + +The core idea: to fill each grid cell, DS looks at the values already present +around it (its *data event*), scans the training image for a location whose +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 +recommended DSBC mode). + +The following tutorials build up from a minimal unconditional simulation to +conditioning and continuous variables. + +Examples +-------- diff --git a/src/gstools/__init__.py b/src/gstools/__init__.py index 8b9d94c2..4345e4e5 100644 --- a/src/gstools/__init__.py +++ b/src/gstools/__init__.py @@ -24,10 +24,23 @@ tools transform normalizer + mps Classes ======= +Multiple Point Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^ +Classes for Multiple Point Statistics (MPS) simulations + +.. currentmodule:: gstools.mps + +.. autosummary:: + DirectSampling + MPSModel + TrainingImage + Zone + Kriging ^^^^^^^ Swiss-Army-Knife for Kriging. For short cut classes see: :any:`gstools.krige` @@ -153,6 +166,7 @@ covmodel, field, krige, + mps, cokriging, normalizer, random, @@ -191,6 +205,7 @@ ) from gstools.field import PGS, SRF, CondSRF from gstools.krige import Krige +from gstools.mps import DirectSampling, MPSModel, TrainingImage, Zone from gstools.tools import ( DEGREE_SCALE, EARTH_RADIUS, @@ -221,6 +236,8 @@ __version__ = "0.0.0.dev0" __all__ = ["__version__"] +__all__ += ["covmodel", "field", "variogram", "krige", "random", "tools"] +__all__ += ["transform", "normalizer", "config", "mps"] __all__ += [ "covmodel", "field", @@ -272,6 +289,10 @@ "SRF", "CondSRF", "PGS", + "DirectSampling", + "MPSModel", + "TrainingImage", + "Zone", "rotated_main_axes", "generate_grid", "generate_st_grid", diff --git a/src/gstools/mps/__init__.py b/src/gstools/mps/__init__.py new file mode 100644 index 00000000..82025f51 --- /dev/null +++ b/src/gstools/mps/__init__.py @@ -0,0 +1,55 @@ +""" +GStools subpackage for Multiple Point Statistics (MPS). + +.. currentmodule:: gstools.mps + +Training Image +^^^^^^^^^^^^^^ +Training image wrapper holding spatial pattern data and distance metrics. + +.. autosummary:: + :toctree: + + TrainingImage + Variable + +MPS Model +^^^^^^^^^ +Configuration class bundling a training image with Direct Sampling search parameters. + +.. autosummary:: + :toctree: + + MPSModel + +Zonation +^^^^^^^^ +Bind a training image to a region of the simulation grid for zonated Direct Sampling. + +.. autosummary:: + :toctree: + + Zone + +Simulation +^^^^^^^^^^ +Direct Sampling simulation following the gstools field interface. + +.. autosummary:: + :toctree: + + DirectSampling +""" + +from gstools.mps.direct_sampling import DirectSampling +from gstools.mps.model import MPSModel +from gstools.mps.training_image import TrainingImage, Variable +from gstools.mps.zone import Zone + +__all__ = [ + "DirectSampling", + "MPSModel", + "TrainingImage", + "Variable", + "Zone", +] diff --git a/src/gstools/mps/data_event.py b/src/gstools/mps/data_event.py new file mode 100644 index 00000000..c9344f54 --- /dev/null +++ b/src/gstools/mps/data_event.py @@ -0,0 +1,64 @@ +"""Per-variable data-event value object for Direct Sampling. + +Bundles the five parallel arrays that describe one variable's data event so +they cannot silently desync. All operations are functional (return a new +``DataEvent``); arrays are never mutated in place. This preserves the +aliasing invariant in :mod:`gstools.mps.simulate`: ``lags_sg`` and ``lags_ti`` +stay independent across truncation, so trimming the TI-frame lags never +mutates the SG-frame lags. +""" + + +class DataEvent: + """One variable's data event: parallel SG arrays plus the TI-frame lags. + + Parameters + ---------- + lags_sg : numpy.ndarray, shape (k, dim) + Lag vectors in the simulation-grid frame (float64). + values : numpy.ndarray, shape (k,) + Data-event values gathered from the simulation grid. + cond_mask : numpy.ndarray of bool, shape (k,) + True where the entry is conditioning data. + lag_norms : numpy.ndarray, shape (k,) + Euclidean norm of each SG lag vector. + lags_ti : numpy.ndarray, shape (k, dim) or None, optional + Lag vectors mapped into the training-image frame. ``None`` until + seam 2 (:meth:`_transform_and_reduce_lags`) computes them. + """ + + __slots__ = ("lags_sg", "values", "cond_mask", "lag_norms", "lags_ti") + + def __init__(self, lags_sg, values, cond_mask, lag_norms, lags_ti=None): + self.lags_sg = lags_sg + self.values = values + self.cond_mask = cond_mask + self.lag_norms = lag_norms + self.lags_ti = lags_ti + + def __len__(self): + """Number of lags ``k`` in the data event.""" + return len(self.values) + + def with_lags_ti(self, lags_ti): + """Return a new :class:`DataEvent` with ``lags_ti`` set, SG arrays shared.""" + return DataEvent( + self.lags_sg, self.values, self.cond_mask, self.lag_norms, lags_ti + ) + + def truncate(self, k): + """Return a new :class:`DataEvent` with every array sliced to ``[:k]``. + + Each field is a new array reference (a slice) on the returned object; + because ``truncate`` is functional — it never writes in place — the + SG/TI aliasing invariant holds regardless of whether ``lags_ti`` shares + ``lags_sg``'s buffer. Callers that need a buffer-independent ``lags_ti`` + must pass a copy (see :meth:`with_lags_ti`). + """ + return DataEvent( + self.lags_sg[:k], + self.values[:k], + self.cond_mask[:k], + self.lag_norms[:k], + None if self.lags_ti is None else self.lags_ti[:k], + ) diff --git a/src/gstools/mps/direct_sampling.py b/src/gstools/mps/direct_sampling.py new file mode 100644 index 00000000..9f45e56c --- /dev/null +++ b/src/gstools/mps/direct_sampling.py @@ -0,0 +1,595 @@ +""" +GStools subpackage providing the Direct Sampling MPS simulation class. + +.. currentmodule:: gstools.mps + +The following classes and functions are provided + +.. autosummary:: + DirectSampling +""" + +import warnings + +import numpy as np + +from gstools.field.base import Field +from gstools.mps.model import MPSModel +from gstools.mps.nonstationary import build_zone_selector, resolve_spec +from gstools.mps.simulate import ds_simulate +from gstools.mps.training_image import _check_category_codes +from gstools.normalizer.tools import apply_mean_norm_trend +from gstools.random.rng import RNG +from gstools.tools.geometric import generate_grid, no_of_angles + +__all__ = ["DirectSampling"] + + +def _check_penalty_matrix_conditioning(var_name, penalty_matrix, values): + """Validate conditioning values against a Variable's penalty_matrix, if set. + + Fails fast at ``set_condition()`` time with a clear ``ValueError`` rather + than raising a raw ``IndexError`` deep in the scan loop when the matrix + is later indexed by an out-of-range or non-integer category code. + """ + if penalty_matrix is None: + return + label = "variable" if var_name is None else f"variable {var_name!r}" + _check_category_codes( + values, + penalty_matrix.shape[0], + f"DirectSampling.set_condition: conditioning values for {label}", + ) + + +def _warn_nonuniform_axes(axes): + """Warn when a transform/zonation is active on non-index-like axes. + + Rotation/scale act on **index lags** and zone masks are per-cell; for + uniformly and equally spaced axes index-space and physical-space geometry + coincide (the spacing matrix h·I commutes with rotation). Otherwise a + physical-space rotation is not an index-space rotation — warn (MPS-local; + Mariethoz2010 §6.2 geometry is defined on cells). + """ + steps = [] + for d, ax in enumerate(axes): + ax = np.asarray(ax, dtype=np.double) + if len(ax) < 2: + continue + diffs = np.diff(ax) + if not np.allclose(diffs, diffs[0]): + warnings.warn( + f"DirectSampling: axis {d} is not uniformly spaced while a " + "geometric transform or zonation is active. Rotation/scale " + "act on index lags (cell offsets); on non-uniform axes the " + "result is not a physical-space transform.", + UserWarning, + stacklevel=3, + ) + return + steps.append(diffs[0]) + if len(steps) > 1 and not np.allclose(steps, steps[0]): + warnings.warn( + "DirectSampling: grid axes have unequal spacing while a " + "geometric transform or zonation is active. Rotation/scale act " + "on index lags (cell offsets); with anisotropic spacing the " + "result is not a physical-space transform.", + UserWarning, + stacklevel=3, + ) + + +class DirectSampling(Field): + """Multiple Point Statistics simulation using Direct Sampling. + + Subclasses :class:`gstools.field.base.Field`. Takes an :class:`MPSModel` + that bundles a :class:`TrainingImage` and all algorithm parameters, and + produces fields on structured grids. + + Parameters + ---------- + model : MPSModel + Algorithm configuration: training image, scan fraction, threshold, + conditioning weight, and boundary strategy. Neighbour count and + maximum search radius live on each :class:`Variable` inside the + :class:`TrainingImage`. Build one with + ``MPSModel(ti, scan_fraction=…, threshold=…)``. + seed : int or nan, optional + Master RNG seed. Default: nan. + + Notes + ----- + Runtime concerns (thread count, progress reporting) are set on the + instance via :attr:`num_threads` or passed as keyword arguments to + :meth:`__call__`. + + See Also + -------- + MPSModel : holds the algorithm parameters. + TrainingImage : training data holder. + """ + + default_field_names = ["field"] + + def __init__( + self, + model, + seed=np.nan, + ): + if not isinstance(model, MPSModel): + raise TypeError( + "DirectSampling requires an MPSModel as its first argument. " + "Wrap a TrainingImage first: " + "DirectSampling(MPSModel(ti, scan_fraction=…))" + ) + + super().__init__(model=None, dim=model.ti.ndim, value_type="scalar") + # Retain the MPSModel as the single source of truth for search + # parameters. It is NOT stored in Field._model (which stays None) — + # Field.pre_pos/field_dim/latlon/temporal assume a CovModel there. + self._mps_model = model + self._ti = model.ti + self._num_threads = None + self._cond_pos = None + self._cond_val = None + self._mv_mean = {} + self._mv_normalizer = {} + self._mv_trend = {} + self.rng = RNG(None if np.isnan(seed) else int(seed)) + if model.ti.multivariate: + for v in model.ti.variables: + if v.name in dir(self): + raise ValueError( + f"DirectSampling: variable name {v.name!r} collides with an " + f"existing attribute." + ) + + def __call__( + self, + pos=None, + seed=np.nan, + path_seed=np.nan, + node_seed=np.nan, + path="random", + mesh_type="structured", + post_process=True, + store=True, + progress=None, + num_threads=None, + ): + """Generate the spatial random field via Direct Sampling. + + For a univariate training image the field is saved as ``self.field`` + and is also returned. For a multivariate training image no single + ``self.field`` is set — each variable is stored under its own name + (accessible via ``self[variable]`` / :attr:`all_fields`) and a + ``{variable: numpy.ndarray}`` dict is returned. + + Parameters + ---------- + pos : :class:`list`, optional + The position tuple, containing main direction and transversal + directions. Only structured grids are supported. + seed : :class:`int`, optional + Seed for the RNG. If ``np.nan``, the current seed is kept. + Default: ``np.nan`` + path_seed : :class:`int` or :any:`numpy.nan`, optional + Seed controlling the order in which simulation grid nodes are + visited. If ``np.nan`` (default), derived from the master RNG + together with ``node_seed``. Fix this while varying ``node_seed`` + to study the effect of TI search randomness under a constant + visit order, or vice versa. Only has an effect when + ``path="random"``; inert for ``"sequential"`` and explicit arrays. + Default: ``np.nan`` + node_seed : :class:`int` or :any:`numpy.nan`, optional + Seed controlling the TI scan entry point and fallback cell for + every node. If ``np.nan`` (default), derived from the master RNG. + Default: ``np.nan`` + path : :class:`str` or array-like, optional + Node visit order. ``"random"`` (default) shuffles the unknown + nodes uniformly using the ``path_seed``-controlled RNG — the + standard DS behaviour. ``"sequential"`` visits nodes in raster + (lexicographic) order, which is deterministic regardless of + ``path_seed``. An explicit integer array of shape ``(N, dim)`` + provides a caller-supplied visit order and must be a permutation + of exactly the unknown-node set (missing nodes, extra nodes, and + duplicate rows all raise :class:`ValueError`). + Default: ``"random"`` + mesh_type : :class:`str`, optional + Grid type. Must be ``"structured"``. + Default: ``"structured"`` + post_process : :class:`bool`, optional + Whether to apply post-processing transformations (mean, + normalizer, trend) to the field. Default: :any:`True` + store : :class:`bool` or :class:`str`, optional + Whether to store the field (``True``), not store it (``False``), + or store it under a custom name (string). + Default: :any:`True` + progress : :class:`bool` or callable or None, optional + Show simulation progress. ``True`` prints a plain percentage line + (no third-party dependency); a callable is invoked as + ``progress(n_done, n_total)`` once per completed node. + ``None``/``False`` (default) disables it. + + Returns + ------- + field : :class:`numpy.ndarray` or :class:`dict` + For a univariate training image, the simulated field (also saved as + ``self.field``). For a multivariate training image, a + ``{variable: numpy.ndarray}`` dict with all variables on equal + footing — each is also stored as a named field accessible via + ``self[variable]`` / :attr:`all_fields`. + + Notes + ----- + DS post-processing passes (``MPSModel(post_processing=..., post_processing_factor=...)``, + Me13 §4) run after the main simulation pass, over the same + ``num_threads``-parallel dependency DAG. They are distinct from this + method's ``post_process`` kwarg (Field mean/normalizer/trend pipeline). + """ + if mesh_type != "structured": + raise ValueError( + "DirectSampling: only structured grids are supported." + ) + if self._ti.multivariate and isinstance(store, str): + # A multivariate run produces one field per variable; there is no + # single privileged field to store under a custom name. Reject it + # explicitly rather than silently dropping the name (the fields are + # always stored under their variable names). + raise ValueError( + "DirectSampling: a custom store name is not supported for " + "multivariate training images; each variable is stored under " + "its own name. Use store=True/False instead." + ) + name, save = self.get_store_config(store) + pos, shape = self.pre_pos(pos, mesh_type) + dim = len(shape) + flat_pos = generate_grid(self.pos) + rotation_map, rot_stat = resolve_spec( + self._mps_model.rotation, + shape, + no_of_angles(dim), + flat_pos, + "rotation", + ) + scale_map, scale_stat = resolve_spec( + self._mps_model.scale, + shape, + dim, + flat_pos, + "scale", + positive=True, + ) + zones = self._mps_model.zones + selector = ( + build_zone_selector(zones, shape, flat_pos) if zones else None + ) + if ( + rotation_map is not None + or scale_map is not None + or selector is not None + ): + _warn_nonuniform_axes(self.pos) + conditions = self._conditions_to_grid(self.pos) + if not np.isnan(seed): + self.rng.seed = int(seed) + rng_path = ( + self.rng.random + if np.isnan(path_seed) + else RNG(int(path_seed)).random + ) + rng_nodes = ( + self.rng.random + if np.isnan(node_seed) + else RNG(int(node_seed)).random + ) + # Call-time num_threads overrides the instance default. + n_threads = ( + num_threads if num_threads is not None else self._num_threads + ) + # ds_simulate now accepts both univariate and multivariate TIs directly. + # Univariate TIs produce {None: array}; multivariate produce {name: array}. + # Conditions for univariate are {idx: value}; ds_simulate expects + # {idx: {var: value}} so we remap using the sentinel key None. + if self._ti.multivariate: + sim_conditions = conditions # already {idx: {var: val}} + else: + sim_conditions = ( + {idx: {None: val} for idx, val in conditions.items()} + if conditions + else None + ) + result = ds_simulate( + training_image=self._ti, + sim_shape=shape, + threshold=self.threshold, + scan_fraction=self.scan_fraction, + rng_path=rng_path, + rng_nodes=rng_nodes, + conditions=sim_conditions, + cond_weight=self.cond_weight, + boundary=self.boundary, + num_threads=n_threads, + rotation_map=rotation_map, + scale_map=scale_map, + stationary_transform=rot_stat and scale_stat, + progress=progress, + path=path, + zone_tis=[z.ti for z in zones] if zones else None, + zone_selector=selector, + post_processing=self._mps_model.post_processing, + post_processing_factor=self._mps_model.post_processing_factor, + post_processing_path=self._mps_model.post_processing_path, + scan_path=self._mps_model.scan_path, + ) + # Branch only on the return type: multivariate → dict of named arrays; + # univariate → bare array (unwrap the single None key). + if self._ti.multivariate: + # Equal treatment: every variable is a first-class named field + # (no privileged primary). Returned as a dict keyed by variable name. + # Per-variable transforms (mean/normalizer/trend) are applied here + # using the dicts set via set_mv_transforms(); variables absent from + # those dicts fall back to the inherited self.mean/normalizer/trend. + # post_field is then called with process=False to handle storage only. + out = {} + for v in self._ti.variables: + fld = result[v.name] + if post_process: + mv_mean = self._mv_mean.get(v.name, self.mean) + mv_norm = self._mv_normalizer.get(v.name, self.normalizer) + mv_trend = self._mv_trend.get(v.name, self.trend) + fld = apply_mean_norm_trend( + pos=self.pos, + field=fld, + mesh_type=self.mesh_type, + value_type=self.value_type, + mean=mv_mean, + normalizer=mv_norm, + trend=mv_trend, + check_shape=False, + stacked=False, + ) + out[v.name] = self.post_field( + fld, name=v.name, process=False, save=save + ) + return out + return self.post_field(result[None], name, post_process, save) + + def _conditions_to_grid(self, axes): + """Snap conditioning points to nearest grid nodes (Mariethoz2010 §3 ¶12). + + Univariate returns ``{idx: value}``; multivariate returns + ``{idx: {variable: value}}`` with non-finite (NaN) entries skipped. When + two points snap to the same node, the conflict is resolved + **per variable**: for each variable the closest colliding point with a + finite value wins. A farther point therefore still contributes its + finite values for any variable the closer point left ``NaN``, so valid + conditioning data is never silently discarded. + + Parameters + ---------- + axes : tuple of numpy.ndarray + Raw grid axis arrays (``self.pos``). + + Returns + ------- + dict + """ + if self._cond_pos is None: + return {} + if self._ti.multivariate and isinstance(self._cond_val, dict): + if not self._cond_val: + raise ValueError("DirectSampling: cond_val must not be empty") + # idx -> {variable: (value, dist_sq)}; the closest finite value per + # variable wins, so a farther point fills variables the closer one + # left NaN (per-variable collision resolution). + candidates = {} + n_cond = len(next(iter(self._cond_val.values()))) + for k in range(n_cond): + idx, dist_sq = self._snap_to_nearest(axes, k) + var_best = candidates.setdefault(idx, {}) + for v in self._cond_val: + val = self._cond_val[v][k] + if not np.isfinite(val): + continue + if v not in var_best or dist_sq < var_best[v][1]: + var_best[v] = (float(val), dist_sq) + return { + idx: {v: val for v, (val, _) in var_best.items()} + for idx, var_best in candidates.items() + } + # univariate (unchanged) + candidates = {} # idx -> (val, dist_sq) + for k in range(self._cond_val.shape[0]): + idx, dist_sq = self._snap_to_nearest(axes, k) + if idx not in candidates or dist_sq < candidates[idx][1]: + candidates[idx] = (self._cond_val[k], dist_sq) + return {idx: val for idx, (val, _) in candidates.items()} + + 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) + ) + dist_sq = sum( + (axes[d][idx[d]] - self._cond_pos[d][k]) ** 2 + for d in range(self.dim) + ) + return idx, dist_sq + + def set_condition(self, cond_pos, cond_val): + """Set the conditioning data for the simulation. + + Conditioning weight is configured on the :class:`MPSModel` (``cond_weight=``). + + Parameters + ---------- + cond_pos : :class:`list` + The position tuple of the conditioning data ``(x, [y, z])``. + cond_val : :class:`numpy.ndarray` or :class:`dict` + Univariate: values at the conditioning positions. Multivariate: a + ``{variable: numpy.ndarray}`` mapping (use ``numpy.nan`` for a + variable that is not conditioned at a given point). + """ + if self._ti.multivariate and isinstance(cond_val, dict): + if not cond_val: + raise ValueError("DirectSampling: cond_val must not be empty") + unknown = set(cond_val) - {v.name for v in self._ti.variables} + if unknown: + raise ValueError( + f"cond_val contains unknown variable(s): {sorted(unknown)}. " + f"TI variables are: {sorted(v.name for v in self._ti.variables)}" + ) + cond_pos_arr = np.asarray(cond_pos, dtype=np.double).reshape( + self.dim, -1 + ) + n_cond = len(next(iter(cond_val.values()))) + for v, arr in cond_val.items(): + if len(arr) != n_cond: + raise ValueError( + "DirectSampling: all cond_val arrays must have the same " + f"length; got {n_cond} for {next(iter(cond_val))!r} but " + f"{len(arr)} for {v!r}." + ) + if cond_pos_arr.shape[1] != n_cond: + raise ValueError( + "DirectSampling: cond_pos and cond_val length mismatch." + ) + self._cond_pos = cond_pos_arr + self._cond_val = { + v: np.asarray(a, dtype=np.double) for v, a in cond_val.items() + } + for v_name, arr in self._cond_val.items(): + _check_penalty_matrix_conditioning( + v_name, self._ti.variable(v_name).penalty_matrix, arr + ) + elif self._ti.multivariate: + raise ValueError( + "DirectSampling: cond_val must be a dict {variable: array} " + "for multivariate TrainingImages." + ) + else: + from gstools.krige.tools import set_condition as _gs_set_condition + + self._cond_pos, self._cond_val = _gs_set_condition( + cond_pos, cond_val, self.dim + ) + _check_penalty_matrix_conditioning( + None, self._ti.variable().penalty_matrix, self._cond_val + ) + + def set_mv_transforms(self, mean=None, normalizer=None, trend=None): + """Set per-variable post-processing transforms for multivariate simulations. + + Only meaningful when the training image is multivariate. Variables not + listed here fall back to the instance-level ``self.mean``, + ``self.normalizer``, and ``self.trend``. + + Parameters + ---------- + mean : dict of {str: scalar or callable}, optional + Per-variable mean. + normalizer : dict of {str: Normalizer}, optional + Per-variable normalizer. + trend : dict of {str: scalar or callable}, optional + Per-variable trend (applied after denormalization). + """ + from gstools.field.base import _set_mean_trend + from gstools.normalizer.tools import _check_normalizer + + if mean is not None: + self._mv_mean = { + v: _set_mean_trend(m, self.dim) for v, m in mean.items() + } + if normalizer is not None: + self._mv_normalizer = { + v: _check_normalizer(n) for v, n in normalizer.items() + } + if trend is not None: + self._mv_trend = { + v: _set_mean_trend(t, self.dim) for v, t in trend.items() + } + + @property + def mps_model(self): + """:any:`MPSModel`: the search-parameter configuration (training image + parameters).""" + return self._mps_model + + @property + def ti(self): + """:any:`TrainingImage`: The training image model.""" + return self._ti + + def _collapse_per_var(self, attr): + """Read ``attr`` from every TI Variable; scalar if all equal, else a dict. + + Read-only view over the variables' values. Used by the + :attr:`n_neighbors` and :attr:`max_radius` getters. + """ + vars_ = self._ti.variables + vals = [getattr(v, attr) for v in vars_] + if len(set(vals)) == 1: + return vals[0] + return {v.name: getattr(v, attr) for v in vars_} + + @property + def n_neighbors(self): + """:class:`int` or :class:`dict`: n_neighbors per variable (scalar if all equal). + + Read-only. Configure on the :class:`Variable` (``ti.variable(name).n_neighbors = …``) + or at :class:`TrainingImage` construction. + """ + return self._collapse_per_var("n_neighbors") + + @property + def scan_fraction(self): + """:class:`float`: Fraction of the TI scanned per node. Read-only; set on the MPSModel.""" + return self._mps_model.scan_fraction + + @property + def threshold(self): + """:class:`float`: Distance threshold (0.0 → DSBC mode). Read-only; set on the MPSModel.""" + return self._mps_model.threshold + + @property + def cond_weight(self): + """:class:`float`: Conditioning-node weight. Read-only; set on the MPSModel.""" + return self._mps_model.cond_weight + + @property + def boundary(self): + """:class:`str`: Search-window strategy. Read-only; set on the MPSModel.""" + return self._mps_model.boundary + + @property + def scan_path(self): + """:class:`str`: TI window scan order. Read-only; set on the MPSModel.""" + return self._mps_model.scan_path + + @property + def max_radius(self): + """:class:`float` or :any:`None` or :class:`dict`: Euclidean neighbour cap (scalar if all equal). + + Read-only. Configure at :class:`Variable`/:class:`TrainingImage` construction. + """ + return self._collapse_per_var("max_radius") + + @property + def num_threads(self): + """:class:`int` or :any:`None`: Number of threads for outer DAG parallelism.""" + return self._num_threads + + @num_threads.setter + def num_threads(self, value): + self._num_threads = None if value is None else int(value) + + def __repr__(self): + parts = [ + f"DirectSampling(dim={self.dim}, ", + f"n_neighbors={self.n_neighbors}, ", + f"scan_fraction={self.scan_fraction}, ", + f"threshold={self.threshold}, ", + f"boundary={self.boundary!r}", + ] + return "".join(parts) + ")" diff --git a/src/gstools/mps/distance.py b/src/gstools/mps/distance.py new file mode 100644 index 00000000..43feadbf --- /dev/null +++ b/src/gstools/mps/distance.py @@ -0,0 +1,338 @@ +"""Pure distance functions for MPS pattern comparison. + +No class state — takes arrays and scalars, returns floats. +``TrainingImage.distance()`` uses these internally; other algorithms +can import them directly. +""" + +import numpy as np + +__all__ = [ + "compute_node_weights", + "vec_categorical_dist", + "vec_categorical_penalty_dist", + "vec_l1_dist", + "vec_l2_dist", + "vec_lp_dist", + "vec_variation_dist", +] + + +def compute_node_weights( + n, lag_norms, distance_power, cond_mask=None, cond_weight=1.0 +): + """Compute normalized spatial-decay weights for a data event. + + Combines spatial decay (Mariethoz2010 Eq. 5) with conditioning data + multipliers (Mariethoz2010 §3 ¶26). + + Parameters + ---------- + n : int + Number of neighbours in the data event. + lag_norms : array-like or None, shape (n,) + Euclidean norms ``‖h_i‖`` of each lag vector. ``None`` or + ``distance_power == 0`` → uniform spatial weights. A zero lag-norm + (collocated ``h=0`` entry) keeps the unit baseline weight and is not + amplified by the spatial decay — its weight is scaled by ``cond_weight``. + distance_power : float + Exponent δ. ``0.0`` → uniform. + cond_mask : array-like of bool, optional + ``True`` where the neighbour is a conditioning datum. + cond_weight : float, optional + Bonus weight multiplier for conditioning nodes. + + Returns + ------- + numpy.ndarray, shape (n,) + Node weights normalized to sum to 1. + """ + raw_w = np.ones(n, dtype=np.float64) + if lag_norms is not None and distance_power != 0.0: + norms = np.asarray(lag_norms, dtype=np.float64) + # Only non-zero lags decay with distance. A *true* zero lag-norm is a + # collocated/conditioning entry (h=0, e.g. the multivariate same-node + # constraint); it keeps the unit-cell baseline weight 1.0 rather than the + # divergent norm**(-power), and its importance is governed by the + # cond_weight multiplier below (it always carries cond_mask=True). + nz = norms != 0.0 + raw_w[nz] = norms[nz] ** (-distance_power) + + if cond_mask is not None: + # ``raw_w`` is a freshly allocated array (np.ones above, modified in + # place), so no defensive copy is needed before scaling. + raw_w[np.asarray(cond_mask, dtype=bool)] *= cond_weight + + total = raw_w.sum() + if total == 0.0: + # Every neighbour was zeroed out — the canonical case is an all- + # conditioning data event with cond_weight == 0 (δ_c = 0 → conditioning + # ignored entirely, Me13 p.323 → unconditional behaviour). Returning + # zero weights makes the data event non-informative so the node is drawn + # unconditionally, rather than re-weighting the ignored nodes uniformly. + return np.zeros(n, dtype=np.float64) + if not np.isfinite(total): + # Defensive: non-finite weight sum should be unreachable for grid lags + # (‖h‖ >= 1) — fall back to uniform rather than emit NaNs. + return np.full(n, 1.0 / n, dtype=np.float64) + return raw_w / total + + +# --------------------------------------------------------------------------- +# Vectorized variants — same maths, operate on all TI candidates at once. +# Each accepts all_de_ti of shape (max_scan, n) and returns (max_scan,). +# np.dot(X, w) with X (max_scan, n) and w (n,) is a standard BLAS matvec. +# +# NaN handling (``has_nan``): when the TI contains undefined (NaN) cells, a +# candidate data event may have NaN at some positions. With ``has_nan=True`` +# those positions are excluded per-row and the weights are renormalized over +# the defined positions (a proper convex combination); a candidate whose data +# event is *entirely* undefined gets distance ``+inf`` so it is never selected. +# ``has_nan=False`` (the default) is the original, NaN-free computation, kept +# byte-identical and overhead-free for the common case. +# --------------------------------------------------------------------------- + + +def _masked_weights(all_de_ti, node_weights): + """Per-row weights with undefined (NaN) TI positions zeroed. + + Returns + ------- + valid : numpy.ndarray of bool, shape (max_scan, n) + ``True`` where the TI value is defined (finite). + we : numpy.ndarray, shape (max_scan, n) + ``node_weights`` broadcast, zeroed at undefined positions. + wsum : numpy.ndarray, shape (max_scan,) + Row sums of ``we``; ``0`` for a fully-undefined data event. + """ + valid = ~np.isnan(all_de_ti) + we = node_weights[np.newaxis, :] * valid + return valid, we, we.sum(axis=1) + + +def _renorm(numerator, wsum): + """``numerator / wsum`` per row, with ``+inf`` where ``wsum == 0``.""" + out = np.full(numerator.shape, np.inf, dtype=np.float64) + nz = wsum > 0 + out[nz] = numerator[nz] / wsum[nz] + return out + + +def _scaled_abs_diff(data_event_sim, all_de_ti, d_max): + """``|data_event_sim - all_de_ti| / d_max`` with one (max_scan, n) temporary. + + The naive expression allocates three full ``(max_scan, n)`` blocks (one per + operator); folding ``abs`` and the ``d_max`` scaling into the subtraction + result in place keeps just one. Bit-identical to the naive form: a + float32 TI stays in float32 throughout rather than being forced to + 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) + if not np.issubdtype(diff.dtype, np.floating): + diff = diff.astype(np.float64) + np.abs(diff, out=diff) + diff /= d_max + return diff + + +def vec_categorical_dist( + data_event_sim, all_de_ti, node_weights, has_nan=False +): + """Vectorized categorical distance over all TI scan candidates. + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + has_nan : bool, optional + Enable per-row NaN exclusion. Default ``False``. + + Returns + ------- + numpy.ndarray, shape (max_scan,) + Distance in [0, 1] for each candidate. + """ + if not has_nan: + # ``np.dot`` upcasts the boolean mismatch mask to float64 internally, so + # an explicit ``.astype`` only adds a second (max_scan, n) temporary. + return np.dot(data_event_sim != all_de_ti, node_weights) + _, we, wsum = _masked_weights(all_de_ti, node_weights) + # NaN positions compare unequal, but ``we`` is zero there so they do not + # contribute to the mismatch sum. + mism = (we * (data_event_sim != all_de_ti)).sum(axis=1) + return _renorm(mism, wsum) + + +def vec_categorical_penalty_dist( + data_event_sim, all_de_ti, node_weights, penalty_matrix, has_nan=False +): + """Vectorized per-category penalty-matrix distance over all TI scan candidates. + + Generalizes :func:`vec_categorical_dist` by looking up an asymmetric + mismatch cost ``T[u, v]`` instead of a flat binary mismatch (DS_Feature_Checklist + §3.3): ``Σ_i w_i * T[Z(x_i), Z(y_i)] / Σ_i w_i``. + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + penalty_matrix : numpy.ndarray, shape (C, C) + ``T[u, v]``: penalty for TI category ``v`` given SG category ``u``. + has_nan : bool, optional + Enable per-row NaN exclusion. Default ``False``. + + Returns + ------- + numpy.ndarray, shape (max_scan,) + Distance in [0, 1] for each candidate. + """ + if not has_nan: + s = data_event_sim.astype(np.intp) + t = all_de_ti.astype(np.intp) + penalties = penalty_matrix[s, t] + return np.dot(penalties, node_weights) + valid, we, wsum = _masked_weights(all_de_ti, node_weights) + safe_sim = data_event_sim.astype(np.intp) + safe_ti = np.where(valid, all_de_ti, 0).astype(np.intp) + penalties = penalty_matrix[safe_sim, safe_ti] + mism = (we * penalties).sum(axis=1) + return _renorm(mism, wsum) + + +def vec_l1_dist(data_event_sim, all_de_ti, node_weights, d_max, has_nan=False): + """Vectorized L1 distance over all TI scan candidates (Juda2022 Eq. 7 = Mariethoz2010 Eq. 6). + + Mean absolute error normalized by the TI value range ``d_max``. + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + d_max : float + has_nan : bool, optional + + Returns + ------- + numpy.ndarray, shape (max_scan,) + """ + if not has_nan: + return np.dot( + _scaled_abs_diff(data_event_sim, all_de_ti, d_max), node_weights + ) + valid, we, wsum = _masked_weights(all_de_ti, node_weights) + ad = np.where(valid, np.abs(data_event_sim - all_de_ti) / d_max, 0.0) + return _renorm((we * ad).sum(axis=1), wsum) + + +def vec_l2_dist(data_event_sim, all_de_ti, node_weights, d_max, has_nan=False): + """Vectorized L2 distance over all TI scan candidates (Mariethoz2010 Eq. 4–5). + + Weighted RMSE normalized by the TI value range ``d_max``; spatial-decay + weights ``α_i`` embed the ``d_max²`` denominator (Mariethoz2010 Eq. 5). + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + d_max : float + has_nan : bool, optional + + Returns + ------- + numpy.ndarray, shape (max_scan,) + """ + if not has_nan: + sq = _scaled_abs_diff(data_event_sim, all_de_ti, d_max) + sq **= 2 + return np.sqrt(np.dot(sq, node_weights)) + valid, we, wsum = _masked_weights(all_de_ti, node_weights) + sq = np.where(valid, ((data_event_sim - all_de_ti) / d_max) ** 2, 0.0) + return np.sqrt(_renorm((we * sq).sum(axis=1), wsum)) + + +def vec_lp_dist( + data_event_sim, all_de_ti, node_weights, d_max, p, has_nan=False +): + """Vectorized Lp distance over all TI scan candidates. + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + d_max : float + p : float + has_nan : bool, optional + + Returns + ------- + numpy.ndarray, shape (max_scan,) + """ + if not has_nan: + diffs = _scaled_abs_diff(data_event_sim, all_de_ti, d_max) + diffs **= p + return np.dot(diffs, node_weights) ** (1.0 / p) + valid, we, wsum = _masked_weights(all_de_ti, node_weights) + dp = np.where( + valid, (np.abs(data_event_sim - all_de_ti) / d_max) ** p, 0.0 + ) + return _renorm((we * dp).sum(axis=1), wsum) ** (1.0 / p) + + +def vec_variation_dist( + data_event_sim, all_de_ti, node_weights, d_max, p=2.0, has_nan=False +): + """Vectorized variation distance over all TI scan candidates. + + Parameters + ---------- + data_event_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + node_weights : numpy.ndarray, shape (n,) + d_max : float + p : float, optional + Lp aggregation exponent. Default ``2.0``. + has_nan : bool, optional + + Returns + ------- + numpy.ndarray, shape (max_scan,) + Distance in [0, 1]. + """ + if not has_nan: + de_sim_c = data_event_sim - data_event_sim.mean() + all_de_ti_c = all_de_ti - all_de_ti.mean(axis=1, keepdims=True) + diffs = de_sim_c - all_de_ti_c + # 2*d_max normalises the common case to [0, 1]; SG values are not + # bounded by the TI range (conditioning / mean-shifts), so clamp. + return np.minimum( + 1.0, + np.dot(np.abs(diffs / (2 * d_max)) ** p, node_weights) + ** (1.0 / p), + ) + # De-mean each event over its *defined* positions only, so the comparison + # uses a common support per candidate row. + valid = ~np.isnan(all_de_ti) + cnt = valid.sum(axis=1, keepdims=True) + safe_cnt = np.where(cnt > 0, cnt, 1) + m_sim = ( + np.where(valid, data_event_sim[np.newaxis, :], 0.0).sum( + axis=1, keepdims=True + ) + / safe_cnt + ) + m_ti = ( + np.where(valid, all_de_ti, 0.0).sum(axis=1, keepdims=True) / safe_cnt + ) + diffs = (data_event_sim[np.newaxis, :] - m_sim) - (all_de_ti - m_ti) + contr = np.where(valid, np.abs(diffs / (2 * d_max)) ** p, 0.0) + we = node_weights[np.newaxis, :] * valid + agg = _renorm((we * contr).sum(axis=1), we.sum(axis=1)) ** (1.0 / p) + # Clamp only finite aggregates so a fully-undefined row keeps its +inf + # (never selected), matching the other metrics' exclusion contract. + return np.where(np.isfinite(agg), np.minimum(1.0, agg), np.inf) diff --git a/src/gstools/mps/model.py b/src/gstools/mps/model.py new file mode 100644 index 00000000..624c1c66 --- /dev/null +++ b/src/gstools/mps/model.py @@ -0,0 +1,440 @@ +"""MPS configuration object — training image and search parameters.""" + +import warnings + +import numpy as np + +from gstools.mps.training_image import TrainingImage, _check_category_codes +from gstools.mps.zone import Zone + +__all__ = ["MPSModel"] + +_VALID_BOUNDARY = ("strict", "partial") + + +def _validate_boundary(value): + """Validate boundary; return normalized value or raise ValueError.""" + if value not in _VALID_BOUNDARY: + raise ValueError( + f"MPSModel: boundary must be one of {_VALID_BOUNDARY!r}, " + f"got {value!r}" + ) + return value + + +_VALID_SCAN_PATH = ("sequential", "random") + + +def _validate_scan_path(value): + """Validate scan_path; return normalized value or raise ValueError.""" + if value not in _VALID_SCAN_PATH: + raise ValueError( + f"MPSModel: scan_path must be one of {_VALID_SCAN_PATH!r}, " + f"got {value!r}" + ) + return value + + +def _validate_scan_fraction(value): + """Validate scan_fraction in (0, 1]; return float or raise ValueError.""" + if not (0 < float(value) <= 1): + raise ValueError( + f"MPSModel: scan_fraction must be in (0, 1], got {value!r}" + ) + return float(value) + + +def _validate_threshold(value): + """Validate threshold >= 0, warn if > 1; return float or raise ValueError.""" + if float(value) < 0: + raise ValueError(f"MPSModel: threshold must be >= 0, got {value!r}") + if float(value) > 1.0: + warnings.warn( + "MPSModel: threshold > 1.0 guarantees the first candidate is always accepted.", + UserWarning, + stacklevel=3, + ) + return float(value) + + +def _validate_post_processing(value): + """Validate post-processing pass count p >= 0 (Me13 §4).""" + v = int(value) + if v < 0: + raise ValueError( + f"MPSModel: post_processing must be >= 0, got {value!r}" + ) + return v + + +def _validate_post_processing_factor(value): + """Validate p_f >= 1 (Me13 §4 divides f and n by p_f to SAVE cpu).""" + v = float(value) + if v < 1.0: + raise ValueError( + f"MPSModel: post_processing_factor must be >= 1, got {value!r}" + ) + return v + + +def _validate_rotation(rotation, ndim): + """Grid-free rotation validation; returns the spec unchanged.""" + if rotation is None: + return None + if ndim == 1: + raise ValueError( + "MPSModel: rotation must be None on 1-D grids (no rotation " + "planes exist in 1-D); scale remains meaningful." + ) + if callable(rotation): + return rotation + return np.asarray(rotation, dtype=np.float64) + + +def _validate_scale(scale): + """Grid-free scale validation (positivity where checkable).""" + if scale is None or callable(scale): + return scale + arr = np.asarray(scale, dtype=np.float64) + if not np.all(arr > 0): + raise ValueError( + f"MPSModel: scale must be positive everywhere, got minimum " + f"value {float(arr.min())!r}." + ) + return arr + + +def _validate_zones(zones, primary_ti): + """Every zone TI must share the primary TI's variable set and kinds.""" + if zones is None: + return [] + zones = list(zones) + primary_kinds = {v.name: v.categorical for v in primary_ti.variables} + for i, z in enumerate(zones): + if not isinstance(z, Zone): + raise TypeError( + f"MPSModel: zones[{i}] must be a Zone, got {type(z)!r}." + ) + if z.ti.ndim != primary_ti.ndim: + raise ValueError( + f"MPSModel: zones[{i}].ti has dim {z.ti.ndim}, primary TI " + f"has dim {primary_ti.ndim}." + ) + zone_kinds = {v.name: v.categorical for v in z.ti.variables} + if set(zone_kinds) != set(primary_kinds): + raise ValueError( + f"MPSModel: zones[{i}].ti variable set " + f"{sorted(map(str, zone_kinds))} does not match the primary " + f"TI's {sorted(map(str, primary_kinds))}." + ) + for name, kind in primary_kinds.items(): + if zone_kinds[name] != kind: + raise ValueError( + f"MPSModel: zones[{i}].ti variable {name!r} is " + f"{'categorical' if zone_kinds[name] else 'continuous'} " + f"but the primary TI's is " + f"{'categorical' if kind else 'continuous'} — the " + "categorical/continuous kind must match per variable." + ) + # The scan indexes the PRIMARY TI's penalty_matrix with codes read + # from the SELECTED domain, so a zone code >= C would raise a raw + # IndexError mid-scan (and a negative one would wrap silently). + penalty_matrix = primary_ti.variable(name).penalty_matrix + if penalty_matrix is not None: + label = "data" if name is None else f"variable {name!r} data" + _check_category_codes( + z.ti.variable(name).data, + penalty_matrix.shape[0], + f"MPSModel: zones[{i}].ti {label} (checked against the " + "primary TI's penalty_matrix)", + ) + return zones + + +_VALID_POST_PATH = ("random", "sequential", "same") + + +def _validate_post_processing_path(value): + """None -> inherit main-path mode; str from _VALID_POST_PATH; else (N, dim) array.""" + if value is None or (isinstance(value, str) and value in _VALID_POST_PATH): + return value + if isinstance(value, str): + raise ValueError( + f"MPSModel: post_processing_path must be one of " + f"{_VALID_POST_PATH!r}, an (N, dim) integer array, or None " + f"(inherit the main path mode), got {value!r}" + ) + arr = np.asarray(value) + if arr.ndim != 2: + raise ValueError( + f"MPSModel: an explicit post_processing_path must be a 2-D " + f"(N, dim) array, got shape {arr.shape!r}" + ) + return arr + + +def _spec_repr(val): + """Repr for a rotation/scale spec: callables by name, arrays by shape.""" + if callable(val): + return getattr(val, "__name__", repr(val)) + arr = np.asarray(val) + if arr.ndim == 0: + return repr(float(arr)) + if arr.ndim == 1 and arr.size <= 4: + return repr(arr.tolist()) + return f"array(shape={arr.shape})" + + +class MPSModel: + """MPS configuration: training image and search algorithm parameters. + + Analogous to ``CovModel``: holds the training image and the search + hyper-parameters. Carries no seed, thread count, or backend selector — + those are call-time concerns on :class:`~gstools.mps.DirectSampling`. + + Parameters + ---------- + ti : :any:`TrainingImage` + Training image (univariate or multivariate). + scan_fraction : :class:`float`, optional + Fraction of the TI to scan per node (capped at the valid search + window). Must be in (0, 1]. Default: 1.0. + threshold : :class:`float`, optional + Distance threshold for early acceptance. 0.0 → DSBC mode. Default: 0.0. + cond_weight : :class:`float`, optional + Weight multiplier for conditioning nodes in distance. Default: 1.0. + boundary : :class:`str`, optional + Search-window strategy: ``"strict"`` (default) or ``"partial"``. + rotation : :class:`float` or array-like or callable, optional + Geometric anisotropy: rotation angles (in radians) describing how + simulated patterns are oriented relative to the TI. At node ``x``, + the pattern orientation is ``θ(x)``; the engine applies the inverse + rotation to lags. Accepted forms: scalar angle, ``(n_comp,)`` vector + of angles, materialized array, or callable ``f(x, [y, z, …])``. + Must be ``None`` on 1-D TI. Default: ``None`` (stationary identity). + + **Convention:** angles and scales act on *index* lags (not coordinate + lags). Migration from prior versions: if you previously used + ``anis = s``, use ``scale=[1, s]`` instead. + scale : :class:`float` or array-like or callable, optional + Dilation scales: stretch factors describing how simulated patterns + scale relative to the TI. At node ``x``, patterns scale by ``s(x)``. + Accepted forms: scalar, ``(n_comp,)`` vector, materialized array, + or callable ``f(x, [y, z, …])``. All values must be strictly positive. + No axis-0 pin; all dimensions are stretched equally if scalar. + Default: ``None`` (no dilation). + zones : :class:`list` of :any:`Zone`, optional + Zonation regions for non-stationary simulation. Each zone TI must + have the same ``ndim``, variable names, and categorical kind per + variable as the primary TI. Default: ``[]`` (single-TI simulation). + + 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. Configure them on the primary TI. + post_processing : :class:`int`, optional + Number of post-processing passes p (Meerschman et al. 2013, §4). + Each pass re-simulates every non-conditioning node with a fully + informed neighbourhood (search parameters divided by + ``post_processing_factor``) to remove simulation noise. + 0 (default) disables post-processing. Me13 §7 + advises always adding at least one post-processing step (p=1) for + categorical simulations, for noise removal. + + **Cost.** Each pass re-visits essentially the whole grid, so ``p`` + passes mean ``p`` full-grid sweeps. They run over the same dependency + DAG as the main path and so honour ``num_threads``, staying + bit-identical to serial execution for any thread count. + post_processing_factor : :class:`float`, optional + Factor p_f >= 1 to divide ``scan_fraction`` and ``n_neighbors`` + during post-processing passes (Me13 §4). Values > 1 reduce the + search effort per pass (cheaper, coarser re-simulation). + Default: 1.0 (use original parameters). Me13 finds p_f has little + effect in general and recommends p_f=1. + post_processing_path : :class:`str`, array-like, or None, optional + Visit order for the post-processing passes. This is an + implementation extension, not prescribed by the DS papers (Me13 §4 + does not specify the post-pass order). Accepted forms: + + - ``None`` (default): inherit the main-path mode — a + ``"sequential"`` main ``path`` gives sequential post-passes, + anything else gives a fresh random permutation per pass. This + preserves the pre-existing behaviour exactly. + - ``"random"``: fresh random permutation per pass, drawn from the + same RNG stream as the main path. + - ``"sequential"``: raster order, the same every pass; does not + consume the path RNG. + - ``"same"``: reuse the main pass's visit order every pass; does + not consume the path RNG. + - an explicit ``(N, dim)`` integer array: validated the same way as + an explicit main ``path`` (duplicate rows and missing nodes raise + :class:`ValueError`), but against the *post-pass node set* — every + node with at least one non-conditioned variable — rather than the + main path's unknown-node set. This means an explicit + post-processing path may include already-conditioned nodes (a + full-grid raster/spiral works unchanged); such entries are + silently dropped. + scan_path : :class:`str`, optional + TI window scan order per node. ``"sequential"``: contiguous walk from + a random start (M10 para [19]). ``"random"``: the same number of + distinct cells in random order — a GSTools extension that removes the + contiguous run's spatial bias when ``scan_fraction < 1`` and breaks + distance ties uniformly instead of by lowest index. Default: + ``"sequential"``. + """ + + def __init__( + self, + ti, + scan_fraction=1.0, + threshold=0.0, + cond_weight=1.0, + boundary="strict", + rotation=None, + scale=None, + zones=None, + post_processing=0, + post_processing_factor=1.0, + post_processing_path=None, + scan_path="sequential", + ): + if not isinstance(ti, TrainingImage): + raise TypeError( + f"MPSModel: ti must be a TrainingImage, got {type(ti)!r}" + ) + self._ti = ti + self._scan_fraction = _validate_scan_fraction(scan_fraction) + self._threshold = _validate_threshold(threshold) + self._cond_weight = float(cond_weight) + self._boundary = _validate_boundary(boundary) + self._rotation = _validate_rotation(rotation, ti.ndim) + self._scale = _validate_scale(scale) + self._zones = _validate_zones(zones, ti) + self._post_processing = _validate_post_processing(post_processing) + self._post_processing_factor = _validate_post_processing_factor( + post_processing_factor + ) + self._post_processing_path = _validate_post_processing_path( + post_processing_path + ) + self._scan_path = _validate_scan_path(scan_path) + + @property + def ti(self): + """:any:`TrainingImage`: The training image.""" + return self._ti + + @property + def scan_fraction(self): + """:class:`float`: Fraction of the TI scanned per node.""" + return self._scan_fraction + + @scan_fraction.setter + def scan_fraction(self, value): + self._scan_fraction = _validate_scan_fraction(value) + + @property + def threshold(self): + """:class:`float`: Distance threshold for early acceptance.""" + return self._threshold + + @threshold.setter + def threshold(self, value): + self._threshold = _validate_threshold(value) + + @property + def cond_weight(self): + """:class:`float`: Weight multiplier for conditioning nodes.""" + return self._cond_weight + + @cond_weight.setter + def cond_weight(self, value): + self._cond_weight = float(value) + + @property + def boundary(self): + """:class:`str`: Search-window boundary strategy (``"strict"`` or ``"partial"``).""" + return self._boundary + + @boundary.setter + def boundary(self, value): + self._boundary = _validate_boundary(value) + + @property + def post_processing(self): + """:class:`int`: Number of post-processing passes p (Me13 §4). 0 = off.""" + return self._post_processing + + @post_processing.setter + def post_processing(self, value): + self._post_processing = _validate_post_processing(value) + + @property + def post_processing_factor(self): + """:class:`float`: p_f — scan_fraction and n_neighbors are divided by this during post-passes.""" + return self._post_processing_factor + + @post_processing_factor.setter + def post_processing_factor(self, value): + self._post_processing_factor = _validate_post_processing_factor(value) + + @property + def post_processing_path(self): + """:class:`str`, array-like, or None: post-pass visit order. ``None`` inherits the main-path mode.""" + return self._post_processing_path + + @post_processing_path.setter + def post_processing_path(self, value): + self._post_processing_path = _validate_post_processing_path(value) + + @property + def scan_path(self): + """:class:`str`: TI window scan order — ``"sequential"`` or ``"random"``.""" + return self._scan_path + + @scan_path.setter + def scan_path(self, value): + self._scan_path = _validate_scan_path(value) + + @property + def rotation(self): + """Rotation spec (scalar, vector, array, or callable); ``None`` → stationary identity.""" + return self._rotation + + @property + def scale(self): + """Scale spec (scalar, vector, array, or callable); ``None`` → no dilation.""" + return self._scale + + @property + def zones(self): + """:class:`list` of :any:`Zone`: zonation regions (empty → single-TI simulation).""" + return list(self._zones) + + def __repr__(self): + args = [repr(self._ti)] + defaults = dict( + scan_fraction=1.0, + threshold=0.0, + cond_weight=1.0, + boundary="strict", + post_processing=0, + post_processing_factor=1.0, + scan_path="sequential", + ) + for name, default in defaults.items(): + val = getattr(self, f"_{name}") + if val != default: + args.append(f"{name}={val!r}") + if self._rotation is not None: + args.append(f"rotation={_spec_repr(self._rotation)}") + if self._scale is not None: + args.append(f"scale={_spec_repr(self._scale)}") + if self._zones: + args.append(f"zones=[{len(self._zones)} zone(s)]") + if self._post_processing_path is not None: + ppp = self._post_processing_path + val = repr(ppp) if isinstance(ppp, str) else _spec_repr(ppp) + args.append(f"post_processing_path={val}") + return f"MPSModel({', '.join(args)})" diff --git a/src/gstools/mps/neighbors.py b/src/gstools/mps/neighbors.py new file mode 100644 index 00000000..0a2517ca --- /dev/null +++ b/src/gstools/mps/neighbors.py @@ -0,0 +1,369 @@ +"""Data-event geometry for Direct Sampling. + +Neighbour selection, geometric lag transform, and TI search-window bounds. +Pure NumPy + gstools.tools.geometric; no TrainingImage, no simulation state. +""" + +import warnings + +import numpy as np + +from gstools.tools.geometric import matrix_detransform, no_of_angles + + +def _precompute_offsets(shape, max_offset=None): + """Neighbour offsets from the origin, sorted by Euclidean distance. + + Among equidistant offsets the order is a **canonical lexicographic** + tie-break (by squared distance, then by each coordinate). This is + platform- and NumPy-version-independent — unlike the default ``argsort`` + (unstable quicksort), whose tie order is implementation-defined — so that + ``n_neighbors`` cutting mid-shell yields the same neighbour subset, and the + simulation is reproducible across machines. + + Parameters + ---------- + shape : tuple + Simulation grid shape. + max_offset : int, optional + Maximum offset in any dimension. + Default: ``max(shape)``. + + Returns + ------- + numpy.ndarray, shape (N, dim) + """ + dim = len(shape) + if max_offset is None: + max_offset = max(shape) + estimated_elements = (2 * max_offset + 1) ** dim + if estimated_elements > 5_000_000: + raise ValueError( + f"_precompute_offsets would allocate ~{estimated_elements * dim * 8 // 1_000_000} MB " + f"(max_offset={max_offset}, dim={dim}). Set max_radius to bound the " + "neighbourhood search radius." + ) + rng_vals = np.arange(-max_offset, max_offset + 1) + grid = np.array(np.meshgrid(*[rng_vals] * dim, indexing="ij")) + offsets = grid.reshape(dim, -1).T + offsets = offsets[np.any(offsets != 0, axis=1)] + dist_sq = np.sum(offsets**2, axis=1) + # lexsort: last key is primary. Primary = squared distance; ties broken by + # coordinate 0, then 1, ... for a deterministic, portable ordering. + keys = [offsets[:, d] for d in range(dim - 1, -1, -1)] + [dist_sq] + idx = np.lexsort(keys) + return offsets[idx] + + +def _select_neighbors( + x_i, + offset_arr, + sim_shape_arr, + sim_shape, + path_pos_map, + curr_idx, + informed, + max_radius, + n_neighbors, +): + """Closest valid neighbours of ``x_i``, with their path indices (Mariethoz2010 ¶15, Juda2022 Eq. 1). + + A candidate is valid when it is in bounds, has path index ``< curr_idx`` + (already-simulated in path order) or ``-1`` (conditioning data), and — if + ``informed`` is given — is marked informed. ``offset_arr`` is + distance-sorted, so slicing the first ``n_neighbors`` survivors yields the + closest ones. + + Passing ``informed=None`` treats every in-bounds lower-index/conditioning + cell as available; this is correct when building the dependency DAG, where + all earlier-path nodes are informed by definition. + + Returns + ------- + coords : numpy.ndarray, shape (m, dim) + Neighbour coordinates, ``m <= n_neighbors``. + path_idx : numpy.ndarray, shape (m,) + Path index of each neighbour (``-1`` for conditioning data). + """ + # Chunked vectorized scan: process offsets in blocks to amortize Python + # overhead while preserving distance-sorted order and early-stop. + # offset_arr is distance-sorted (ascending dist_sq), so the scan processes + # shells in order. When max_radius is set, dist_sq is monotone-increasing + # and we break the moment the first offset in a chunk exceeds the radius. + # The informed ravel is looked up via flat_safe = np.where(inbounds,flat,0) + # which is safe because the inbounds mask gates every use of that value. + _CHUNK = 256 + dim = offset_arr.shape[1] + r_sq = max_radius * max_radius if max_radius is not None else None + # C-order strides: stride[d] = prod(sim_shape[d+1:]) + strides = np.array( + [int(np.prod(sim_shape[d + 1 :])) for d in range(dim)], dtype=np.intp + ) + # Precompute ravel of informed for vectorized lookup when informed is not None. + informed_flat = informed.ravel() if informed is not None else None + + found_coords = [] + found_vidx = [] + n_found = 0 + + n_off = len(offset_arr) + b0 = 0 + while b0 < n_off and n_found < n_neighbors: + chunk = offset_arr[b0 : b0 + _CHUNK] # shape (C, dim) + + # Early-stop on radius: dist_sq is monotone-increasing; if the first + # offset in this chunk already exceeds r_sq, all subsequent ones do too. + if r_sq is not None: + chunk_dsq = np.einsum("ij,ij->i", chunk, chunk) + first_over = np.searchsorted(chunk_dsq, r_sq, side="right") + if first_over == 0: + break # entire remaining offset_arr is beyond radius + chunk = chunk[:first_over] + chunk_dsq = chunk_dsq[:first_over] + + cands = x_i + chunk # (C, dim) + + # In-bounds mask + inbounds = np.all((cands >= 0) & (cands < sim_shape_arr), axis=1) + + # Flat indices — use flat_safe (clamp to 0 for out-of-bounds rows) so + # array indexing is always valid; only results where inbounds=True are used. + flat = cands @ strides # (C,) + flat_safe = np.where(inbounds, flat, 0) + vi = path_pos_map[flat_safe] # (C,) + + # Path-order / conditioning filter + idx_ok = (vi < curr_idx) | (vi == -1) + + # Informed filter: when informed is None every in-bounds earlier-path + # cell is available (DAG mode); otherwise check the boolean flat. + if informed_flat is not None: + inf_ok = informed_flat[flat_safe] + else: + inf_ok = np.ones(len(chunk), dtype=bool) + + valid = inbounds & idx_ok & inf_ok + hits = np.flatnonzero(valid) # distance-sorted order preserved + + need = n_neighbors - n_found + if len(hits) > need: + hits = hits[:need] + + if len(hits): + found_coords.append(cands[hits]) + found_vidx.append(vi[hits]) + n_found += len(hits) + + b0 += len(chunk) # advance by the (possibly truncated) chunk size + + if found_coords: + return ( + np.concatenate(found_coords).astype(offset_arr.dtype, copy=False), + np.concatenate(found_vidx).astype(path_pos_map.dtype, copy=False), + ) + return ( + np.empty((0, dim), dtype=offset_arr.dtype), + np.empty(0, dtype=path_pos_map.dtype), + ) + + +def _transform_lags(lags, M, *arrays): + """Apply geometric transform M and deduplicate collapsed lags. + + Parameters + ---------- + lags : numpy.ndarray, shape (k, dim) + Integer SG lag offsets (float64). + M : numpy.ndarray, shape (dim, dim) + Detransform matrix from :func:`_lag_transform_matrix`. + *arrays : numpy.ndarray + Parallel arrays of length k to slice by the same keep_idx. + + Returns + ------- + lags_ti : numpy.ndarray, shape (k', dim) + Transformed lags (rounded to nearest integer, deduped). + *sliced : numpy.ndarray + Input arrays sliced to keep_idx. + """ + lags_ti = np.rint(lags @ M.T) + if len(lags_ti): + _, keep_idx = np.unique(lags_ti, axis=0, return_index=True) + keep_idx = np.sort(keep_idx) + if len(keep_idx) < len(lags_ti): + # Constant message (no per-call count) so the stdlib default + # "once per location" filter collapses the routine anisotropic case + # to a single warning per session instead of one per node. + warnings.warn( + "gstools.mps: anisotropy/rotation transform collapsed neighbour lag(s) onto " + "duplicate TI positions; the duplicates are excluded from the " + "data event. Reduce the anisotropy ratio or rotation to retain " + "them.", + RuntimeWarning, + stacklevel=2, + ) + lags_ti = lags_ti[keep_idx] + arrays = tuple(a[keep_idx] for a in arrays) + return (lags_ti,) + arrays + + +def _reduce_to_fit(lags_ti, ti_shape, *arrays): + """Globally reduce an over-large (transformed) data event to fit the TI. + + Implements Mariethoz2010 para [43]: *"rotation or affinity may result in + large data events that do not fit in the TI. In such cases, the data event + nodes located outside of the TI are ignored until it becomes possible to + scan the TI with this new, reduced data event."* + + The most-outside node — the one with the largest ``|lag|`` in the + most-violating dimension — is dropped first and the test repeated, until a + non-empty TI scan window exists for the reduced event. Nodes are dropped by + being *outside the TI* (geometry), **regardless of SG neighbour rank**, + which is the explicit distinction from :func:`_window_bounds` partial mode + (furthest-first). The reduction is **global** — computed once per simulation + node from the transformed lags and the TI shape alone, independent of which + TI cell is being scanned. The collocated ``h=0`` row is never dropped. + + A window over a lag set exists iff, including the anchor at the origin, the + per-dimension extent ``max(0, max h_d) - min(0, min h_d)`` is at most + ``ti_shape_d - 1`` (a single lag with ``|h_d| > ti_d - 1`` therefore does + not fit and is reduced away). + + Parameters + ---------- + lags_ti : numpy.ndarray, shape (k, dim) + Transformed (TI-frame) integer lag vectors. + ti_shape : array-like, shape (dim,) + Training image shape. + *arrays : numpy.ndarray + Parallel arrays of length ``k`` sliced identically to ``lags_ti``. + + Returns + ------- + tuple + ``(reduced_lags_ti, *reduced_arrays)``. + """ + ti1 = np.asarray(ti_shape) - 1 + idx = np.arange(len(lags_ti)) + while len(idx): + sub = lags_ti[idx] + nzmask = np.any(sub != 0, axis=1) + if not nzmask.any(): + break + nz = sub[nzmask] + # Include the anchor (origin) so a lone far lag is correctly infeasible. + extent = np.maximum(0, nz.max(axis=0)) - np.minimum(0, nz.min(axis=0)) + over = extent - ti1 + if np.all(over <= 0): + break + d = int(np.argmax(over)) # most-violating dimension + cand = np.flatnonzero(nzmask) + drop = cand[int(np.argmax(np.abs(sub[cand, d])))] + idx = np.delete(idx, drop) + return (lags_ti[idx],) + tuple(a[idx] for a in arrays) + + +def _window_bounds(lags_ti, ti_shape, boundary): + """Compute the TI search window for a set of lag vectors (Mariethoz2010 ¶18, Juda2022 Eq. 5). + + h=0 rows (collocated constraints) do not constrain the window — they map + anchor ``y`` to ``y`` itself so every window position is valid for them. + + Parameters + ---------- + lags_ti : numpy.ndarray, shape (k, dim) + Lag vectors in TI frame (integer-valued float64). May include h=0 rows. + ti_shape : numpy.ndarray, shape (dim,) + TI shape as an integer array. + boundary : str + ``"strict"`` or ``"partial"``. + + Returns + ------- + win_lo : numpy.ndarray, shape (dim,), dtype int + win_hi : numpy.ndarray, shape (dim,), dtype int + valid_count : int + Number of lags to use (``lags_ti[:valid_count]``). ``-1`` signals that + no subset of the data event fits inside the TI; the caller should draw a + random TI value instead of scanning. + """ + ti_shape_arr = np.asarray(ti_shape, dtype=int) + dim = len(ti_shape_arr) + nz = np.flatnonzero(np.any(lags_ti != 0, axis=1)) + if not len(nz): + return np.zeros(dim, dtype=int), ti_shape_arr - 1, len(lags_ti) + + lags_nz = lags_ti[nz] + if boundary == "strict": + win_lo = np.maximum(0, np.ceil(-lags_nz.min(axis=0))).astype(int) + win_hi = np.minimum( + ti_shape_arr - 1, + np.floor(ti_shape_arr - 1 - lags_nz.max(axis=0)), + ).astype(int) + if np.all(win_lo <= win_hi): + return win_lo, win_hi, len(lags_ti) + # Strict window infeasible (TI too small for the full data event). + # Fall through to partial truncation, but do not do so silently. + warnings.warn( + "gstools.mps: boundary='strict' could not fit the full data event inside the " + "TI; falling back to partial mode (dropping the furthest " + "neighbour(s)). Ensure the TI is at least as large as the data " + "event extent to enforce strict mode.", + RuntimeWarning, + stacklevel=2, + ) + + keep = len(lags_ti) + while keep > 0: + lv_k = lags_ti[:keep] + nzk = lv_k[np.any(lv_k != 0, axis=1)] + if not len(nzk): + return np.zeros(dim, dtype=int), ti_shape_arr - 1, keep + sw_lo = np.maximum(0, np.ceil(-nzk.min(axis=0))).astype(int) + sw_hi = np.minimum( + ti_shape_arr - 1, + np.floor(ti_shape_arr - 1 - nzk.max(axis=0)), + ).astype(int) + if np.all(sw_lo <= sw_hi): + return sw_lo, sw_hi, keep + keep -= 1 + return None, None, -1 + + +def _lag_transform_matrix(dim, rotation_map, scale_map, x_i): + """Detransform matrix M for a node's per-node rotation/scale. + + Consumes the canonical per-node maps produced by + :func:`gstools.mps.nonstationary.resolve_spec` — always shaped + ``grid_shape + (n_comp,)`` (stationary specs are zero-stride broadcast + views), so indexing is uniform and no ndim heuristics exist (B5). + + Parameters + ---------- + dim : int + Spatial dimensionality of the simulation grid. + rotation_map : numpy.ndarray or None + Canonical rotation map, shape ``grid_shape + (no_of_angles(dim),)``. + ``None`` → no rotation. + scale_map : numpy.ndarray or None + Canonical scale map, shape ``grid_shape + (dim,)``. ``None`` → no + dilation. No axis-0 pin: a uniform value is M10's affinity ``r``. + x_i : numpy.ndarray, shape (dim,) + Integer grid coordinates of the current simulation node. + + Returns + ------- + M : numpy.ndarray, shape (dim, dim) + ``matrix_detransform(dim, θ(x_i), s(x_i))`` — the SG→TI inverse map + (Mariethoz2010 §6.2: simulated structures are θ-rotated and s×-sized + relative to the TI, so lags are pulled back by the inverse). Lags are + row vectors, applied as ``lags_ti = lags_sg @ M.T``. + """ + idx = tuple(int(c) for c in x_i) + angles = ( + rotation_map[idx] + if rotation_map is not None + else np.zeros(no_of_angles(dim)) + ) + scale = scale_map[idx] if scale_map is not None else np.ones(dim) + return matrix_detransform(dim, angles, scale) diff --git a/src/gstools/mps/nonstationary.py b/src/gstools/mps/nonstationary.py new file mode 100644 index 00000000..b267560c --- /dev/null +++ b/src/gstools/mps/nonstationary.py @@ -0,0 +1,287 @@ +"""Call-time resolution of non-stationarity and zonation specs. + +`MPSModel` stores rotation/scale/zone specs grid-independently; once +`DirectSampling.__call__` knows the grid, this module resolves each spec into +its canonical per-node form: + +- rotation/scale -> array of shape ``grid_shape + (n_comp,)`` plus a + ``stationary`` flag. Stationary specs resolve to zero-copy read-only + ``np.broadcast_to`` views, so the engine can hoist the transform-matrix + construction out of the node loop without paying per-node memory. +- ``Zone.where`` masks -> one integer selector array over the grid + (0 = primary TI, i+1 = ``zones[i]``); overlaps raise. + +Resolution is by **exact shape match**, never ndim heuristics — this is what +structurally eliminates bug B5 (flattened per-node maps silently treated as +stationary). Genuine collisions (a 1-D grid of length ``n_comp``) raise a +ValueError naming both interpretations. + +Callables follow the established GSTools mean/trend convention: invoked as +``f(*pos)`` with one flat coordinate array per dimension, returning +``(n_nodes,)`` (broadcast across components) or ``(n_nodes, n_comp)``. The +trailing component axis is a deliberate deviation from GSTools vector-field +callables (which lead with a ``(dim,)`` axis): trailing is what per-node +indexing ``arr[tuple(x_i)]`` consumes. +""" + +import numpy as np + +__all__ = ["resolve_spec", "resolve_where", "build_zone_selector"] + + +def _check_positive(arr, name): + if not np.all(np.asarray(arr) > 0): + raise ValueError( + f"MPSModel: all {name} values must be > 0 " + f"(minimum found: {float(np.min(arr))!r})." + ) + + +def _stationary_view(values, grid_shape, n_comp): + """Zero-copy read-only broadcast of an (n_comp,) vector to the grid.""" + values = np.ascontiguousarray(values, dtype=np.float64) + return np.broadcast_to( + values.reshape((1,) * len(grid_shape) + (n_comp,)), + grid_shape + (n_comp,), + ) + + +def _materialize_callable(func, grid_shape, n_comp, pos, name): + n_nodes = int(np.prod(grid_shape)) + vals = np.asarray(func(*pos), dtype=np.float64) + if vals.shape == (n_nodes,): + return np.broadcast_to( + np.ascontiguousarray(vals).reshape(grid_shape + (1,)), + grid_shape + (n_comp,), + ) + if vals.shape == (n_nodes, n_comp): + return np.ascontiguousarray(vals).reshape(grid_shape + (n_comp,)) + raise ValueError( + f"MPSModel: the {name} callable must return shape ({n_nodes},) " + f"(one value per node, broadcast across components) or " + f"({n_nodes}, {n_comp}) (per-node components); got {vals.shape!r}." + ) + + +def resolve_spec(spec, grid_shape, n_comp, pos, name, positive=False): + """Resolve a rotation/scale spec to its canonical per-node array. + + Parameters + ---------- + spec : None, scalar, array-like, or callable + The grid-independent spec stored on :class:`MPSModel`. + grid_shape : tuple of int + Simulation grid shape. + n_comp : int + Component count: ``no_of_angles(dim)`` for rotation, ``dim`` for + scale. + pos : tuple of numpy.ndarray + One flat coordinate array per dimension + (:func:`gstools.tools.geometric.generate_grid` output); callables are + invoked as ``f(*pos)``. + name : str + Parameter name for error messages (``"rotation"``/``"scale"``). + positive : bool, optional + Require all resolved values > 0 (scale). Default: False. + + Returns + ------- + arr : numpy.ndarray of shape grid_shape + (n_comp,), or None + Canonical per-node array (``None`` when ``spec`` is ``None``). + Stationary specs return a zero-copy read-only broadcast view. + stationary : bool + True when the value is node-independent (the engine may hoist the + transform-matrix construction out of the node loop). + + Examples + -------- + >>> import numpy as np + >>> from gstools.mps.nonstationary import resolve_spec + >>> grid_shape = (5, 5) + >>> x = np.arange(5) + >>> y = np.zeros(5) + >>> pos = (x, y) + >>> spec = 45.0 + >>> n_comp = 1 + >>> arr, stationary = resolve_spec(spec, grid_shape, n_comp, pos, "rotation") + >>> arr.shape + (5, 5, 1) + >>> stationary + True + >>> arr[0, 0] + array([45.]) + """ + if spec is None: + return None, True + grid_shape = tuple(int(s) for s in grid_shape) + if callable(spec): + arr = _materialize_callable(spec, grid_shape, n_comp, pos, name) + if positive: + _check_positive(arr, name) + return arr, False + arr = np.asarray(spec, dtype=np.float64) + if positive: + _check_positive(arr, name) + if arr.ndim == 0: + return ( + _stationary_view(np.full(n_comp, float(arr)), grid_shape, n_comp), + True, + ) + is_stationary_form = arr.shape == (n_comp,) + is_grid_scalar = arr.shape == grid_shape + is_grid_full = arr.shape == grid_shape + (n_comp,) + if is_stationary_form and (is_grid_scalar or is_grid_full): + raise ValueError( + f"MPSModel: {name} of shape {arr.shape!r} is ambiguous on a grid " + f"of shape {grid_shape!r}: it matches both a stationary " + f"{n_comp}-component value and a per-node map. Disambiguate by " + f"adding the trailing component axis (shape " + f"{grid_shape + (n_comp,)!r}) or by using a callable." + ) + if is_stationary_form: + return _stationary_view(arr, grid_shape, n_comp), True + if is_grid_scalar: + return ( + np.broadcast_to(arr[..., None], grid_shape + (n_comp,)), + False, + ) + if is_grid_full: + return arr, False + raise ValueError( + f"MPSModel: {name} of shape {arr.shape!r} matches no accepted form " + f"for a grid of shape {grid_shape!r}: scalar, ({n_comp},) stationary " + f"vector, {grid_shape!r} per-node map, or " + f"{grid_shape + (n_comp,)!r} per-node components. Per-node maps must " + "be shaped like the grid, not flattened." + ) + + +def resolve_where(where, grid_shape, pos): + """Resolve a ``Zone.where`` spec to a boolean mask of the grid shape. + + Parameters + ---------- + where : numpy.ndarray of bool or callable + A boolean mask of ``grid_shape``, or a callable ``f(*pos) -> bool`` + evaluated on flat coordinate arrays. + grid_shape : tuple of int + Simulation grid shape. + pos : tuple of numpy.ndarray + One flat coordinate array per dimension + (:func:`gstools.tools.geometric.generate_grid` output); callables are + invoked as ``f(*pos)``. + + Returns + ------- + numpy.ndarray of bool, shape grid_shape + Boolean mask indicating the region. + + Examples + -------- + >>> import numpy as np + >>> from gstools.mps.nonstationary import resolve_where + >>> grid_shape = (3, 3) + >>> x = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2]) + >>> y = np.array([0, 1, 2, 0, 1, 2, 0, 1, 2]) + >>> pos = (x, y) + >>> where = lambda x, y: x >= 1 + >>> mask = resolve_where(where, grid_shape, pos) + >>> mask.shape + (3, 3) + >>> mask + array([[False, False, False], + [ True, True, True], + [ True, True, True]]) + """ + grid_shape = tuple(int(s) for s in grid_shape) + n_nodes = int(np.prod(grid_shape)) + if callable(where): + mask = np.asarray(where(*pos)) + if mask.dtype != np.bool_: + raise ValueError( + f"Zone: the where callable must return a boolean mask, got " + f"dtype {mask.dtype!r} — values are never silently coerced." + ) + if mask.shape == (n_nodes,): + return mask.reshape(grid_shape) + if mask.shape == grid_shape: + return mask + raise ValueError( + f"Zone: the where callable must return shape ({n_nodes},) or " + f"{grid_shape!r}, got {mask.shape!r}." + ) + mask = np.asarray(where) + if mask.dtype != np.bool_: + raise ValueError( + f"Zone: where must be a boolean array, got dtype {mask.dtype!r}." + ) + if mask.shape != grid_shape: + raise ValueError( + f"Zone: where shape {mask.shape!r} does not match the simulation " + f"grid shape {grid_shape!r}." + ) + return mask + + +def build_zone_selector(zones, grid_shape, pos): + """Fold resolved zone masks into one integer selector over the grid. + + Parameters + ---------- + zones : list of Zone + Zones to fold into a selector. + grid_shape : tuple of int + Simulation grid shape. + pos : tuple of numpy.ndarray + One flat coordinate array per dimension + (:func:`gstools.tools.geometric.generate_grid` output); passed to + :func:`resolve_where` for each zone. + + Returns + ------- + numpy.ndarray of numpy.intp, shape grid_shape + ``0`` = primary TI, ``i + 1`` = ``zones[i]``. + + Raises + ------ + ValueError + When two zones' resolved regions overlap (deterministic — no silent + precedence order). + + Examples + -------- + >>> import numpy as np + >>> from gstools.mps.nonstationary import build_zone_selector + >>> from gstools.mps.zone import Zone + >>> from gstools.mps.training_image import TrainingImage + >>> ti_a = TrainingImage(np.ones((3, 3))) + >>> ti_b = TrainingImage(np.ones((3, 3)) * 2) + >>> zone_a = Zone(ti_a, where=lambda x, y: x < 1) + >>> zone_b = Zone(ti_b, where=lambda x, y: x >= 2) + >>> zones = [zone_a, zone_b] + >>> grid_shape = (3, 3) + >>> x = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2]) + >>> y = np.array([0, 1, 2, 0, 1, 2, 0, 1, 2]) + >>> pos = (x, y) + >>> selector = build_zone_selector(zones, grid_shape, pos) + >>> selector + array([[1, 1, 1], + [0, 0, 0], + [2, 2, 2]]) + """ + grid_shape = tuple(int(s) for s in grid_shape) + selector = np.zeros(grid_shape, dtype=np.intp) + covered = np.zeros(grid_shape, dtype=bool) + for i, zone in enumerate(zones): + mask = resolve_where(zone.where, grid_shape, pos) + overlap = covered & mask + if overlap.any(): + raise ValueError( + f"MPSModel: zone {i}'s region overlaps an earlier zone at " + f"{int(overlap.sum())} node(s) (first at index " + f"{tuple(int(c) for c in np.argwhere(overlap)[0])}). Zone " + "regions must be disjoint." + ) + covered |= mask + selector[mask] = i + 1 + return selector diff --git a/src/gstools/mps/runner.py b/src/gstools/mps/runner.py new file mode 100644 index 00000000..b281a582 --- /dev/null +++ b/src/gstools/mps/runner.py @@ -0,0 +1,333 @@ +"""Execution layer for Direct Sampling. + +Drives a node visit order — the main simulation path or one post-processing +pass — either serially or in parallel over a dependency DAG (deterministic +regardless of thread count), plus the optional progress display. Pure stdlib + +NumPy; no TrainingImage, no engine import. +""" + +import queue + +import numpy as np + +from gstools.mps.neighbors import _select_neighbors + + +def _build_dag_base( + path, + sim_shape, + offset_arr, + vmap_dict, + n_k_dict, + max_radius=None, + pos_map=None, +): + """Unified dependency-DAG builder for both univariate and multivariate DS. + + Parameters + ---------- + vmap_dict : dict of {key: numpy.ndarray} + One position map per key. For univariate DS pass ``{"": path_pos_map}``. + n_k_dict : dict of {key: int} + Maximum neighbours per key, matching ``vmap_dict``. + pos_map : numpy.ndarray of numpy.intp or None, optional + Flat-cell → index in ``path`` (``-1`` for cells absent from it), used + **only** to orient edges. ``None`` (default, main simulation path) + takes the orientation from the gating index returned by + :func:`neighbors._select_neighbors`, which is the pre-existing + behaviour. Pass it when ``vmap_dict`` no longer carries visit order — + i.e. for a post-processing pass, whose all-``-1`` vmap makes every + informed cell available (Me13 §4 "fully informed neighbourhood") and + therefore returns no usable ordering information. See Notes. + Returns + ------- + indegree : dict of {key: numpy.ndarray of int32, shape (N,)} + out_edges : list of list of (int, key) + ``out_edges[j]`` → ``(i, key)`` pairs to process when node ``j`` completes. + coord_cache : dict of {key: (numpy.ndarray, numpy.ndarray, int)} + ``(flat_buf, counts, stride)`` per variable ``key``. Node ``i``'s + neighbours are the **raveled cell indices** + ``flat_buf[i * stride : i * stride + counts[i]]``, in the same + distance-sorted order ``_select_neighbors`` returned them; + ``_gather_neighborhood`` unravels them back to ``(m, dim)`` coordinates. + + Stored flat rather than as a list of ``N`` dicts of ``(m, dim)`` arrays + because that shape dominates memory on large grids: one int32 cell + index replaces ``dim`` int64 coordinates, and the fixed stride removes + the per-node dict/ndarray object overhead entirely. + + Selection uses the shared (global) ``max_radius`` -- a superset of any + variable's own, possibly smaller, radius. The parallel engine reuses + these directly for the DAG edges, but ``_gather_neighborhood`` must + re-filter them by each variable's own radius before use; because + ``_select_neighbors`` scans ``offset_arr`` in strictly increasing + distance order, that filtered result is bit-identical to what a second, + per-variable ``_select_neighbors`` call would have produced. + + Notes + ----- + Dependencies must be computed via the same :func:`neighbors._select_neighbors` + call the engine uses per node (with the shared radius, a superset of every + variable's own), so the DAG edges are a superset of the actual simulation + neighbour relation — extra edges only add scheduling constraints, never + incorrectness, and this is the invariant that makes parallel execution + produce bit-identical output to the serial path once + ``_gather_neighborhood`` re-filters per variable. + + Every edge runs from the lower to the higher visit position, so the graph + is acyclic with the visit order itself as a topological order. Both + dependency directions reduce to that one rule: + + * neighbour at position ``< i`` → node ``i`` reads its **new** value, so it + must complete first (a read-after-write true dependency — the only kind + the main path can have, since its vmap gates candidates to earlier + positions). + * neighbour at position ``> i`` → node ``i`` reads its **old** value, so + ``i`` must complete before that neighbour overwrites it (a + write-after-read anti-dependency). This only arises with ``pos_map``, + where an already-simulated grid makes later-positioned cells readable. + + Because the read relation is not exactly symmetric (``n_neighbors`` + truncation lets a boundary node reach farther than its own neighbours + reach back), an edge may be discovered from either endpoint, so ``seen`` + dedupes before ``indegree`` is counted — counting one pair twice yields a + single matching decrement and deadlocks the dispatcher. With + ``pos_map=None`` every selected neighbour is at an earlier position, no + duplicate is reachable, and the edge set is identical to the pre-existing + one. + """ + N = len(path) + sim_shape_arr = np.array(sim_shape) + + # Flat cell indices fit int32 for any grid under 2**31 cells, which is + # every grid the pure-Python engine can traverse in practice. + n_cells = int(np.prod(sim_shape)) + flat_dt = np.int32 if n_cells < 2**31 else np.intp + + indegree = {k: np.zeros(N, dtype=np.int32) for k in vmap_dict} + out_edges = [[] for _ in range(N)] + # Neighbour cache in fixed-stride form: one flat buffer per key holding + # raveled cell indices at ``i * stride``, plus the per-node count. The + # stride is exact -- _select_neighbors returns at most ``n_k`` neighbours. + coord_cache = { + k: ( + np.empty(N * n_k_dict[k], dtype=flat_dt), + np.zeros(N, dtype=np.int32), + n_k_dict[k], + ) + for k in vmap_dict + } + seen = set() + for i in range(N): + x_i = path[i] + for key, vmap in vmap_dict.items(): + coords, vidx = _select_neighbors( + x_i, + offset_arr, + sim_shape_arr, + sim_shape, + vmap, + i, + None, + max_radius, + n_k_dict[key], + ) + flat_buf, counts, stride = coord_cache[key] + m = len(coords) + flat = ( + np.ravel_multi_index(coords.T, sim_shape) + if m + else np.empty(0, dtype=np.intp) + ) + flat_buf[i * stride : i * stride + m] = flat + counts[i] = m + # Orientation index: the gating index for the main path, the + # visit position for a post-processing pass (all--1 vmap). + # ``flat`` is reused here -- the ravel is computed once per node. + pos = vidx if pos_map is None or not m else pos_map[flat] + for j in pos[pos >= 0]: + j = int(j) + if j == i: + continue + lo, hi = (j, i) if j < i else (i, j) + if (lo, hi, key) in seen: + continue + seen.add((lo, hi, key)) + indegree[key][hi] += 1 + out_edges[lo].append((hi, key)) + return indegree, out_edges, coord_cache + + +def _make_progress(progress, total, desc): + """Build ``(update, close)`` callbacks for an optional progress display. + + Parameters + ---------- + progress : bool or callable or None + ``None``/``False`` disables it. ``True`` prints the percentage at 5 % + steps on a single overwritten line (no third-party dependency). A + callable is invoked as ``progress(n_done, total)`` once per completed + simulation node. + total : int + Number of nodes that will be simulated (``len(path)``). + desc : str + Short label for the progress line (e.g. ``"DS"``). + + Returns + ------- + update : callable + Call (no arguments) once per completed node. + close : callable + Call once when the simulation finishes. + """ + if not progress or total <= 0: + return (lambda: None), (lambda: None) + if callable(progress): + state = {"done": 0} + + def update(): + state["done"] += 1 + progress(state["done"], total) + + return update, (lambda: None) + # Dependency-free progress: overwrite a single line at 5 % steps. + state = {"done": 0, "last": -1} + + def update(): + state["done"] += 1 + pct = 5 * (state["done"] * 20 // total) + if pct != state["last"]: + state["last"] = pct + print( + f"\r{desc}: {pct:3d}% ({state['done']}/{total})", + end="", + flush=True, + ) + + def close(): + print() + + return update, close + + +def _run_path( + path, + u_start, + u_fallback, + simulate_fn, + write_fn, + update_fn, + executor, + offset_arr, + vmap, + n_k, + sim_shape, + max_radius, + on_cache_ready=None, + pos_map=None, +): + """Dispatch the node simulation path: serial or parallel DAG. + + Parameters + ---------- + path : numpy.ndarray, shape (N, dim) + Ordered sequence of grid coordinates to simulate. + u_start : numpy.ndarray, shape (N,) + Per-node uniform random values controlling TI scan entry points. + u_fallback : numpy.ndarray, shape (N, dim) + Per-node uniform random values for fallback cell draws. + simulate_fn : callable + ``simulate_fn(i, x_i, u_start_i, u_fallback_i)`` → result dict + mapping variable names to simulated values for node ``i``. + write_fn : callable + ``write_fn(node_tuple, result)`` → None. Writes the result dict + into the simulation grid and marks the node informed. + update_fn : callable + ``update_fn()`` → None. Called once per completed node (progress). + executor : concurrent.futures.ThreadPoolExecutor or None + If not ``None``, nodes are dispatched in parallel over the DAG built + by :func:`_build_dag_base`. ``None`` → strictly serial execution. + offset_arr : numpy.ndarray, shape (M, dim) + Distance-sorted neighbour offsets from :func:`neighbors._precompute_offsets`. + vmap : dict of {str: numpy.ndarray} + Per-variable position maps (path index or ``-1`` for conditioning). + n_k : dict of {str: int} + Maximum neighbour count per variable. + sim_shape : tuple + Simulation grid shape. + max_radius : float or None + Euclidean cap on neighbour selection; ``None`` → unlimited. + on_cache_ready : callable or None, optional + When not ``None`` and ``executor`` is not ``None``, called once as + ``on_cache_ready(coord_cache)`` immediately after :func:`_build_dag_base` + returns and before any futures are submitted. ``coord_cache[key]`` is + the ``(flat_buf, counts, stride)`` triple of pre-selected neighbour cell + indices for variable ``key`` from the DAG pass. The engine stores it as + ``self._neighbor_cache`` so :meth:`_gather_neighborhood` can skip the + redundant ``_select_neighbors`` call on the parallel path. ``None`` in + serial mode (no DAG built, no cache). + pos_map : numpy.ndarray of numpy.intp or None, optional + Forwarded to :func:`_build_dag_base` to orient edges by visit position + rather than by ``vmap``'s gating index. Required for a + post-processing pass, whose all-``-1`` ``vmap`` carries no ordering. + ``None`` (default) → main-path behaviour. + + Notes + ----- + Drives both the main simulation path and every post-processing pass; the + two differ only in the ``vmap``/``pos_map`` pair they hand in (main: + order-gated reads, orientation from the gate; post-pass: fully informed + reads, orientation from ``pos_map``). Note that syn-processing — which + re-opens *already-decided* nodes mid-path rather than after it — is not + expressible this way and would need its own dispatcher. + """ + variables = list(vmap) + + if executor is not None: + indegree, out_edges, coord_cache = _build_dag_base( + path, + sim_shape, + offset_arr, + vmap, + n_k, + max_radius, + pos_map, + ) + if on_cache_ready is not None: + on_cache_ready(coord_cache) + remaining = {v: indegree[v].copy() for v in variables} + submitted = np.zeros(len(path), dtype=bool) + done_q = queue.Queue() + counts = {"submitted": 0, "done": 0} + + def _ready(i): + return all(remaining[v][i] == 0 for v in variables) + + def _run(i): + return i, simulate_fn(i, path[i], u_start[i], u_fallback[i]) + + def _submit(i): + if submitted[i]: + return + submitted[i] = True + executor.submit(_run, i).add_done_callback(done_q.put) + counts["submitted"] += 1 + + for i in range(len(path)): + if _ready(i): + _submit(i) + + while counts["done"] < counts["submitted"]: + i, result = done_q.get().result() + counts["done"] += 1 + write_fn(tuple(int(c) for c in path[i]), result) + update_fn() + for j, v in out_edges[i]: + remaining[v][j] -= 1 + if _ready(j): + _submit(j) + else: + for i in range(len(path)): + result = simulate_fn(i, path[i], u_start[i], u_fallback[i]) + write_fn(tuple(int(c) for c in path[i]), result) + update_fn() diff --git a/src/gstools/mps/scan.py b/src/gstools/mps/scan.py new file mode 100644 index 00000000..5662d0ec --- /dev/null +++ b/src/gstools/mps/scan.py @@ -0,0 +1,248 @@ +"""TI search-window scanning for Direct Sampling. + +Scans a candidate window for the best weighted-distance match (greedy +early-exit in DS mode, running-best argmin in DSBC mode). Depends only on +distance.py; receives TI arrays and the vectorized distance callable as +parameters (does not own a TrainingImage). +""" + +from dataclasses import dataclass + +import numpy as np +from numpy.random import default_rng + +from gstools.mps.distance import compute_node_weights + + +@dataclass(frozen=True) +class _ScanConfig: + """Domain-constant inputs to :func:`_scan_for_match`, built once per domain. + + Every field is invariant across the whole node path *and* across + post-processing passes — it describes the domain (TI data, shape, + per-variable ``d_max``) and engine-level search configuration, never + anything that varies per pass. ``scan_fraction`` is a per-pass value + (Meerschman2013 §4 divides it by ``post_processing_factor``) and is passed + to :func:`_scan_for_match` directly instead, from the caller's ``_Pass``. + """ + + variables: tuple + weights: dict + ti_vars: dict + ti_flat: dict + ti_strides: dict + ti_shape: object + threshold: float + cond_weight: float + distance_power: float + vec_distance_var: object + d_max: dict # {var: float or None} — normalization range per variable, + # from the domain's (zone) TI; kernel type stays primary's. + ti_has_nan: bool + scan_path: str = "sequential" # window scan order; see module docstring + + +# DS-mode scan block size. Large enough that per-call NumPy overhead is +# negligible (essentially full vectorization speed), small enough that the +# greedy DS scan does not overcompute far past the first accepted match. +# This is a call-overhead-amortization constant, not a cache-tuned one. +_SCAN_BLOCK = 4096 + + +def _scan_window( + lo, + win_shape, + u_start_i, + max_scan, + threshold, + dist_fn, + scan_path="sequential", +): + """Chunked vectorized TI window scan (pure-Python path). + + Parameters + ---------- + lo : numpy.ndarray + Lower-left anchor of the search window in TI coordinates. + win_shape : tuple of int + Shape of the search window. + u_start_i : float + The node's pre-drawn uniform in ``[0, 1)``. ``"sequential"``: start + position ``int(u_start_i * win_size)``. ``"random"``: stream seed. + max_scan : int + Maximum number of candidates to evaluate. + threshold : float + Distance threshold for early exit (DS mode). ``<= 0`` → DSBC (no early exit). + dist_fn : callable + ``dist_fn(y_blk)`` → 1-D distance array for a block of candidate + anchor coordinates ``y_blk`` of shape ``(b, dim)``. + scan_path : str, optional + ``"sequential"`` (default) or ``"random"``. See the module docstring. + + Returns + ------- + y : numpy.ndarray, shape (dim,) or None + Coordinate of the best matching TI anchor, or ``None`` when no candidate + in the window has a finite distance (every candidate excluded — e.g. an + all-undefined window on a masked TI). The caller treats ``None`` as the + empty-neighbourhood case and draws a defined TI cell. + """ + win_size = int(np.prod(win_shape)) + # "sequential": wrap-around walk from a random start (Mariethoz2010 ¶19, + # Juda2022 §2). "random": max_scan distinct cells in random order, keyed on + # the node's own u_start_i so no extra RNG draw is needed. + # + # Deliberate exception to the "use gstools.random.RNG" rule: reproducibility + # is unaffected, since the only entropy is u_start_i, itself drawn from the + # engine's gstools RNG. gstools RNG cannot serve this call anyway -- it + # exposes no ``choice``, and its legacy-RandomState ``.random`` implements + # ``choice(replace=False)`` via a full permutation, i.e. O(win_size) + # instead of O(max_scan). + # + # Positions are built per ``_SCAN_BLOCK`` inside the loop below rather than + # for all ``max_scan`` candidates upfront, since the greedy DS scan + # typically exits long before the last block. This is unconditional + # rather than mode-gated: the sequential walk's block is a cheap arange + # either way, and it is bit-identical to building everything upfront. + if scan_path == "random": + positions = default_rng(int(u_start_i * 2**53)).choice( + win_size, max_scan, replace=False + ) + start = None + else: + positions = None + start = int(u_start_i * win_size) + + # DSBC (threshold <= 0) has no threshold-based early exit, but an exact + # match d == 0 is the best attainable candidate, so accept it immediately + # (retained for categorical, where d == 0 is common — Juda2022 §2). Both + # modes therefore scan in blocks of ``_SCAN_BLOCK`` and track the running + # best, so a full-window DSBC scan never materialises one giant distance + # array. Block argmin + strict ``<`` keep the first-in-scan-order winner, + # matching a single ``argmin`` over the whole window. + accept = threshold if threshold > 0 else 0.0 + + # DS mode (threshold > 0): strict acceptance d < t (Mariethoz2010 ¶23). + # DSBC mode (threshold <= 0, accept == 0): accept the exact match d == 0, + # i.e. d <= 0, since distances are non-negative (Juda2022 §2). + dsbc = threshold <= 0 + best_d, best_y = np.inf, None + for b0 in range(0, max_scan, _SCAN_BLOCK): + b1 = min(b0 + _SCAN_BLOCK, max_scan) + # Same integers the upfront build produced for this slice: the + # sequential walk is ``(start + k) % win_size`` elementwise, and + # ``unravel_index`` is elementwise, so blocking changes nothing. + pos_blk = ( + (start + np.arange(b0, b1)) % win_size + if positions is None + else positions[b0:b1] + ) + y_blk = lo + np.column_stack(np.unravel_index(pos_blk, win_shape)) + d_blk = dist_fn(y_blk) + under = d_blk <= accept if dsbc else d_blk < accept + if np.any(under): + return y_blk[int(np.argmax(under))] + k = int(np.argmin(d_blk)) + if d_blk[k] < best_d: + best_d = float(d_blk[k]) + best_y = y_blk[k] + # ``best_y is None`` means every candidate had a non-finite (excluded) + # distance — only reachable on a masked TI where the whole window is + # undefined. Signal the caller to fall back to a defined TI draw (M10 + # para [15] empty-neighbourhood handling). For a fully-defined TI the loop + # always sets best_y, so this returns a real anchor unchanged. + return best_y + + +def _scan_for_match( + lo, + win_shape, + int_lags, + de_v, + cm_v, + ln_v, + u_start_i, + scan_targets, + scan_fraction, + cfg, +): + """Joint multivariate DS scan over a TI search window. + + Scans candidates in randomized order (greedy DS early-exit when the first + candidate with distance ``< threshold`` is found; DSBC running-best argmin + when ``threshold <= 0``). The window is the per-variable intersection so + every ``y + lag`` is in bounds and the gather needs no clipping; the h=0 + lag maps to ``y`` itself. Returns the single best TI anchor coordinate + ``y``, or ``None`` when every candidate in the window is undefined (masked + TI — caller falls back to a random defined cell). + + Parameters + ---------- + scan_fraction : float + This pass's scan fraction (main path, or divided by + ``post_processing_factor`` for a post-pass) — the one field of the + old ``_ScanConfig`` that varies per pass rather than per domain. + """ + win_size = int(np.prod(win_shape)) + ti_size = int(np.prod(cfg.ti_shape)) + # Scan fraction is of the TI (Mariethoz2010 ¶24, Juda2022 §2), capped at + # the valid search window so we never wrap around and re-scan anchors. + max_scan = max(1, min(win_size, int(scan_fraction * ti_size))) + + active_vars = [v for v in cfg.variables if v in int_lags] + active_w_total = sum(cfg.weights[v] for v in active_vars) + precomp_w = { + v: compute_node_weights( + len(de_v[v]), ln_v[v], cfg.distance_power, cm_v[v], cfg.cond_weight + ) + for v in active_vars + } + # Precompute flat lag offsets for each variable: flat_il[v] = int_lags[v] @ strides. + # Combined with base = y_blk @ strides, all_de_ti = ti_flat[v][base[:,None] + flat_il[v][None,:]]. + # In-bounds invariant: every y + lag is guaranteed in-bounds by window construction + # (_intersect_search_windows), so this flat take needs no bounds checking. + flat_il = {v: int_lags[v] @ cfg.ti_strides[v] for v in active_vars} + + def _dist_block(y_blk): + d = np.zeros(len(y_blk)) + for v in active_vars: + base = y_blk @ cfg.ti_strides[v] # shape (B,) + all_de_ti = cfg.ti_flat[v][base[:, None] + flat_il[v][None, :]] + d += cfg.weights[v] * cfg.vec_distance_var( + v, + de_v[v], + all_de_ti, + cm_v[v], + cfg.cond_weight, + ln_v[v], + weights=precomp_w[v], + d_max=cfg.d_max[v], + has_nan=cfg.ti_has_nan, + ) + # Renormalize so the joint distance stays in [0, 1] even when + # some variables have no data event and are excluded from int_lags. + # Without this, the threshold fires on a compressed scale and + # accepts matches that should be rejected. + if 0.0 < active_w_total < 1.0: + d /= active_w_total + if cfg.ti_has_nan: + # Never select an anchor whose pasted value would be undefined: + # the matched cell must be defined in every target variable. + center_ok = np.ones(len(y_blk), dtype=bool) + ys = tuple(y_blk.T) + for v in scan_targets: + cv = cfg.ti_vars[v][ys] + if np.issubdtype(cv.dtype, np.floating): + center_ok &= np.isfinite(cv) + d = np.where(center_ok, d, np.inf) + return d + + return _scan_window( + lo, + win_shape, + u_start_i, + max_scan, + cfg.threshold, + _dist_block, + cfg.scan_path, + ) diff --git a/src/gstools/mps/simulate.py b/src/gstools/mps/simulate.py new file mode 100644 index 00000000..97c66d04 --- /dev/null +++ b/src/gstools/mps/simulate.py @@ -0,0 +1,1120 @@ +"""Direct Sampling simulation engine. + +`ds_simulate` is the entry point; `_DirectSamplingEngine` holds one run's +state (output grids, informed masks, config) explicitly — what used to be +captured by closures inside `ds_simulate`. The engine orchestrates per-node +simulation by calling the stateless `neighbors`, `scan`, and `runner` modules. +""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from math import prod + +import numpy as np + +from gstools import config +from gstools.mps.data_event import DataEvent +from gstools.mps.neighbors import ( + _lag_transform_matrix, + _precompute_offsets, + _reduce_to_fit, + _select_neighbors, + _transform_lags, + _window_bounds, +) +from gstools.mps.runner import _make_progress, _run_path +from gstools.mps.scan import _scan_for_match, _ScanConfig + + +@dataclass(frozen=True) +class _Pass: + """Everything that varies per pass — the main path or one post-processing round. + + Threaded through the node-simulation seams as an argument, so the engine's + own ``n_k``/``scan_fraction``/``domains`` stay un-mutated base configuration + and any further phase is just another ``_Pass``. + + Parameters + ---------- + order : numpy.ndarray, shape (N, dim) + This pass's visit order. + vmap : dict of {str: numpy.ndarray} + Per-variable availability map (see :func:`_build_vmap`). All ``-1`` for + a post-pass — Me13 §4's "fully informed neighbourhood". + pos_map : numpy.ndarray or None + Visit-order orientation for the DAG (see :func:`runner._build_dag_base`). + ``None`` for the main path, whose vmap already carries the order. + n_k : dict of {str: int} + Per-variable neighbour count (post-passes divide by + ``post_processing_factor``, floored at 1). + scan_fraction : float + Likewise divided by ``post_processing_factor`` on a post-pass. + neighbor_cache : dict + One-slot box for the DAG-build neighbour cache, filled under key + ``"coords"``. Fresh per pass, so no cache can leak between passes; + stays empty in serial mode. + """ + + order: object + vmap: dict + pos_map: object + n_k: dict + scan_fraction: float + neighbor_cache: dict + + +@dataclass(frozen=True) +class _Domain: + """One scan domain: the primary TI or a zone TI, with its precomputations. + + Search hyper-parameters (n_neighbors, max_radius, weights, distance + kernel) always come from the PRIMARY TI; the domain contributes the data + arrays, shape, per-variable d_max, and NaN bookkeeping (spec §3). + """ + + ti: object # TrainingImage + ti_shape: object # numpy.ndarray (dim,) + ti_vars: dict # {var: ndarray} + ti_flat: dict + ti_strides: dict + has_nan: bool + finite_flat: object # ndarray or None + scan_config: object # _ScanConfig + + +def _build_path(unknown, path, rng_path, sim_shape): + """Build the (N, dim) node visit order. + + Parameters + ---------- + unknown : numpy.ndarray of bool, shape sim_shape + Mask of nodes that require simulation (at least one uninformed variable). + path : str or array-like + ``"random"`` — uniformly shuffled raster order (default DS behaviour). + ``"sequential"`` — lexicographic (raster) order; ``rng_path`` is not + consumed, making the visit order deterministic without a ``path_seed``. + An explicit ``(N, dim)`` integer array — the caller-supplied visit + order. Must include every unknown node; conditioned nodes present in + the array are silently skipped so that a full-grid path (e.g. a spiral + over all nodes) works unchanged with conditioning data. Duplicate rows + and missing unknown nodes are still errors. + rng_path : numpy.random.RandomState + Controls shuffling when ``path="random"``; unused for the other modes. + sim_shape : tuple of int + Simulation grid shape. + + Returns + ------- + numpy.ndarray, shape (N, dim), dtype numpy.intp + Node visit order. + + Raises + ------ + ValueError + For an unrecognised string or an invalid explicit array. + """ + base = np.argwhere( + unknown + ) # raster (lexicographic) order over unknown nodes + if isinstance(path, str): + if path == "sequential": + return base + if path == "random": + return base[rng_path.permutation(len(base))] + raise ValueError( + f"DirectSampling: path must be 'random', 'sequential', or an " + f"(N, dim) array; got {path!r}" + ) + arr = np.asarray(path) + dim = len(sim_shape) + # Shape check + if arr.ndim != 2 or arr.shape[1] != dim: + raise ValueError( + f"DirectSampling: explicit path shape must be (N, dim={dim}); " + f"got {arr.shape!r}" + ) + # Integer check and bounds check + if not np.issubdtype(arr.dtype, np.integer): + # Allow float arrays with integer values (e.g. from argwhere stored as float) + if not np.all(arr == np.floor(arr)): + raise ValueError( + "DirectSampling: explicit path must contain integer coordinates" + ) + arr = arr.astype(np.intp) + else: + arr = arr.astype(np.intp, copy=False) + for d in range(dim): + if np.any(arr[:, d] < 0) or np.any(arr[:, d] >= sim_shape[d]): + raise ValueError( + f"DirectSampling: explicit path has out-of-bounds coordinates " + f"along axis {d} for grid shape {sim_shape!r}" + ) + # Validation: duplicates → error; conditioned extras → silently drop; + # missing unknown nodes → error. + flat_path = np.ravel_multi_index(arr.T, sim_shape) + flat_base = ( + np.ravel_multi_index(base.T, sim_shape) + if len(base) + else np.empty(0, dtype=np.intp) + ) + if len(np.unique(flat_path)) != len(flat_path): + raise ValueError( + "DirectSampling: explicit path contains duplicate nodes" + ) + # Keep only unknown-set nodes (conditioned entries are silently dropped) + unknown_mask = np.isin(flat_path, flat_base) + arr = arr[unknown_mask] + flat_path = flat_path[unknown_mask] + # Every unknown node must appear in the (filtered) path + if not np.array_equal(np.sort(flat_path), np.sort(flat_base)): + raise ValueError( + "DirectSampling: explicit path is missing one or more unknown nodes " + "(every unset grid node must appear in the path)" + ) + return arr + + +def _order_positions(order, sim_shape): + """Flat-cell → index in ``order`` (``-1`` for cells absent from it).""" + m = np.full(int(np.prod(sim_shape)), -1, dtype=np.intp) + if len(order): + m[np.ravel_multi_index(order.T, sim_shape)] = np.arange(len(order)) + return m + + +def _build_vmap(order, is_cond, variables, sim_shape): + """Per-variable availability map: visit index, or ``-1`` = always available. + + A cell conditioned in variable ``v`` is ``-1`` in ``vmap[v]`` so its known + value is never gated by visit order — essential for a partially-conditioned + node. Cells absent from ``order`` are already ``-1``. + """ + base = _order_positions(order, sim_shape) + vmap = {} + for v in variables: + m = base.copy() + m[np.flatnonzero(is_cond[v].reshape(-1))] = -1 + vmap[v] = m + return vmap + + +class _DirectSamplingEngine: + """Holds one Direct Sampling run's state and orchestrates it.""" + + def __init__( + self, + training_image, + sim_shape, + threshold, + scan_fraction, + rng_path, + rng_nodes, + conditions=None, + cond_weight=1.0, + boundary="strict", + rotation_map=None, + scale_map=None, + stationary_transform=False, + path="random", + zone_tis=None, + zone_selector=None, + post_processing=0, + post_processing_factor=1.0, + post_processing_path=None, + scan_path="sequential", + ): + self.training_image = training_image + self.variables = [v.name for v in training_image.variables] + self.weights = ( + { + v.name: training_image.weights[v.name] + for v in training_image.variables + } + if training_image.multivariate + else {None: 1.0} + ) + self.ti_shape = np.array(training_image.shape) + self.sim_shape = sim_shape + self.sim_shape_arr = np.array(sim_shape) + self.dim = len(sim_shape) + self.threshold = threshold + self.scan_fraction = scan_fraction + self.cond_weight = cond_weight + self.boundary = boundary + # Window scan order; copied into every domain's _ScanConfig by + # _build_domain, so zones inherit it. + self.scan_path = scan_path + self.rotation_map = rotation_map + self.scale_map = scale_map + # Stationary transform: compute M once per simulation (today's fast + # path) instead of rebuilding it per node. For per-node maps, cache + # matrices keyed on the (θ..., s...) value tuple — real-world maps + # are often piecewise constant, collapsing construction to a dict + # lookup (worst case: per-node cost, dominated by the TI scan). + self._m_cache = {} + self._stationary_M = None + if ( + rotation_map is not None or scale_map is not None + ) and stationary_transform: + self._stationary_M = _lag_transform_matrix( + self.dim, + rotation_map, + scale_map, + np.zeros(self.dim, dtype=int), + ) + + self.n_k = {v.name: v.n_neighbors for v in training_image.variables} + + self.sg = {v: np.full(sim_shape, np.nan) for v in self.variables} + self.informed = { + v: np.zeros(sim_shape, dtype=bool) for v in self.variables + } + self.is_cond = { + v: np.zeros(sim_shape, dtype=bool) for v in self.variables + } + if conditions: + for idx, vd in conditions.items(): + for v, val in vd.items(): + self.sg[v][idx] = val + self.is_cond[v][idx] = True + self.informed[v][idx] = True + + self.post_processing = int(post_processing) + self.post_processing_factor = float(post_processing_factor) + self._rng_path = rng_path + self._rng_nodes = rng_nodes + # Post-pass visit order: None inherits the main-path mode (an + # explicit main path array has no meaning for a full-grid re-visit + # -> random). Me13 §4 does not prescribe the post-pass order; the + # explicit option is an implementation extension. + if post_processing_path is None: + post_processing_path = ( + "sequential" + if isinstance(path, str) and path == "sequential" + else "random" + ) + self._post_path = post_processing_path + + self.max_radius_per_var = { + v.name: v.max_radius for v in training_image.variables + } + _radii = list(self.max_radius_per_var.values()) + # None means "unbounded" for that variable (docstring: "None -> no + # limit"). offset_arr is shared across variables, so if any variable + # is unbounded the shared candidate set must be too -- capping it to + # a finite sibling's radius would silently truncate the unbounded + # variable's neighbourhood. + if any(r is None for r in _radii): + global_max_radius = None + else: + global_max_radius = max(_radii) if _radii else None + max_off_int = ( + int(np.ceil(global_max_radius)) + if global_max_radius is not None + else None + ) + self.offset_arr = _precompute_offsets(sim_shape, max_off_int) + self.max_radius = global_max_radius + + # Node path: every node with >= 1 uninformed variable. Fully-conditioned + # nodes need no simulation and are excluded (they stay -1 / always available). + unknown = np.zeros(sim_shape, dtype=bool) + for v in self.variables: + unknown |= np.isnan(self.sg[v]) + self.path = _build_path(unknown, path, rng_path, sim_shape) + n_nodes = len(self.path) + self.u_start = rng_nodes.uniform(size=n_nodes) + self.u_fallback = rng_nodes.uniform(size=(n_nodes, self.dim)) + + self.vmap = _build_vmap( + self.path, self.is_cond, self.variables, sim_shape + ) + + # Domains: index 0 is always the primary TI; zone TIs (if any) follow + # in `zones` order (spec: 0 = primary, i+1 = zone_tis[i]). Search + # hyper-parameters (n_k, max_radius, weights, distance kernel) stay + # engine-level (always the primary's) — only the search domain (TI + # shape/data/d_max/NaN bookkeeping) varies per zone. + self.zone_selector = zone_selector + self.domains = [self._build_domain(training_image)] + for zti in zone_tis or []: + self.domains.append(self._build_domain(zti)) + if zone_selector is not None: + if zone_selector.shape != tuple(sim_shape): + raise ValueError( + f"ds_simulate: zone_selector shape " + f"{zone_selector.shape!r} does not match the simulation " + f"grid shape {tuple(sim_shape)!r}." + ) + smin, smax = int(zone_selector.min()), int(zone_selector.max()) + if smin < 0 or smax >= len(self.domains): + raise ValueError( + f"ds_simulate: zone_selector values must be in " + f"[0, {len(self.domains) - 1}] (0 = primary TI, i + 1 = " + f"zone_tis[i]), got range [{smin}, {smax}]." + ) + + def _build_domain(self, ti): + """Precompute one scan domain (flat arrays, strides, d_max, NaN).""" + ti_shape = np.array(ti.shape) + ti_shape_tuple = tuple(int(s) for s in ti.shape) + _dim = len(ti_shape_tuple) + ti_vars = {v.name: v.data for v in ti.variables} + ti_strides = { + v.name: np.array( + [prod(ti_shape_tuple[d + 1 :]) for d in range(_dim)], + dtype=np.intp, + ) + for v in ti.variables + } + ti_flat = { + v.name: np.ascontiguousarray(v.data).ravel() for v in ti.variables + } + has_nan = ti.has_nan + if has_nan: + finite_all = np.ones(ti_shape_tuple, dtype=bool) + for a in ti_vars.values(): + if np.issubdtype(a.dtype, np.floating): + finite_all &= ~np.isnan(a) + finite_flat = np.flatnonzero(finite_all.reshape(-1)) + if finite_flat.size == 0: + raise ValueError( + "TrainingImage has no cell defined in all variables " + "(every cell is NaN in at least one variable); cannot " + "simulate." + ) + else: + finite_flat = None + # Per-domain d_max: the selected zone TI's value range normalizes + # continuous distances (spec §3 — threshold/scan_fraction semantics + # follow the selected TI). Kernel type stays the primary's + # (vec_distance_var is bound to self.training_image). + d_max = {v.name: v.d_max for v in ti.variables} + scan_config = _ScanConfig( + variables=self.variables, + weights=self.weights, + ti_vars=ti_vars, + ti_flat=ti_flat, + ti_strides=ti_strides, + ti_shape=ti_shape, + threshold=self.threshold, + cond_weight=self.cond_weight, + distance_power=self.training_image.distance_power, + vec_distance_var=self.training_image.vec_distance_var, + d_max=d_max, + ti_has_nan=has_nan, + scan_path=self.scan_path, + ) + return _Domain( + ti=ti, + ti_shape=ti_shape, + ti_vars=ti_vars, + ti_flat=ti_flat, + ti_strides=ti_strides, + has_nan=has_nan, + finite_flat=finite_flat, + scan_config=scan_config, + ) + + def _rand_fallback(self, targets, u_fb_i, dom): + # Single random TI cell supplies the whole node-vector (preserves the + # joint relationship); never an independent draw per variable. On a + # masked TI the draw is restricted to cells defined in every variable. + # Draws from the SELECTED zone TI's domain, never the primary — this + # is what keeps the subset property true on rare fallback draws. + if dom.has_nan: + flat = dom.finite_flat[int(u_fb_i[0] * len(dom.finite_flat))] + cell = tuple( + int(c) for c in np.unravel_index(flat, tuple(dom.ti_shape)) + ) + else: + cell = tuple( + int(u_fb_i[d] * s) for d, s in enumerate(dom.ti_shape) + ) + return {v: float(dom.ti_vars[v][cell]) for v in targets} + + def _node_transform_matrix(self, x_i): + """Per-node M with value-tuple caching (dict get/set are GIL-atomic; + a rare duplicate computation under threads is benign and value-equal).""" + if self._stationary_M is not None: + return self._stationary_M + idx = tuple(int(c) for c in x_i) + ang = self.rotation_map[idx] if self.rotation_map is not None else None + sc = self.scale_map[idx] if self.scale_map is not None else None + key = ( + None if ang is None else tuple(ang.tolist()), + None if sc is None else tuple(sc.tolist()), + ) + M = self._m_cache.get(key) + if M is None: + M = _lag_transform_matrix( + self.dim, self.rotation_map, self.scale_map, x_i + ) + self._m_cache[key] = M + return M + + # ------------------------------------------------------------------ + # Simulation pipeline helpers (called only from _simulate_node) + # ------------------------------------------------------------------ + + def _gather_neighborhood(self, pss, x_i, x_i_t, curr_idx): + """Seam 1 — build per-variable data-event arrays for node ``x_i``. + + For each variable, selects the ``n_k[var]`` closest already-informed + neighbours, converts them to lag vectors, and prepends the collocated + ``h=0`` row when the variable is already known at this node (only + possible via conditioning). + + Parameters + ---------- + pss : _Pass + The current pass (main path or one post-processing round). + x_i : numpy.ndarray, shape (dim,) + Integer grid coordinates of the current node. + x_i_t : tuple + ``tuple(int(c) for c in x_i)`` — precomputed for indexing. + curr_idx : int + Path index of the current node (neighbours must have index < this). + + Returns + ------- + events : dict[str, DataEvent] + One per variable, ``lags_ti`` unset (``None``). + """ + events = {} + for var in self.variables: + # Parallel path: the DAG pass already selected neighbours for this + # node/variable (same call, informed=None, all earlier-path deps + # guaranteed informed by DAG ordering) using the shared/global + # radius, so reuse those coords instead of recomputing -- but + # re-filter by this variable's own radius below (see r). Serial + # path (cache absent): compute normally, already per-variable. + cache = pss.neighbor_cache.get("coords") + if cache is not None: + # Fixed-stride flat cache (see runner._build_dag_base): the + # node's neighbours are raveled cell indices, unravelled back + # to (m, dim) here. The round-trip is lossless -- every cached + # index came from an in-bounds coordinate -- and preserves the + # distance-sorted order the re-filter below relies on. + flat_buf, counts, stride = cache[var] + m = int(counts[curr_idx]) + coords = ( + np.column_stack( + np.unravel_index( + flat_buf[ + curr_idx * stride : curr_idx * stride + m + ], + self.sim_shape, + ) + ) + if m + else np.empty((0, self.dim), dtype=np.intp) + ) + r = self.max_radius_per_var[var] + # The DAG cache was built with the shared (global) radius, a + # superset of any variable's own (possibly smaller) radius. + # Since _select_neighbors scans offset_arr in strictly + # increasing distance order, the first n_k[var] valid hits + # under the global radius are a prefix-superset of what this + # variable's own radius would find -- re-filtering here + # reproduces the serial result exactly (Mariethoz2010 ¶15). + if r is not None and len(coords): + d_sq = np.sum((coords - x_i) ** 2, axis=1) + coords = coords[d_sq <= r * r] + else: + coords, _ = _select_neighbors( + x_i, + self.offset_arr, + self.sim_shape_arr, + self.sim_shape, + pss.vmap[var], + curr_idx, + self.informed[var], + self.max_radius_per_var[var], + pss.n_k[var], + ) + if len(coords): + lv = (coords - x_i).astype(np.float64) + dv = self.sg[var][tuple(coords.T)] + cv = self.is_cond[var][tuple(coords.T)] + lnv = np.linalg.norm(lv, axis=1) + else: + lv = np.empty((0, self.dim), dtype=np.float64) + dv = np.empty(0) + cv = np.empty(0, dtype=bool) + lnv = np.empty(0) + # collocated h=0 — a variable known at this very node (only possible + # via conditioning, as the node's unknown variables are what we fill). + # Never added for a target variable (uninformed here by definition). + if self.informed[var][x_i_t]: + lv = np.concatenate([np.zeros((1, self.dim)), lv], axis=0) + dv = np.concatenate([[self.sg[var][x_i_t]], dv]) + cv = np.concatenate([[self.is_cond[var][x_i_t]], cv]) + lnv = np.concatenate([[0.0], lnv]) + events[var] = DataEvent(lv, dv, cv, lnv) + return events + + def _transform_and_reduce_lags(self, x_i, events, dom): + """Seam 2 — map SG lags into TI frame (non-stationary) or copy (stationary). + + When ``rotation_map`` or ``scale_map`` is set, applies the per-node + detransform matrix, deduplicates collapsed lags + (:func:`_transform_lags`), and globally drops lags that cannot fit the + selected domain's TI (:func:`_reduce_to_fit`, Mariethoz2010 para [43]). + + The stationary fast-path sets ``lags_ti`` to a **copy** of ``lags_sg`` + so partial-mode truncation in seam 3 can never alias back into the SG + lags. + + Parameters + ---------- + x_i : numpy.ndarray, shape (dim,) + Current node coordinates (used to index per-node maps). + events : dict[str, DataEvent] + Output of :meth:`_gather_neighborhood` (``lags_ti is None``). + dom : _Domain + The domain (primary or zone) selected for this node. + + Returns + ------- + dict[str, DataEvent] + New events with ``lags_ti`` populated and SG arrays possibly trimmed + (non-stationary dedup / reduce-to-fit). + """ + if self.rotation_map is not None or self.scale_map is not None: + M = self._node_transform_matrix(x_i) + out = {} + for v in self.variables: + de = events[v] + lv_ti, lags_sg, values, cond_mask, lag_norms = _transform_lags( + de.lags_sg, + M, + de.lags_sg, + de.values, + de.cond_mask, + de.lag_norms, + ) + # M10 para [43]: globally reduce an over-large transformed event + # to fit the TI, dropping the most-outside node first (by TI + # extent, regardless of SG rank). One reduction per simulation + # node, independent of the TI scan position. + lv_ti, lags_sg, values, cond_mask, lag_norms = _reduce_to_fit( + lv_ti, dom.ti_shape, lags_sg, values, cond_mask, lag_norms + ) + out[v] = DataEvent( + lags_sg, values, cond_mask, lag_norms, lv_ti + ) + return out + # Stationary: TI frame == SG frame. Copy so truncation stays independent. + return { + v: events[v].with_lags_ti(events[v].lags_sg.copy()) + for v in self.variables + } + + def _intersect_search_windows(self, events, dom): + """Seam 3 — compute and intersect per-variable TI search windows. + + Calls :func:`_window_bounds` for every variable, truncates the event to + the fitting lag count in partial mode, and intersects the per-variable + windows into a single ``(win_lo, win_hi)`` pair. + + Returns ``win_lo is None`` (sentinel) when any single-variable window is + infeasible or the intersection is empty; the caller draws a random + fallback. + + Parameters + ---------- + events : dict[str, DataEvent] + TI-frame events from :meth:`_transform_and_reduce_lags`. + dom : _Domain + The domain (primary or zone) selected for this node. + + Returns + ------- + win_lo : numpy.ndarray or None + win_hi : numpy.ndarray or None + events : dict[str, DataEvent] + Possibly truncated (only when ``win_lo`` is not ``None``). + """ + win_lo = np.zeros(self.dim, dtype=int) + win_hi = dom.ti_shape - 1 + for var in self.variables: + de = events[var] + lo, hi, valid_count = _window_bounds( + de.lags_ti, dom.ti_shape, self.boundary + ) + if valid_count == -1: + # Infeasible: no subset of this variable's lags fits the TI. + return None, None, events + if valid_count < len(de): + # Truncate TI-frame and SG arrays to the same keep count. + events[var] = de.truncate(valid_count) + win_lo = np.maximum(win_lo, lo) + win_hi = np.minimum(win_hi, hi) + + if np.any(win_lo > win_hi): + # Infeasible: the per-variable windows do not intersect. + return None, None, events + + return win_lo, win_hi, events + + def _scan_and_retrieve( + self, + pss, + win_lo, + win_hi, + events, + targets, + u_start_i, + u_fallback_i, + dom, + ): + """Seam 4 — scan the TI window and copy the matched cell to targets. + + See module docstring for the scan semantics. Unpacks ``events`` into + the per-variable dicts the stateless :func:`_scan_for_match` kernel + expects (its signature is unchanged). + + Parameters + ---------- + pss : _Pass + The current pass (main path or one post-processing round). + win_lo, win_hi : numpy.ndarray, shape (dim,) + events : dict[str, DataEvent] + TI-frame events after truncation. + targets : list[str] + u_start_i : float + u_fallback_i : numpy.ndarray, shape (dim,) + dom : _Domain + The domain (primary or zone) selected for this node. + + Returns + ------- + dict[str, float] + """ + # Integer lags per variable (already exact integers as float64, incl. + # the 0.0 h=0 row); reused for the scan and the mean-shift gather. + int_lags = { + v: events[v].lags_ti.astype(int) + for v in self.variables + if len(events[v]) + } + de_v = {v: events[v].values for v in self.variables} + cm_v = {v: events[v].cond_mask for v in self.variables} + ln_v = {v: events[v].lag_norms for v in self.variables} + y = _scan_for_match( + win_lo, + tuple(win_hi - win_lo + 1), + int_lags, + de_v, + cm_v, + ln_v, + u_start_i, + targets, + pss.scan_fraction, + dom.scan_config, + ) + if y is None: + # No candidate in the window was defined (masked TI): treat as the + # empty-neighbourhood case and draw a defined TI cell. + return self._rand_fallback(targets, u_fallback_i, dom) + y_t = tuple(int(c) for c in y) + result = {} + for v in targets: + ti_val = float(dom.ti_vars[v][y_t]) + il = int_lags.get(v) + de_ti_v = ( + dom.ti_vars[v][tuple((y + il).T)] + if il is not None + else np.empty(0) + ) + result[v] = self.training_image.adjust_value( + ti_val, de_v[v], de_ti_v, var=v + ) + return result + + def _simulate_node(self, pss, curr_idx, x_i, u_start_i, u_fallback_i): + """Simulate one node: a short pipeline of four named steps. + + Parameters + ---------- + pss : _Pass + The current pass (main path or one post-processing round). + """ + x_i_t = tuple(int(c) for c in x_i) + # Zonation selects WHICH search domain this node scans; the data + # event is built from SG neighbours regardless of their zones, + # which keeps zone boundaries coherent (spec §3 Mechanism). + dom = ( + self.domains[int(self.zone_selector[x_i_t])] + if self.zone_selector is not None + else self.domains[0] + ) + targets = [v for v in self.variables if np.isnan(self.sg[v][x_i_t])] + + # Step 1: collect informed neighbours and build per-variable data events. + events = self._gather_neighborhood(pss, x_i, x_i_t, curr_idx) + + # Step 2: map SG lags into TI frame (stationary path copies so step 3 + # truncation never aliases back into the SG lags). + events = self._transform_and_reduce_lags(x_i, events, dom) + + if all(len(events[v]) == 0 for v in self.variables): + return self._rand_fallback(targets, u_fallback_i, dom) + + # Step 3: compute and intersect per-variable TI search windows. + win_lo, win_hi, events = self._intersect_search_windows(events, dom) + if win_lo is None: + return self._rand_fallback(targets, u_fallback_i, dom) + + # Step 4: scan the TI window and paste the matched cell into targets. + return self._scan_and_retrieve( + pss, win_lo, win_hi, events, targets, u_start_i, u_fallback_i, dom + ) + + def _write_result(self, node, result): + for v, val in result.items(): + if np.isnan(val): + raise ValueError( + f"Simulation produced NaN for {(v, node)}. Check TI data." + ) + self.sg[v][node] = val + self.informed[v][node] = True + + def run(self, num_threads=None, progress=None): + n_threads = ( + num_threads + if num_threads is not None + else (config.NUM_THREADS or 1) + ) + executor = ( + ThreadPoolExecutor(max_workers=n_threads) + if n_threads > 1 + else None + ) + main_pass = _Pass( + order=self.path, + vmap=self.vmap, + pos_map=None, + n_k=self.n_k, + scan_fraction=self.scan_fraction, + neighbor_cache={}, + ) + try: + self._dispatch_pass( + main_pass, + self.u_start, + self.u_fallback, + self._simulate_node, + executor, + progress, + "DS", + ) + # One executor for the whole run: the post-passes reuse it rather + # than each spinning up their own pool. + self._run_post_passes(progress=progress, executor=executor) + finally: + if executor is not None: + executor.shutdown(wait=True) + return self.sg + + def _dispatch_pass( + self, pss, u_start, u_fallback, node_fn, executor, progress, desc + ): + """Run one pass (main path or post-processing round) through :func:`runner._run_path`. + + Parameters + ---------- + pss : _Pass + The pass to run; supplies order, vmap, pos_map and ``n_k``. + u_start, u_fallback : numpy.ndarray + Per-node random draws for this pass. + node_fn : callable + ``node_fn(pss, i, x_i, u_start_i, u_fallback_i)`` → result dict. + desc : str + Progress label. + + Notes + ----- + In parallel mode the DAG build already calls ``_select_neighbors`` per + node×variable, so ``on_cache_ready`` hands those coordinates to + ``pss.neighbor_cache`` before any node future runs and + :meth:`_gather_neighborhood` skips the redundant call. Serial mode + leaves the box empty and recomputes normally. + """ + update, close = _make_progress(progress, len(pss.order), desc) + try: + _run_path( + pss.order, + u_start, + u_fallback, + lambda i, x_i, u_st, u_fb: node_fn(pss, i, x_i, u_st, u_fb), + self._write_result, + update, + executor, + self.offset_arr, + pss.vmap, + pss.n_k, + self.sim_shape, + self.max_radius, + on_cache_ready=( + None + if executor is None + else lambda cache: pss.neighbor_cache.__setitem__( + "coords", cache + ) + ), + pos_map=pss.pos_map, + ) + finally: + close() + + def _reopen_and_simulate(self, pss, i, x_i, u_start_i, u_fallback_i): + """Post-pass node kernel: erase the node's own values, then simulate. + + Erasing the node's ``informed`` flag suppresses the collocated ``h=0`` + self-constraint that would otherwise anchor the re-draw to its own + stale value, and makes :meth:`_simulate_node` pick the erased + variables up as targets. Conditioning data is never erased. + + Safe to run concurrently: every cell this node goes on to read is one + the DAG pass selected for it, and each such pair carries an edge, so + no reader of this node's cell is in flight while it is erased + (see :func:`runner._build_dag_base` Notes). + """ + x_t = tuple(int(c) for c in x_i) + for v in self.variables: + if not self.is_cond[v][x_t]: + self.sg[v][x_t] = np.nan + self.informed[v][x_t] = False + return self._simulate_node(pss, i, x_i, u_start_i, u_fallback_i) + + def _run_post_passes(self, progress=None, executor=None): + """Post-processing (Meerschman2013 §4 / CASE 3). + + Re-simulates every node with >= 1 non-conditioned variable against a + **fully informed** neighbourhood: the pass-scoped all-``-1`` vmap makes + every informed cell available, so unlike the main path a node also sees + cells it *precedes* in the visit order and reads their + not-yet-re-simulated values — that is what refines the existing + realization instead of redrawing it. ``pos_map`` then gives + :func:`runner._build_dag_base` the ordering its vmap no longer carries, + so the DAG encodes the Gauss–Seidel dependency in both directions and + each pass parallelizes while staying bit-identical to serial. + + ``scan_fraction`` and ``n_neighbors`` are divided by + ``post_processing_factor`` (Me13 §4) into a per-round ``_Pass``. + """ + if self.post_processing <= 0: + return + resim = np.zeros(self.sim_shape, dtype=bool) + for v in self.variables: + resim |= ~self.is_cond[v] + if not resim.any(): + return + p_f = self.post_processing_factor + pass_n_k = { + v: max(1, int(round(n / p_f))) for v, n in self.n_k.items() + } + pass_scan_fraction = self.scan_fraction / p_f + sg_size = int(np.prod(self.sim_shape)) + # Round-invariant (no order-dependence): every cell is available + # regardless of visit position, so build it once and reuse. + pass_vmap = { + v: np.full(sg_size, -1, dtype=np.intp) for v in self.variables + } + + for _ in range(self.post_processing): + if isinstance(self._post_path, str) and self._post_path == "same": + # resim is exactly the main path's unknown-node set (a node + # is re-simulatable iff it was not conditioned, and only + # conditioning informs the SG before the main pass). + order = self.path + else: + order = _build_path( + resim, self._post_path, self._rng_path, self.sim_shape + ) + pss = _Pass( + order=order, + vmap=pass_vmap, + pos_map=_order_positions(order, self.sim_shape), + n_k=pass_n_k, + scan_fraction=pass_scan_fraction, + neighbor_cache={}, + ) + self._dispatch_pass( + pss, + self._rng_nodes.uniform(size=len(order)), + self._rng_nodes.uniform(size=(len(order), self.dim)), + self._reopen_and_simulate, + executor, + progress, + "DS-post", + ) + + +def ds_simulate( + training_image, + sim_shape, + threshold, + scan_fraction, + rng_path, + rng_nodes, + conditions=None, + cond_weight=1.0, + boundary="strict", + num_threads=None, + rotation_map=None, + scale_map=None, + stationary_transform=False, + progress=None, + path="random", + zone_tis=None, + zone_selector=None, + post_processing=0, + post_processing_factor=1.0, + post_processing_path=None, + scan_path="sequential", +): + """Node-wise multivariate Direct Sampling (Mariethoz2010 §3, Eq. 8). + + Co-simulates every variable of a dict-valued :class:`TrainingImage` on one + structured grid, treating all variables equally. The path visits every + *node* with at least one unknown variable; at each node a single joint scan + finds the TI cell ``y`` minimising the weighted distance ``Σ_k w_k d_k`` over + all variables, and that one cell's whole vector ``{TI[v][y]}`` is copied to + the node's *uninformed* variables. Because every variable at a node is drawn + from the same TI cell, the joint (cross-variable) relationship is reproduced + exactly. Variables already known at the node (only ever via conditioning + data) act as collocated ``h = 0`` constraints, weighted by ``cond_weight``. + + Parameters + ---------- + training_image : TrainingImage + Training image; per-variable ``n_neighbors`` and ``max_radius`` are + read from each :class:`Variable` object. + sim_shape : tuple + Simulation grid shape. + threshold : float + Distance threshold (Juda2022 §2). ``0.0`` -> DSBC mode. + scan_fraction : float + Fraction of the TI to scan per node, capped at the valid search window. + rng_path : numpy.random.RandomState + RandomState controlling the simulation path (node visit order). + Only consumed when ``path="random"``; unused otherwise. + rng_nodes : numpy.random.RandomState + RandomState controlling TI scan entry points and fallback cells. + path : str or array-like, optional + Node visit order. ``"random"`` (default) shuffles the unknown nodes + uniformly using ``rng_path`` — the standard DS behaviour. + ``"sequential"`` visits nodes in raster (lexicographic) order, which + is deterministic and does not consume ``rng_path``. An explicit + integer array of shape ``(N, dim)`` provides a caller-supplied order + and must be a permutation of exactly the unknown-node set (missing + nodes, extra nodes, and duplicate rows all raise ``ValueError``). + Default: ``"random"``. + conditions : dict, optional + ``{node_index: {variable: value}}`` conditioning data. + cond_weight : float, optional + Weight delta for conditioning nodes (Mariethoz2010 §3 ¶26). + boundary : str, optional + ``"strict"`` (default) or ``"partial"`` search-window strategy. + num_threads : int or None, optional + Threads for the node-wise DAG. ``None`` -> ``config.NUM_THREADS``. + rotation_map : numpy.ndarray or None, optional + Canonical per-node rotation map, shape + ``sim_shape + (no_of_angles(dim),)``, as produced by + :func:`gstools.mps.nonstationary.resolve_spec`. ``None`` → no + rotation (stationary). + scale_map : numpy.ndarray or None, optional + Canonical per-node scale map, shape ``sim_shape + (dim,)``, as + produced by :func:`gstools.mps.nonstationary.resolve_spec`. ``None`` + → no dilation (stationary). All values must be positive. + stationary_transform : bool, optional + When ``True`` (and at least one of ``rotation_map``/``scale_map`` is + set), the detransform matrix ``M`` is computed once for the whole + simulation instead of being resolved per node. Set by the caller when + both maps resolved as node-independent. Default: ``False``. + progress : bool or callable or None, optional + Show simulation progress. ``True`` prints a plain percentage line + (no third-party dependency); a callable is invoked as + ``progress(n_done, n_total)`` once per completed node. + ``None``/``False`` (default) disables it. + zone_tis : list of TrainingImage or None, optional + Zone training images (Mariethoz2010 para [40]): a node inside a + zone's region scans that zone's TI instead of the primary one. + Indexed by ``zone_selector`` (``0`` = primary, ``i + 1`` = + ``zone_tis[i]``). ``None`` (default) → no zonation, exact single-TI + behaviour. Produced by :meth:`DirectSampling.__call__` from + :attr:`MPSModel.zones`. Only a zone TI's data, shape and ``d_max`` + are used — ``n_neighbors``, ``max_radius``, ``distance``, ``weight`` + and ``penalty_matrix`` always come from ``training_image``. + zone_selector : numpy.ndarray of numpy.intp or None, optional + Per-node domain selector, shape ``sim_shape``. ``0`` selects the + primary TI, ``i + 1`` selects ``zone_tis[i]``. ``None`` (default) → + every node scans the primary TI. Zonation only changes *which* TI a + node's scan/window/fallback draw uses; RNG consumption and the DAG + are untouched, so output stays deterministic and thread-count + invariant regardless of zoning. + post_processing : int, optional + Number of post-processing passes (Meerschman2013 §4 / CASE 3) run + after the main path completes. Each pass re-simulates every node + that has at least one non-conditioned variable, using a fully + informed neighbourhood (every other node's current value is an + eligible neighbour, `n_neighbors`/`max_radius` permitting) drawn in + a fresh random (or sequential, matching ``path``) order. 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 for any thread count. ``0`` + (default) → no post-processing; bit-identical to omitting the + parameter and consumes no extra RNG draws. + post_processing_factor : float, optional + Divides ``scan_fraction`` and every variable's ``n_neighbors`` + (floored at 1) during post-processing passes only (Meerschman2013 + §4): a larger factor trades pattern fidelity for speed on the + (typically much more numerous) post-pass visits. Ignored when + ``post_processing`` is ``0``. Default: ``1.0`` (no reduction). + post_processing_path : str, array-like, or None, optional + Visit order for the post-processing passes (an implementation + extension — Me13 §4 does not prescribe the post-pass order). + ``None`` (default) inherits the main-path mode: a ``"sequential"`` + main ``path`` gives sequential post-passes, anything else gives a + fresh random permutation per pass, drawn from ``rng_path`` + (unchanged pre-existing behaviour). ``"random"`` and + ``"sequential"`` behave as for the main ``path``. ``"same"`` reuses + the main pass's visit order every pass; it does not + consume ``rng_path``. An explicit ``(N, dim)`` integer array is + validated like an explicit main ``path`` but against the post-pass + node set (every node with at least one non-conditioned variable), + so it may include already-conditioned nodes (silently dropped). + scan_path : :class:`str`, optional + TI window scan order: ``"sequential"`` (default, Mariethoz2010 para + [19]) or ``"random"`` (GSTools extension). Default: ``"sequential"`` + + Returns + ------- + dict + ``{variable: numpy.ndarray}`` — one simulated field per variable. + """ + engine = _DirectSamplingEngine( + training_image, + sim_shape, + threshold, + scan_fraction, + rng_path, + rng_nodes, + conditions=conditions, + cond_weight=cond_weight, + boundary=boundary, + rotation_map=rotation_map, + scale_map=scale_map, + stationary_transform=stationary_transform, + path=path, + zone_tis=zone_tis, + zone_selector=zone_selector, + post_processing=post_processing, + post_processing_factor=post_processing_factor, + post_processing_path=post_processing_path, + scan_path=scan_path, + ) + return engine.run(num_threads=num_threads, progress=progress) diff --git a/src/gstools/mps/training_image.py b/src/gstools/mps/training_image.py new file mode 100644 index 00000000..c6fac50e --- /dev/null +++ b/src/gstools/mps/training_image.py @@ -0,0 +1,860 @@ +""" +GStools subpackage providing the TrainingImage class for MPS simulations. + +.. currentmodule:: gstools.mps + +The following classes and functions are provided + +.. autosummary:: + Variable + TrainingImage +""" + +import warnings + +import numpy as np + +from gstools.mps.distance import ( + compute_node_weights, + vec_categorical_dist, + vec_categorical_penalty_dist, + vec_l1_dist, + vec_l2_dist, + vec_lp_dist, + vec_variation_dist, +) + +__all__ = ["Variable", "TrainingImage"] + + +def _any_float_nan(*arrays): + """Return True if any floating-dtype array in *arrays* contains NaN.""" + return any( + np.issubdtype(arr.dtype, np.floating) and np.isnan(arr).any() + for arr in arrays + ) + + +def _data_range(arr): + """Continuous data range over *defined* cells (NaN excluded). + + Returns the ``max - min`` spread of the finite values, or ``1.0`` when the + 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 + return dmax if dmax > 0 else 1.0 + + +def _parse_distance(distance): + """Parse a continuous-distance string into (p_norm, variation_p_norm). + + Exactly one of the two return values is non-``None``: + ``"l

"`` -> ``(p, None)``; ``"variation"`` -> ``(None, 2.0)``; + ``"variation

"`` -> ``(None, p)``. + + Returns + ------- + tuple of (float or None, float or None) + """ + distance_lower = str(distance).lower() + if distance_lower.startswith("l"): + try: + p_val = float(distance_lower[1:]) + except ValueError: + raise ValueError( + f"TrainingImage: distance starting with 'l' must be followed by " + f"a positive number (e.g. 'l1', 'l2', 'l3.5'). Got {distance!r}" + ) + if p_val <= 0: + raise ValueError( + f"TrainingImage: Lp norm exponent must be > 0, got {p_val}." + ) + return p_val, None + if distance_lower == "variation": + return None, 2.0 + if distance_lower.startswith("variation"): + try: + p_val = float(distance_lower[len("variation") :]) + except ValueError: + raise ValueError( + f"TrainingImage: distance starting with 'variation' must be " + f"followed by a positive number (e.g. 'variation1', 'variation1.5'). " + f"Got {distance!r}" + ) + if p_val <= 0: + raise ValueError( + f"TrainingImage: variation exponent must be > 0, got {p_val}." + ) + return None, p_val + raise ValueError( + f"TrainingImage: distance must be 'l

' (e.g. 'l1', 'l2'), " + f"'variation', or 'variation

' (e.g. 'variation1'). " + f"Got {distance!r}" + ) + + +def _check_category_codes(values, n_cat, context): + """Validate that every finite value is an integer category code < ``n_cat``. + + A ``penalty_matrix`` is indexed directly by the category codes it meets, so + any non-integer or out-of-range code turns into a raw ``IndexError`` deep in + the scan loop (or, for a negative code, a silently wrapped lookup). Every + array that can reach that indexing — a :class:`Variable`'s own data, the + conditioning values, a zone TI's data — must be screened here first. + ``context`` prefixes the error message with what was checked. + """ + finite_vals = np.asarray(values, dtype=np.double) + finite_vals = finite_vals[np.isfinite(finite_vals)] + if not finite_vals.size: + return + non_integer = finite_vals != np.floor(finite_vals) + out_of_range = (finite_vals < 0) | (finite_vals >= n_cat) + invalid = non_integer | out_of_range + if invalid.any(): + bad_val = finite_vals[invalid].flat[0] + raise ValueError( + f"{context}: with a penalty_matrix set, values must be " + f"non-negative integer-valued category codes < {n_cat}; found " + f"invalid value {bad_val!r}." + ) + + +def _view_variable(var, slices): + """Variable sharing ``var``'s configuration with ``data = var.data[slices]``. + + The data is a NumPy **view** (no copy; the parent buffer is already + read-only, so the view is too). ``d_max`` is recomputed from the sliced + data so per-zone continuous normalization reflects the sub-region. + """ + view = var.data[slices] + new = Variable.__new__(Variable) + new._name = var._name + new._data = view + new._categorical = var._categorical + new._distance = var._distance + new._penalty_matrix = var._penalty_matrix + new._weight = var._weight + new._max_radius = var._max_radius + new._has_nan = _any_float_nan(view) + new._p_norm = var._p_norm + new._variation_p_norm = var._variation_p_norm + new._d_max = None if var._categorical else _data_range(view) + new._n_neighbors = var._n_neighbors + return new + + +_SENTINEL = object() + + +class Variable: + """One channel's data and per-variable distance configuration for MPS. + + Parameters + ---------- + name : str or None + Valid Python identifier (unique within a TrainingImage). ``None`` is + reserved for the anonymous variable created by univariate + ``TrainingImage`` sugar — not for direct user construction. + data : numpy.ndarray + n-D array. Copied defensively at construction. + categorical : bool, optional + Whether the variable is categorical. Default: ``True``. + distance : str, optional + Continuous-distance metric: ``"l1"`` (default), ``"l2"``, ``"lp"`` + (p > 0), ``"variation"``, ``"variation

"``. Ignored when + ``categorical=True``. + weight : float or None, optional + Relative weight for the multivariate joint distance. ``None`` means + equal share (resolved at ``TrainingImage`` level). Default: ``None``. + n_neighbors : int, optional + Maximum neighbours in the data event. Default: 32. + max_radius : float or None, optional + Exclude SG neighbours beyond this Euclidean distance from the data + event. ``None`` → no limit (default). + penalty_matrix : numpy.ndarray or None, optional + ``(C, C)`` per-category mismatch penalty matrix ``T[u, v]`` for + categorical variables (DS_Feature_Checklist §3.3). ``T[u, u] == 0`` + and ``T[u, v] ∈ (0, 1]`` for ``u != v``; asymmetric matrices are + allowed. ``None`` (default) uses the standard binary mismatch. + Only valid when ``categorical=True``; ``data`` must then contain + non-negative integer-valued codes ``< C`` at every finite cell. + """ + + def __init__( + self, + name, + data, + *, + categorical=True, + distance="l1", + weight=None, + n_neighbors=32, + max_radius=None, + penalty_matrix=None, + ): + if name is not None and ( + not isinstance(name, str) or not name.isidentifier() + ): + raise ValueError( + f"Variable: name must be a valid Python identifier, got {name!r}." + ) + self._name = name + self._data = np.array(data, copy=True) + self._categorical = bool(categorical) + self._distance = str(distance) + self._weight = None if weight is None else float(weight) + if max_radius is not None and float(max_radius) <= 0: + raise ValueError( + f"Variable: max_radius must be a positive float, got {max_radius!r}." + ) + self._max_radius = None if max_radius is None else float(max_radius) + self._has_nan = _any_float_nan(self._data) + if self._has_nan: + warnings.warn( + f"Variable {name!r}: contains NaN cell(s); treated as undefined " + "(excluded from continuous data range and pattern distances, " + "never pasted into the simulation).", + UserWarning, + stacklevel=2, + ) + if not self._categorical: + self._p_norm, self._variation_p_norm = _parse_distance( + self._distance + ) + self._d_max = _data_range(self._data) + else: + self._p_norm = None + self._variation_p_norm = None + self._d_max = None + self._penalty_matrix = self._validate_penalty_matrix(penalty_matrix) + # set via setter so the variation guard runs on construction too + self._n_neighbors = 32 + self.n_neighbors = n_neighbors + # Mark the data array read-only: the engine reads it directly and + # nothing downstream should ever write through .data. + self._data.flags.writeable = False + + def _validate_penalty_matrix(self, penalty_matrix): + """Validate and freeze a per-category penalty matrix, or return None. + + Constraints (DS_Feature_Checklist §3.3): 2-D square, zero diagonal, + off-diagonal entries in ``(0, 1]``; asymmetric matrices are allowed. + The variable's own data must contain non-negative integer-valued + codes ``< C`` at every finite cell. + """ + if penalty_matrix is None: + return None + if not self._categorical: + raise ValueError( + f"Variable {self._name!r}: penalty_matrix is only valid for " + "categorical variables (categorical=False was given)." + ) + mat = np.array(penalty_matrix, copy=True, dtype=np.float64) + if mat.ndim != 2 or mat.shape[0] != mat.shape[1]: + raise ValueError( + f"Variable {self._name!r}: penalty_matrix must be a 2D square " + f"array, got shape {mat.shape!r}." + ) + n_cat = mat.shape[0] + diag = np.diagonal(mat) + bad_diag = np.flatnonzero(diag != 0.0) + if bad_diag.size: + raise ValueError( + f"Variable {self._name!r}: penalty_matrix diagonal must be all-" + f"zero; violated at index/indices {bad_diag.tolist()} " + f"(values {diag[bad_diag].tolist()})." + ) + off_diag_mask = ~np.eye(n_cat, dtype=bool) + off_vals = mat[off_diag_mask] + bad_off = (off_vals <= 0.0) | (off_vals > 1.0) + if bad_off.any(): + off_idx = np.argwhere(off_diag_mask) + bad_pairs = off_idx[bad_off] + bad_vals = off_vals[bad_off] + raise ValueError( + f"Variable {self._name!r}: penalty_matrix off-diagonal entries " + "must be in (0, 1]; violated at " + f"{[tuple(p) for p in bad_pairs.tolist()]} " + f"(values {bad_vals.tolist()})." + ) + _check_category_codes( + self._data, n_cat, f"Variable {self._name!r} data" + ) + mat.flags.writeable = False + return mat + + # --- read-only properties --- + + @property + def name(self): + """:class:`str` or None: Variable name.""" + return self._name + + @property + def data(self): + """:class:`numpy.ndarray`: Training image data array (shared internal reference). + + The returned array is the live internal buffer and must **not** be + mutated by the caller. The array is marked read-only (``flags.writeable + == False``) at construction time to enforce this at the NumPy level. + """ + return self._data + + @property + def categorical(self): + """:class:`bool`: Whether the variable is categorical.""" + return self._categorical + + @property + def distance(self): + """:class:`str`: Distance metric string (e.g. ``"l1"``, ``"variation"``).""" + return self._distance + + @property + def weight(self): + """:class:`float` or None: Raw (un-normalized) weight, or None for equal share.""" + return self._weight + + @property + def p_norm(self): + """:class:`float` or None: Lp exponent for continuous variables; None for categorical/variation.""" + return self._p_norm + + @property + def variation_p_norm(self): + """:class:`float` or None: Variation-distance exponent; None unless distance is 'variation'.""" + return self._variation_p_norm + + @property + def d_max(self): + """:class:`float` or None: Data range (max−min over finite cells) for normalization.""" + return self._d_max + + @property + def has_nan(self): + """:class:`bool`: True if the data array contains any NaN.""" + return self._has_nan + + @property + def max_radius(self): + """:class:`float` or None: Maximum neighbour radius, or ``None`` for no limit.""" + return self._max_radius + + @property + def penalty_matrix(self): + """:class:`numpy.ndarray` of shape (C, C) or None: Per-category mismatch penalties; None uses the default binary mismatch.""" + return self._penalty_matrix + + # --- mutable property --- + + @property + def n_neighbors(self): + """:class:`int`: Maximum neighbours in the data event.""" + return self._n_neighbors + + @n_neighbors.setter + def n_neighbors(self, value): + v = int(value) + if v < 1: + raise ValueError( + f"Variable: n_neighbors must be >= 1, got {value!r}." + ) + if self._variation_p_norm is not None and v < 2: + raise ValueError( + "Variable: distance='variation' requires n_neighbors >= 2 " + "(a single-point data event has no local mean deviation)." + ) + self._n_neighbors = v + + def __repr__(self): + parts = [ + f"Variable({self._name!r}, shape={self._data.shape}", + f"categorical={self._categorical}", + f"distance={self._distance!r}", + f"n_neighbors={self._n_neighbors}", + ] + if self._max_radius is not None: + parts.append(f"max_radius={self._max_radius!r}") + if self._penalty_matrix is not None: + parts.append(f"penalty_matrix={self._penalty_matrix.shape!r}") + return ", ".join(parts) + ")" + + +class TrainingImage: + """Training image for multiple point statistics simulation. + + The MPS analogue of :class:`gstools.CovModel`: encapsulates training + data and the distance function for comparing data events. + + Parameters + ---------- + data : :class:`numpy.ndarray` or list of :class:`Variable` + Training image data. Pass a numpy array for a univariate TI; pass a + non-empty list of :class:`Variable` objects for a multivariate TI. + categorical : :class:`bool`, optional + Whether the variable is categorical (univariate TIs only). Default: ``True``. + distance : :class:`str`, optional + Distance metric for continuous variables (univariate TIs only): + ``"l1"`` (default), ``"l2"``, ``"lp"`` (p > 0), ``"variation"``, + ``"variation

"``. Ignored when ``categorical=True``. + n_neighbors : :class:`int`, optional + Maximum neighbours in the data event (univariate TIs only). Default: 32. + max_radius : :class:`float` or None, optional + Exclude SG neighbours beyond this distance (univariate TIs only). Default: None. + distance_power : :class:`float`, optional + Exponent δ for spatial-decay weighting of neighbours + (Mariethoz2010 Eq. 3). Applied to **all** distance types. + ``0.0`` → uniform weights (oracle-compatible default). + ``1.0`` → closer neighbours weighted more heavily. + penalty_matrix : :class:`numpy.ndarray` or None, optional + Per-category mismatch penalty matrix (univariate TIs only; see + :class:`Variable`). Default: ``None`` (binary mismatch). + """ + + def __init__( + self, + data, + *, + categorical=True, + distance="l1", + n_neighbors=32, + max_radius=None, + distance_power=0.0, + penalty_matrix=None, + ): + self._distance_power = float(distance_power) + if self._distance_power < 0: + raise ValueError("TrainingImage: distance_power must be >= 0") + + if isinstance(data, list): + self._init_from_variables(data) + elif isinstance(data, Variable): + # Univariate sugar: caller supplies a pre-configured Variable + self._multivariate = False + self._variables = [data] + self._var_map = {data.name: data} + self._shape = data.data.shape + self._has_nan = data.has_nan + self._normalized_weights = None + else: + # Univariate sugar: bare array → anonymous Variable (name=None) + self._multivariate = False + var = Variable( + None, + data, + categorical=categorical, + distance=distance, + weight=1.0, + n_neighbors=n_neighbors, + max_radius=max_radius, + penalty_matrix=penalty_matrix, + ) + self._variables = [var] + self._var_map = {None: var} + self._shape = var.data.shape + self._has_nan = var.has_nan + self._normalized_weights = None + + def _init_from_variables(self, var_list): + if not var_list or not all(isinstance(v, Variable) for v in var_list): + raise TypeError( + "TrainingImage: data must be a numpy.ndarray (univariate) or a " + "non-empty list of Variable objects (multivariate)." + ) + names = [v.name for v in var_list] + if any(n is None for n in names): + raise ValueError( + "TrainingImage: Variable names must not be None in a Variable list." + ) + if len(set(names)) != len(names): + raise ValueError( + f"TrainingImage: Variable names must be unique, got {names!r}." + ) + shapes = {v.data.shape for v in var_list} + if len(shapes) != 1: + raise ValueError( + "TrainingImage: All variables must have the same shape." + ) + self._multivariate = True + self._variables = list(var_list) + self._var_map = {v.name: v for v in self._variables} + self._shape = shapes.pop() + self._has_nan = any(v.has_nan for v in self._variables) + + # Weight normalization: all-None → uniform 1/m; all-explicit → normalize; + # mixed → error. + raw = [v.weight for v in var_list] + n = len(var_list) + if all(w is None for w in raw): + self._normalized_weights = {v.name: 1.0 / n for v in var_list} + elif any(w is None for w in raw): + raise ValueError( + "TrainingImage: mixing None and explicit weights is not allowed. " + "Either specify weight on every Variable or on none." + ) + else: + total = sum(raw) + if total <= 0: + raise ValueError( + "TrainingImage: sum of weights must be positive." + ) + self._normalized_weights = { + v.name: v.weight / total for v in var_list + } + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def has_nan(self): + """:class:`bool`: True if any Variable contains NaN.""" + return self._has_nan + + @property + def data(self): + """:class:`numpy.ndarray` or None: Raw data for univariate TIs; None for MV.""" + if self._multivariate: + return None + return self._var_map[None].data + + @property + def ndim(self): + """:class:`int`: Number of spatial dimensions.""" + return len(self._shape) + + @property + def shape(self): + """:class:`tuple`: Shape of the training image.""" + return self._shape + + @property + def multivariate(self): + """:class:`bool`: True for Variable-list TIs; False for bare-array TIs.""" + return self._multivariate + + @property + def variables(self): + """:class:`list` of :class:`Variable`: All variables (univariate has one anonymous entry).""" + return list(self._variables) + + @property + def weights(self): + """:class:`dict` or None: Normalized {name: float} weights for MV TIs; None for UV.""" + if not self._multivariate: + return None + return dict(self._normalized_weights) + + def variable(self, name=_SENTINEL): + """Return the live Variable by name, or the sole variable if called with no argument. + + Parameters + ---------- + name : str, optional + Variable name. Omit to return the single variable of a one-variable TI. + + Returns + ------- + Variable + """ + if name is _SENTINEL: + if len(self._variables) != 1: + raise TypeError( + "variable() with no argument requires exactly one variable; " + f"this TI has {len(self._variables)}." + ) + return self._variables[0] + if name not in self._var_map: + raise KeyError(f"TrainingImage: no variable named {name!r}.") + return self._var_map[name] + + def _validate_window_slices(self, slices): + """Normalize/validate window slices: unit step, in-bounds, non-degenerate.""" + if isinstance(slices, slice): + slices = (slices,) + try: + slices = tuple(slices) + except TypeError: + raise ValueError( + f"TrainingImage.window: expected a tuple of {self.ndim} " + f"slice objects (one per dimension), got {slices!r}." + ) + if len(slices) != self.ndim or not all( + isinstance(s, slice) for s in slices + ): + raise ValueError( + f"TrainingImage.window: expected a tuple of {self.ndim} " + f"slice objects (one per dimension), got {slices!r}." + ) + out = [] + for d, sl in enumerate(slices): + if sl.step not in (None, 1): + raise ValueError( + f"TrainingImage.window: axis {d} has step {sl.step!r}; " + "only unit-step slices are allowed — a strided slice " + "subsamples the TI and destroys pattern continuity." + ) + start = 0 if sl.start is None else int(sl.start) + stop = self._shape[d] if sl.stop is None else int(sl.stop) + if start < 0 or stop > self._shape[d]: + raise ValueError( + f"TrainingImage.window: slice {start}:{stop} on axis {d} " + f"is out of bounds for TI shape {self._shape!r} " + "(negative indices are not supported)." + ) + if stop - start < 1: + raise ValueError( + f"TrainingImage.window: slice {start}:{stop} on axis {d} " + "is degenerate (empty)." + ) + out.append(slice(start, stop)) + return tuple(out) + + def window(self, slices): + """Return a TrainingImage **view** over a sub-region of this TI. + + Supports zonated simulation from sub-regions of one large TI + (Mariethoz et al. 2010, para [40]) without copying data. + + Parameters + ---------- + slices : tuple of :class:`slice` + One unit-step, in-bounds, non-degenerate slice per dimension, + e.g. ``np.s_[:250, 300:]``. A single slice is accepted for 1-D. + + Returns + ------- + TrainingImage + A view sharing this TI's data buffers. Per-variable ``d_max`` is + recomputed from the sliced data; all other per-variable settings + (kind, distance, weight, ``n_neighbors``, ``max_radius``) carry + over unchanged. + + Examples + -------- + >>> import numpy as np + >>> import gstools as gs + >>> ti = gs.TrainingImage(np.zeros((100, 100))) + >>> sub = ti.window(np.s_[:50, 25:75]) + >>> sub.shape + (50, 50) + """ + slices = self._validate_window_slices(slices) + new_vars = [_view_variable(v, slices) for v in self._variables] + ti = TrainingImage.__new__(TrainingImage) + ti._distance_power = self._distance_power + ti._multivariate = self._multivariate + ti._variables = new_vars + ti._var_map = {v.name: v for v in new_vars} + ti._shape = new_vars[0].data.shape + ti._has_nan = any(v.has_nan for v in new_vars) + ti._normalized_weights = ( + dict(self._normalized_weights) if self._multivariate else None + ) + return ti + + @property + def categorical(self): + """:class:`bool`: Whether the variable is categorical (univariate TIs only).""" + if self._multivariate: + raise AttributeError( + "TrainingImage.categorical is not available for multivariate TIs. " + "Use ti.variable(name).categorical." + ) + return self._var_map[None].categorical + + @property + def distance_type(self): + """:class:`str`: Distance metric string (univariate TIs only).""" + if self._multivariate: + raise AttributeError( + "TrainingImage.distance_type is not available for multivariate TIs. " + "Use ti.variable(name).distance." + ) + return self._var_map[None].distance + + @property + def distance_power(self): + """:class:`float`: Spatial-decay exponent δ for node weighting.""" + return self._distance_power + + # ------------------------------------------------------------------ + # Distance + # ------------------------------------------------------------------ + + def _dispatch_metric( + self, + categorical, + p_norm, + vp_norm, + d_max, + de_sim, + all_de_ti, + w, + penalty_matrix=None, + has_nan=False, + ): + """Select and call the right vectorized distance function. + + Parameters + ---------- + categorical : bool + p_norm : float or None + vp_norm : float or None + d_max : float or None + de_sim : numpy.ndarray, shape (n,) + all_de_ti : numpy.ndarray, shape (max_scan, n) + w : numpy.ndarray, shape (n,) + penalty_matrix : numpy.ndarray of shape (C, C) or None, optional + Per-category mismatch penalties for categorical variables; ``None`` + uses the default binary mismatch (:func:`vec_categorical_dist`). + has_nan : bool, optional + Enable per-row exclusion of undefined (NaN) TI positions, with + per-row weight renormalization. Default ``False``. + + Returns + ------- + numpy.ndarray, shape (max_scan,) + Distance in [0, 1] for each candidate. + """ + # Dispatch order: categorical > l1 (p==1) > l2 (p==2) > lp > variation. + # l1/l2 are explicit fast-paths (not folded into lp) so the specialised + # BLAS-friendly kernels are always selected for p in {1, 2}. + if categorical: + if penalty_matrix is not None: + return vec_categorical_penalty_dist( + de_sim, all_de_ti, w, penalty_matrix, has_nan=has_nan + ) + return vec_categorical_dist(de_sim, all_de_ti, w, has_nan=has_nan) + if p_norm == 1.0: + return vec_l1_dist(de_sim, all_de_ti, w, d_max, has_nan=has_nan) + if p_norm == 2.0: + return vec_l2_dist(de_sim, all_de_ti, w, d_max, has_nan=has_nan) + if p_norm is not None: + return vec_lp_dist( + de_sim, all_de_ti, w, d_max, p_norm, has_nan=has_nan + ) + return vec_variation_dist( + de_sim, all_de_ti, w, d_max, vp_norm, has_nan=has_nan + ) + + def adjust_value(self, ti_val, data_event_sim, data_event_ti, var=None): + """Adjust matched TI value before assignment to SG. + + For ``distance="variation"``, applies the mean-shift correction + (Mariethoz2010 Eq. 9): Z(x_i) = Z(y) − Z̄(y) + Z̄(x_i). + For all other metrics returns *ti_val* unchanged. + + Parameters + ---------- + ti_val : float + Raw value at the matched TI node. + data_event_sim : array-like + SG data event (used to compute Z̄(x_i)). + data_event_ti : array-like + TI data event (used to compute Z̄(y)). + var : str or None, optional + Variable name for multivariate TIs. ``None`` selects the anonymous + univariate variable. + + Returns + ------- + float + """ + v = self._var_map[var] + if v.variation_p_norm is None or v.categorical: + return ti_val + data_event_sim = np.asarray(data_event_sim, dtype=np.float64) + data_event_ti = np.asarray(data_event_ti, dtype=np.float64) + if data_event_sim.size == 0 or data_event_ti.size == 0: + return ti_val + ti_mean = ( + np.nanmean(data_event_ti) + if np.isfinite(data_event_ti).any() + else 0.0 + ) + return float(ti_val - ti_mean + np.nanmean(data_event_sim)) + + def vec_distance_var( + self, + var, + de_sim, + all_de_ti, + cond_mask=None, + cond_weight=1.0, + lag_norms=None, + weights=None, + d_max=None, + has_nan=False, + ): + """Vectorized distance for one variable over all TI scan candidates. + + Parameters + ---------- + var : str or None + Variable name (str for multivariate TIs; None for univariate internal). + de_sim : array-like, shape (n,) + SG data event for this variable. + all_de_ti : array-like, shape (max_scan, n) + TI data events for every scan candidate. + cond_mask : array-like of bool, optional + cond_weight : float, optional + lag_norms : array-like, shape (n,), optional + weights : numpy.ndarray, optional + Pre-computed node weights. If given, skips the internal + ``compute_node_weights`` call. + d_max : float or None, optional + Override for the normalization range — used by zonated + simulation, where ``d_max`` comes from the selected zone TI; + ``None`` -> this variable's own ``d_max``. + has_nan : bool, optional + Enable per-row exclusion of undefined (NaN) TI positions, with + per-row weight renormalization. Default ``False``. + + Returns + ------- + numpy.ndarray, shape (max_scan,) + Distance in [0, 1] for each candidate. + """ + v = self._var_map[var] # var is a str or None (univariate internal) + # Categorical distance only uses !=, which is dtype-agnostic; skip the + # float64 allocation on the categorical path and keep it for continuous. + if v.categorical: + de_sim = np.asarray(de_sim) + all_de_ti = np.asarray(all_de_ti) + else: + de_sim = np.asarray(de_sim, dtype=np.float64) + all_de_ti = np.asarray(all_de_ti, dtype=np.float64) + n = len(de_sim) + if n == 0: + return np.zeros(len(all_de_ti)) + w = ( + weights + if weights is not None + else compute_node_weights( + n, lag_norms, self._distance_power, cond_mask, cond_weight + ) + ) + return self._dispatch_metric( + v.categorical, + v.p_norm, + v.variation_p_norm, + v.d_max if d_max is None else d_max, + de_sim, + all_de_ti, + w, + penalty_matrix=v.penalty_matrix, + has_nan=has_nan, + ) + + def __repr__(self): + if self._multivariate: + names = [v.name for v in self._variables] + return f"TrainingImage(variables={names!r}, shape={self.shape})" + v = self._var_map[None] + return ( + f"TrainingImage(shape={self.shape}, " + f"categorical={v.categorical}, " + f"distance={v.distance!r})" + ) diff --git a/src/gstools/mps/zone.py b/src/gstools/mps/zone.py new file mode 100644 index 00000000..de574298 --- /dev/null +++ b/src/gstools/mps/zone.py @@ -0,0 +1,92 @@ +""" +GStools subpackage providing the Zone class for zonated MPS simulation. + +.. currentmodule:: gstools.mps + +The following classes are provided + +.. autosummary:: + Zone +""" + +import numpy as np + +from gstools.mps.training_image import TrainingImage + +__all__ = ["Zone"] + + +class Zone: + """A training image bound to a region of the simulation grid. + + Zonated Direct Sampling (Mariethoz et al. 2010, para [40]): nodes inside + the zone's region scan this zone's TI instead of the primary one. The + data event is still built from simulation-grid neighbours regardless of + their zones, which keeps zone boundaries coherent. + + Parameters + ---------- + ti : :any:`TrainingImage` + Zone training image. May be a sub-region view created with + :meth:`TrainingImage.window`. Must share the primary TI's variable + set (validated by :class:`MPSModel`). + + Only the zone TI's **data, shape and continuous range** (``d_max``) + are used. Search hyper-parameters and the distance kernel — + ``n_neighbors``, ``max_radius``, ``distance``, ``weight``, + ``penalty_matrix`` — always come from the primary TI; setting them + here has no effect. + where : :class:`numpy.ndarray` of :class:`bool` or callable + Region of the simulation grid this zone covers: a boolean array of + the grid shape, or a callable ``f(x, [y, z, ...]) -> bool mask`` + evaluated on flat coordinate arrays (resolved at call time). + Non-boolean arrays raise — no silent ``!= 0`` coercion. A user + holding an integer zonation raster writes + ``Zone(ti_k, where=(labels == k))``. + + Examples + -------- + >>> import numpy as np + >>> import gstools as gs + >>> ti_b = gs.TrainingImage(np.ones((50, 50))) + >>> zone = gs.Zone(ti_b, where=lambda x, y: x >= 20) + >>> zone.ti.shape + (50, 50) + """ + + def __init__(self, ti, where): + if not isinstance(ti, TrainingImage): + raise TypeError( + f"Zone: ti must be a TrainingImage, got {type(ti)!r}" + ) + self._ti = ti + if callable(where): + self._where = where + else: + arr = np.asarray(where) + if arr.dtype != np.bool_: + raise ValueError( + f"Zone: where must be a boolean array or a callable, got " + f"dtype {arr.dtype!r}. For an integer zonation raster " + "use e.g. where=(labels == k) — values are never " + "silently coerced." + ) + self._where = arr + self._where_is_callable = callable(where) + + @property + def ti(self): + """:any:`TrainingImage`: This zone's training image.""" + return self._ti + + @property + def where(self): + """:class:`numpy.ndarray` of bool or callable: The zone region spec.""" + return self._where + + def __repr__(self): + if self._where_is_callable: + wr = getattr(self._where, "__name__", "callable") + else: + wr = f"array(shape={self._where.shape})" + return f"Zone({self._ti!r}, where={wr})" diff --git a/src/gstools/tools/__init__.py b/src/gstools/tools/__init__.py index 1f68dbaf..e155dd3e 100644 --- a/src/gstools/tools/__init__.py +++ b/src/gstools/tools/__init__.py @@ -40,11 +40,15 @@ rotated_main_axes set_angles set_anis + set_scale no_of_angles rotation_planes givens_rotation matrix_rotate matrix_derotate + matrix_scale + matrix_transform + matrix_detransform matrix_isotropify matrix_anisotropify matrix_isometrize @@ -89,14 +93,18 @@ matrix_anisometrize, matrix_anisotropify, matrix_derotate, + matrix_detransform, matrix_isometrize, matrix_isotropify, matrix_rotate, + matrix_scale, + matrix_transform, no_of_angles, rotated_main_axes, rotation_planes, set_angles, set_anis, + set_scale, ) from gstools.tools.special import ( confidence_scaling, @@ -139,11 +147,15 @@ "tpl_gau_spec_dens", "set_angles", "set_anis", + "set_scale", "no_of_angles", "rotation_planes", "givens_rotation", "matrix_rotate", "matrix_derotate", + "matrix_scale", + "matrix_transform", + "matrix_detransform", "matrix_isotropify", "matrix_anisotropify", "matrix_isometrize", diff --git a/src/gstools/tools/geometric.py b/src/gstools/tools/geometric.py index 5ba80127..fe38004d 100644 --- a/src/gstools/tools/geometric.py +++ b/src/gstools/tools/geometric.py @@ -8,11 +8,15 @@ .. autosummary:: set_angles set_anis + set_scale no_of_angles rotation_planes givens_rotation matrix_rotate matrix_derotate + matrix_scale + matrix_transform + matrix_detransform matrix_isotropify matrix_anisotropify matrix_isometrize @@ -35,11 +39,15 @@ __all__ = [ "set_angles", "set_anis", + "set_scale", "no_of_angles", "rotation_planes", "givens_rotation", "matrix_rotate", "matrix_derotate", + "matrix_scale", + "matrix_transform", + "matrix_detransform", "matrix_isotropify", "matrix_anisotropify", "matrix_isometrize", @@ -54,6 +62,7 @@ "latlon2pos", "pos2latlon", "chordal_to_great_circle", + "great_circle_to_chordal", ] @@ -123,6 +132,56 @@ def set_anis(dim, anis): return out_anis +def set_scale(dim, scale): + """Set the axis scaling factors for the given dimension. + + Unlike :func:`set_anis` there is no padding and no pinned axis: a scalar + is broadcast to all ``dim`` axes (uniform dilation), an exact + ``dim``-vector passes through, and anything else raises. + + Parameters + ---------- + dim : :class:`int` + spatial dimension + scale : :class:`float` or :class:`list` + scaling factor per axis; a scalar means uniform dilation + + Returns + ------- + scale : :class:`numpy.ndarray` of shape (dim,) + the validated per-axis scaling factors + + Raises + ------ + ValueError + If ``scale`` is neither a scalar nor an exact ``dim``-vector, or if + any value is not strictly positive. + + Examples + -------- + >>> set_scale(3, 2.0) + array([2., 2., 2.]) + >>> set_scale(2, [1.0, 0.5]) + array([1. , 0.5]) + """ + arr = np.asarray(scale, dtype=np.double) + if arr.ndim == 0: + arr = np.full(dim, float(arr)) + elif arr.shape != (dim,): + raise ValueError( + f"set_scale: scale must be a scalar or a vector of exactly " + f"length dim={dim}, got shape {arr.shape!r}. Short vectors are " + "not padded." + ) + else: + arr = arr.copy() + if not np.all(arr > 0): + raise ValueError( + f"set_scale: all scale values must be > 0, got {arr!r}" + ) + return arr + + def no_of_angles(dim): """Calculate number of rotation angles depending on the dimension. @@ -234,6 +293,85 @@ def matrix_derotate(dim, angles): return result +def matrix_scale(dim, scale): + """Create a diagonal per-axis scaling matrix. + + Parameters + ---------- + dim : :class:`int` + spatial dimension + scale : :class:`float` or :class:`list` + scaling factor per axis (see :func:`set_scale`) + + Returns + ------- + :class:`numpy.ndarray` of shape (dim, dim) + Scaling matrix ``diag(set_scale(dim, scale))``. + + Examples + -------- + >>> matrix_scale(2, [1.0, 0.5]) + array([[1. , 0. ], + [0. , 0.5]]) + """ + return np.diag(set_scale(dim, scale)) + + +def matrix_transform(dim, angles, scale): + """Create a matrix to rotate and scale points (rotation ∘ scaling). + + Parameters + ---------- + dim : :class:`int` + spatial dimension + angles : :class:`float` or :class:`list` + the rotation angles of the target coordinate-system + scale : :class:`float` or :class:`list` + scaling factor per axis (see :func:`set_scale`) + + Returns + ------- + :class:`numpy.ndarray` of shape (dim, dim) + ``matrix_rotate(dim, angles) @ matrix_scale(dim, scale)``. + + Examples + -------- + >>> matrix_transform(2, [0.0], [1.0, 0.5]) + array([[1. , 0. ], + [0. , 0.5]]) + """ + return np.matmul(matrix_rotate(dim, angles), matrix_scale(dim, scale)) + + +def matrix_detransform(dim, angles, scale): + """Create the exact inverse of :func:`matrix_transform`. + + Parameters + ---------- + dim : :class:`int` + spatial dimension + angles : :class:`float` or :class:`list` + the rotation angles of the target coordinate-system + scale : :class:`float` or :class:`list` + scaling factor per axis (see :func:`set_scale`) + + Returns + ------- + :class:`numpy.ndarray` of shape (dim, dim) + ``matrix_scale(dim, 1/scale) @ matrix_derotate(dim, angles)``. + + Examples + -------- + >>> matrix_detransform(2, [0.0], [1.0, 0.5]) + array([[1., 0.], + [0., 2.]]) + """ + return np.matmul( + matrix_scale(dim, 1.0 / set_scale(dim, scale)), + matrix_derotate(dim, angles), + ) + + def matrix_isotropify(dim, anis): """Create a stretching matrix to make things isotrope. @@ -250,7 +388,7 @@ def matrix_isotropify(dim, anis): Stretching matrix. """ anis = set_anis(dim, anis) - return np.diag(np.concatenate(([1.0], 1.0 / anis))) + return matrix_scale(dim, np.concatenate(([1.0], 1.0 / anis))) def matrix_anisotropify(dim, anis): @@ -269,7 +407,7 @@ def matrix_anisotropify(dim, anis): Stretching matrix. """ anis = set_anis(dim, anis) - return np.diag(np.concatenate(([1.0], anis))) + return matrix_scale(dim, np.concatenate(([1.0], anis))) def matrix_isometrize(dim, angles, anis): @@ -289,9 +427,8 @@ def matrix_isometrize(dim, angles, anis): :class:`numpy.ndarray` Transformation matrix. """ - return np.matmul( - matrix_isotropify(dim, anis), matrix_derotate(dim, angles) - ) + anis = set_anis(dim, anis) + return matrix_detransform(dim, angles, np.concatenate(([1.0], anis))) def matrix_anisometrize(dim, angles, anis): @@ -311,9 +448,8 @@ def matrix_anisometrize(dim, angles, anis): :class:`numpy.ndarray` Transformation matrix. """ - return np.matmul( - matrix_rotate(dim, angles), matrix_anisotropify(dim, anis) - ) + anis = set_anis(dim, anis) + return matrix_transform(dim, angles, np.concatenate(([1.0], anis))) def rotated_main_axes(dim, angles): diff --git a/tests/test_geometric.py b/tests/test_geometric.py new file mode 100644 index 00000000..95536843 --- /dev/null +++ b/tests/test_geometric.py @@ -0,0 +1,123 @@ +""" +This is the unittest of the geometric tools module. +""" + +import unittest + +import numpy as np + +from gstools.tools.geometric import ( + matrix_anisometrize, + matrix_anisotropify, + matrix_derotate, + matrix_detransform, + matrix_isometrize, + matrix_isotropify, + matrix_rotate, + matrix_scale, + matrix_transform, + no_of_angles, + set_anis, + set_scale, +) + + +# Frozen copies of the PRE-refactor implementations: the bit-identical +# regression reference (oracle a of the non-stationarity redesign spec). +def _ref_isotropify(dim, anis): + anis = set_anis(dim, anis) + return np.diag(np.concatenate(([1.0], 1.0 / anis))) + + +def _ref_anisotropify(dim, anis): + anis = set_anis(dim, anis) + return np.diag(np.concatenate(([1.0], anis))) + + +def _ref_isometrize(dim, angles, anis): + return np.matmul(_ref_isotropify(dim, anis), matrix_derotate(dim, angles)) + + +def _ref_anisometrize(dim, angles, anis): + return np.matmul(matrix_rotate(dim, angles), _ref_anisotropify(dim, anis)) + + +class TestSetScale(unittest.TestCase): + def test_scalar_broadcasts_to_dim_vector(self): + np.testing.assert_array_equal(set_scale(3, 2.0), [2.0, 2.0, 2.0]) + np.testing.assert_array_equal(set_scale(1, 0.5), [0.5]) + + def test_exact_dim_vector_passes(self): + np.testing.assert_array_equal(set_scale(2, [1.0, 0.5]), [1.0, 0.5]) + + def test_short_vector_raises(self): + # exact-match discipline: NO padding (unlike set_anis) — B5 history + with self.assertRaises(ValueError): + set_scale(3, [1.0, 2.0]) + + def test_long_vector_raises(self): + with self.assertRaises(ValueError): + set_scale(2, [1.0, 2.0, 3.0]) + + def test_2d_array_raises(self): + with self.assertRaises(ValueError): + set_scale(2, [[1.0, 2.0]]) + + def test_nonpositive_raises(self): + with self.assertRaises(ValueError): + set_scale(2, 0.0) + with self.assertRaises(ValueError): + set_scale(2, [1.0, -0.5]) + + +class TestTransformInverse(unittest.TestCase): + def test_detransform_is_exact_inverse(self): + rng = np.random.default_rng(42) + for dim in (1, 2, 3, 4): + for _ in range(20): + angles = rng.uniform(-np.pi, np.pi, no_of_angles(dim)) + scale = rng.uniform(0.2, 5.0, dim) + M = matrix_transform(dim, angles, scale) + Minv = matrix_detransform(dim, angles, scale) + np.testing.assert_allclose(Minv @ M, np.eye(dim), atol=1e-12) + + def test_uniform_dilation(self): + # matrix_scale with a scalar is uniform dilation (M10 affinity r): + # inexpressible in the axis-0-pinned anis parameterization. + np.testing.assert_array_equal(matrix_scale(3, 2.0), 2.0 * np.eye(3)) + + +class TestWrapperBitIdentical(unittest.TestCase): + """Oracle (a): randomized angles/anis — new wrappers bit-identical to old.""" + + def test_all_four_wrappers_bit_identical(self): + rng = np.random.default_rng(0) + for dim in (2, 3, 4): + for _ in range(50): + angles = rng.uniform(-np.pi, np.pi, no_of_angles(dim)) + anis = rng.uniform(0.1, 4.0, dim - 1) + np.testing.assert_array_equal( + matrix_isotropify(dim, anis), _ref_isotropify(dim, anis) + ) + np.testing.assert_array_equal( + matrix_anisotropify(dim, anis), + _ref_anisotropify(dim, anis), + ) + np.testing.assert_array_equal( + matrix_isometrize(dim, angles, anis), + _ref_isometrize(dim, angles, anis), + ) + np.testing.assert_array_equal( + matrix_anisometrize(dim, angles, anis), + _ref_anisometrize(dim, angles, anis), + ) + + def test_wrappers_still_pad_short_anis(self): + # set_anis leniency is preserved for the CovModel-facing wrappers. + np.testing.assert_array_equal( + matrix_isotropify(3, 0.5), _ref_isotropify(3, 0.5) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mps.py b/tests/test_mps.py new file mode 100644 index 00000000..14b2af11 --- /dev/null +++ b/tests/test_mps.py @@ -0,0 +1,4359 @@ +#!/usr/bin/env python +"""Unittest for the MPS module (TrainingImage and DirectSampling).""" + +import unittest +import warnings + +import numpy as np + +import gstools as gs +from gstools import config as gs_config +from gstools.mps.data_event import DataEvent +from gstools.mps.direct_sampling import DirectSampling +from gstools.mps.distance import ( + compute_node_weights, + vec_categorical_dist, + vec_categorical_penalty_dist, + vec_l1_dist, + vec_l2_dist, + vec_lp_dist, + vec_variation_dist, +) +from gstools.mps.model import MPSModel +from gstools.mps.neighbors import ( + _lag_transform_matrix, + _precompute_offsets, + _reduce_to_fit, + _select_neighbors, + _transform_lags, + _window_bounds, +) +from gstools.mps.nonstationary import ( + build_zone_selector, + resolve_spec, + resolve_where, +) +from gstools.mps.runner import _build_dag_base +from gstools.mps.scan import _scan_window +from gstools.mps.simulate import ( + _DirectSamplingEngine, + _order_positions, + _Pass, + ds_simulate, +) +from gstools.mps.training_image import TrainingImage, Variable +from gstools.tools.geometric import generate_grid + + +def _flat_pos(grid_shape): + """Flat per-dimension coordinate arrays for a unit-spaced grid.""" + return generate_grid([np.arange(s, dtype=float) for s in grid_shape]) + + +def _uni_dist(ti, de_sim, de_ti, **kw): + """Univariate data-event distance via the vectorized production path.""" + row = np.asarray(de_ti, dtype=float)[np.newaxis, :] + return float(ti.vec_distance_var(None, de_sim, row, **kw)[0]) + + +def _mv_dist(ti, de_sim, de_ti, **kw): + """Multivariate joint data-event distance via the vectorized path.""" + total = 0.0 + for v in ti.variables: + row = np.asarray(de_ti[v.name], dtype=float)[np.newaxis, :] + total += ti.weights[v.name] * float( + ti.vec_distance_var(v.name, de_sim[v.name], row, **kw)[0] + ) + return total + + +class TestDirectSamplingParallel(unittest.TestCase): + def test_valid_values(self): + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)) + ti = TrainingImage(data, n_neighbors=4) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.num_threads = 2 + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertEqual(field.shape, (8, 8)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_reproducible(self): + # DAG parallelism is deterministic: same seed → same parallel result + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)) + ti = TrainingImage(data, n_neighbors=4) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.num_threads = 2 + pos = [np.arange(8, dtype=float)] * 2 + self.assertTrue(np.array_equal(ds(pos, seed=7), ds(pos, seed=7))) + + def test_conditioning_preserved(self): + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)) + ti = TrainingImage(data, n_neighbors=4) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.num_threads = 2 + ds.set_condition([[5.0], [5.0]], [2]) + field = ds([np.arange(10, dtype=float)] * 2, seed=0) + self.assertEqual(field[5, 5], 2) + + def test_global_config(self): + # num_threads=None reads gs_config.NUM_THREADS + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)) + ti = TrainingImage(data, n_neighbors=4) + pos = [np.arange(8, dtype=float)] * 2 + old = gs_config.NUM_THREADS + try: + gs_config.NUM_THREADS = 2 + field = DirectSampling(MPSModel(ti, scan_fraction=0.2))( + pos, seed=7 + ) + finally: + gs_config.NUM_THREADS = old + self.assertEqual(field.shape, (8, 8)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_large_batches(self): + # n_neighbors=2 → sparse DAG → large ready batches + rng = np.random.default_rng(1) + data = rng.integers(0, 2, (30, 30)) + ti = TrainingImage(data, n_neighbors=2) + pos = [np.arange(12, dtype=float)] * 2 + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.num_threads = 4 + field = ds(pos, seed=42) + self.assertEqual(field.shape, (12, 12)) + self.assertTrue(np.all(np.isin(field, [0.0, 1.0]))) + + def test_stress(self): + # large grid, sparse DAG, conditioning, varying thread counts + rng = np.random.default_rng(3) + data = rng.integers(0, 4, (40, 40)) + ti = TrainingImage(data, n_neighbors=4) + pos = [np.arange(25, dtype=float)] * 2 + cond_pos = [ + rng.integers(0, 25, 20).astype(float), + rng.integers(0, 25, 20).astype(float), + ] + cond_val = rng.integers(0, 4, 20).astype(float) + for nt in (2, 4, 8): + ds = DirectSampling(MPSModel(ti, scan_fraction=0.4)) + ds.num_threads = nt + ds.set_condition(cond_pos, cond_val) + field = ds(pos, seed=11) + self.assertEqual(field.shape, (25, 25)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2, 3]))) + + def test_all_nan_ti_no_typeerror(self): + # Regression test for _scan_window None return on all-NaN TI. + # When every candidate distance is NaN, best_y stays None without + # the fallback. Verify the fallback is triggered and no TypeError occurs + # (ValueError from NaN output is acceptable for all-NaN TI). + ti_data = np.full((5, 5), np.nan, dtype=float) + ti = TrainingImage( + ti_data, categorical=False, distance="l2", n_neighbors=2 + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.num_threads = 1 + try: + field = ds([np.arange(3, dtype=float)] * 2, seed=42) + # If we get here, simulation completed without TypeError. + # Field may contain NaN due to the all-NaN TI. + self.assertIsNotNone(field) + except TypeError as e: + self.fail(f"Fallback did not prevent TypeError: {e}") + except ValueError: + # ValueError from _write_result NaN check is acceptable when + # TI is all-NaN; the important thing is no TypeError occurs. + pass + + +class TestVariable(unittest.TestCase): + def test_basic_categorical(self): + data = np.array([[0, 1], [1, 0]], dtype=int) + v = Variable("cat", data, categorical=True, n_neighbors=8) + self.assertEqual(v.name, "cat") + self.assertTrue(v.categorical) + self.assertEqual(v.n_neighbors, 8) + self.assertIsNone(v.weight) + self.assertIsNone(v.p_norm) + self.assertIsNone(v.variation_p_norm) + self.assertIsNone(v.d_max) + + def test_data_defensive_copy(self): + data = np.array([0, 1, 2, 3]) + v = Variable("x", data) + data[0] = 99 + self.assertEqual(v.data[0], 0) + + def test_continuous_attributes(self): + v = Variable( + "p", np.linspace(0, 1, 20), categorical=False, distance="l2" + ) + self.assertEqual(v.p_norm, 2.0) + self.assertIsNone(v.variation_p_norm) + self.assertGreater(v.d_max, 0) + + def test_variation_distance(self): + v = Variable( + "p", + np.linspace(0, 1, 20), + categorical=False, + distance="variation", + n_neighbors=2, + ) + self.assertIsNone(v.p_norm) + self.assertEqual(v.variation_p_norm, 2.0) + + def test_invalid_name_raises(self): + with self.assertRaisesRegex(ValueError, "identifier"): + Variable("123bad", np.zeros(4)) + with self.assertRaisesRegex(ValueError, "identifier"): + Variable("", np.zeros(4)) + with self.assertRaisesRegex(ValueError, "identifier"): + Variable("a b", np.zeros(4)) + + def test_name_none_allowed(self): + # None is the internal sentinel for the univariate anonymous variable + v = Variable(None, np.zeros(4)) + self.assertIsNone(v.name) + + def test_n_neighbors_setter(self): + v = Variable("x", np.zeros(4)) + v.n_neighbors = 8 + self.assertEqual(v.n_neighbors, 8) + with self.assertRaisesRegex(ValueError, "n_neighbors"): + v.n_neighbors = 0 + + def test_read_only_name(self): + v = Variable("x", np.zeros(4)) + with self.assertRaises(AttributeError): + v.name = "y" + + def test_read_only_categorical(self): + v = Variable("x", np.zeros(4)) + with self.assertRaises(AttributeError): + v.categorical = False + + def test_weight_stored(self): + v = Variable("x", np.zeros(4), weight=0.7) + self.assertAlmostEqual(v.weight, 0.7) + + def test_repr(self): + v = Variable("x", np.zeros((4, 4))) + self.assertIn("Variable", repr(v)) + self.assertIn("x", repr(v)) + + def test_max_radius_none_default(self): + v = Variable("x", np.zeros(4)) + self.assertIsNone(v.max_radius) + + def test_max_radius_stored(self): + v = Variable("x", np.zeros(4), max_radius=50.0) + self.assertAlmostEqual(v.max_radius, 50.0) + + def test_max_radius_zero_raises(self): + with self.assertRaisesRegex(ValueError, "max_radius"): + Variable("x", np.zeros(4), max_radius=0.0) + + def test_max_radius_negative_raises(self): + with self.assertRaisesRegex(ValueError, "max_radius"): + Variable("x", np.zeros(4), max_radius=-5.0) + + def test_max_radius_read_only(self): + v = Variable("x", np.zeros(4), max_radius=10.0) + with self.assertRaises(AttributeError): + v.max_radius = 20.0 + + +class TestTrainingImage(unittest.TestCase): + def setUp(self): + arr_cat = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]], dtype=float) + self.ti_cat = TrainingImage(arr_cat, categorical=True) + + arr_cont = np.linspace(0.0, 1.0, 20) + self.ti_cont = TrainingImage( + arr_cont, categorical=False, distance="l1" + ) + + def test_properties(self): + np.testing.assert_array_equal( + self.ti_cat.data, + np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]], dtype=float), + ) + self.assertEqual(self.ti_cat.ndim, 2) + self.assertEqual(self.ti_cat.shape, (3, 3)) + self.assertTrue(self.ti_cat.categorical) + self.assertEqual( + self.ti_cat.distance_type, "l1" + ) # default ignored for cat + self.assertIsInstance(repr(self.ti_cat), str) + self.assertIn("TrainingImage", repr(self.ti_cat)) + + self.assertEqual(self.ti_cont.ndim, 1) + self.assertEqual(self.ti_cont.shape, (20,)) + self.assertFalse(self.ti_cont.categorical) + self.assertEqual(self.ti_cont.distance_type, "l1") + + def test_raise(self): + with self.assertRaises(ValueError): + TrainingImage(np.ones(10), categorical=False, distance="l0") + with self.assertRaises(ValueError): + TrainingImage(np.ones(10), categorical=False, distance="labc") + with self.assertRaises(ValueError): + TrainingImage(np.ones(10), categorical=False, distance="invalid") + + def test_distance_categorical(self): + # Identical events → 0.0 + a = np.array([0.0, 1.0, 0.0]) + dist = _uni_dist(self.ti_cat, a, a) + self.assertAlmostEqual(dist, 0.0) + + # Completely mismatched, uniform weights → 1.0 + b = np.array([1.0, 0.0, 1.0]) + dist = _uni_dist(self.ti_cat, a, b) + self.assertAlmostEqual(dist, 1.0) + + # One of three mismatched → 1/3 + c = np.array([1.0, 1.0, 0.0]) + dist = _uni_dist(self.ti_cat, a, c) + self.assertAlmostEqual(dist, 1.0 / 3.0) + + # Two of four mismatched → 0.5 (spec-required half-mismatch case) + a4 = np.array([0.0, 1.0, 0.0, 1.0]) + c4 = np.array([1.0, 0.0, 0.0, 1.0]) + dist = _uni_dist(self.ti_cat, a4, c4) + self.assertAlmostEqual(dist, 0.5) + + def test_distance_continuous(self): + x = np.array([0.0, 0.5, 1.0]) + y = np.array([0.2, 0.3, 0.8]) + + # l1 + ti_l1 = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="l1" + ) + self.assertAlmostEqual(_uni_dist(ti_l1, x, x), 0.0) + self.assertAlmostEqual(_uni_dist(ti_l1, x, y), 0.2, places=6) + + # l2 + ti_l2 = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="l2" + ) + self.assertAlmostEqual(_uni_dist(ti_l2, x, x), 0.0) + self.assertAlmostEqual(_uni_dist(ti_l2, x, y), 0.2, places=6) + + # lp (p=3.5) — non-uniform diffs [0.3, 0.1, 0.3] distinguish lp from l1/l2 + y_lp = np.array([0.3, 0.4, 0.7]) + ti_lp = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="l3.5" + ) + self.assertAlmostEqual(_uni_dist(ti_lp, x, x), 0.0) + self.assertAlmostEqual(_uni_dist(ti_lp, x, y_lp), 0.2680, places=3) + self.assertGreater( + _uni_dist(ti_lp, x, y_lp), _uni_dist(ti_l1, x, y_lp) + ) + + # variation (default p=2) + ti_var = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="variation" + ) + self.assertAlmostEqual(_uni_dist(ti_var, x, x), 0.0) + self.assertAlmostEqual(_uni_dist(ti_var, x, y), 0.094281, places=5) + # constant shift → distance = 0 (key behavioral property of variation distance) + self.assertAlmostEqual(_uni_dist(ti_var, x, x + 0.15), 0.0, places=10) + + # variation1 (L^1 aggregation) + ti_var1 = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="variation1" + ) + self.assertAlmostEqual(_uni_dist(ti_var1, x, x), 0.0) + self.assertAlmostEqual(_uni_dist(ti_var1, x, y), 0.08889, places=4) + self.assertAlmostEqual(_uni_dist(ti_var1, x, x + 0.15), 0.0, places=10) + # L^1 < L^2 for non-uniform diffs + self.assertLess(_uni_dist(ti_var1, x, y), _uni_dist(ti_var, x, y)) + + # variation2 explicit matches variation (default p) + ti_var2 = TrainingImage( + np.linspace(0.0, 1.0, 10), categorical=False, distance="variation2" + ) + self.assertAlmostEqual( + _uni_dist(ti_var2, x, y), _uni_dist(ti_var, x, y), places=10 + ) + + def test_adjust_value(self): + # Categorical and lp: passthrough + self.assertAlmostEqual( + self.ti_cat.adjust_value( + 0.7, np.array([0.1, 0.3]), np.array([0.4, 0.6]) + ), + 0.7, + ) + self.assertAlmostEqual( + self.ti_cont.adjust_value( + 0.7, np.array([0.1, 0.3]), np.array([0.4, 0.6]) + ), + 0.7, + ) + + # variation: Z(y) - Z_bar(y) + Z_bar(x) = 0.7 - 0.6 + 0.3 = 0.4 + ti_var = TrainingImage( + np.linspace(0.0, 1.0, 20), categorical=False, distance="variation" + ) + result = ti_var.adjust_value( + 0.7, np.array([0.1, 0.3, 0.5]), np.array([0.4, 0.6, 0.8]) + ) + self.assertAlmostEqual(result, 0.4, places=6) + self.assertNotAlmostEqual(result, 0.7) # must not be passthrough + + def test_distance_weights(self): + a = np.array([0.0, 1.0, 0.0]) + b = np.array([1.0, 1.0, 0.0]) # first element differs + + # cond_weight=2 on first node → it gets weight 0.5 (double) + d1 = _uni_dist( + self.ti_cat, a, b, cond_mask=[True, False, False], cond_weight=1.0 + ) + d2 = _uni_dist( + self.ti_cat, a, b, cond_mask=[True, False, False], cond_weight=2.0 + ) + self.assertGreater(d2, d1) + + # distance_power shifts weight toward closer neighbours — use non-uniform + # differences so the weighted sums actually differ: diffs = [0, 0, 0.5] + ti_p = TrainingImage( + np.linspace(0.0, 1.0, 10), + categorical=False, + distance="l1", + distance_power=1.0, + ) + ti_flat = TrainingImage( + np.linspace(0.0, 1.0, 10), + categorical=False, + distance="l1", + distance_power=0.0, + ) + x = np.array([0.0, 0.5, 1.0]) + z = np.array([0.0, 0.5, 0.5]) # only third element differs + lags = np.array([1.0, 2.0, 3.0]) + d_power = _uni_dist(ti_p, x, z, lag_norms=lags) + d_flat = _uni_dist(ti_flat, x, z, lag_norms=lags) + # power=1 weights far neighbours less → smaller distance for far mismatch + self.assertLess(d_power, d_flat) + + def test_distance_empty_event(self): + dist = _uni_dist(self.ti_cat, np.array([]), np.array([])) + self.assertAlmostEqual(dist, 0.0) + + def test_distance_functions_directly(self): + a = np.array([0.0, 1.0, 0.0]) + b = np.array([1.0, 0.0, 1.0]) + w = np.array([1 / 3, 1 / 3, 1 / 3]) + + # weights sum to 1 + w2 = compute_node_weights(3, None, 0.0) + self.assertAlmostEqual(w2.sum(), 1.0) + + # cond_weight=2 on first node → uniform spatial weights → w[0] = 2/(2+1+1) = 0.5 + w3 = compute_node_weights( + 3, + None, + 0.0, + cond_mask=[True, False, False], + cond_weight=2.0, + ) + self.assertAlmostEqual(w3.sum(), 1.0) + self.assertAlmostEqual(w3[0], 0.5, places=6) + + # The simulation calls only the vectorized distance functions; test + # them directly by passing a single candidate as a shape-(1, n) row and + # reading element [0] out. + def cat(s, t): + return float(vec_categorical_dist(s, t[np.newaxis, :], w)[0]) + + def l1(s, t, dm): + return float(vec_l1_dist(s, t[np.newaxis, :], w, dm)[0]) + + def l2(s, t, dm): + return float(vec_l2_dist(s, t[np.newaxis, :], w, dm)[0]) + + def lp(s, t, dm, p): + return float(vec_lp_dist(s, t[np.newaxis, :], w, dm, p)[0]) + + def var(s, t, dm, p=2.0): + return float(vec_variation_dist(s, t[np.newaxis, :], w, dm, p)[0]) + + # categorical: identical → 0, opposite → 1 + self.assertAlmostEqual(cat(a, a), 0.0) + self.assertAlmostEqual(cat(a, b), 1.0) + + # continuous: identical → 0 + x = np.array([0.0, 0.5, 1.0]) + d_max = 1.0 + self.assertAlmostEqual(l1(x, x, d_max), 0.0) + self.assertAlmostEqual(l2(x, x, d_max), 0.0) + self.assertAlmostEqual(lp(x, x, d_max, 3.5), 0.0) + self.assertAlmostEqual(var(x, x, d_max), 0.0) + self.assertAlmostEqual(var(x, x, d_max, p=1.0), 0.0) + + # distances in [0, 1] + y = np.array([0.2, 0.3, 0.8]) + self.assertAlmostEqual(l1(x, y, d_max), 0.2, places=6) + self.assertAlmostEqual(l2(x, y, d_max), 0.2, places=6) + self.assertAlmostEqual(var(x, y, d_max), 0.094281, places=5) + self.assertAlmostEqual(var(x, y, d_max, p=1.0), 0.08889, places=4) + # p=2 explicit matches default + self.assertAlmostEqual( + var(x, y, d_max, p=2.0), var(x, y, d_max), places=10 + ) + + # lp: non-uniform diffs [0.3, 0.1, 0.3] verify the p-norm exponent is used + y_lp = np.array([0.3, 0.4, 0.7]) + self.assertAlmostEqual(lp(x, y_lp, d_max, 3.5), 0.2680, places=3) + self.assertGreater(lp(x, y_lp, d_max, 3.5), l1(x, y_lp, d_max)) + + def test_variation_dist_bounded(self): + """variation_dist with distance_power > 0 must stay in [0, 1].""" + # Adversarial: weight concentrated on maximally anti-correlated element + x = np.array([0.0, 1.0, 0.0]) + y = np.array([1.0, 0.0, 1.0]) + lags = np.array([10.0, 0.1, 10.0]) + w = compute_node_weights(3, lags, 1.0) + d = float(vec_variation_dist(x, y[np.newaxis, :], w, 1.0)[0]) + self.assertGreaterEqual(d, 0.0) + self.assertLessEqual(d, 1.0) + self.assertAlmostEqual(d, 0.661747, places=5) + # Also via the vectorized per-variable production path + ti = TrainingImage( + np.linspace(0.0, 1.0, 10), + categorical=False, + distance="variation", + distance_power=1.0, + ) + self.assertLessEqual(_uni_dist(ti, x, y, lag_norms=lags), 1.0) + + def test_variation_dist_out_of_range_clamped(self): + """Out-of-range SG values (from conditioning / mean-shift) must clamp to [0, 1].""" + ti = TrainingImage( + np.linspace(0.0, 1.0, 10), # d_max == 1.0 + categorical=False, + distance="variation", + ) + de_sim = np.array([5.0, 0.0]) # 5.0 is far outside the TI range + de_ti = np.array([0.0, 1.0]) + self.assertLessEqual(_uni_dist(ti, de_sim, de_ti), 1.0) + vec = ti.vec_distance_var(None, de_sim, de_ti[np.newaxis, :]) + self.assertEqual(vec.shape, (1,)) + self.assertLessEqual(vec[0], 1.0) + + def test_variation_lp_parsing(self): + """variation

string is parsed correctly and rejects bad inputs.""" + data = np.linspace(0.0, 1.0, 10) + for spec in ("variation", "variation1", "variation1.5", "variation2"): + ti = TrainingImage(data, categorical=False, distance=spec) + self.assertEqual(ti.distance_type, spec) + # invalid suffix + with self.assertRaises(ValueError): + TrainingImage(data, categorical=False, distance="variationX") + # non-positive exponent + with self.assertRaises(ValueError): + TrainingImage(data, categorical=False, distance="variation0") + with self.assertRaises(ValueError): + TrainingImage(data, categorical=False, distance="variation-1") + + def test_variation_lp_adjust_value(self): + """adjust_value mean-shift applies for all variation

variants.""" + de_sim = np.array([0.1, 0.3, 0.5]) # mean = 0.3 + de_ti = np.array([0.4, 0.6, 0.8]) # mean = 0.6 + # expected: 0.7 - 0.6 + 0.3 = 0.4 + for spec in ("variation", "variation1", "variation1.5"): + ti = TrainingImage( + np.linspace(0.0, 1.0, 20), categorical=False, distance=spec + ) + self.assertAlmostEqual( + ti.adjust_value(0.7, de_sim, de_ti), 0.4, places=6 + ) + + def test_node_weights_zero_cond_weight(self): + """All-conditioning event with cond_weight=0 → ignored (Me13 δ_c=0). + + The conditioning data is dropped from the distance entirely (zero + weights, non-informative data event → unconditional behaviour), not + re-weighted uniformly. Weights must still be finite (no NaNs). + """ + w = compute_node_weights( + 3, + lag_norms=None, + distance_power=0.0, + cond_mask=np.array([True, True, True]), + cond_weight=0.0, + ) + self.assertTrue(np.all(np.isfinite(w))) + np.testing.assert_allclose(w, np.zeros(3)) + + def test_node_weights_zero_lag_norm_not_amplified(self): + """A true zero lag-norm (collocated h=0) must keep the unit baseline + weight under distance_power > 0, not the divergent 1e-10**(-power), and + cond_weight must be the knob that scales it.""" + # raw = [baseline 1.0, 1**-2=1.0, 2**-2=0.25] -> zero-lag == unit-lag, + # and never dominates the data event. + w = compute_node_weights(3, [0.0, 1.0, 2.0], 2.0) + self.assertTrue(np.all(np.isfinite(w))) + self.assertAlmostEqual(w[0], w[1]) + self.assertGreater(w[0], w[2]) + self.assertLess(w[0], 0.9) # not the old ~1.0 blow-up + # cond_weight scales the collocated entry (it carries cond_mask=True). + w_c = compute_node_weights( + 3, + [0.0, 1.0, 2.0], + 2.0, + cond_mask=np.array([True, False, False]), + cond_weight=5.0, + ) + self.assertGreater(w_c[0], w[0]) + + def test_warning_prefixes(self): + # threshold>1 warning carries an "MPSModel:" prefix + ti = TrainingImage(np.ones(4), categorical=True) + with self.assertWarnsRegex(UserWarning, r"MPSModel:"): + MPSModel(ti, threshold=5.0) + # NaN warning now comes from Variable construction + nan_data = np.array([np.nan, 1.0, 2.0]) + with self.assertWarns(UserWarning) as cm: + Variable("x", nan_data, categorical=False) + self.assertTrue( + any("Variable" in str(w.message) for w in cm.warnings), + "Expected 'Variable' in NaN warning message", + ) + + +class TestDirectSampling(unittest.TestCase): + def setUp(self): + # 1-D categorical TI: alternating 0/1, length 20 + arr1d = np.tile([0, 1], 10).astype(float) + self.ti1d = TrainingImage(arr1d, categorical=True, n_neighbors=8) + + # 2-D categorical TI: 8×8 checkerboard + self.ti2d = TrainingImage( + (np.indices((8, 8)).sum(axis=0) % 2).astype(float), + categorical=True, + n_neighbors=8, + ) + + rng = np.random.default_rng(0) + self.ti2d_rand = TrainingImage( + rng.integers(0, 2, size=(20, 20)).astype(float), + categorical=True, + n_neighbors=8, + ) + + # 1-D continuous TI + self.ti1d_cont = TrainingImage( + np.linspace(0.0, 1.0, 20), + categorical=False, + distance="l1", + n_neighbors=8, + ) + + self.x1d = np.arange(10, dtype=float) + self.x2d = np.arange(6, dtype=float) + self.y2d = np.arange(6, dtype=float) + + def test_model_is_retained(self): + # Regression: the MPSModel passed in must not be silently discarded. + model = MPSModel(self.ti1d) + ds = DirectSampling(model) + self.assertIs(ds.mps_model, model) + # Field's covariance-model slot must stay None (DirectSampling is not + # a CovModel-based field; pre_pos/field_dim rely on this). + self.assertIsNone(ds.model) + + def test_raise(self): + with self.assertRaises(ValueError): + MPSModel(self.ti1d, boundary="bad") + ds = DirectSampling(MPSModel(self.ti1d)) + with self.assertRaises(ValueError): + ds([self.x1d], seed=42, mesh_type="unstructured") + + def test_repr(self): + ds = DirectSampling(MPSModel(self.ti1d)) + r = repr(ds) + self.assertIsInstance(r, str) + self.assertIn("DirectSampling", r) + + def test_properties(self): + ds = DirectSampling( + MPSModel( + self.ti1d, + scan_fraction=0.5, + threshold=0.05, + cond_weight=2.0, + boundary="partial", + ) + ) + self.assertIs(ds.ti, self.ti1d) + self.assertEqual(ds.n_neighbors, 8) + self.assertAlmostEqual(ds.scan_fraction, 0.5) + self.assertAlmostEqual(ds.threshold, 0.05) + self.assertAlmostEqual(ds.cond_weight, 2.0) + self.assertEqual(ds.boundary, "partial") + self.assertIsNone(ds.max_radius) + + def test_offsets_shape(self): + off = _precompute_offsets((5, 5)) + # shape: (N, 2) for 2-D, no zero row + self.assertEqual(off.ndim, 2) + self.assertEqual(off.shape[1], 2) + self.assertFalse(np.any(np.all(off == 0, axis=1))) + # sorted by Euclidean norm + norms = np.linalg.norm(off, axis=1) + self.assertTrue(np.all(norms[:-1] <= norms[1:])) + + def test_offsets_1d(self): + off = _precompute_offsets((10,)) + self.assertEqual(off.shape[1], 1) + self.assertFalse(np.any(off == 0)) + + def test_offsets_max_offset(self): + off = _precompute_offsets((5, 5), max_offset=1) + self.assertLessEqual(np.abs(off).max(), 1) + # 2-D, max_offset=1: 3^2 - 1 = 8 neighbours + self.assertEqual(off.shape, (8, 2)) + + def test_shape_1d(self): + ds = DirectSampling(MPSModel(self.ti1d, scan_fraction=1.0)) + field = ds([self.x1d], seed=42) + self.assertEqual(field.shape, (10,)) + self.assertFalse(np.any(np.isnan(field))) + + def test_shape_2d(self): + ds = DirectSampling(MPSModel(self.ti2d, scan_fraction=1.0)) + field = ds([self.x2d, self.y2d], seed=42) + self.assertEqual(field.shape, (6, 6)) + self.assertFalse(np.any(np.isnan(field))) + # All output values must be in the TI value set {0, 1} + unique_vals = set(np.unique(field)) + self.assertTrue(unique_vals.issubset({0.0, 1.0})) + + def test_regression_1d(self): + ds = DirectSampling(MPSModel(self.ti1d, scan_fraction=1.0)) + field = ds([self.x1d], seed=42) + self.assertAlmostEqual(field[0], 1.0) + self.assertAlmostEqual(field[5], 0.0) + self.assertAlmostEqual(field[9], 0.0) + + def test_regression_2d(self): + ds = DirectSampling(MPSModel(self.ti2d, scan_fraction=1.0)) + field = ds([self.x2d, self.y2d], seed=42) + self.assertAlmostEqual(field[0, 0], 1.0) + self.assertAlmostEqual(field[2, 3], 0.0) + self.assertAlmostEqual(field[5, 5], 1.0) + + def test_seeded_reproducibility(self): + ds = DirectSampling(MPSModel(self.ti2d_rand, scan_fraction=0.5)) + pos = [self.x2d, self.y2d] + fa = ds(pos, seed=99) + fb = ds(pos, seed=99) + fc = ds(pos, seed=100) + # Same seed → identical output + self.assertTrue(np.allclose(fa, fb)) + # Different seed → different output + self.assertFalse(np.allclose(fa, fc)) + # Pin two values for seed=99; stable across NumPy versions because DS + # uses RandomState (MT19937) throughout, matching the rest of GSTools. + self.assertAlmostEqual(fa[0, 0], 0.0) + self.assertAlmostEqual(fa[3, 4], 0.0) + + def test_conditioning_honored(self): + ds = DirectSampling(MPSModel(self.ti1d, scan_fraction=1.0)) + # Three exact grid node positions — spec requires ≥ 3 to exercise multi-point handling + cond_pos = [np.array([2.0, 4.0, 7.0])] + cond_val = np.array([0.0, 1.0, 1.0]) + ds.set_condition(cond_pos, cond_val) + field = ds([self.x1d], seed=5) + self.assertAlmostEqual(field[2], 0.0) + self.assertAlmostEqual(field[4], 1.0) + self.assertAlmostEqual(field[7], 1.0) + + def test_boundary_partial(self): + ds = DirectSampling( + MPSModel( + self.ti2d, + scan_fraction=1.0, + boundary="partial", + ) + ) + field = ds([self.x2d, self.y2d], seed=42) + self.assertEqual(field.shape, (6, 6)) + self.assertFalse(np.any(np.isnan(field))) + + def test_boundary_partial_collapse_recovers(self): + # TI far smaller than lag span → partial mode must recover, not raise + ti_tiny = TrainingImage( + np.random.default_rng(0).random((3, 3)), + categorical=False, + distance="l1", + ) + ds = DirectSampling( + MPSModel( + ti_tiny, + scan_fraction=1.0, + boundary="partial", + ) + ) + field = ds([np.arange(30, dtype=float)] * 2, seed=1) + self.assertEqual(field.shape, (30, 30)) + self.assertFalse(np.any(np.isnan(field))) + + def test_scan_fraction_window_semantics(self): + """scan_fraction=0.1 applies to the window, not the TI — no crash, valid output.""" + rng = np.random.default_rng(0) + ti = TrainingImage( + rng.integers(0, 2, (20, 20)).astype(float), categorical=True + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.1)) + field = ds([np.arange(6, dtype=float)] * 2, seed=0) + self.assertEqual(field.shape, (6, 6)) + self.assertFalse(np.any(np.isnan(field))) + self.assertTrue(set(np.unique(field)).issubset({0.0, 1.0})) + + def test_continuous_ti(self): + ds = DirectSampling( + MPSModel( + self.ti1d_cont, + scan_fraction=1.0, + threshold=0.05, + ) + ) + field = ds([np.arange(8, dtype=float)], seed=42) + self.assertEqual(field.shape, (8,)) + self.assertFalse(np.any(np.isnan(field))) + self.assertTrue(np.all(field >= 0.0)) + self.assertTrue(np.all(field <= 1.0)) + + def test_ds_simulate_direct(self): + rng = np.random.RandomState(7) + result = ds_simulate( + self.ti1d, + sim_shape=(8,), + threshold=0.0, + scan_fraction=1.0, + rng_path=rng, + rng_nodes=rng, + ) + # univariate result dict has key None + self.assertIn(None, result) + arr = result[None] + self.assertEqual(arr.shape, (8,)) + self.assertFalse(np.any(np.isnan(arr))) + self.assertTrue(set(np.unique(arr)).issubset({0.0, 1.0})) + + def test_empty_search_window_recovery(self): + # n_neighbors >> TI size collapses search windows → must recover silently + ti_tiny = TrainingImage(np.array([0.0, 1.0, 0.0]), categorical=True) + ds = DirectSampling(MPSModel(ti_tiny, scan_fraction=1.0)) + field = ds([np.arange(5, dtype=float)], seed=1) + self.assertEqual(field.shape, (5,)) + self.assertFalse(np.any(np.isnan(field))) + self.assertTrue(set(np.unique(field)).issubset({0.0, 1.0})) + + def test_mpsmodel_setters_validate(self): + m = MPSModel(self.ti1d) + # setters normalize/validate in one place + m.scan_fraction = 0.5 + self.assertAlmostEqual(m.scan_fraction, 0.5) + m.boundary = "partial" + self.assertEqual(m.boundary, "partial") + with self.assertRaises(ValueError): + m.scan_fraction = 0.0 # out of (0, 1] + with self.assertRaises(ValueError): + m.boundary = "bad" + with self.assertRaises(ValueError): + m.threshold = -1.0 + + def test_threshold_warning_points_at_user_code(self): + import warnings as _w + + # constructor path: the warning must originate from THIS test file, + # not from inside gstools/mps/model.py + with _w.catch_warnings(record=True) as rec: + _w.simplefilter("always") + MPSModel(self.ti1d, threshold=5.0) + self.assertTrue(any("model.py" not in str(x.filename) for x in rec)) + # direct-setter path: same expectation + m = MPSModel(self.ti1d) + with _w.catch_warnings(record=True) as rec: + _w.simplefilter("always") + m.threshold = 5.0 + self.assertTrue(any("model.py" not in str(x.filename) for x in rec)) + + def test_gstools_namespace(self): + self.assertIs(gs.DirectSampling, DirectSampling) + self.assertIs(gs.TrainingImage, TrainingImage) + self.assertFalse(hasattr(gs, "Variable")) + self.assertIs(gs.mps.DirectSampling, DirectSampling) + self.assertIs(gs.mps.TrainingImage, TrainingImage) + self.assertIs(gs.mps.Variable, Variable) + + def test_config_is_read_only_view(self): + model = MPSModel( + self.ti1d, threshold=0.3, scan_fraction=0.5, cond_weight=4.0 + ) + ds = DirectSampling(model) + # Getters read straight from the shared model (no instance copy). + self.assertAlmostEqual(ds.threshold, 0.3) + self.assertAlmostEqual(ds.scan_fraction, 0.5) + self.assertAlmostEqual(ds.cond_weight, 4.0) + # Setters are gone: config lives on the MPSModel. + with self.assertRaises(AttributeError): + ds.threshold = 0.1 + with self.assertRaises(AttributeError): + ds.cond_weight = 2.0 + with self.assertRaises(AttributeError): + ds.scan_fraction = 0.5 + with self.assertRaises(AttributeError): + ds.boundary = "partial" + with self.assertRaises(AttributeError): + ds.n_neighbors = 4 + with self.assertRaises(AttributeError): + ds.max_radius = 5.0 + + +class TestMultivariateTrainingImage(unittest.TestCase): + def test_shape_mismatch(self): + with self.assertRaisesRegex(ValueError, "same shape"): + TrainingImage( + [ + Variable("a", np.zeros((4, 4))), + Variable("b", np.zeros((3, 3))), + ] + ) + + def test_equal_weights(self): + ti = TrainingImage( + [ + Variable("a", np.zeros((4, 4), dtype=int)), + Variable("b", np.ones((4, 4), dtype=int)), + ] + ) + self.assertTrue(ti.multivariate) + self.assertEqual([v.name for v in ti.variables], ["a", "b"]) + self.assertAlmostEqual(ti.weights["a"], 0.5) + self.assertAlmostEqual(ti.weights["b"], 0.5) + self.assertEqual(ti.shape, (4, 4)) + self.assertEqual(ti.ndim, 2) + + def test_weights_must_sum_positive(self): + # all-zero weights are invalid + with self.assertRaisesRegex(ValueError, "positive"): + TrainingImage( + [ + Variable("a", np.zeros((4, 4), dtype=int), weight=0.0), + Variable("b", np.zeros((4, 4), dtype=int), weight=0.0), + ] + ) + + def test_distance_weighted_sum(self): + ti = TrainingImage( + [ + Variable("a", np.zeros((4, 4), dtype=int), weight=0.5), + Variable("b", np.zeros((4, 4), dtype=int), weight=0.5), + ] + ) + de_sg = {"a": np.array([1, 1]), "b": np.array([0, 0])} + de_ti = {"a": np.array([0, 0]), "b": np.array([0, 0])} + self.assertAlmostEqual(_mv_dist(ti, de_sg, de_ti), 0.5) + + def test_per_variable_categorical_and_distance(self): + ti = TrainingImage( + [ + Variable("cat", np.zeros((4, 4), dtype=int), categorical=True), + Variable( + "cont", + np.linspace(0, 100, 16).reshape(4, 4), + categorical=False, + distance="l2", + ), + ] + ) + self.assertTrue(ti.variable("cat").categorical) + self.assertFalse(ti.variable("cont").categorical) + self.assertAlmostEqual(ti.variable("cont").d_max, 100.0) + + def test_univariate_variation_p_preserved(self): + ti = TrainingImage( + np.linspace(0, 100, 16).reshape(4, 4), + categorical=False, + distance="variation1.5", + ) + self.assertFalse(ti.multivariate) + self.assertEqual(ti.variable().variation_p_norm, 1.5) + self.assertIsNone(ti.variable().p_norm) + self.assertIsNone(ti.weights) + + def test_variable_accessor(self): + v_a = Variable("a", np.arange(16).reshape(4, 4)) + v_b = Variable("b", np.zeros((4, 4), dtype=int)) + ti = TrainingImage([v_a, v_b]) + self.assertIs(ti.variable("a"), v_a) # same object + # univariate TIs have no named variables + uni = TrainingImage(np.zeros((4, 4), dtype=int)) + v_uni = uni.variable() + self.assertIsNone(v_uni.name) + with self.assertRaises(KeyError): + uni.variable("a") + + def test_adjust_value_multivariate(self): + ti = TrainingImage( + [ + Variable( + "v", + np.linspace(0, 100, 16).reshape(4, 4), + categorical=False, + distance="variation", + ), + Variable("c", np.zeros((4, 4), dtype=int), categorical=True), + ] + ) + self.assertAlmostEqual( + ti.adjust_value( + 50.0, + np.array([10.0, 20.0, 30.0]), + np.array([40.0, 50.0, 60.0]), + var="v", + ), + 20.0, + ) + self.assertEqual( + ti.adjust_value(1.0, np.array([0, 1]), np.array([1, 0]), var="c"), + 1.0, + ) + + def test_mixing_none_and_explicit_weights_raises(self): + with self.assertRaisesRegex(ValueError, "mixing"): + TrainingImage( + [ + Variable("a", np.zeros((4, 4)), weight=0.5), + Variable("b", np.zeros((4, 4)), weight=None), + ] + ) + + def test_duplicate_names_raises(self): + with self.assertRaisesRegex(ValueError, "unique"): + TrainingImage( + [ + Variable("x", np.zeros((4, 4))), + Variable("x", np.zeros((4, 4))), + ] + ) + + +class TestMultivariateDirectSampling(unittest.TestCase): + def test_fills_all_nodes(self): + # Node-wise path must leave no NaN in any variable. + rng = np.random.default_rng(42) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + field = ds([np.arange(6, dtype=float)] * 2, seed=1) + self.assertFalse(np.any(np.isnan(field["a"]))) + self.assertFalse(np.any(np.isnan(field["b"]))) + + def test_output_shapes(self): + rng = np.random.default_rng(0) + ti = TrainingImage( + [ + Variable("primary", rng.integers(0, 3, (20, 20))), + Variable("secondary", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.1)) + field = ds([np.arange(10, dtype=float)] * 2, seed=0) + self.assertEqual(field["primary"].shape, (10, 10)) + self.assertIn("secondary", field) + self.assertEqual(field["secondary"].shape, (10, 10)) + + def test_values_valid(self): + rng = np.random.default_rng(1) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 4, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + field = ds([np.arange(8, dtype=float)] * 2, seed=5) + self.assertTrue(np.all(np.isin(field["a"], [0, 1, 2, 3]))) + self.assertTrue(np.all(np.isin(field["b"], [0, 1]))) + + def test_per_variable_n_neighbors(self): + rng = np.random.default_rng(3) + ti = TrainingImage( + [ + Variable( + "primary", rng.integers(0, 3, (20, 20)), n_neighbors=8 + ), + Variable( + "secondary", rng.integers(0, 2, (20, 20)), n_neighbors=2 + ), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + field = ds([np.arange(8, dtype=float)] * 2, seed=7) + self.assertEqual(field["primary"].shape, (8, 8)) + self.assertFalse(np.any(np.isnan(field["primary"]))) + self.assertFalse(np.any(np.isnan(field["secondary"]))) + + def test_3d_runs(self): + rng = np.random.default_rng(8) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (10, 10, 10))), + Variable("b", rng.integers(0, 2, (10, 10, 10))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + field = ds([np.arange(4, dtype=float)] * 3, seed=0) + self.assertEqual(field["a"].shape, (4, 4, 4)) + self.assertFalse(np.any(np.isnan(field["a"]))) + self.assertFalse(np.any(np.isnan(field["b"]))) + + def test_partial_boundary_runs(self): + rng = np.random.default_rng(11) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling( + MPSModel(ti, scan_fraction=0.3, boundary="partial") + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=2) + self.assertFalse(np.any(np.isnan(field["a"]))) + self.assertFalse(np.any(np.isnan(field["b"]))) + self.assertTrue(np.all(np.isin(field["a"], [0, 1]))) + + def test_ds_mode_threshold(self): + rng = np.random.default_rng(12) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 3, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5, threshold=0.3)) + field = ds([np.arange(8, dtype=float)] * 2, seed=4) + self.assertFalse(np.any(np.isnan(field["a"]))) + self.assertTrue(np.all(np.isin(field["a"], [0, 1, 2]))) + self.assertTrue(np.all(np.isin(field["b"], [0, 1]))) + + def test_continuous_variation_variable(self): + # A continuous variable with variation distance exercises the + # mean-shift adjust_value_var path end to end. + rng = np.random.default_rng(13) + ti = TrainingImage( + [ + Variable( + "cont", + rng.random((20, 20)) * 10.0, + categorical=False, + distance="variation", + ), + Variable( + "cat", rng.integers(0, 2, (20, 20)), categorical=True + ), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + field = ds([np.arange(8, dtype=float)] * 2, seed=6) + self.assertEqual(field["cont"].shape, (8, 8)) + self.assertTrue(np.all(np.isfinite(field["cont"]))) + self.assertTrue(np.all(np.isin(field["cat"], [0, 1]))) + + def test_joint_cell_invariant(self): + # Core acceptance test for node-wise co-simulation: every node's vector + # must be copied from a SINGLE TI cell. Build a TI where ``b`` is an + # injective function of ``a`` (b = a + 100), so a consistent (a, b) pair + # exists at exactly one TI cell. If both variables at every node trace + # to one cell, ``b == a + 100`` must hold everywhere. + ids = np.arange(64).reshape(8, 8) + ti = TrainingImage( + [ + Variable("a", ids, categorical=True), + Variable("b", ids + 100, categorical=True), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0, threshold=0.0)) + field = ds([np.arange(6, dtype=float)] * 2, seed=0) + np.testing.assert_array_equal(field["b"], field["a"] + 100) + + def test_equal_treatment_named_fields(self): + # No privileged primary: all variables are first-class named fields. + rng = np.random.default_rng(20) + ti = TrainingImage( + [ + Variable("x", rng.integers(0, 2, (15, 15))), + Variable("y", rng.integers(0, 2, (15, 15))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + field = ds([np.arange(6, dtype=float)] * 2, seed=0) + self.assertEqual(set(field), {"x", "y"}) + self.assertIn("x", ds.field_names) + self.assertIn("y", ds.field_names) + np.testing.assert_array_equal(ds["x"], field["x"]) + np.testing.assert_array_equal(ds["y"], field["y"]) + + def test_invalid_variable_name_raises(self): + with self.assertRaisesRegex(ValueError, "identifier"): + Variable("a b", np.zeros((5, 5), dtype=int)) + + def test_n_neighbors_getter_scalar(self): + arr = np.zeros((10, 10), dtype=int) + ti = TrainingImage( + [ + Variable("a", arr, n_neighbors=8), + Variable("b", arr, n_neighbors=8), + ] + ) + ds = DirectSampling(MPSModel(ti)) + self.assertEqual(ds.n_neighbors, 8) + + def test_n_neighbors_getter_dict(self): + arr = np.zeros((10, 10), dtype=int) + ti = TrainingImage( + [ + Variable("a", arr, n_neighbors=8), + Variable("b", arr, n_neighbors=4), + ] + ) + ds = DirectSampling(MPSModel(ti)) + self.assertEqual(ds.n_neighbors, {"a": 8, "b": 4}) + + def test_max_radius_setter_univariate(self): + # Configure max_radius at construction; getter reflects the value. + ti = TrainingImage( + np.zeros((10, 10), dtype=int), n_neighbors=4, max_radius=5.0 + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + self.assertAlmostEqual(ds.max_radius, 5.0) + self.assertAlmostEqual(ds.ti.variable().max_radius, 5.0) + + def test_max_radius_getter_dict_when_different(self): + arr = np.zeros((10, 10), dtype=int) + ti = TrainingImage( + [ + Variable("a", arr, max_radius=20.0), + Variable("b", arr, max_radius=10.0), + ] + ) + ds = DirectSampling(MPSModel(ti)) + self.assertEqual(ds.max_radius, {"a": 20.0, "b": 10.0}) + + def test_set_condition_basic(self): + rng = np.random.default_rng(0) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.set_condition( + cond_pos=[[2.0, 5.0], [2.0, 5.0]], + cond_val={"a": np.array([1, 0]), "b": np.array([0, 1])}, + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertEqual(field["a"][2, 2], 1) + self.assertEqual(field["b"][2, 2], 0) + self.assertEqual(field["a"][5, 5], 0) + self.assertEqual(field["b"][5, 5], 1) + + def test_set_condition_partial_nan(self): + # NaN for b at the conditioning point -> b unconstrained there; only a + # is conditioned, and b is filled by the simulation (not NaN). + rng = np.random.default_rng(1) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.set_condition( + cond_pos=[[3.0], [3.0]], + cond_val={"a": np.array([1]), "b": np.array([np.nan])}, + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=2) + self.assertEqual(field["a"][3, 3], 1) + self.assertFalse(np.isnan(field["b"][3, 3])) + + def test_set_condition_collision(self): + # Both points snap to node (4, 4); (4.1, 4.1) is closer than (4.4, 4.4) + # -> the closer point's values win for all variables. + rng = np.random.default_rng(2) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.set_condition( + cond_pos=[[4.4, 4.1], [4.4, 4.1]], + cond_val={"a": np.array([0, 1]), "b": np.array([0, 1])}, + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=3) + self.assertEqual(field["a"][4, 4], 1) + self.assertEqual(field["b"][4, 4], 1) + + def test_collocated_constraint(self): + # b is a unique index per TI cell; a is a deterministic function of b. + # Conditioning b at the single simulated node forces the collocated + # (h=0) term to select the TI cell whose b matches, so the co-simulated + # a must equal that cell's a. DSBC full scan => exact global argmin. + # 1x1 sim grid: exactly one node is simulated; with b conditioned there, + # the collocated h=0 term alone determines which TI cell is matched. + nb = np.arange(36).reshape(6, 6) + na = (nb * 7) % 5 + ti = TrainingImage([Variable("a", na), Variable("b", nb)]) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0, threshold=0.0)) + ds.set_condition( + cond_pos=[[0.0], [0.0]], + cond_val={"a": np.array([np.nan]), "b": np.array([20])}, + ) + field = ds([np.arange(1, dtype=float)] * 2, seed=0) + # b=20 sits at TI cell (3, 2); a there is (20*7) % 5 == 0 + self.assertEqual(field["a"][0, 0], (20 * 7) % 5) + + def test_univariate_set_condition_still_works(self): + # Regression guard: scalar set_condition path is unchanged. + rng = np.random.default_rng(0) + ti = TrainingImage(rng.integers(0, 3, (20, 20))) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.set_condition([[5.0], [5.0]], [2]) + field = ds([np.arange(10, dtype=float)] * 2, seed=0) + self.assertEqual(field[5, 5], 2) + + def test_set_condition_length_mismatch(self): + ti = TrainingImage( + [ + Variable("a", np.zeros((10, 10), dtype=int)), + Variable("b", np.zeros((10, 10), dtype=int)), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + # cond_pos has 1 point but cond_val has 2 -> length mismatch + with self.assertRaisesRegex(ValueError, "mismatch"): + ds.set_condition( + cond_pos=[[3.0], [3.0]], + cond_val={"a": np.array([1, 0]), "b": np.array([1, 0])}, + ) + # per-variable arrays of different lengths + with self.assertRaisesRegex(ValueError, "same"): + ds.set_condition( + cond_pos=[[3.0, 4.0], [3.0, 4.0]], + cond_val={"a": np.array([1, 0]), "b": np.array([1])}, + ) + + def test_parallel_valid_values(self): + rng = np.random.default_rng(4) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 3, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds.num_threads = 2 + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertTrue(np.all(np.isin(field["a"], [0, 1, 2]))) + self.assertTrue(np.all(np.isin(field["b"], [0, 1]))) + + def test_parallel_matches_serial(self): + # The node-vertex DAG commits every per-variable neighbour before a node + # runs, so num_threads > 1 is bit-identical to serial. + rng = np.random.default_rng(5) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 3, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + pos = [np.arange(8, dtype=float)] * 2 + ds_s = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds_s.num_threads = 1 + ds_p = DirectSampling(MPSModel(ti, scan_fraction=0.2)) + ds_p.num_threads = 2 + f_s = ds_s(pos, seed=7) + f_p = ds_p(pos, seed=7) + np.testing.assert_array_equal(f_s["a"], f_p["a"]) + np.testing.assert_array_equal(f_s["b"], f_p["b"]) + + def test_parallel_matches_serial_partial_conditioning(self): + # Highest-risk DAG path: partial (NaN) conditioning + threads must stay + # bit-identical to serial. + rng = np.random.default_rng(17) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 3, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + pos = [np.arange(8, dtype=float)] * 2 + cond_pos = [np.array([1.0, 4.0, 6.0]), np.array([2.0, 5.0, 7.0])] + cond_val = { + "a": np.array([1.0, np.nan, 2.0]), # b unconstrained at point 0 + "b": np.array([np.nan, 1.0, 0.0]), # a unconstrained at point 1 + } + ds_s = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_s.num_threads = 1 + ds_s.set_condition(cond_pos, cond_val) + ds_p = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_p.num_threads = 4 + ds_p.set_condition(cond_pos, cond_val) + f_s = ds_s(pos, seed=7) + f_p = ds_p(pos, seed=7) + np.testing.assert_array_equal(f_s["a"], f_p["a"]) + np.testing.assert_array_equal(f_s["b"], f_p["b"]) + + def test_parallel_matches_serial_mismatched_max_radius(self): + # The DAG neighbour cache is built with the shared/global radius (a + # superset of any variable's own, possibly smaller, radius) and must + # be re-filtered per variable before use in _gather_neighborhood -- + # otherwise num_threads > 1 silently diverges from serial whenever + # variables declare different max_radius values (including an + # unbounded None mixed with a finite sibling). + rng = np.random.default_rng(23) + ti = TrainingImage( + [ + Variable( + "a", + rng.integers(0, 3, (20, 20)), + max_radius=None, + n_neighbors=12, + ), + Variable( + "b", + rng.integers(0, 2, (20, 20)), + max_radius=3.0, + n_neighbors=12, + ), + ] + ) + pos = [np.arange(8, dtype=float)] * 2 + ds_s = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_s.num_threads = 1 + ds_p = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_p.num_threads = 4 + f_s = ds_s(pos, seed=7) + f_p = ds_p(pos, seed=7) + np.testing.assert_array_equal(f_s["a"], f_p["a"]) + np.testing.assert_array_equal(f_s["b"], f_p["b"]) + + def test_parallel_matches_serial_mismatched_n_neighbors(self): + # The DAG neighbour cache is stored flat, one fixed-stride buffer per + # variable, with the stride being that variable's own n_neighbors. A + # stride shared across variables (or taken from the wrong one) would + # read a neighbour slice offset into the previous node's entry, so + # pin the case where the two strides genuinely differ. + rng = np.random.default_rng(29) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 3, (20, 20)), n_neighbors=20), + Variable("b", rng.integers(0, 2, (20, 20)), n_neighbors=5), + ] + ) + pos = [np.arange(8, dtype=float)] * 2 + ds_s = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_s.num_threads = 1 + ds_p = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_p.num_threads = 4 + f_s = ds_s(pos, seed=7) + f_p = ds_p(pos, seed=7) + np.testing.assert_array_equal(f_s["a"], f_p["a"]) + np.testing.assert_array_equal(f_s["b"], f_p["b"]) + + def test_parallel_conditioning_preserved(self): + rng = np.random.default_rng(6) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.num_threads = 2 + ds.set_condition( + cond_pos=[[3.0], [3.0]], + cond_val={"a": np.array([1]), "b": np.array([0])}, + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=9) + self.assertEqual(field["a"][3, 3], 1) + self.assertEqual(field["b"][3, 3], 0) + + def test_set_condition_validation_errors(self): + ti = TrainingImage( + [ + Variable("a", np.zeros((10, 10), dtype=int)), + Variable("b", np.zeros((10, 10), dtype=int)), + ] + ) + cases = { + "array_instead_of_dict": (np.array([1]), "dict"), + "empty_dict": ({}, "empty"), + "unknown_variable": ( + {"a": np.array([1.0]), "c": np.array([1.0])}, + "unknown variable", + ), + } + for label, (cond_val, regex) in cases.items(): + with self.subTest(label=label): + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + with self.assertRaisesRegex(ValueError, regex): + ds.set_condition([[3.0], [3.0]], cond_val) + + def test_set_condition_collision_per_variable_merge(self): + # Two points snap to node (4,4). The closer point {a:1, b:nan} wins + # variable a; the farther point {a:0, b:0} still fills variable b, which + # the closer point left NaN (per-variable collision resolution, C1). + rng = np.random.default_rng(7) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds.set_condition( + # point 0 at (4.4,4.4) {a:0,b:0}; point 1 at (4.1,4.1) {a:1,b:nan} + cond_pos=[[4.4, 4.1], [4.4, 4.1]], + cond_val={"a": np.array([0, 1]), "b": np.array([0, np.nan])}, + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=1) + # closer point wins a; farther point's finite b is not discarded + self.assertEqual(field["a"][4, 4], 1) + self.assertEqual(field["b"][4, 4], 0) + + def test_distance_power_multivariate(self): + # distance_power > 0 exercises the per-variable lag-norm weighting path. + rng = np.random.default_rng(8) + ti = TrainingImage( + [ + Variable( + "cont", + rng.random((20, 20)) * 10.0, + categorical=False, + distance="l2", + ), + Variable( + "cat", rng.integers(0, 2, (20, 20)), categorical=True + ), + ], + distance_power=1.0, + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + field = ds([np.arange(8, dtype=float)] * 2, seed=3) + self.assertEqual(field["cont"].shape, (8, 8)) + self.assertTrue(np.all(np.isfinite(field["cont"]))) + self.assertTrue(np.all(np.isin(field["cat"], [0, 1]))) + + def test_single_variable_multivariate(self): + rng = np.random.default_rng(9) + ti = TrainingImage([Variable("only", rng.integers(0, 3, (20, 20)))]) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertEqual(set(field), {"only"}) + self.assertEqual(field["only"].shape, (8, 8)) + self.assertTrue(np.all(np.isin(field["only"], [0, 1, 2]))) + + def test_threshold_renormalization_with_empty_variable(self): + # When variable 'b' has no informed neighbours (first node on the + # path), the joint distance must still be renormalized + # to [0,1] so the threshold comparison is meaningful. + # Build a TI where 'a' and 'b' are injective: b = a + 100 (same as + # test_joint_cell_invariant). Use threshold > 0 (DS mode, not DSBC). + # The first simulated node has zero neighbours for both variables, so + # it falls back to a random TI cell — both variables must be drawn from + # the same cell (b == a + 100 for that node too). + ids = np.arange(64).reshape(8, 8) + ti = TrainingImage( + [ + Variable("a", ids, categorical=True), + Variable("b", ids + 100, categorical=True), + ] + ) + # scan_fraction=1.0, threshold=0.01: very strict but must still complete + # without NaN and must reproduce the joint relationship everywhere. + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0, threshold=0.01)) + field = ds([np.arange(6, dtype=float)] * 2, seed=0) + self.assertFalse(np.any(np.isnan(field["a"]))) + self.assertFalse(np.any(np.isnan(field["b"]))) + np.testing.assert_array_equal(field["b"], field["a"] + 100) + + def test_mv_custom_store_name_raises(self): + # A custom store name has no single field to bind to for a + # multivariate run; reject it instead of silently dropping it. + rng = np.random.default_rng(0) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + with self.assertRaisesRegex(ValueError, "custom store name"): + ds([np.arange(6, dtype=float)] * 2, seed=0, store="myrun") + + def test_mv_store_false_not_stored(self): + # store=False must still work for MV (no fields persisted on the model). + rng = np.random.default_rng(0) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (20, 20))), + Variable("b", rng.integers(0, 2, (20, 20))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + out = ds([np.arange(6, dtype=float)] * 2, seed=0, store=False) + self.assertEqual(set(out), {"a", "b"}) + self.assertEqual(list(ds.field_names), []) + + def test_mv_run_does_not_set_self_field(self): + # Equal-treatment MV: no single self.field; per-variable named fields only. + rng = np.random.default_rng(0) + ti = TrainingImage( + [ + Variable("a", rng.integers(0, 2, (15, 15))), + Variable("b", rng.integers(0, 2, (15, 15))), + ] + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds([np.arange(6, dtype=float)] * 2, seed=0) + self.assertFalse(hasattr(ds, "field")) + self.assertIn("a", ds.field_names) + self.assertIn("b", ds.field_names) + + +class TestNonstationarity(unittest.TestCase): + """Geometric non-stationarity for DirectSampling (MPSModel rotation/scale).""" + + def _make_ds(self, ti_shape=(20, 20), **kw): + rng = np.random.default_rng(0) + data = rng.integers(0, 3, ti_shape) + n_neighbors = kw.pop("n_neighbors", 4) + ti = gs.mps.TrainingImage(data, n_neighbors=n_neighbors) + defaults = dict(scan_fraction=0.2) + defaults.update(kw) + return gs.mps.DirectSampling(MPSModel(ti, **defaults)), ti + + def test_scalar_rotation_valid_values(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=np.pi / 4) + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertEqual(field.shape, (8, 8)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_rotation_changes_output(self): + ds_plain, ti = self._make_ds(ti_shape=(30, 30), n_neighbors=8) + pos = [np.arange(10, dtype=float)] * 2 + f_plain = ds_plain(pos, seed=7) + + ds_rot = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=np.pi / 4) + ) + f_rot = ds_rot(pos, seed=7) + self.assertFalse(np.array_equal(f_plain, f_rot)) + + def test_array_rotation_map_runs(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + angle_map = np.linspace(0, np.pi / 2, 64).reshape(8, 8) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=angle_map) + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=1) + self.assertEqual(field.shape, (8, 8)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_anis_changes_output(self): + ds_plain, ti = self._make_ds(ti_shape=(30, 30), n_neighbors=8) + pos = [np.arange(10, dtype=float)] * 2 + f_plain = ds_plain(pos, seed=3) + + ds_anis = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, scale=[1.0, 0.5]) + ) + f_anis = ds_anis(pos, seed=3) + self.assertFalse(np.array_equal(f_plain, f_anis)) + + def test_combined_rotation_anis_runs(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel( + ti, scan_fraction=0.2, rotation=np.pi / 6, scale=[1.0, 0.5] + ) + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=2) + self.assertEqual(field.shape, (8, 8)) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_conditioning_preserved(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.3, rotation=np.pi / 4) + ) + ds.set_condition([[4.0], [4.0]], [2]) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertEqual(int(field[4, 4]), 2) + + def test_partial_boundary_runs(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel( + ti, scan_fraction=0.2, boundary="partial", rotation=np.pi / 4 + ) + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertTrue(np.all(np.isin(field, [0, 1, 2]))) + + def test_3d_rotation_runs(self): + rng = np.random.default_rng(0) + data = rng.integers(0, 2, (12, 12, 12)) + ti = gs.mps.TrainingImage(data, n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.1, rotation=np.pi / 4) + ) + field = ds([np.arange(5, dtype=float)] * 3, seed=0) + self.assertEqual(field.shape, (5, 5, 5)) + self.assertTrue(np.all(np.isin(field, [0, 1]))) + + def test_collapsed_window_fallback(self): + # 90° rotation on a tiny TI can force all windows to collapse. + # Output must be finite and within TI values — no crash, no NaN. + rng = np.random.default_rng(0) + data = rng.integers(0, 2, (4, 4)) + ti = gs.mps.TrainingImage(data) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=1.0, rotation=np.pi / 2) + ) + field = ds([np.arange(6, dtype=float)] * 2, seed=0) + self.assertTrue(np.all(np.isfinite(field))) + self.assertTrue(np.all(np.isin(field, [0, 1]))) + + def test_identity_matches_no_transform(self): + # θ=0, scale=1 must produce bit-identical output to the plain path. + ds_plain, ti = self._make_ds(ti_shape=(20, 20), n_neighbors=8) + pos = [np.arange(8, dtype=float)] * 2 + f_plain = ds_plain(pos, seed=5) + + ds_id = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=0.0, scale=1.0) + ) + f_id = ds_id(pos, seed=5) + np.testing.assert_array_equal(f_plain, f_id) + + def test_multivariate_rotation_valid_output(self): + # Exercises the multivariate transform path: shape, value subsets, + # and that rotation changes output vs no rotation (same seed). + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage( + [ + Variable( + "a", + rng.integers(0, 3, (20, 20)), + categorical=True, + n_neighbors=4, + ), + Variable( + "b", + rng.random((20, 20)), + categorical=False, + distance="l1", + n_neighbors=4, + ), + ] + ) + pos = [np.arange(8, dtype=float)] * 2 + + ds_plain = gs.mps.DirectSampling(MPSModel(ti, scan_fraction=0.2)) + result_plain = ds_plain(pos, seed=9) + + ds_rot = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=np.pi / 4) + ) + result_rot = ds_rot(pos, seed=9) + + self.assertEqual(result_rot["a"].shape, (8, 8)) + self.assertEqual(result_rot["b"].shape, (8, 8)) + self.assertTrue(np.all(np.isin(result_rot["a"], [0, 1, 2]))) + self.assertTrue(np.all(np.isfinite(result_rot["b"]))) + self.assertFalse(np.array_equal(result_plain["a"], result_rot["a"])) + + def test_flattened_rotation_map_raises(self): + # A per-node map passed flattened (length Nx*Ny) on a 2-D grid must + # raise, not silently apply only element [0]. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=np.linspace(0, np.pi, 25)) + ) + with self.assertRaisesRegex(ValueError, "shape"): + ds([np.arange(5, dtype=float)] * 2, seed=0) + + def test_3d_stationary_rotation_triple_runs(self): + # A genuine stationary multi-component value (3 Tait-Bryan angles for a + # 3-D grid) is still accepted (length == no_of_angles(3) == 3). + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage( + rng.integers(0, 2, (12, 12, 12)), n_neighbors=4 + ) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.1, rotation=np.array([0.1, 0.2, 0.3])) + ) + field = ds([np.arange(5, dtype=float)] * 3, seed=0) + self.assertEqual(field.shape, (5, 5, 5)) + self.assertTrue(np.all(np.isin(field, [0, 1]))) + + def test_scalar_vs_materialized_map_bit_identical(self): + # Scalar spec (hoisted-M stationary path) vs a hand-materialized + # constant per-node map (per-node path) must agree exactly. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + pos = [np.arange(8, dtype=float)] * 2 + f_scalar = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=0.3) + )(pos, seed=11) + f_map = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=np.full((8, 8), 0.3)) + )(pos, seed=11) + np.testing.assert_array_equal(f_scalar, f_map) + + def test_callable_vs_materialized_bit_identical(self): + # Callable form vs a hand-materialized array of the same field must agree exactly. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (20, 20)), n_neighbors=4) + pos = [np.arange(8, dtype=float)] * 2 + + def rot(x, y): + return (y / 8.0) * (np.pi / 4.0) + + gx, gy = np.meshgrid(pos[0], pos[1], indexing="ij") + rot_arr = (gy / 8.0) * (np.pi / 4.0) + + f_call = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=rot) + )(pos, seed=3) + f_arr = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, rotation=rot_arr) + )(pos, seed=3) + np.testing.assert_array_equal(f_call, f_arr) + + def test_uniform_dilation_runs_and_differs(self): + # Uniform scale=0.5 (pure dilation) runs, stays in the TI value set, + # and differs from the identity run. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (30, 30)), n_neighbors=8) + pos = [np.arange(10, dtype=float)] * 2 + f_id = gs.mps.DirectSampling(MPSModel(ti, scan_fraction=0.2))( + pos, seed=7 + ) + f_half = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.2, scale=0.5) + )(pos, seed=7) + self.assertTrue(np.all(np.isin(f_half, [0, 1, 2]))) + self.assertFalse(np.array_equal(f_id, f_half)) + + def test_nonuniform_axis_spacing_warns(self): + # Non-uniform axis spacing breaks the index-space geometry assumption + # that lag transforms rely on; must warn rather than silently misfit. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (15, 15)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.3, rotation=np.pi / 4) + ) + xs = np.array([0.0, 1.0, 3.0, 7.0]) + ys = np.arange(4, dtype=float) + with self.assertWarns(UserWarning): + ds([xs, ys], seed=0) + + def test_unequal_axis_spacing_warns(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (15, 15)), n_neighbors=4) + ds = gs.mps.DirectSampling(MPSModel(ti, scan_fraction=0.3, scale=0.5)) + with self.assertWarns(UserWarning): + ds([np.arange(4.0), 2.0 * np.arange(4.0)], seed=0) + + def test_uniform_spacing_no_warning(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (15, 15)), n_neighbors=4) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.3, rotation=np.pi / 4) + ) + import warnings as _w + + with _w.catch_warnings(): + _w.simplefilter("error", UserWarning) + ds([np.arange(6.0)] * 2, seed=0) + + def test_set_nonstationary_removed(self): + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (10, 10))) + ds = gs.mps.DirectSampling(MPSModel(ti)) + self.assertFalse(hasattr(ds, "set_nonstationary")) + + +class TestFullArraySnapshot(unittest.TestCase): + """Bit-identical full-array regression pins — the step-by-step acceptance gate. + + These arrays are captured from the pre-refactoring engine with fixed seeds and + must remain bit-identical (np.testing.assert_array_equal) after every + refactoring step. + """ + + # --- Reference TIs (seeded deterministically) --- + @classmethod + def setUpClass(cls): + arr1d = np.tile([0.0, 1.0], 10) + cls.ti1d = TrainingImage(arr1d, categorical=True) + cls.ti2d = TrainingImage( + (np.indices((8, 8)).sum(axis=0) % 2).astype(float), + categorical=True, + ) + rng0 = np.random.default_rng(0) + cls.ti2d_rand = TrainingImage( + rng0.integers(0, 3, (20, 20)).astype(float), categorical=True + ) + rng_mv = np.random.default_rng(7) + cls.ti_mv = TrainingImage( + [ + Variable("a", rng_mv.integers(0, 3, (20, 20)).astype(float)), + Variable("b", rng_mv.integers(0, 2, (20, 20)).astype(float)), + ] + ) + cls.x1d = np.arange(10, dtype=float) + cls.x2d = np.arange(6, dtype=float) + cls.y2d = np.arange(6, dtype=float) + + def test_snapshot(self): + # --- univariate 1D, seed=42 --- + ds = DirectSampling(MPSModel(self.ti1d, scan_fraction=1.0)) + f = ds([self.x1d], seed=42) + np.testing.assert_array_equal( + f, + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + err_msg="snap_uni_1d seed=42", + ) + # seed=99 + f = ds([self.x1d], seed=99) + np.testing.assert_array_equal( + f, + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + err_msg="snap_uni_1d seed=99", + ) + + # --- univariate 2D checkerboard, seed=42 --- + ds2 = DirectSampling(MPSModel(self.ti2d, scan_fraction=1.0)) + f = ds2([self.x2d, self.y2d], seed=42) + np.testing.assert_array_equal( + f, + [ + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + ], + err_msg="snap_uni_2d_checker seed=42", + ) + # seed=99 + f = ds2([self.x2d, self.y2d], seed=99) + np.testing.assert_array_equal( + f, + [ + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 1.0, 0.0, 1.0, 0.0], + ], + err_msg="snap_uni_2d_checker seed=99", + ) + + # --- univariate 2D random TI, seed=42 --- + ds3 = DirectSampling(MPSModel(self.ti2d_rand, scan_fraction=0.5)) + f = ds3([self.x2d, self.y2d], seed=42) + np.testing.assert_array_equal( + f, + [ + [0.0, 0.0, 2.0, 0.0, 2.0, 2.0], + [0.0, 0.0, 2.0, 2.0, 0.0, 2.0], + [2.0, 0.0, 1.0, 0.0, 1.0, 0.0], + [2.0, 0.0, 1.0, 2.0, 0.0, 0.0], + [2.0, 0.0, 1.0, 1.0, 2.0, 1.0], + [2.0, 2.0, 0.0, 2.0, 1.0, 0.0], + ], + err_msg="snap_uni_rand seed=42", + ) + + # --- multivariate 2D, seed=42 --- + ds_mv = DirectSampling(MPSModel(self.ti_mv, scan_fraction=0.5)) + res = ds_mv([self.x2d, self.y2d], seed=42) + np.testing.assert_array_equal( + res["a"], + [ + [2.0, 1.0, 2.0, 0.0, 2.0, 0.0], + [2.0, 2.0, 2.0, 1.0, 2.0, 1.0], + [1.0, 0.0, 0.0, 1.0, 2.0, 2.0], + [2.0, 1.0, 0.0, 0.0, 1.0, 1.0], + [0.0, 1.0, 2.0, 1.0, 2.0, 2.0], + [0.0, 0.0, 0.0, 1.0, 1.0, 2.0], + ], + err_msg="snap_mv var=a seed=42", + ) + np.testing.assert_array_equal( + res["b"], + [ + [1.0, 1.0, 0.0, 0.0, 1.0, 1.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0], + [1.0, 1.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0, 0.0], + ], + err_msg="snap_mv var=b seed=42", + ) + + +class TestDSSeedControl(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)) + self.ti = TrainingImage(data.astype(float), n_neighbors=4) + self.pos = [np.arange(8, dtype=float)] * 2 + + def test_fixed_path_seed_different_node_seed(self): + # Same visit order, different TI search → different output + ds = DirectSampling(MPSModel(self.ti, scan_fraction=0.3)) + fa = ds(self.pos, path_seed=1, node_seed=10) + fb = ds(self.pos, path_seed=1, node_seed=99) + self.assertFalse(np.array_equal(fa, fb)) + + def test_fixed_node_seed_different_path_seed(self): + # Same TI search, different visit order → different output + ds = DirectSampling(MPSModel(self.ti, scan_fraction=0.3)) + fa = ds(self.pos, path_seed=1, node_seed=10) + fb = ds(self.pos, path_seed=99, node_seed=10) + self.assertFalse(np.array_equal(fa, fb)) + + def test_both_seeds_fixed_reproducible(self): + # Explicit seeds on both → fully reproducible across calls + ds = DirectSampling(MPSModel(self.ti, scan_fraction=0.3)) + fa = ds(self.pos, path_seed=7, node_seed=42) + fb = ds(self.pos, path_seed=7, node_seed=42) + self.assertTrue(np.array_equal(fa, fb)) + + def test_default_still_reproducible(self): + # No explicit seeds → same seed= value still reproducible + ds = DirectSampling(MPSModel(self.ti, scan_fraction=0.3)) + fa = ds(self.pos, seed=5) + fb = ds(self.pos, seed=5) + self.assertTrue(np.array_equal(fa, fb)) + + +class TestTransformLagsCollapse(unittest.TestCase): + def test_collapsed_lags_warn(self): + # Strong anisotropy flattens the y-axis: SG lags (0,1) and (0,2) both + # round to TI lag (0,0). The dropped neighbour must be warned about. + M = np.array([[1.0, 0.0], [0.0, 0.1]]) + lags = np.array([[0.0, 1.0], [0.0, 2.0]]) + de = np.array([5.0, 9.0]) + with self.assertWarns(RuntimeWarning): + lags_ti, de_out = _transform_lags(lags, M, de) + self.assertEqual(len(lags_ti), 1) + self.assertEqual(de_out.tolist(), [5.0]) # first occurrence kept + + def test_no_collapse_no_warning(self): + # Identity transform keeps both lags distinct → no warning. + M = np.eye(2) + lags = np.array([[0.0, 1.0], [0.0, 2.0]]) + de = np.array([5.0, 9.0]) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + lags_ti, de_out = _transform_lags(lags, M, de) + self.assertEqual(len(lags_ti), 2) + + +class TestReduceToFit(unittest.TestCase): + """M10 para [43]: global reduce-to-fit, drop most-outside node by rank (#3).""" + + def test_drops_oob_regardless_of_rank(self): + # The near (low-rank) lag maps out of a 4x4 TI; the far (high-rank) lag + # is feasible. para [43] keeps the feasible one and drops the OOB one — + # the opposite of partial-mode furthest-first truncation. + lags_ti = np.array([[0.0, 10.0], [1.0, 0.0]]) + de = np.array([5.0, 9.0]) + out_lags, out_de = _reduce_to_fit(lags_ti, (4, 4), de) + np.testing.assert_array_equal(out_lags, [[1.0, 0.0]]) + np.testing.assert_array_equal( + out_de, [9.0] + ) # parallel array sliced too + + def test_both_extremes_overspan_reduced(self): + # Two lags each fit alone but jointly over-span a 4-wide TI (ti-1==3): + # extent 3-(-3)=6 > 3. One outermost node is dropped until it fits. + lags_ti = np.array([[0.0, 3.0], [0.0, -3.0]]) + (out_lags,) = _reduce_to_fit(lags_ti, (4, 4)) + self.assertEqual(len(out_lags), 1) + + def test_collocated_never_dropped_and_inbounds_kept(self): + # h=0 and an in-bounds lag survive; the over-long lag is removed. + lags_ti = np.array([[0.0, 0.0], [3.0, 0.0], [9.0, 0.0]]) + (out_lags,) = _reduce_to_fit(lags_ti, (4, 4)) # ti-1 == 3 + self.assertIn([0.0, 0.0], out_lags.tolist()) # collocated kept + self.assertNotIn([9.0, 0.0], out_lags.tolist()) # most-outside dropped + + def test_already_fits_is_noop(self): + lags_ti = np.array([[1.0, 0.0], [0.0, -1.0]]) + (out_lags,) = _reduce_to_fit(lags_ti, (20, 20)) + np.testing.assert_array_equal(out_lags, lags_ti) + + def test_empty(self): + (out,) = _reduce_to_fit(np.empty((0, 2)), (4, 4)) + self.assertEqual(out.size, 0) + + def test_strong_anis_retains_pattern_neighbour(self): + # End to end: strong anisotropy on a small TI used to collapse the + # window to a random draw; with para [43] the feasible neighbour is + # retained, so output stays valid and finite (no crash, no NaN). + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (6, 6))) + ds = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=1.0, scale=[1.0, 0.1]) + ) + field = ds([np.arange(8, dtype=float)] * 2, seed=0) + self.assertTrue(np.all(np.isfinite(field))) + self.assertTrue(set(np.unique(field)).issubset({0.0, 1.0})) + + +class TestLagTransformMatrix(unittest.TestCase): + """Canonical-map transform semantics.""" + + def test_scale_half_doubles_ti_lags(self): + # scale=0.5 -> structures half the TI size -> TI-frame lags + # are 2x the SG lags (lag_ti = (1/s) * R^-1 * lag_sg). + scale_map = np.broadcast_to(np.array([0.5, 0.5]), (4, 4, 2)) + M = _lag_transform_matrix(2, None, scale_map, np.array([0, 0])) + lags = np.array([[3.0, 0.0], [0.0, 5.0]]) + lags_ti = np.rint(lags @ M.T) + np.testing.assert_array_equal(lags_ti, [[6.0, 0.0], [0.0, 10.0]]) + + def test_uniform_dilation_isotropic(self): + # Uniform dilation was inexpressible in the axis-0-pinned anis form. + scale_map = np.broadcast_to(np.array([2.0, 2.0]), (3, 3, 2)) + M = _lag_transform_matrix(2, None, scale_map, np.array([1, 1])) + np.testing.assert_allclose(M, 0.5 * np.eye(2)) + + def test_rotation_only_matches_detransform(self): + from gstools.tools.geometric import matrix_detransform + + rot_map = np.full((4, 4, 1), np.pi / 6) + M = _lag_transform_matrix(2, rot_map, None, np.array([2, 3])) + np.testing.assert_array_equal( + M, matrix_detransform(2, [np.pi / 6], [1.0, 1.0]) + ) + + def test_per_node_map_indexed_at_node(self): + rot_map = np.zeros((4, 4, 1)) + rot_map[3, 1, 0] = np.pi / 2 + M_id = _lag_transform_matrix(2, rot_map, None, np.array([0, 0])) + M_rot = _lag_transform_matrix(2, rot_map, None, np.array([3, 1])) + np.testing.assert_allclose(M_id, np.eye(2), atol=1e-15) + self.assertFalse(np.allclose(M_rot, np.eye(2))) + + def test_geometry_drop_ignores_sg_rank(self): + # The NEAREST lag (SG rank 0) lands outside the TI -> it is dropped + # (geometry rule), not the furthest (rank rule). + lags_ti = np.array([[9.0, 0.0], [1.0, 1.0], [2.0, 0.0]]) + values = np.array([10.0, 20.0, 30.0]) + out_lags, out_vals = _reduce_to_fit(lags_ti, (5, 5), values) + np.testing.assert_array_equal(out_lags, [[1.0, 1.0], [2.0, 0.0]]) + np.testing.assert_array_equal(out_vals, [20.0, 30.0]) + + +class TestVariationNNeighbors(unittest.TestCase): + def test_variation_n1_raises_at_variable_construction(self): + with self.assertRaisesRegex(ValueError, "variation"): + Variable( + "x", + np.linspace(0.0, 1.0, 20), + categorical=False, + distance="variation", + n_neighbors=1, + ) + + def test_variation_n2_ok(self): + v = Variable( + "x", + np.linspace(0.0, 1.0, 20), + categorical=False, + distance="variation", + n_neighbors=2, + ) + ti = TrainingImage([v]) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5)) + self.assertEqual(ds.n_neighbors, 2) + + def test_non_variation_n1_ok(self): + v = Variable( + "x", + np.linspace(0.0, 1.0, 20), + categorical=False, + distance="l2", + n_neighbors=1, + ) + ti = TrainingImage([v]) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5)) + self.assertEqual(ds.n_neighbors, 1) + + def test_variation_guard_is_atomic(self): + # Variation guard raises on the Variable; the value stays at 3. + v = Variable( + "x", + np.linspace(0.0, 1.0, 20), + categorical=False, + distance="variation", + n_neighbors=3, + ) + ti = TrainingImage([v]) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5)) + with self.assertRaises(ValueError): + v.n_neighbors = 1 + self.assertEqual(ds.n_neighbors, 3) # unchanged + + +class TestStrictBoundaryWarning(unittest.TestCase): + def test_strict_infeasible_warns_and_falls_back(self): + # A lag larger than the TI cannot fit any anchor in strict mode, so the + # function must warn before falling back to partial truncation. + ti_shape = np.array([5]) + lags_ti = np.array( + [[10.0]] + ) # |lag| 10 > TI size 5 → strict infeasible + with self.assertWarns(RuntimeWarning): + lo, hi, keep = _window_bounds(lags_ti, ti_shape, "strict") + # falls back: partial loop drops the over-long lag → keep < len(lags_ti) + self.assertLess(keep, len(lags_ti) + 1) + + def test_strict_feasible_no_warning(self): + # A lag that fits leaves strict mode satisfied → no warning. + ti_shape = np.array([20]) + lags_ti = np.array([[1.0]]) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + lo, hi, keep = _window_bounds(lags_ti, ti_shape, "strict") + self.assertEqual(keep, 1) + + +class TestScanWindowThreshold(unittest.TestCase): + def test_ds_mode_strict_threshold(self): + # Mariethoz2010 ¶23: DS acceptance is strict d < t. A candidate at + # exactly the threshold (0.1) must be rejected; the 0.05 candidate is + # accepted instead. With the old inclusive d <= t the at-threshold + # candidate would win first (scan order). + win_shape = (2,) + lo = np.array([0]) + dists = np.array([0.1, 0.05]) + + def dist_fn(y_blk): + return dists[: len(y_blk)] + + y = _scan_window(lo, win_shape, 0, 2, 0.1, dist_fn) + self.assertEqual(int(y[0]), 1) # the 0.05 candidate, not the 0.1 one + + def test_scan_window_greedy_first_under_threshold(self): + # DS mode (threshold > 0) must return the FIRST candidate in scan order + # with d < threshold — NOT the global-argmin candidate. + # Design: candidate 0 has d=0.15 (under threshold 0.2, not the argmin); + # candidate 1 has d=0.05 (under threshold 0.2, IS the argmin). + # np.argmax(under) returns the first True in [True, True] == index 0, + # confirming greedy (first-under-threshold) semantics, not argmin semantics. + win_shape = (3,) + lo = np.array([0]) + # distances for positions 0, 1, 2 in scan order + dists = np.array([0.15, 0.05, 0.30]) + + def dist_fn(y_blk): + idxs = (y_blk[:, 0] - lo[0]).astype(int) + return dists[idxs] + + # Argmin candidate is position 1 (d=0.05); first-under-threshold is + # position 0 (d=0.15 < 0.2). The function must return position 0. + y = _scan_window(lo, win_shape, 0, 3, 0.2, dist_fn) + self.assertEqual( + int(y[0]), 0 + ) # first-under-threshold, not argmin (pos 1) + + def test_dsbc_accepts_exact_match(self): + # DSBC (t=0): exact match d == 0 is still accepted (d <= 0). + win_shape = (2,) + lo = np.array([0]) + dists = np.array([0.0, 0.5]) + + def dist_fn(y_blk): + return dists[: len(y_blk)] + + y = _scan_window(lo, win_shape, 0, 2, 0.0, dist_fn) + self.assertEqual(int(y[0]), 0) # the exact-match candidate + + +class TestRandomScanPath(unittest.TestCase): + """MPSModel(scan_path='random'): GSTools extension (M10 para [19] is sequential).""" + + def test_random_order_samples_the_whole_window_without_repeats(self): + # Contract: same number of DISTINCT candidates as sequential mode, + # spread over the window instead of one contiguous run, and full + # coverage when max_scan == win_size. Grid the 40x40 window into 16 + # 10x10 blocks: 160 uniform draws leave a block empty with negligible + # probability, while 160 contiguous cells span 4 rows -> <= 8 blocks. + def visited(u_start_i, max_scan, scan_path, win_shape=(40, 40)): + seen = [] + + def dist_fn(y_blk): # never accepted (DSBC needs d <= 0) + seen.append(y_blk.copy()) + return np.ones(len(y_blk)) + + _scan_window( + np.zeros(len(win_shape), dtype=int), + win_shape, + u_start_i, + max_scan, + 0.0, + dist_fn, + scan_path, + ) + return np.concatenate(seen, axis=0) + + rnd = visited(0.42, 160, "random") + self.assertEqual(len(np.unique(rnd, axis=0)), 160) + blocks = lambda v: {(int(r) // 10, int(c) // 10) for r, c in v} + self.assertEqual(len(blocks(rnd)), 16) + self.assertLessEqual(len(blocks(visited(0.42, 160, "sequential"))), 8) + full = visited(0.81, 64, "random", win_shape=(8, 8)) + np.testing.assert_array_equal( + np.sort(np.ravel_multi_index(tuple(full.T), (8, 8))), np.arange(64) + ) + + def test_option_reaches_the_engine_and_keeps_output_valid(self): + rng = np.random.default_rng(0) + data = rng.integers(0, 3, (20, 20)).astype(float) + ti = TrainingImage(data, categorical=True, n_neighbors=4) + pos = [np.arange(8, dtype=float)] * 2 + seeds = dict(path_seed=1, node_seed=10) + + self.assertEqual(MPSModel(ti).scan_path, "sequential") + with self.assertRaises(ValueError): + MPSModel(ti, scan_path="spiral") + + ds_seq = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + ds_rnd = DirectSampling( + MPSModel(ti, scan_fraction=0.3, scan_path="random") + ) + f_rnd = ds_rnd(pos, num_threads=1, **seeds) + # Plumbed through (otherwise identical), reproducible, a subset of the + # TI values, and thread-count independent: the scan order is keyed on + # the pre-drawn per-node u_start, never on execution order. + self.assertFalse(np.array_equal(ds_seq(pos, **seeds), f_rnd)) + np.testing.assert_array_equal( + f_rnd, ds_rnd(pos, num_threads=1, **seeds) + ) + np.testing.assert_array_equal( + f_rnd, ds_rnd(pos, num_threads=4, **seeds) + ) + self.assertTrue(np.isin(f_rnd, np.unique(data)).all()) + + +class TestNaNTrainingImage(unittest.TestCase): + """Masked / incomplete TIs: NaN cells are undefined (warn + handle). + + Contract: NaN cells are treated as undefined. They are (a) excluded from + the continuous data range ``d_max``, (b) excluded per-position from every + candidate distance, and (c) never pasted into the field. Construction warns + once. A TI with no fully-defined cell cannot be simulated and raises. + """ + + def test_nan_excluded_from_dmax(self): + # A NaN must not collapse d_max to the 1.0 fallback. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ti = TrainingImage( + np.array([0.0, np.nan, 100.0]), + categorical=False, + distance="l1", + ) + self.assertAlmostEqual(ti.variable()._d_max, 100.0) + + def test_construction_warns_on_nan(self): + with self.assertWarns(UserWarning): + TrainingImage( + np.array([0.0, np.nan, 1.0]), categorical=False, distance="l1" + ) + + def test_partial_nan_continuous_runs_finite(self): + # A NaN patch must not crash and must produce finite output within + # the defined data range. + data = np.random.RandomState(0).rand(20, 20) + data[5:8, 5:8] = np.nan + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ti = TrainingImage(data, categorical=False, distance="l1") + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0)) + field = ds([np.arange(15, dtype=float)] * 2, seed=1) + self.assertEqual(field.shape, (15, 15)) + self.assertTrue(np.all(np.isfinite(field))) + self.assertGreaterEqual(field.min(), np.nanmin(data)) + self.assertLessEqual(field.max(), np.nanmax(data)) + + def test_partial_nan_categorical_runs_subset(self): + # A categorical NaN patch must not crash, and output must be a + # subset of the *defined* categories. + data = np.random.RandomState(0).randint(0, 2, (20, 20)).astype(float) + data[5:8, 5:8] = np.nan + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ti = TrainingImage(data, categorical=True) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0)) + field = ds([np.arange(15, dtype=float)] * 2, seed=1) + self.assertTrue(np.all(np.isfinite(field))) + self.assertTrue(set(np.unique(field)).issubset({0.0, 1.0})) + + def test_all_nan_ti_raises(self): + # No fully-defined cell → cannot paste any value → clear error. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ti = TrainingImage( + np.full((5, 5), np.nan), categorical=False, distance="l2" + ) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0)) + with self.assertRaises(ValueError): + ds([np.arange(3, dtype=float)] * 2, seed=0) + + +class TestTrainingImageWindow(unittest.TestCase): + """TrainingImage.window(slices) sub-region views (M10 para [40]).""" + + def _cont_ti(self): + rng = np.random.default_rng(0) + data = rng.random((20, 30)) * 10.0 + return gs.mps.TrainingImage(data, categorical=False), data + + def test_window_is_view_no_copy(self): + ti, data = self._cont_ti() + win = ti.window(np.s_[5:15, 10:25]) + self.assertEqual(win.shape, (10, 15)) + self.assertTrue( + np.shares_memory(win.variable().data, ti.variable().data) + ) + np.testing.assert_array_equal(win.variable().data, data[5:15, 10:25]) + + def test_window_d_max_from_slice(self): + # d_max of the view is the range of the SLICED data, not the parent's. + data = np.zeros((10, 10)) + data[0, 0] = 100.0 # parent range 100, outside the window + data[5:, 5:] = np.arange(25).reshape(5, 5) # window range 24 + ti = gs.mps.TrainingImage(data, categorical=False) + win = ti.window(np.s_[5:10, 5:10]) + self.assertEqual(win.variable().d_max, 24.0) + self.assertEqual(ti.variable().d_max, 100.0) + + def test_window_preserves_config(self): + rng = np.random.default_rng(1) + ti = gs.mps.TrainingImage( + [ + Variable( + "a", + rng.integers(0, 3, (12, 12)), + weight=2.0, + n_neighbors=7, + ), + Variable( + "b", + rng.random((12, 12)), + categorical=False, + distance="l2", + weight=1.0, + max_radius=4.0, + ), + ] + ) + win = ti.window(np.s_[2:10, 0:6]) + self.assertEqual(win.shape, (8, 6)) + self.assertTrue(win.multivariate) + self.assertEqual(win.weights, ti.weights) + self.assertEqual(win.variable("a").n_neighbors, 7) + self.assertEqual(win.variable("b").distance, "l2") + self.assertEqual(win.variable("b").max_radius, 4.0) + + def test_open_ended_slices(self): + ti, data = self._cont_ti() + win = ti.window(np.s_[:10, 15:]) + np.testing.assert_array_equal(win.variable().data, data[:10, 15:]) + + def test_strided_slice_raises(self): + ti, _ = self._cont_ti() + with self.assertRaisesRegex(ValueError, "step"): + ti.window(np.s_[0:10:2, 0:10]) + + def test_out_of_bounds_raises(self): + ti, _ = self._cont_ti() + with self.assertRaisesRegex(ValueError, "bounds"): + ti.window(np.s_[0:25, 0:10]) # axis 0 has length 20 + with self.assertRaisesRegex(ValueError, "bounds"): + ti.window(np.s_[-3:10, 0:10]) # negative indices rejected + + def test_degenerate_slice_raises(self): + ti, _ = self._cont_ti() + with self.assertRaisesRegex(ValueError, "degenerate|empty"): + ti.window(np.s_[5:5, 0:10]) + + def test_wrong_slice_count_raises(self): + ti, _ = self._cont_ti() + with self.assertRaisesRegex(ValueError, "slice"): + ti.window(np.s_[0:10]) + + def test_non_iterable_raises_valueerror(self): + # Non-iterable arguments (int, None) must raise ValueError, not TypeError. + ti, _ = self._cont_ti() + with self.assertRaisesRegex(ValueError, "slice"): + ti.window(5) + with self.assertRaisesRegex(ValueError, "slice"): + ti.window(None) + + def test_1d_single_slice(self): + ti = gs.mps.TrainingImage( + np.arange(50, dtype=float), categorical=False + ) + win = ti.window(np.s_[10:30]) + self.assertEqual(win.shape, (20,)) + + +class TestPrecomputeOffsetsGuard(unittest.TestCase): + def test_large_3d_grid_raises_valueerror(self): + # 200^3 with default max_offset=200 → (401)^3 > 5M → guard fires + with self.assertRaises(ValueError) as ctx: + _precompute_offsets((200, 200, 200)) + msg = str(ctx.exception) + self.assertIn("max_offset", msg) + self.assertIn("max_radius", msg) + + def test_large_3d_grid_with_max_radius_ok(self): + # explicit small max_offset bypasses the guard + off = _precompute_offsets((200, 200, 200), max_offset=10) + self.assertEqual(off.shape[1], 3) + + def test_small_grid_ok(self): + # small 2-D grid stays under threshold + off = _precompute_offsets((20, 20)) + self.assertFalse(np.any(np.all(off == 0, axis=1))) + + +class TestStatisticalValidity(unittest.TestCase): + """Core correctness contract: relaxed thresholds and large TIs must reproduce TI statistics.""" + + def test_categorical_histogram_reproduced(self): + """Simulated category proportions must stay within 0.08 of the TI proportions.""" + rng = np.random.default_rng(0) + data = rng.choice( + [0, 1, 2], size=(100, 100), p=[0.5, 0.3, 0.2] + ).astype(float) + ti = TrainingImage(data, categorical=True, n_neighbors=8) + ti_props = np.array([np.mean(data == v) for v in [0, 1, 2]]) + + ds = DirectSampling( + MPSModel( + ti, + scan_fraction=1.0, + threshold=0.0, + ) + ) + pos = [np.arange(50, dtype=float)] * 2 + field = ds(pos, seed=0) + + # (a) every category must appear — collapse to a single value is a bug + self.assertEqual( + set(np.unique(field)), + {0.0, 1.0, 2.0}, + msg="At least one TI category is missing from the simulated field.", + ) + + # (b) per-category proportions within 0.08 of TI proportions + sim_props = np.array([np.mean(field == v) for v in [0, 1, 2]]) + np.testing.assert_allclose( + sim_props, + ti_props, + atol=0.08, + err_msg="Simulated category proportions deviate more than 0.08 from TI.", + ) + + def test_continuous_mean_preserved(self): + """Simulated mean and std must be within 0.25 / 0.30 of the TI values.""" + rng = np.random.default_rng(0) + data = rng.standard_normal((100, 100)) + ti = TrainingImage( + data, categorical=False, distance="l2", n_neighbors=8 + ) + ti_mean = float(data.mean()) + ti_std = float(data.std()) + + ds = DirectSampling( + MPSModel( + ti, + scan_fraction=1.0, + threshold=0.05, + ) + ) + pos = [np.arange(40, dtype=float)] * 2 + field = ds(pos, seed=0) + + self.assertAlmostEqual( + float(field.mean()), + ti_mean, + delta=0.25, + msg="Simulated field mean deviates by more than 0.25 from TI mean.", + ) + self.assertAlmostEqual( + float(field.std()), + ti_std, + delta=0.30, + msg="Simulated field std deviates by more than 0.30 from TI std.", + ) + + +class TestMVTransformsAndReporting(unittest.TestCase): + """Behaviour tests for set_mv_transforms, post_process, progress, and cond_weight.""" + + @classmethod + def setUpClass(cls): + rng = np.random.default_rng(0) + cls.mv_data = { + "a": rng.integers(0, 3, (20, 20)).astype(float), + "b": rng.integers(0, 2, (20, 20)).astype(float), + } + cls.mv_ti = TrainingImage( + [ + Variable("a", cls.mv_data["a"], n_neighbors=4), + Variable("b", cls.mv_data["b"], n_neighbors=4), + ] + ) + cls.pos = [np.arange(6, dtype=float)] * 2 + + def test_set_mv_transforms_mean_applied(self): + """set_mv_transforms(mean={'a': 100.0}) must shift variable 'a' by 100.""" + ds = DirectSampling(MPSModel(self.mv_ti, scan_fraction=0.3)) + ds.set_mv_transforms(mean={"a": 100.0}) + + field_pp = ds(self.pos, seed=5) + field_raw = ds(self.pos, seed=5, post_process=False) + + # post-processed 'a' == raw 'a' + 100 everywhere + np.testing.assert_allclose( + field_pp["a"], + field_raw["a"] + 100.0, + err_msg="Post-processed 'a' should equal raw 'a' + 100.", + ) + # variable 'b' has no transform — must be unaffected + np.testing.assert_array_equal( + field_pp["b"], + field_raw["b"], + err_msg="Variable 'b' should be unaffected by the 'a' mean transform.", + ) + + def test_post_process_false_returns_raw(self): + """post_process=False must return values that are a subset of TI categories.""" + ds = DirectSampling(MPSModel(self.mv_ti, scan_fraction=0.3)) + ds.set_mv_transforms(mean={"a": 100.0}) + field_raw = ds(self.pos, seed=7, post_process=False) + + ti_a_values = set(np.unique(self.mv_data["a"])) + sim_a_values = set(np.unique(field_raw["a"])) + self.assertTrue( + sim_a_values.issubset(ti_a_values), + msg=f"post_process=False 'a' values {sim_a_values} are not a subset of TI values {ti_a_values}.", + ) + + def test_progress_callback_invoked(self): + """progress callable must be called exactly n_nodes times; final call done == total == n_nodes.""" + rng = np.random.default_rng(1) + data = rng.integers(0, 3, (20, 20)).astype(float) + ti = TrainingImage(data, n_neighbors=4) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.3)) + pos = [np.arange(6, dtype=float)] * 2 + n_nodes = 6 * 6 + + calls = [] + + def cb(done, total): + calls.append((done, total)) + + ds(pos, seed=0, progress=cb) + + self.assertEqual( + len(calls), + n_nodes, + msg=f"Progress callback should be called {n_nodes} times, got {len(calls)}.", + ) + last_done, last_total = calls[-1] + self.assertEqual(last_done, n_nodes) + self.assertEqual(last_total, n_nodes) + + def test_cond_weight_changes_output(self): + """Different cond_weight values must yield different fields while honoring the conditioned node.""" + rng = np.random.default_rng(2) + data = rng.integers(0, 3, (20, 20)).astype(float) + ti = TrainingImage(data, n_neighbors=8) + pos = [np.arange(8, dtype=float)] * 2 + cond_pos = [[4.0], [4.0]] + cond_val = [2] + + ds1 = DirectSampling(MPSModel(ti, scan_fraction=0.3, cond_weight=1.0)) + ds1.set_condition(cond_pos, cond_val) + f1 = ds1(pos, seed=0) + + ds5 = DirectSampling(MPSModel(ti, scan_fraction=0.3, cond_weight=5.0)) + ds5.set_condition(cond_pos, cond_val) + f5 = ds5(pos, seed=0) + + # the two fields must differ (cond_weight propagates through the distance) + self.assertFalse( + np.array_equal(f1, f5), + msg="cond_weight=1.0 and cond_weight=5.0 produced identical output; cond_weight has no effect.", + ) + # the conditioned node must keep its value in both runs + self.assertEqual(int(f1[4, 4]), 2) + self.assertEqual(int(f5[4, 4]), 2) + + +class TestReturnTypeContract(unittest.TestCase): + """Return-type contract: univariate returns ndarray; multivariate returns dict.""" + + @classmethod + def setUpClass(cls): + # Small categorical 2-D TI — fast to simulate. + rng = np.random.default_rng(55) + cls.ti_uni = TrainingImage( + rng.integers(0, 3, (15, 15)).astype(float), + categorical=True, + n_neighbors=4, + ) + # Small 2-variable MV TI. + rng2 = np.random.default_rng(56) + cls.ti_mv = TrainingImage( + [ + Variable( + "alpha", + rng2.integers(0, 2, (15, 15)).astype(float), + n_neighbors=4, + ), + Variable( + "beta", + rng2.integers(0, 3, (15, 15)).astype(float), + n_neighbors=4, + ), + ] + ) + cls.pos = [np.arange(5, dtype=float)] * 2 + cls.sim_shape = (5, 5) + + # ------------------------------------------------------------------ + # Univariate: must return a bare numpy.ndarray, correct shape/dtype, + # values a subset of the TI. + # ------------------------------------------------------------------ + + def test_univariate_contract(self): + ds = DirectSampling(MPSModel(self.ti_uni, scan_fraction=0.5)) + out = ds(self.pos, seed=101) + self.assertIsInstance(out, np.ndarray) + self.assertEqual(out.shape, self.sim_shape) + self.assertTrue(np.issubdtype(out.dtype, np.number)) + ti_vals = set(np.unique(self.ti_uni.data)) + self.assertTrue(set(np.unique(out)).issubset(ti_vals)) + + # ------------------------------------------------------------------ + # Multivariate: must return a dict keyed by variable name, each value + # a correctly-shaped ndarray with values a subset of its own TI variable. + # ------------------------------------------------------------------ + + def test_multivariate_contract(self): + ds = DirectSampling(MPSModel(self.ti_mv, scan_fraction=0.5)) + out = ds(self.pos, seed=105) + self.assertIsInstance(out, dict) + self.assertEqual(set(out), {v.name for v in self.ti_mv.variables}) + for v in self.ti_mv.variables: + arr = out[v.name] + self.assertIsInstance(arr, np.ndarray) + self.assertEqual(arr.shape, self.sim_shape) + ti_vals = set(np.unique(v.data)) + self.assertTrue(set(np.unique(arr)).issubset(ti_vals)) + + # ------------------------------------------------------------------ + # n-D generality guard — not hardcoded 2-D + # ------------------------------------------------------------------ + + def test_univariate_nd_returns_ndarray_correct_shape(self): + cases = [ + ( + np.tile([0.0, 1.0], 10), + 1.0, + [np.arange(7, dtype=float)], + (7,), + ), + ( + np.random.default_rng(57) + .integers(0, 2, (8, 8, 8)) + .astype(float), + 0.3, + [np.arange(4, dtype=float)] * 3, + (4, 4, 4), + ), + ] + for data, scan_fraction, pos, shape in cases: + with self.subTest(shape=shape): + ti = TrainingImage(data, categorical=True) + ds = DirectSampling(MPSModel(ti, scan_fraction=scan_fraction)) + out = ds(pos, seed=110) + self.assertIsInstance(out, np.ndarray) + self.assertEqual(out.shape, shape) + + +class TestSimulationPath(unittest.TestCase): + """Tests for the pluggable path= parameter in DirectSampling / ds_simulate.""" + + def _make_ds( + self, seed_data=0, shape=(20, 20), n_vals=3, scan_fraction=0.5 + ): + rng = np.random.default_rng(seed_data) + data = rng.integers(0, n_vals, shape).astype(float) + ti = TrainingImage(data, categorical=True, n_neighbors=4) + return DirectSampling(MPSModel(ti, scan_fraction=scan_fraction)), ti + + # ------------------------------------------------------------------ + # "sequential" mode + # ------------------------------------------------------------------ + + def test_sequential_path_order(self): + """path='sequential' -> engine visits nodes in lexicographic (raster) order.""" + ds, _ = self._make_ds() + pos = [np.arange(5, dtype=float)] * 2 + sim_shape = (5, 5) + # Patch ds_simulate to capture the engine's path array before returning. + from gstools.mps import simulate as sim_mod + + captured = {} + orig_cls = sim_mod._DirectSamplingEngine + + class _PatchedEngine(orig_cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + captured["path"] = self.path.copy() + + sim_mod._DirectSamplingEngine = _PatchedEngine + try: + ds(pos, seed=1, path="sequential") + finally: + sim_mod._DirectSamplingEngine = orig_cls + + expected = np.argwhere( + np.ones(sim_shape, dtype=bool) + ) # all nodes, raster + np.testing.assert_array_equal(captured["path"], expected) + + def test_sequential_deterministic_without_path_seed(self): + """path='sequential' produces the same field on two calls without fixing path_seed.""" + ds, _ = self._make_ds() + pos = [np.arange(6, dtype=float)] * 2 + f1 = ds(pos, seed=3, path="sequential") + f2 = ds(pos, seed=3, path="sequential") + np.testing.assert_array_equal(f1, f2) + + def test_sequential_valid_values(self): + """path='sequential' output values are a subset of TI values.""" + ds, ti = self._make_ds() + pos = [np.arange(6, dtype=float)] * 2 + field = ds(pos, seed=5, path="sequential") + self.assertEqual(field.shape, (6, 6)) + self.assertFalse(np.any(np.isnan(field))) + ti_vals = set(np.unique(ti.data)) + self.assertTrue(set(np.unique(field)).issubset(ti_vals)) + + def test_sequential_conditioning_honored(self): + """path='sequential' honors exact conditioning (invariant: cond == value).""" + ds, _ = self._make_ds() + ds.set_condition([[3.0], [2.0]], [1.0]) + field = ds([np.arange(8, dtype=float)] * 2, seed=7, path="sequential") + self.assertEqual(field[3, 2], 1.0) + + # ------------------------------------------------------------------ + # explicit array mode + # ------------------------------------------------------------------ + + def test_explicit_path_order_honored(self): + """Explicit path is used exactly as supplied — verified via a degenerate TI.""" + # TI is a constant (all zeros), so every simulated value is 0 regardless + # of order; we just verify no error and shape is correct. + ti = TrainingImage(np.zeros((10, 10), dtype=float), categorical=True) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0)) + pos = [np.arange(4, dtype=float)] * 2 + # Build path in reverse raster order + base = np.argwhere(np.ones((4, 4), dtype=bool)) + explicit = base[::-1].copy() + field = ds(pos, seed=0, path=explicit) + self.assertEqual(field.shape, (4, 4)) + np.testing.assert_array_equal(field, np.zeros((4, 4))) + + def test_explicit_path_subset_property(self): + """Explicit path: output values are a subset of TI values.""" + ds, ti = self._make_ds() + pos = [np.arange(5, dtype=float)] * 2 + base = np.argwhere(np.ones((5, 5), dtype=bool)) + # reverse order + explicit = base[::-1].copy() + field = ds(pos, seed=0, path=explicit) + ti_vals = set(np.unique(ti.data)) + self.assertTrue(set(np.unique(field)).issubset(ti_vals)) + + def test_explicit_path_conditioning_honored(self): + """Explicit path: conditioning data are still honored exactly.""" + ds, _ = self._make_ds() + ds.set_condition([[2.0], [4.0]], [2.0]) + pos = [np.arange(6, dtype=float)] * 2 + sim_shape = (6, 6) + # All nodes except the conditioned one + unknown_mask = np.ones(sim_shape, dtype=bool) + unknown_mask[2, 4] = ( + False # conditioned node excluded from unknown set + ) + base = np.argwhere(unknown_mask) + field = ds(pos, seed=2, path=base) + self.assertEqual(field[2, 4], 2.0) + + # ------------------------------------------------------------------ + # Validation errors (explicit array) + # ------------------------------------------------------------------ + + def test_explicit_path_validation_errors(self): + """Malformed explicit paths each raise ValueError with a specific message.""" + base = np.argwhere(np.ones((4, 4), dtype=bool)) + oob = base.copy() + oob[0, 0] = 99 + dup = base.copy() + dup[-1] = dup[0] + cases = { + "wrong_dim": (np.array([0, 1, 2, 3]), "shape"), # 1-D, not (N,2) + "wrong_second_dim": (np.zeros((5, 3), dtype=int), "shape"), + "out_of_bounds_coord": (oob, "out-of-bounds"), + "missing_node": (base[:-1].copy(), "missing"), + "duplicate_node": (dup, "duplicate"), + "invalid_string": ("zigzag", "path"), + } + ds, _ = self._make_ds() + pos = [np.arange(4, dtype=float)] * 2 + for label, (path, regex) in cases.items(): + with self.subTest(label=label): + with self.assertRaisesRegex(ValueError, regex): + ds(pos, seed=0, path=path) + + def test_explicit_extra_node_silently_skipped(self): + """Conditioned nodes present in an explicit path are silently skipped. + + A full-grid path (e.g. a spiral over all nodes) should work unchanged + with conditioning data — the engine drops pre-filled entries itself. + """ + ds, ti = self._make_ds() + ds.set_condition([[0.0], [0.0]], [0.0]) + pos = [np.arange(4, dtype=float)] * 2 + # Full raster includes (0,0) which is now conditioned. + # The engine should drop it silently and simulate the remaining 15 nodes. + full_raster = np.argwhere(np.ones((4, 4), dtype=bool)) + field = ds(pos, seed=0, path=full_raster) + self.assertEqual(field.shape, (4, 4)) + # Conditioned node must keep its exact value + self.assertEqual(field[0, 0], 0.0) + # All values must be a subset of TI values + ti_vals = set(np.unique(ti.data)) + self.assertTrue(set(np.unique(field)).issubset(ti_vals)) + + # ------------------------------------------------------------------ + # Regression: "random" default is byte-identical to previous behavior + # ------------------------------------------------------------------ + + def test_random_default_regression(self): + """path='random' (default) output is byte-identical to pre-path behavior. + + Regression value captured from the code before the path parameter was + added, using seed=42, scan_fraction=0.5, 8x8 grid, 3-valued categorical + TI of shape (20,20) built from np.random.default_rng(0). + """ + rng_data = np.random.default_rng(0) + data = rng_data.integers(0, 3, (20, 20)).astype(float) + ti = TrainingImage(data, categorical=True) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5)) + field = ds([np.arange(8, dtype=float)] * 2, seed=42) + expected = np.array( + [ + [0.0, 1.0, 1.0, 2.0, 2.0, 0.0, 2.0, 1.0], + [2.0, 0.0, 0.0, 2.0, 1.0, 0.0, 0.0, 1.0], + [0.0, 2.0, 1.0, 2.0, 2.0, 2.0, 0.0, 2.0], + [2.0, 2.0, 0.0, 2.0, 2.0, 0.0, 1.0, 1.0], + [1.0, 2.0, 1.0, 1.0, 2.0, 1.0, 2.0, 0.0], + [2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0], + [2.0, 1.0, 0.0, 1.0, 2.0, 1.0, 2.0, 1.0], + [1.0, 1.0, 1.0, 0.0, 2.0, 2.0, 0.0, 2.0], + ] + ) + np.testing.assert_array_equal(field, expected) + + def test_path_seed_inert_for_sequential(self): + """path_seed has no effect when path='sequential' (documented behavior).""" + ds, _ = self._make_ds() + pos = [np.arange(5, dtype=float)] * 2 + f1 = ds(pos, seed=0, path="sequential", path_seed=1) + f2 = ds(pos, seed=0, path="sequential", path_seed=99) + np.testing.assert_array_equal(f1, f2) + + def test_ds_simulate_direct_sequential(self): + """ds_simulate path='sequential' passes through correctly.""" + from gstools.random.rng import RNG + + arr1d = np.tile([0, 1], 10).astype(float) + ti = TrainingImage(arr1d, categorical=True, n_neighbors=4) + rng = RNG(7) + result = ds_simulate( + ti, + sim_shape=(6,), + threshold=0.0, + scan_fraction=1.0, + rng_path=rng.random, + rng_nodes=rng.random, + path="sequential", + ) + self.assertIn(None, result) + arr = result[None] + self.assertEqual(arr.shape, (6,)) + self.assertFalse(np.any(np.isnan(arr))) + self.assertTrue(set(np.unique(arr)).issubset({0.0, 1.0})) + + def test_1d_sequential_path_order(self): + """1-D sequential path is [[0],[1],...,[N-1]] (n-D correctness check).""" + arr1d = np.tile([0.0, 1.0], 10) + ti = TrainingImage(arr1d, categorical=True) + ds = DirectSampling(MPSModel(ti, scan_fraction=1.0)) + pos = [np.arange(5, dtype=float)] + + from gstools.mps import simulate as sim_mod + + captured = {} + orig_cls = sim_mod._DirectSamplingEngine + + class _PatchedEngine(orig_cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + captured["path"] = self.path.copy() + + sim_mod._DirectSamplingEngine = _PatchedEngine + try: + ds(pos, seed=0, path="sequential") + finally: + sim_mod._DirectSamplingEngine = orig_cls + + expected = np.arange(5).reshape(5, 1) + np.testing.assert_array_equal(captured["path"], expected) + + +class TestCondWeightOverride(unittest.TestCase): + """cond_weight lives on MPSModel; DirectSampling is a read-only view.""" + + def setUp(self): + rng = np.random.default_rng(42) + self.ti_data = rng.integers(0, 3, (20, 20)) + + def test_shared_model_both_see_same_cond_weight(self): + """Two DirectSampling from the same MPSModel both read the same cond_weight.""" + ti = TrainingImage(self.ti_data, n_neighbors=4) + model = MPSModel(ti, scan_fraction=0.5, cond_weight=1.0) + ds1 = DirectSampling(model) + ds2 = DirectSampling(model) + + # Both read from the shared model — no override mechanism. + self.assertAlmostEqual(ds1.cond_weight, 1.0) + self.assertAlmostEqual(ds2.cond_weight, 1.0) + self.assertAlmostEqual(model.cond_weight, 1.0) + + def test_cond_weight_on_model_is_honored(self): + """Two DS with identical MPSModel(cond_weight=2) produce the same output.""" + ti1 = TrainingImage(self.ti_data, n_neighbors=4) + ds1 = DirectSampling(MPSModel(ti1, scan_fraction=0.5, cond_weight=2.0)) + cpos = [np.array([5.0, 10.0]), np.array([5.0, 10.0])] + cval = np.array([1.0, 2.0]) + ds1.set_condition(cpos, cval) + out1 = ds1([np.arange(15, dtype=float)] * 2, seed=7) + + ti2 = TrainingImage(self.ti_data, n_neighbors=4) + ds2 = DirectSampling(MPSModel(ti2, scan_fraction=0.5, cond_weight=2.0)) + ds2.set_condition(cpos, cval) + out2 = ds2([np.arange(15, dtype=float)] * 2, seed=7) + + np.testing.assert_array_equal(out1, out2) + + +class TestDataEvent(unittest.TestCase): + def _make(self, k=3, dim=2): + lags_sg = np.arange(k * dim, dtype=np.float64).reshape(k, dim) + values = np.arange(k, dtype=np.float64) + cond_mask = np.array([True, False, False][:k], dtype=bool) + lag_norms = np.linalg.norm(lags_sg, axis=1) + return DataEvent(lags_sg, values, cond_mask, lag_norms) + + def test_len_is_lag_count(self): + de = self._make(k=3) + self.assertEqual(len(de), 3) + + def test_lags_ti_defaults_none(self): + de = self._make() + self.assertIsNone(de.lags_ti) + + def test_with_lags_ti_sets_field_and_keeps_sg(self): + de = self._make(k=3) + ti = de.lags_sg * 2 + de2 = de.with_lags_ti(ti) + np.testing.assert_array_equal(de2.lags_ti, ti) + np.testing.assert_array_equal(de2.lags_sg, de.lags_sg) + self.assertIsNone(de.lags_ti) # original unchanged + + def test_truncate_slices_all_arrays(self): + de = self._make(k=3).with_lags_ti(np.arange(6.0).reshape(3, 2)) + t = de.truncate(2) + self.assertEqual(len(t), 2) + np.testing.assert_array_equal(t.lags_sg, de.lags_sg[:2]) + np.testing.assert_array_equal(t.values, de.values[:2]) + np.testing.assert_array_equal(t.cond_mask, de.cond_mask[:2]) + np.testing.assert_array_equal(t.lag_norms, de.lag_norms[:2]) + np.testing.assert_array_equal(t.lags_ti, de.lags_ti[:2]) + + def test_truncate_does_not_mutate_lags_sg_via_lags_ti(self): + # Aliasing invariant: truncating must not let lags_ti writes hit lags_sg. + de = self._make(k=3) + de = de.with_lags_ti( + de.lags_sg.copy() + ) # caller must copy for independence + t = de.truncate(2) + t.lags_ti[:] = -999.0 # mutate the truncated TI-frame copy + # original SG lags must be untouched + self.assertFalse(np.any(de.lags_sg == -999.0)) + + def test_truncate_preserves_none_lags_ti(self): + de = self._make(k=3) + self.assertIsNone(de.truncate(2).lags_ti) + + +class TestPerVariableSetterDedup(unittest.TestCase): + def _mv(self, n_neighbors=32, max_radius=None): + a = Variable( + "a", + np.zeros((8, 8), dtype=int), + categorical=True, + n_neighbors=n_neighbors, + max_radius=max_radius, + ) + b = Variable( + "b", + np.zeros((8, 8), dtype=int), + categorical=True, + n_neighbors=n_neighbors, + max_radius=max_radius, + ) + return DirectSampling( + MPSModel(TrainingImage([a, b]), scan_fraction=0.2) + ) + + def test_n_neighbors_variable_setter_works(self): + # After construction the Variable.n_neighbors setter is still mutable. + ds = self._mv(n_neighbors=5) + ds.ti.variable("a").n_neighbors = 7 + self.assertEqual(ds.ti.variable("a").n_neighbors, 7) + self.assertEqual(ds.n_neighbors, {"a": 7, "b": 5}) + + def test_max_radius_none_default(self): + # Default is None; getter collapses to scalar. + ds = self._mv() + self.assertIsNone(ds.max_radius) + + +class TestSpecResolver(unittest.TestCase): + """Exact-shape spec resolution for rotation/scale (stationary vs per-node).""" + + def test_none_passthrough(self): + arr, stat = resolve_spec( + None, (4, 4), 1, _flat_pos((4, 4)), "rotation" + ) + self.assertIsNone(arr) + self.assertTrue(stat) + + def test_scalar_is_stationary_broadcast_view(self): + arr, stat = resolve_spec(0.7, (4, 5), 1, _flat_pos((4, 5)), "rotation") + self.assertTrue(stat) + self.assertEqual(arr.shape, (4, 5, 1)) + self.assertTrue(np.all(arr == 0.7)) + # zero-copy: broadcast view, not materialized + self.assertEqual(arr.strides[:2], (0, 0)) + self.assertFalse(arr.flags.writeable) + + def test_stationary_component_vector(self): + # 3 Tait-Bryan angles on a 3-D grid (n_comp = no_of_angles(3) = 3) + arr, stat = resolve_spec( + np.array([0.1, 0.2, 0.3]), + (4, 4, 4), + 3, + _flat_pos((4, 4, 4)), + "rotation", + ) + self.assertTrue(stat) + self.assertEqual(arr.shape, (4, 4, 4, 3)) + np.testing.assert_array_equal(arr[2, 1, 3], [0.1, 0.2, 0.3]) + + def test_grid_shape_form_4a(self): + vals = np.linspace(0, 1, 20).reshape(4, 5) + arr, stat = resolve_spec( + vals, (4, 5), 2, _flat_pos((4, 5)), "scale", positive=False + ) + self.assertFalse(stat) + self.assertEqual(arr.shape, (4, 5, 2)) + self.assertEqual(arr[3, 4, 0], arr[3, 4, 1]) # broadcast across comps + + def test_grid_plus_comp_form_4b(self): + vals = np.random.default_rng(0).random((4, 5, 2)) + 0.1 + arr, stat = resolve_spec(vals, (4, 5), 2, _flat_pos((4, 5)), "scale") + self.assertFalse(stat) + np.testing.assert_array_equal(arr, vals) + + def test_flattened_per_node_map_raises(self): + # A flattened (n_nodes,) map on a 2-D grid must raise, never + # silently degrade to stationary. + with self.assertRaisesRegex(ValueError, "shape"): + resolve_spec( + np.linspace(0, 1, 20), (4, 5), 1, _flat_pos((4, 5)), "rotation" + ) + + def test_collision_n_nodes_equals_n_comp_raises(self): + # 1-D grid of length n_comp — both readings match; must be a loud + # ValueError naming both interpretations. + with self.assertRaisesRegex( + ValueError, "stationary.*per-node|per-node.*stationary" + ): + resolve_spec( + np.array([0.1, 0.2, 0.3]), (3,), 3, _flat_pos((3,)), "rotation" + ) + + def test_per_node_map_on_1d_grid_is_form_4a(self): + # Valid per-node maps on 1-D grids must be expressible. + vals = np.linspace(0.5, 2.0, 7) + arr, stat = resolve_spec(vals, (7,), 1, _flat_pos((7,)), "scale") + self.assertFalse(stat) + self.assertEqual(arr.shape, (7, 1)) + np.testing.assert_array_equal(arr[:, 0], vals) + + def test_callable_form(self): + def f(x, y): + return x * 0.1 + y + + pos = _flat_pos((3, 4)) + arr, stat = resolve_spec(f, (3, 4), 1, pos, "rotation") + self.assertFalse(stat) + self.assertEqual(arr.shape, (3, 4, 1)) + self.assertAlmostEqual(float(arr[2, 3, 0]), 2 * 0.1 + 3) + + def test_callable_with_component_axis(self): + def f(x, y): + return np.stack([np.ones_like(x), 0.5 * np.ones_like(x)], axis=-1) + + arr, stat = resolve_spec(f, (3, 4), 2, _flat_pos((3, 4)), "scale") + self.assertEqual(arr.shape, (3, 4, 2)) + np.testing.assert_array_equal(arr[1, 1], [1.0, 0.5]) + + def test_callable_wrong_shape_raises(self): + with self.assertRaisesRegex(ValueError, "callable"): + resolve_spec( + lambda x, y: np.zeros((5,)), + (3, 4), + 1, + _flat_pos((3, 4)), + "rotation", + ) + + def test_positive_validation(self): + with self.assertRaisesRegex(ValueError, "positive|> 0"): + resolve_spec( + -1.0, (4, 4), 2, _flat_pos((4, 4)), "scale", positive=True + ) + with self.assertRaisesRegex(ValueError, "positive|> 0"): + resolve_spec( + np.zeros((4, 4)), + (4, 4), + 2, + _flat_pos((4, 4)), + "scale", + positive=True, + ) + + +class TestZoneConstruction(unittest.TestCase): + def _ti(self): + return gs.mps.TrainingImage( + np.random.default_rng(0).integers(0, 2, (10, 10)) + ) + + def test_zone_holds_ti_and_where(self): + ti = self._ti() + mask = np.zeros((5, 5), dtype=bool) + z = gs.Zone(ti, where=mask) + self.assertIs(z.ti, ti) + np.testing.assert_array_equal(z.where, mask) + + def test_non_ti_raises(self): + with self.assertRaises(TypeError): + gs.Zone(np.zeros((5, 5)), where=np.ones((5, 5), dtype=bool)) + + def test_non_boolean_where_raises(self): + # No silent != 0 coercion of a non-boolean mask. + with self.assertRaisesRegex(ValueError, "bool"): + gs.Zone(self._ti(), where=np.ones((5, 5), dtype=int)) + + def test_callable_where_accepted(self): + z = gs.Zone(self._ti(), where=lambda x, y: x > 2) + self.assertTrue(callable(z.where)) + + +class TestMPSModelNonstationarySpec(unittest.TestCase): + def _ti(self, shape=(10, 10)): + return gs.mps.TrainingImage( + np.random.default_rng(0).integers(0, 2, shape) + ) + + def test_defaults(self): + m = MPSModel(self._ti()) + self.assertIsNone(m.rotation) + self.assertIsNone(m.scale) + self.assertEqual(m.zones, []) + + def test_specs_stored(self): + rot = np.pi / 4 + sc = [1.0, 0.5] + m = MPSModel(self._ti(), rotation=rot, scale=sc) + self.assertEqual(m.rotation, rot) + np.testing.assert_array_equal(m.scale, sc) + + def test_rotation_on_1d_ti_raises(self): + # no_of_angles(1) == 0 — every rotation form is degenerate on a 1-D TI. + ti1d = gs.mps.TrainingImage( + np.arange(30, dtype=float), categorical=False + ) + with self.assertRaisesRegex(ValueError, "1-D|rotation"): + MPSModel(ti1d, rotation=0.5) + + def test_scale_on_1d_ti_allowed(self): + ti1d = gs.mps.TrainingImage( + np.arange(30, dtype=float), categorical=False + ) + m = MPSModel(ti1d, scale=2.0) # dilation along the single axis + self.assertEqual(m.scale, 2.0) + + def test_nonpositive_scale_raises_at_construction(self): + with self.assertRaisesRegex(ValueError, "positive|> 0"): + MPSModel(self._ti(), scale=0.0) + with self.assertRaisesRegex(ValueError, "positive|> 0"): + MPSModel(self._ti(), scale=np.full((10, 10), -1.0)) + + def test_callable_specs_accepted(self): + m = MPSModel( + self._ti(), + rotation=lambda x, y: x * 0.01, + scale=lambda x, y: 1.0 + 0 * x, + ) + self.assertTrue(callable(m.rotation)) + self.assertTrue(callable(m.scale)) + + def test_zone_variable_set_must_match(self): + rng = np.random.default_rng(0) + primary = gs.mps.TrainingImage( + [Variable("a", rng.integers(0, 2, (8, 8)))] + ) + wrong_name = gs.mps.TrainingImage( + [Variable("b", rng.integers(0, 2, (8, 8)))] + ) + z = gs.Zone(wrong_name, where=np.ones((5, 5), dtype=bool)) + with self.assertRaisesRegex(ValueError, "variable"): + MPSModel(primary, zones=[z]) + + def test_zone_variable_kind_must_match(self): + rng = np.random.default_rng(0) + primary = gs.mps.TrainingImage( + [Variable("a", rng.integers(0, 2, (8, 8)), categorical=True)] + ) + wrong_kind = gs.mps.TrainingImage( + [Variable("a", rng.random((8, 8)), categorical=False)] + ) + z = gs.Zone(wrong_kind, where=np.ones((5, 5), dtype=bool)) + with self.assertRaisesRegex(ValueError, "categorical|kind"): + MPSModel(primary, zones=[z]) + + def test_zone_ndim_must_match(self): + z = gs.Zone( + gs.mps.TrainingImage(np.zeros((5, 5, 5))), + where=np.ones((5, 5), dtype=bool), + ) + with self.assertRaisesRegex(ValueError, "dim"): + MPSModel(self._ti(), zones=[z]) + + def test_non_zone_in_zones_raises(self): + with self.assertRaises(TypeError): + MPSModel(self._ti(), zones=[self._ti()]) + + def test_zone_codes_checked_against_primary_penalty_matrix(self): + # The scan indexes the PRIMARY TI's penalty_matrix with codes read from + # the selected zone, so an out-of-range zone code must be rejected at + # construction rather than raising IndexError mid-simulation. + rng = np.random.default_rng(0) + primary = gs.TrainingImage( + rng.integers(0, 2, (8, 8)).astype(float), + categorical=True, + penalty_matrix=np.array([[0.0, 1.0], [1.0, 0.0]]), + ) + out_of_range = gs.TrainingImage( + rng.integers(0, 4, (8, 8)).astype(float), categorical=True + ) + z = gs.Zone(out_of_range, where=np.ones((5, 5), dtype=bool)) + with self.assertRaisesRegex(ValueError, "penalty_matrix"): + MPSModel(primary, zones=[z]) + in_range = gs.TrainingImage( + rng.integers(0, 2, (8, 8)).astype(float), categorical=True + ) + MPSModel( + primary, zones=[gs.Zone(in_range, where=np.ones((5, 5), bool))] + ) + + def test_repr_array_by_shape_callable_by_name(self): + def my_rot(x, y): + return x * 0.0 + + m1 = MPSModel(self._ti(), rotation=my_rot, scale=np.ones((10, 10, 2))) + r = repr(m1) + self.assertIn("my_rot", r) + self.assertIn("shape=(10, 10, 2)", r) + m2 = MPSModel(self._ti()) + self.assertNotIn("rotation", repr(m2)) + + +class TestWhereAndSelector(unittest.TestCase): + def test_resolve_where_array(self): + mask = np.zeros((4, 4), dtype=bool) + mask[2:] = True + out = resolve_where(mask, (4, 4), _flat_pos((4, 4))) + np.testing.assert_array_equal(out, mask) + + def test_resolve_where_callable(self): + out = resolve_where(lambda x, y: x >= 2, (4, 4), _flat_pos((4, 4))) + self.assertEqual(out.shape, (4, 4)) + self.assertTrue(out[3, 0]) + self.assertFalse(out[1, 0]) + + def test_resolve_where_callable_non_bool_raises(self): + with self.assertRaisesRegex(ValueError, "bool"): + resolve_where(lambda x, y: x + y, (4, 4), _flat_pos((4, 4))) + + def test_resolve_where_wrong_shape_raises(self): + with self.assertRaisesRegex(ValueError, "shape"): + resolve_where( + np.zeros((3, 3), dtype=bool), (4, 4), _flat_pos((4, 4)) + ) + + def test_selector_values(self): + ti = gs.mps.TrainingImage(np.zeros((5, 5))) + m1 = np.zeros((4, 4), dtype=bool) + m1[:2] = True + m2 = np.zeros((4, 4), dtype=bool) + m2[3:] = True + sel = build_zone_selector( + [gs.Zone(ti, where=m1), gs.Zone(ti, where=m2)], + (4, 4), + _flat_pos((4, 4)), + ) + self.assertEqual(sel[0, 0], 1) + self.assertEqual(sel[3, 0], 2) + self.assertEqual(sel[2, 0], 0) # uncovered -> primary + + def test_overlapping_zones_raise(self): + # Overlapping zones must raise, not silently resolve via precedence order. + ti = gs.mps.TrainingImage(np.zeros((5, 5))) + m1 = np.zeros((4, 4), dtype=bool) + m1[:2] = True + m2 = np.zeros((4, 4), dtype=bool) + m2[1:] = True # overlaps row 1 + with self.assertRaisesRegex(ValueError, "overlap"): + build_zone_selector( + [gs.Zone(ti, where=m1), gs.Zone(ti, where=m2)], + (4, 4), + _flat_pos((4, 4)), + ) + + +class TestZonatedSimulation(unittest.TestCase): + """Zonated DS: per-zone TI substitution (M10 para [40]).""" + + def _two_zone_setup(self, sg=10): + rng = np.random.default_rng(0) + ti_a = gs.mps.TrainingImage( + rng.integers(0, 3, (15, 15)), n_neighbors=4 + ) + ti_b = gs.mps.TrainingImage( + rng.integers(10, 13, (15, 15)), n_neighbors=4 + ) + gx, _ = np.meshgrid(np.arange(sg), np.arange(sg), indexing="ij") + zone = gs.Zone(ti_b, where=(gx >= sg // 2)) + model = MPSModel(ti_a, scan_fraction=0.5, zones=[zone]) + pos = [np.arange(sg, dtype=float)] * 2 + return model, pos, sg + + def test_per_zone_subset_property(self): + # Disjoint TI value sets -> values in zone z must be a subset of TI_z + # values. Also covers the fallback path: fallback draws must come from + # the selected zone TI, or rare fallbacks would leak primary values. + model, pos, sg = self._two_zone_setup() + field = gs.mps.DirectSampling(model)(pos, seed=0) + self.assertTrue(np.all(np.isin(field[: sg // 2], [0, 1, 2]))) + self.assertTrue(np.all(np.isin(field[sg // 2 :], [10, 11, 12]))) + + def test_zone_thread_count_determinism(self): + # Zones + rotation, varying num_threads, must produce identical output. + rng = np.random.default_rng(1) + ti_a = gs.mps.TrainingImage( + rng.integers(0, 3, (15, 15)), n_neighbors=4 + ) + ti_b = gs.mps.TrainingImage( + rng.integers(10, 13, (15, 15)), n_neighbors=4 + ) + gx, _ = np.meshgrid(np.arange(8), np.arange(8), indexing="ij") + model = MPSModel( + ti_a, + scan_fraction=0.5, + rotation=np.pi / 6, + zones=[gs.Zone(ti_b, where=(gx >= 4))], + ) + pos = [np.arange(8, dtype=float)] * 2 + f1 = gs.mps.DirectSampling(model)(pos, seed=5, num_threads=1) + f4 = gs.mps.DirectSampling(model)(pos, seed=5, num_threads=4) + np.testing.assert_array_equal(f1, f4) + + def test_overlapping_zones_raise_at_call(self): + # Overlap must be detected at resolve time, inside __call__. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 2, (10, 10))) + m1 = np.zeros((6, 6), dtype=bool) + m1[:3] = True + m2 = np.zeros((6, 6), dtype=bool) + m2[2:] = True + model = MPSModel( + ti, zones=[gs.Zone(ti, where=m1), gs.Zone(ti, where=m2)] + ) + ds = gs.mps.DirectSampling(model) + with self.assertRaisesRegex(ValueError, "overlap"): + ds([np.arange(6, dtype=float)] * 2, seed=0) + + def test_per_zone_d_max(self): + # Distances in each zone must be normalized by that zone TI's d_max. + # White-box: the zone domain's scan config carries the zone's range. + from gstools.random.rng import RNG + + rng = np.random.default_rng(0) + ti_narrow = gs.mps.TrainingImage( + rng.random((10, 10)), categorical=False + ) # range ~1 + ti_wide = gs.mps.TrainingImage( + rng.random((10, 10)) * 100.0, categorical=False + ) # range ~100 + sel = np.zeros((6, 6), dtype=np.intp) + sel[3:] = 1 + engine = _DirectSamplingEngine( + ti_narrow, + (6, 6), + threshold=0.1, + scan_fraction=1.0, + rng_path=RNG(0).random, + rng_nodes=RNG(1).random, + zone_tis=[ti_wide], + zone_selector=sel, + ) + d0 = engine.domains[0].scan_config.d_max[None] + d1 = engine.domains[1].scan_config.d_max[None] + self.assertAlmostEqual(d0, ti_narrow.variable().d_max) + self.assertAlmostEqual(d1, ti_wide.variable().d_max) + self.assertGreater(d1, 10 * d0) + + def test_window_zone_d_max_from_slice(self): + # Sub-region zone: d_max comes from the slice, not the parent TI. + data = np.zeros((10, 10)) + data[0, 0] = 100.0 + data[5:, 5:] = np.arange(25).reshape(5, 5) + ti = gs.mps.TrainingImage(data, categorical=False) + from gstools.random.rng import RNG + + sel = np.zeros((4, 4), dtype=np.intp) + sel[2:] = 1 + engine = _DirectSamplingEngine( + ti, + (4, 4), + threshold=0.1, + scan_fraction=1.0, + rng_path=RNG(0).random, + rng_nodes=RNG(1).random, + zone_tis=[ti.window(np.s_[5:10, 5:10])], + zone_selector=sel, + ) + self.assertEqual(engine.domains[1].scan_config.d_max[None], 24.0) + + def test_composite_zone_ti_joint_copy(self): + # Composite multivariate zone TI with a swapped A-channel. + # A-values in the zone ⊆ composite A values; B-values ⊆ composite B + # values; every zone (a, b) pair exists co-located in the composite + # (the whole TI-cell vector is copied — joint-scan invariant). + rng = np.random.default_rng(3) + prim_a = rng.integers(0, 3, (15, 15)) + prim_b = rng.integers(0, 2, (15, 15)) + comp_a = rng.integers(5, 8, (15, 15)) + comp_b = rng.integers(2, 4, (15, 15)) + primary = gs.mps.TrainingImage( + [ + Variable("a", prim_a, n_neighbors=4), + Variable("b", prim_b, n_neighbors=4), + ] + ) + composite = gs.mps.TrainingImage( + [ + Variable("a", comp_a, n_neighbors=4), + Variable("b", comp_b, n_neighbors=4), + ] + ) + gx, _ = np.meshgrid(np.arange(8), np.arange(8), indexing="ij") + model = MPSModel( + primary, + scan_fraction=0.5, + zones=[gs.Zone(composite, where=(gx >= 4))], + ) + out = gs.mps.DirectSampling(model)( + [np.arange(8, dtype=float)] * 2, seed=0 + ) + za, zb = out["a"][4:], out["b"][4:] + self.assertTrue(np.all(np.isin(za, [5, 6, 7]))) + self.assertTrue(np.all(np.isin(zb, [2, 3]))) + zone_pairs = set(zip(za.ravel().tolist(), zb.ravel().tolist())) + ti_pairs = set(zip(comp_a.ravel().tolist(), comp_b.ravel().tolist())) + self.assertTrue(zone_pairs <= ti_pairs) + self.assertTrue(np.all(np.isin(out["a"][:4], [0, 1, 2]))) + + def test_zone_with_conditioning(self): + # Conditioning honored exactly inside a zone. + model, pos, sg = self._two_zone_setup() + ds = gs.mps.DirectSampling(model) + ds.set_condition([[7.0], [7.0]], [11]) + field = ds(pos, seed=0) + self.assertEqual(int(field[7, 7]), 11) + + def test_no_zones_unchanged(self): + # zones=[] must be bit-identical to the pre-zonation engine. + rng = np.random.default_rng(0) + ti = gs.mps.TrainingImage(rng.integers(0, 3, (15, 15)), n_neighbors=4) + pos = [np.arange(8, dtype=float)] * 2 + f_plain = gs.mps.DirectSampling(MPSModel(ti, scan_fraction=0.5))( + pos, seed=2 + ) + f_empty = gs.mps.DirectSampling( + MPSModel(ti, scan_fraction=0.5, zones=[]) + )(pos, seed=2) + np.testing.assert_array_equal(f_plain, f_empty) + + +class TestVecDistanceDmaxOverride(unittest.TestCase): + def test_d_max_override(self): + ti = gs.mps.TrainingImage( + np.array([[0.0, 1.0], [2.0, 3.0]]), categorical=False + ) # own range = 3 + de = np.array([0.0]) + cand = np.array([[3.0]]) + d_own = ti.vec_distance_var(None, de, cand) + d_ovr = ti.vec_distance_var(None, de, cand, d_max=6.0) + self.assertAlmostEqual(float(d_own[0]), 1.0) + self.assertAlmostEqual(float(d_ovr[0]), 0.5) + + +class TestVecCategoricalPenaltyDist(unittest.TestCase): + """Unit tests for vec_categorical_penalty_dist (DS_Feature_Checklist §3.3).""" + + def setUp(self): + # Asymmetric 3-category penalty matrix: T[u,u]=0, T[u,v] in (0,1]. + self.T = np.array( + [ + [0.0, 0.5, 0.8], + [0.3, 0.0, 0.6], + [0.9, 0.4, 0.0], + ] + ) + self.sim = np.array([0.0, 1.0, 2.0]) + self.w = np.array([0.2, 0.3, 0.5]) + + def test_hand_computed_no_nan(self): + ti = np.array([[0.0, 1.0, 2.0], [1.0, 0.0, 2.0]]) + d = vec_categorical_penalty_dist(self.sim, ti, self.w, self.T) + # row0: exact match -> 0 + # row1: 0.2*T[0,1] + 0.3*T[1,0] + 0.5*T[2,2] = 0.2*0.5+0.3*0.3+0 = 0.19 + np.testing.assert_allclose(d, [0.0, 0.19]) + + def test_hand_computed_has_nan(self): + ti = np.array([[0.0, 1.0, 2.0], [1.0, np.nan, 2.0]]) + d = vec_categorical_penalty_dist( + self.sim, ti, self.w, self.T, has_nan=True + ) + # row1: valid=[T,F,T], we=[0.2,0,0.5], wsum=0.7 + # mism = 0.2*T[0,1] + 0 + 0.5*T[2,2] = 0.1 + # dist = 0.1 / 0.7 + np.testing.assert_allclose(d, [0.0, 0.1 / 0.7]) + + def test_all_nan_row_is_inf(self): + ti = np.array([[np.nan, np.nan, np.nan]]) + d = vec_categorical_penalty_dist( + self.sim, ti, self.w, self.T, has_nan=True + ) + self.assertTrue(np.isinf(d[0])) + + def test_reduction_to_binary_mismatch_no_nan(self): + # T = 1 - eye(C) collapses exactly to vec_categorical_dist. + rng = np.random.default_rng(0) + c, n, max_scan = 4, 5, 20 + sim = rng.integers(0, c, n).astype(float) + ti = rng.integers(0, c, (max_scan, n)).astype(float) + w = rng.random(n) + w /= w.sum() + t_binary = 1.0 - np.eye(c) + d_pen = vec_categorical_penalty_dist(sim, ti, w, t_binary) + d_bin = vec_categorical_dist(sim, ti, w) + np.testing.assert_allclose(d_pen, d_bin) + + def test_reduction_to_binary_mismatch_has_nan(self): + rng = np.random.default_rng(1) + c, n, max_scan = 4, 5, 20 + sim = rng.integers(0, c, n).astype(float) + ti = rng.integers(0, c, (max_scan, n)).astype(float) + ti[3, 2] = np.nan + ti[7, :] = np.nan # fully undefined row -> +inf in both + w = rng.random(n) + w /= w.sum() + t_binary = 1.0 - np.eye(c) + d_pen = vec_categorical_penalty_dist( + sim, ti, w, t_binary, has_nan=True + ) + d_bin = vec_categorical_dist(sim, ti, w, has_nan=True) + np.testing.assert_allclose(d_pen, d_bin, equal_nan=True) + + +class TestPenaltyMatrixVariable(unittest.TestCase): + """Variable construction validation for penalty_matrix (DS_Feature_Checklist §3.3).""" + + def test_default_none(self): + v = Variable("x", np.array([0, 1, 0, 1])) + self.assertIsNone(v.penalty_matrix) + + def test_accept_valid_asymmetric(self): + t = np.array([[0.0, 0.5, 0.8], [0.3, 0.0, 0.6], [0.9, 0.4, 0.0]]) + v = Variable("x", np.array([0, 1, 2, 0]), penalty_matrix=t) + np.testing.assert_array_equal(v.penalty_matrix, t) + # not symmetric, and that's fine + self.assertFalse(np.allclose(v.penalty_matrix, v.penalty_matrix.T)) + + def test_penalty_matrix_read_only(self): + t = 1.0 - np.eye(2) + v = Variable("x", np.array([0, 1, 0, 1]), penalty_matrix=t) + with self.assertRaises(ValueError): + v.penalty_matrix[0, 1] = 0.1 + + def test_reject_bad_shape(self): + for t in (np.zeros((2, 3)), np.zeros((2, 2, 2))): + with self.subTest(shape=t.shape): + with self.assertRaisesRegex(ValueError, "square"): + Variable("x", np.array([0, 1]), penalty_matrix=t) + + def test_reject_nonzero_diagonal(self): + t = np.array([[0.0, 0.5], [0.3, 0.1]]) + with self.assertRaisesRegex(ValueError, "diagonal"): + Variable("x", np.array([0, 1, 0, 1]), penalty_matrix=t) + + def test_reject_bad_off_diagonal(self): + # zero, negative, and >1 are all invalid off-diagonal penalties. + for val in (0.0, -0.2, 1.5): + t = np.array([[0.0, val], [0.3, 0.0]]) + with self.subTest(val=val): + with self.assertRaisesRegex(ValueError, "off-diagonal"): + Variable("x", np.array([0, 1, 0, 1]), penalty_matrix=t) + + def test_reject_with_categorical_false(self): + t = 1.0 - np.eye(2) + with self.assertRaisesRegex(ValueError, "categorical"): + Variable( + "x", + np.linspace(0.0, 1.0, 4), + categorical=False, + penalty_matrix=t, + ) + + def test_reject_bad_data_codes(self): + # C = 3, valid codes {0, 1, 2}: non-integer, out-of-range, negative. + t = 1.0 - np.eye(3) + for data in ([0.0, 1.5, 2.0], [0.0, 1.0, 3.0], [0.0, -1.0, 2.0]): + with self.subTest(data=data): + with self.assertRaisesRegex(ValueError, "integer"): + Variable("x", np.array(data), penalty_matrix=t) + + def test_nan_data_ignored_by_range_check(self): + # NaN cells are undefined and must not trip the integer/range check. + t = 1.0 - np.eye(3) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + v = Variable("x", np.array([0.0, np.nan, 2.0]), penalty_matrix=t) + self.assertIsNotNone(v.penalty_matrix) + + def test_repr_includes_penalty_matrix_shape(self): + t = 1.0 - np.eye(3) + v = Variable("x", np.array([0, 1, 2, 0]), penalty_matrix=t) + self.assertIn("penalty_matrix=(3, 3)", repr(v)) + v_none = Variable("y", np.array([0, 1, 2, 0])) + self.assertNotIn("penalty_matrix", repr(v_none)) + + def test_window_view_retains_penalty_matrix(self): + t = np.array([[0.0, 0.5, 0.8], [0.3, 0.0, 0.6], [0.9, 0.4, 0.0]]) + rng = np.random.default_rng(2) + ti = gs.mps.TrainingImage( + [Variable("a", rng.integers(0, 3, (12, 12)), penalty_matrix=t)] + ) + win = ti.window(np.s_[2:10, 0:6]) + np.testing.assert_array_equal(win.variable("a").penalty_matrix, t) + + def test_simulation_stays_subset_of_ti_codes(self): + """End-to-end: a penalty_matrix must not invent values.""" + t = np.array([[0.0, 0.5, 0.8], [0.3, 0.0, 0.6], [0.9, 0.4, 0.0]]) + rng = np.random.default_rng(5) + ti_data = rng.integers(0, 3, (25, 25)).astype(float) + ti = TrainingImage(ti_data, n_neighbors=6, penalty_matrix=t) + ds = DirectSampling(MPSModel(ti, scan_fraction=0.5, threshold=0.1)) + out = ds([np.arange(15, dtype=float)] * 2, seed=42) + self.assertTrue( + set(np.unique(out).tolist()) <= set(np.unique(ti_data).tolist()) + ) + + +class TestPenaltyMatrixSetCondition(unittest.TestCase): + def test_univariate_bad_value_raises(self): + # out-of-range (5.0) and non-integer (1.5) codes both fail fast here. + t = 1.0 - np.eye(3) + ti = TrainingImage( + np.array([0, 1, 2, 0, 1, 2], dtype=float), penalty_matrix=t + ) + for bad_val in (5.0, 1.5): + ds = DirectSampling(MPSModel(ti)) + with self.subTest(val=bad_val): + with self.assertRaisesRegex(ValueError, "penalty_matrix"): + ds.set_condition([[0.0]], [bad_val]) + + def test_univariate_valid_code_accepted(self): + t = 1.0 - np.eye(3) + ti = TrainingImage( + np.array([0, 1, 2, 0, 1, 2], dtype=float), penalty_matrix=t + ) + ds = DirectSampling(MPSModel(ti)) + ds.set_condition([[0.0]], [2.0]) # must not raise + + def test_multivariate_out_of_range_raises(self): + t = 1.0 - np.eye(2) + rng = np.random.default_rng(3) + ti = gs.mps.TrainingImage( + [ + Variable("a", rng.integers(0, 2, (10, 10)), penalty_matrix=t), + Variable("b", rng.integers(0, 2, (10, 10))), + ] + ) + ds = DirectSampling(MPSModel(ti)) + with self.assertRaisesRegex(ValueError, "penalty_matrix"): + ds.set_condition([[0.0], [0.0]], {"a": [9.0], "b": [0.0]}) + + def test_multivariate_no_penalty_matrix_unaffected(self): + rng = np.random.default_rng(4) + ti = gs.mps.TrainingImage( + [ + Variable("a", rng.integers(0, 2, (10, 10))), + Variable("b", rng.integers(0, 2, (10, 10))), + ] + ) + ds = DirectSampling(MPSModel(ti)) + ds.set_condition([[0.0], [0.0]], {"a": [99.0], "b": [0.0]}) + + +class TestPostProcessingParams(unittest.TestCase): + """MPSModel post-processing parameter validation (Me13 §4).""" + + def _ti(self): + return gs.TrainingImage(np.zeros((8, 8)), categorical=True) + + def test_defaults(self): + model = gs.MPSModel(self._ti()) + self.assertEqual(model.post_processing, 0) + self.assertEqual(model.post_processing_factor, 1.0) + + def test_accepts_valid(self): + model = gs.MPSModel( + self._ti(), post_processing=2, post_processing_factor=2.0 + ) + self.assertEqual(model.post_processing, 2) + self.assertEqual(model.post_processing_factor, 2.0) + model.post_processing = 1 + model.post_processing_factor = 4.0 + self.assertEqual(model.post_processing, 1) + self.assertEqual(model.post_processing_factor, 4.0) + + def test_rejects_negative_passes(self): + with self.assertRaises(ValueError): + gs.MPSModel(self._ti(), post_processing=-1) + + def test_rejects_factor_below_one(self): + with self.assertRaises(ValueError): + gs.MPSModel(self._ti(), post_processing_factor=0.5) + + +def _isolated_count(field): + """Count cells whose value differs from ALL axis-neighbours (noise proxy).""" + f = np.asarray(field) + n = np.zeros(f.shape, dtype=int) + same = np.zeros(f.shape, dtype=int) + for ax in range(f.ndim): + for sh in (1, -1): + rolled = np.roll(f, sh, axis=ax) + valid = np.ones(f.shape, dtype=bool) + edge = [slice(None)] * f.ndim + edge[ax] = 0 if sh == 1 else -1 + valid[tuple(edge)] = False + n += valid + same += valid & (rolled == f) + return int(np.sum((same == 0) & (n > 0))) + + +class TestPostProcessingPass(unittest.TestCase): + """Me13 §4 / CASE 3: re-simulation with fully informed neighbourhoods.""" + + def setUp(self): + rng = np.random.RandomState(42) + ti = np.zeros((40, 40)) + ti[:, 20:] = 1.0 + flip = rng.rand(40, 40) < 0.05 + ti[flip] = 1.0 - ti[flip] + self.ti_data = ti + + def _run(self, seed=20260717, cond=None, num_threads=None, **model_kw): + ti = gs.TrainingImage(self.ti_data, categorical=True, n_neighbors=8) + model = gs.MPSModel(ti, scan_fraction=0.25, threshold=0.2, **model_kw) + ds = gs.DirectSampling(model, seed=seed) + if cond is not None: + ds.set_condition(*cond) + if num_threads is not None: + ds.num_threads = num_threads + return ds((np.arange(24.0), np.arange(24.0)), store=False) + + def test_noop_matches_default(self): + base = self._run() + noop = self._run(post_processing=0, post_processing_factor=2.0) + np.testing.assert_array_equal(base, noop) + + def test_subset_property_after_passes(self): + base = self._run(post_processing=0) + field = self._run(post_processing=2, post_processing_factor=2.0) + self.assertTrue(np.isin(field, np.unique(self.ti_data)).all()) + # the passes must actually re-draw, not no-op + self.assertFalse(np.array_equal(base, field)) + + def test_conditioning_preserved_exactly(self): + cond = (([2.0, 10.0, 21.0], [3.0, 15.0, 8.0]), [1.0, 0.0, 1.0]) + field = self._run(post_processing=2, cond=cond) + self.assertEqual(field[2, 3], 1.0) + self.assertEqual(field[10, 15], 0.0) + self.assertEqual(field[21, 8], 1.0) + + def test_deterministic_given_seed(self): + a = self._run(post_processing=1) + b = self._run(post_processing=1) + np.testing.assert_array_equal(a, b) + + def test_thread_count_invariance(self): + # Post-passes run over the same dependency DAG as the main path, so + # every thread count must reproduce the serial result bit-exactly. + ref = self._run(post_processing=2, num_threads=1) + for n_threads in (2, 4, 8): + with self.subTest(num_threads=n_threads): + np.testing.assert_array_equal( + ref, self._run(post_processing=2, num_threads=n_threads) + ) + + def test_fully_informed_neighbourhood(self): + """Me13 §4: a post-pass node sees cells it *precedes* in the order. + + Guards the pass-scoped all ``-1`` vmap. Gating post-pass reads by + visit position instead (as the main path does) would leave the + first-visited node with an empty neighbourhood, degrading the pass + from a refinement of the realization into an independent redraw. + """ + ti = gs.TrainingImage(self.ti_data, categorical=True, n_neighbors=8) + eng = _DirectSamplingEngine( + ti, + (12, 12), + 0.2, + 0.25, + np.random.RandomState(0), + np.random.RandomState(1), + ) + eng.run(num_threads=1) + # Reproduce the post-pass vmap/pass, then probe the first-visited node. + pss = _Pass( + order=eng.path, + vmap={v: np.full(144, -1, dtype=np.intp) for v in eng.variables}, + pos_map=None, + n_k=eng.n_k, + scan_fraction=eng.scan_fraction, + neighbor_cache={}, + ) + x_0 = eng.path[0] + events = eng._gather_neighborhood( + pss, x_0, tuple(int(c) for c in x_0), 0 + ) + for v in eng.variables: + self.assertGreater( + len(events[v]), + 0, + "post-pass node 0 must have a fully informed neighbourhood", + ) + + def test_noise_not_increased(self): + # Me13 CASE 3 (quantified): a post-pass must not add isolated-cell + # noise on a two-facies TI at the pinned seed. + noisy = self._run(post_processing=0) + cleaned = self._run(post_processing=1) + self.assertLessEqual(_isolated_count(cleaned), _isolated_count(noisy)) + + def test_engine_base_config_never_mutated(self): + """Per-pass search parameters live in ``_Pass``, not on the engine. + + Post-passes used to install their factor-reduced ``n_k``/``domains``/ + ``vmap`` onto the engine and restore them in a ``finally``. That is + what made the mechanism single-level (nothing inside a pass could set + its own parameters) and thread-safe only by accident. Every pass must + now see the engine's *base* configuration untouched. + """ + observed = [] + orig = _DirectSamplingEngine._simulate_node + + def spy(engine, pss, i, x_i, u_st, u_fb): + observed.append( + ( + tuple(sorted(engine.n_k.items())), + engine.scan_fraction, + tuple(sorted(pss.n_k.items())), + pss.scan_fraction, + ) + ) + return orig(engine, pss, i, x_i, u_st, u_fb) + + _DirectSamplingEngine._simulate_node = spy + try: + self._run(post_processing=2, post_processing_factor=2.0) + finally: + _DirectSamplingEngine._simulate_node = orig + + engine_cfg = {(o[0], o[1]) for o in observed} + self.assertEqual( + len(engine_cfg), + 1, + f"engine base config mutated during the run: {engine_cfg}", + ) + # Base config: n_neighbors=8, scan_fraction=0.25 (see _run). + self.assertEqual(engine_cfg.pop(), (((None, 8),), 0.25)) + # The factor-reduced values must still reach the kernel via _Pass: + # 8 / 2.0 = 4 neighbours, 0.25 / 2.0 = 0.125 scan fraction. + pass_cfg = {(o[2], o[3]) for o in observed} + self.assertEqual( + pass_cfg, {(((None, 8),), 0.25), (((None, 4),), 0.125)} + ) + + def test_per_pass_neighbour_cache_is_isolated(self): + """Each pass owns its cache box, so a stale one cannot leak forward. + + The cache is keyed on a pass's visit order; reusing one engine-level + slot meant correctness depended on remembering to null it per pass. + """ + boxes = [] + orig = _DirectSamplingEngine._simulate_node + + def spy(engine, pss, i, x_i, u_st, u_fb): + boxes.append(id(pss.neighbor_cache)) + return orig(engine, pss, i, x_i, u_st, u_fb) + + _DirectSamplingEngine._simulate_node = spy + try: + self._run(post_processing=2, num_threads=4) + finally: + _DirectSamplingEngine._simulate_node = orig + + # main pass + 2 post-passes, each with its own box + self.assertEqual(len(set(boxes)), 3) + + def test_post_pass_erases_only_own_non_conditioned_cell(self): + """``_reopen_and_simulate`` touches this node only, skipping conditioning. + + Reading later-positioned neighbours' *old* values is what makes a pass + a refinement, so nothing but the visited node may be erased. + """ + ti = gs.TrainingImage(self.ti_data, categorical=True, n_neighbors=8) + eng = _DirectSamplingEngine( + ti, + (8, 8), + 0.2, + 0.25, + np.random.RandomState(0), + np.random.RandomState(1), + conditions={(1, 1): {None: 1.0}, (5, 6): {None: 0.0}}, + ) + eng.run(num_threads=1) + before = eng.sg[None].copy() + pss = _Pass( + order=eng.path, + vmap={v: np.full(64, -1, dtype=np.intp) for v in eng.variables}, + pos_map=_order_positions(eng.path, (8, 8)), + n_k=eng.n_k, + scan_fraction=eng.scan_fraction, + neighbor_cache={}, + ) + # Erase-and-simulate one node, then check the blast radius. + x_0 = eng.path[0] + eng._reopen_and_simulate(pss, 0, x_0, 0.5, np.array([0.5, 0.5])) + changed = np.argwhere(before != eng.sg[None]) + self.assertLessEqual(len(changed), 1, f"touched {changed.tolist()}") + if len(changed): + np.testing.assert_array_equal(changed[0], x_0) + # Conditioned cells keep their values and stay out of every visit order. + self.assertEqual(eng.sg[None][1, 1], 1.0) + self.assertEqual(eng.sg[None][5, 6], 0.0) + for v in eng.variables: + cells = np.flatnonzero(eng.is_cond[v].reshape(-1)) + self.assertTrue(np.all(eng.vmap[v][cells] == -1)) + + def test_multivariate_joint_subset(self): + a = self.ti_data + b = 1.0 - a + ti = gs.TrainingImage( + [ + Variable("A", a, categorical=True, n_neighbors=6), + Variable("B", b, categorical=True, n_neighbors=6), + ] + ) + model = gs.MPSModel( + ti, scan_fraction=0.25, threshold=0.2, post_processing=1 + ) + ds = gs.DirectSampling(model, seed=3) + out = ds((np.arange(16.0), np.arange(16.0)), store=False) + pairs_ti = set(zip(a.ravel(), b.ravel())) + pairs_sim = set(zip(out["A"].ravel(), out["B"].ravel())) + self.assertTrue(pairs_sim <= pairs_ti) + + +class TestPostPassDag(unittest.TestCase): + """_build_dag_base edge orientation for the two vmap/pos_map regimes.""" + + def setUp(self): + self.shape = (5, 5) + self.offsets = _precompute_offsets(self.shape) + # Sequential order so visit position == raster index (readable asserts). + self.order = np.stack( + np.unravel_index(np.arange(25), self.shape), axis=-1 + ) + self.pos = _order_positions(self.order, self.shape) + self.main_vmap = {"v": self.pos.copy()} + self.post_vmap = {"v": np.full(25, -1, dtype=np.intp)} + self.n_k = {"v": 4} + + def _edges(self, vmap, pos_map): + _, out_edges, _ = _build_dag_base( + self.order, + self.shape, + self.offsets, + vmap, + self.n_k, + None, + pos_map, + ) + return {(a, b) for a, es in enumerate(out_edges) for b, _ in es} + + def _reads(self, i, vmap): + """Visit positions node ``i`` selects as neighbours under ``vmap``.""" + coords, _ = _select_neighbors( + self.order[i], + self.offsets, + np.array(self.shape), + self.shape, + vmap["v"], + i, + None, + None, + self.n_k["v"], + ) + if not len(coords): + return [] + return [ + int(p) + for p in self.pos[np.ravel_multi_index(coords.T, self.shape)] + ] + + def test_pos_map_none_matches_main_path(self): + # Default (pos_map=None) must reproduce the pre-existing main-path + # edge set, which the order-gated vmap already orients for us. + self.assertEqual( + self._edges(self.main_vmap, None), + self._edges(self.main_vmap, self.pos), + ) + + def test_all_edges_run_forward_in_visit_order(self): + # Acyclicity: the visit order itself is a topological order. + for a, b in self._edges(self.post_vmap, self.pos): + self.assertLess(a, b) + + def test_indegree_counts_each_pair_once(self): + # The read relation is not exactly symmetric (n_neighbors truncation + # lets a boundary node reach farther than its neighbours reach back), + # so an edge can be discovered from either endpoint. Double-counting + # indegree would deadlock the dispatcher, as only one decrement ever + # arrives per pair. + indegree, out_edges, _ = _build_dag_base( + self.order, + self.shape, + self.offsets, + self.post_vmap, + self.n_k, + None, + self.pos, + ) + pairs = [(a, b) for a, es in enumerate(out_edges) for b, _ in es] + self.assertEqual(len(pairs), len(set(pairs))) + self.assertEqual(int(indegree["v"].sum()), len(pairs)) + + def test_every_read_pair_is_ordered(self): + """No unsynchronized pair: each node's own selected neighbours all + carry an edge with it, which is what makes erasing a node's value + inside its worker safe under concurrency. + + Node 0 also pins the write-after-read direction the main path never + has — it reads only *later*-positioned nodes (their pre-pass values), + so the DAG must order it before them. Under the order-gated main-path + vmap node 0 would read nothing at all; that is the regression here. + """ + edges = self._edges(self.post_vmap, self.pos) + for i in range(25): + for j in self._reads(i, self.post_vmap): + pair = (j, i) if j < i else (i, j) + self.assertIn( + pair, edges, f"node {i} reads {j} unsynchronized" + ) + node0 = self._reads(0, self.post_vmap) + self.assertTrue(node0, "node 0 read nothing — not fully informed") + self.assertTrue(all(p > 0 for p in node0)) + self.assertEqual(self._reads(0, self.main_vmap), []) + + +class TestPostProcessingPath(unittest.TestCase): + """post_processing_path: post-pass visit order (implementation extension, + not prescribed by Me13 §4).""" + + def setUp(self): + rng = np.random.RandomState(42) + ti = np.zeros((40, 40)) + ti[:, 20:] = 1.0 + flip = rng.rand(40, 40) < 0.05 + ti[flip] = 1.0 - ti[flip] + self.ti_data = ti + + def _run(self, seed=20260717, cond=None, path="random", **model_kw): + ti = gs.TrainingImage(self.ti_data, categorical=True, n_neighbors=8) + model = gs.MPSModel( + ti, + scan_fraction=0.25, + threshold=0.2, + post_processing=1, + **model_kw, + ) + ds = gs.DirectSampling(model, seed=seed) + if cond is not None: + ds.set_condition(*cond) + return ds((np.arange(24.0), np.arange(24.0)), path=path, store=False) + + def test_default_inherits_main_mode(self): + a = self._run(path="sequential") + b = self._run(path="sequential", post_processing_path="sequential") + np.testing.assert_array_equal(a, b) + + def test_none_equals_random_for_random_main_path(self): + a = self._run() + b = self._run(post_processing_path="random") + np.testing.assert_array_equal(a, b) + + def test_same_is_deterministic_and_valid(self): + cond = (([5.0], [5.0]), [1.0]) + a = self._run(post_processing_path="same", cond=cond) + b = self._run(post_processing_path="same", cond=cond) + np.testing.assert_array_equal(a, b) + self.assertTrue(np.isin(a, np.unique(self.ti_data)).all()) + self.assertEqual(a[5, 5], 1.0) + + def test_explicit_raster_equals_sequential(self): + raster = np.argwhere(np.ones((24, 24), dtype=bool)) + a = self._run(post_processing_path=raster) + b = self._run(post_processing_path="sequential") + np.testing.assert_array_equal(a, b) + + def test_invalid_string_and_shape_raise(self): + ti = gs.TrainingImage(self.ti_data, categorical=True, n_neighbors=8) + with self.assertRaises(ValueError): + gs.MPSModel(ti, post_processing_path="bogus") + with self.assertRaises(ValueError): + gs.MPSModel(ti, post_processing_path=np.array([1, 2, 3])) + + def test_explicit_path_missing_nodes_raises(self): + half = np.argwhere(np.ones((24, 24), dtype=bool))[:288] + with self.assertRaises(ValueError): + self._run(post_processing_path=half) + + +if __name__ == "__main__": + unittest.main()