Source code for openquake.hazardlib.correlation_models.circulant_embedding

# The Hazard Library
# Copyright (C) 2026 GEM Foundation
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Circulant embedding for stationary multivariate Gaussian fields.

The implementation follows the block-circulant construction described by
Chan and Wood (1999). It embeds the requested rectangular grid in a larger
periodic grid, factorizes the small cross-IMT spectral covariance at each
Fourier mode, and applies those factors with real FFTs.

References
----------
Chan, G., and Wood, A. T. A. (1999). Simulation of stationary Gaussian
vector fields. Statistics and Computing, 9, 265-268.
https://doi.org/10.1023/A:1008903804954

Dietrich, C. R., and Newsam, G. N. (1993). A fast and exact method for
multidimensional Gaussian stochastic simulations. Water Resources Research,
29(8), 2861-2869. https://doi.org/10.1029/93WR01070
"""

from dataclasses import dataclass

import numpy
from pyproj import Transformer
from scipy.fft import next_fast_len
from scipy.spatial import cKDTree


MAX_GRID_CELL_RATIO = 4
GRID_TOLERANCE = 0.01


EPS = 1E-10

def _pair(value, name, cast):
    """Return a validated pair of grid parameters."""
    if numpy.isscalar(value):
        value = (value, value)
    if len(value) != 2:
        raise ValueError(f'{name} must contain two values')
    pair = tuple(cast(item) for item in value)
    if any(item <= 0 for item in pair):
        raise ValueError(f'{name} values must be positive')
    return pair


def _utm_crs(lons, lats):
    """Return the local UTM coordinate reference system."""
    longitude = float(numpy.median(lons))
    latitude = float(numpy.median(lats))
    if not -80 <= latitude <= 84:
        raise ValueError('Circulant embedding requires sites within the '
                         'UTM latitude range')
    zone = min(60, max(1, int((longitude + 180) // 6) + 1))
    return 32600 + zone if latitude >= 0 else 32700 + zone


def _grid_spacing(x, y):
    """Estimate the projected spacing from nearest neighbours."""
    coordinates = numpy.column_stack((x, y))
    distances = cKDTree(coordinates).query(
        coordinates, k=2, workers=1)[0][:, 1]
    distances = distances[numpy.isfinite(distances) & (distances > 0)]
    if not len(distances):
        raise ValueError('Cannot determine the correlation grid spacing')
    return float(numpy.median(distances))


[docs]@dataclass(frozen=True) class RegularGridLayout: """Projected regular-grid geometry for a geographic site collection.""" grid_shape: tuple spacing: tuple site_indices: numpy.ndarray crs: int projected_origin: tuple maximum_error: float
[docs] @classmethod def from_sites(cls, sites, max_cell_ratio=MAX_GRID_CELL_RATIO): """Infer a square UTM lattice, retaining holes as unused cells.""" lons = numpy.asarray(sites.lons, dtype=numpy.float64) lats = numpy.asarray(sites.lats, dtype=numpy.float64) if len(lons) < 4 or len(lons) != len(lats): raise ValueError( 'Circulant embedding requires at least four grid sites') if not numpy.isfinite(lons).all() or not numpy.isfinite(lats).all(): raise ValueError('Correlation grid coordinates must be finite') crs = _utm_crs(lons, lats) transformer = Transformer.from_crs( 4326, crs, always_xy=True) x, y = transformer.transform(lons, lats) initial_spacing = _grid_spacing(x, y) # Fit the lattice origin after assigning preliminary integer cells. ix = numpy.rint( (x - x.min()) / initial_spacing).astype(numpy.int64) iy = numpy.rint( (y - y.min()) / initial_spacing).astype(numpy.int64) spacing_x, x0 = numpy.polyfit(ix, x, 1) spacing_y, y0 = numpy.polyfit(iy, y, 1) ix = numpy.rint((x - x0) / spacing_x).astype(numpy.int64) iy = numpy.rint((y - y0) / spacing_y).astype(numpy.int64) spacing_x, x0 = numpy.polyfit(ix, x, 1) spacing_y, y0 = numpy.polyfit(iy, y, 1) errors = numpy.hypot( x - (x0 + ix * spacing_x), y - (y0 + iy * spacing_y)) tolerance = max( 2.0, max(spacing_x, spacing_y) * GRID_TOLERANCE) if errors.max() > tolerance: raise ValueError( 'Sites do not form an axis-aligned regular UTM grid; ' f'maximum coordinate error is {errors.max():g} m') minimum_x = ix.min() minimum_y = iy.min() ix -= minimum_x iy -= minimum_y nx = int(ix.max()) + 1 ny = int(iy.max()) + 1 if nx < 2 or ny < 2: raise ValueError( 'Circulant embedding requires at least two grid rows and ' 'columns') indices = iy * nx + ix if len(numpy.unique(indices)) != len(indices): raise ValueError('Multiple sites occupy one correlation grid cell') if nx * ny > max_cell_ratio * len(indices): raise ValueError( 'The enclosing correlation grid contains more than ' f'{max_cell_ratio:g} cells per occupied site') return cls( (ny, nx), (float(spacing_y / 1000), float(spacing_x / 1000)), indices, crs, (float(y0 + minimum_y * spacing_y), float(x0 + minimum_x * spacing_x)), float(errors.max()))
[docs] def grid_coordinates(self, sites): """Return fractional row and column coordinates for ``sites``.""" transformer = Transformer.from_crs( 4326, self.crs, always_xy=True) if len(sites.lons) == 1: x, y = transformer.transform( float(sites.lons[0]), float(sites.lats[0])) x = numpy.array([x]) y = numpy.array([y]) else: x, y = transformer.transform(sites.lons, sites.lats) origin_y, origin_x = self.projected_origin spacing_y, spacing_x = self.spacing rows = (numpy.asarray(y) - origin_y) / (spacing_y * 1000) columns = (numpy.asarray(x) - origin_x) / (spacing_x * 1000) return rows, columns
[docs] def expanded(self, sites, margin): """Return a grid enlarged around ``sites`` by ``margin`` cells.""" if not isinstance(margin, (int, numpy.integer)) or margin < 0: raise ValueError('margin must be a non-negative integer') rows, columns = self.grid_coordinates(sites) rounded_rows = numpy.rint(rows) rounded_columns = numpy.rint(columns) rows = numpy.where( numpy.abs(rows - rounded_rows) <= GRID_TOLERANCE, rounded_rows, rows) columns = numpy.where( numpy.abs(columns - rounded_columns) <= GRID_TOLERANCE, rounded_columns, columns) if not len(rows): return self ny, nx = self.grid_shape lower_y = min(0, int(numpy.floor(rows.min())) - margin) lower_x = min(0, int(numpy.floor(columns.min())) - margin) upper_y = max(ny - 1, int(numpy.ceil(rows.max())) + margin) upper_x = max(nx - 1, int(numpy.ceil(columns.max())) + margin) if (lower_y, lower_x, upper_y, upper_x) == ( 0, 0, ny - 1, nx - 1): return self old_rows, old_columns = numpy.divmod(self.site_indices, nx) new_nx = upper_x - lower_x + 1 indices = ((old_rows - lower_y) * new_nx + old_columns - lower_x) spacing_y, spacing_x = self.spacing origin_y, origin_x = self.projected_origin origin = (origin_y + lower_y * spacing_y * 1000, origin_x + lower_x * spacing_x * 1000) return type(self)( (upper_y - lower_y + 1, new_nx), self.spacing, indices, self.crs, origin, self.maximum_error)
@property def occupancy(self): """Return the fraction of enclosing grid cells containing a site.""" return len(self.site_indices) / numpy.prod(self.grid_shape)
def _embedding_shape(grid_shape, multiplier): """Return FFT-efficient dimensions for a periodic embedding.""" # Twice the target extent prevents periodic wrap-around from changing # any covariance within the original grid. A nearby fast length makes # the transforms cheaper without changing that guarantee. return tuple(next_fast_len(max(1, 2 * multiplier * (size - 1))) for size in grid_shape) def _lag_distances(shape, spacing): """Return distances from the origin on the periodic grid.""" ny, nx = shape # Each coordinate uses its shortest displacement around the torus. This # produces the first block row of the block-circulant covariance. y = numpy.minimum(numpy.arange(ny), ny - numpy.arange(ny)) x = numpy.minimum(numpy.arange(nx), nx - numpy.arange(nx)) return numpy.hypot( y[:, numpy.newaxis] * spacing[0], x[numpy.newaxis, :] * spacing[1]) def _covariance_lags(model, imts, shape, spacing, component, context): """Return cross-IMT covariance blocks for all periodic lags.""" distances = _lag_distances(shape, spacing) num_imts = len(imts) # correlation_block returns IMT-major rows for every lag against one # origin site. Move the lag dimension first to obtain one M x M block at # every embedded grid cell. blocks = model.correlation_block( distances.reshape(-1, 1), imts, imts, component, context) return blocks.reshape(num_imts, -1, num_imts).transpose( 1, 0, 2).reshape(*shape, num_imts, num_imts) def _spectral_root(covariance_lags): """Return the Hermitian square root at every Fourier mode.""" # A block-circulant covariance is diagonal in space after the FFT. What # remains at each frequency is only a small cross-IMT covariance matrix. spectrum = numpy.fft.rfft2(covariance_lags, axes=(0, 1)) transpose = spectrum.swapaxes(-1, -2).conj() scale = max(1.0, float(numpy.abs(spectrum).max())) tolerance = 100 * numpy.finfo(float).eps * scale # Check before averaging so a genuinely asymmetric model is not silently # hidden as numerical roundoff. if not numpy.allclose(spectrum, transpose, rtol=EPS, atol=tolerance): raise ValueError( 'The embedded spectral covariance is not Hermitian') spectrum = (spectrum + transpose) / 2 eigenvalues, eigenvectors = numpy.linalg.eigh(spectrum) minimum = float(eigenvalues.min()) # FFT roundoff accumulates with the number of embedded cells. Only values # within that scale-aware tolerance may be clipped; a material negative # value requires a larger embedding. tolerance *= numpy.prod(covariance_lags.shape[:2]) if minimum < -tolerance: return minimum, None eigenvalues = numpy.maximum(eigenvalues, 0) # Form V sqrt(Lambda) V* once so every realization needs only one small # matrix-vector multiplication at each frequency. scaled_vectors = ( eigenvectors * numpy.sqrt(eigenvalues)[..., numpy.newaxis, :]) root = scaled_vectors @ eigenvectors.swapaxes(-1, -2).conj() return minimum, root
[docs]@dataclass(frozen=True) class CirculantEmbeddingFactor: """FFT factorization of an IMT-major regular-grid covariance. Use :meth:`build` to construct a positive-semidefinite periodic embedding. :meth:`apply` accepts a two-dimensional array containing one white-noise vector per column and returns fields in IMT-major order. """ spectral_root: numpy.ndarray grid_shape: tuple embedded_shape: tuple num_imts: int site_indices: numpy.ndarray embedding_multiplier: int minimum_eigenvalue: float
[docs] @classmethod def build(cls, model, imts, grid_shape, spacing, component=None, context=None, site_indices=None, max_multiplier=8): """Build an embedding, enlarging it until its spectrum is PSD.""" grid_shape = _pair(grid_shape, 'grid_shape', int) spacing = _pair(spacing, 'spacing', float) if not imts: raise ValueError('At least one IMT is required') if max_multiplier < 1: raise ValueError('max_multiplier must be positive') indices = cls._validate_indices(site_indices, grid_shape) minimum = numpy.nan for multiplier in range(1, max_multiplier + 1): # Extending the periodic domain reduces artificial interaction # across its boundary while preserving the requested covariance. embedded_shape = _embedding_shape(grid_shape, multiplier) covariance_lags = _covariance_lags( model, imts, embedded_shape, spacing, component, context) minimum, root = _spectral_root(covariance_lags) if root is not None: return cls( root, grid_shape, embedded_shape, len(imts), indices, multiplier, minimum) raise ValueError( 'The circulant embedding is not positive semidefinite through ' f'multiplier {max_multiplier}; minimum eigenvalue is ' f'{minimum:g}')
@staticmethod def _validate_indices(site_indices, grid_shape): """Return flattened output-cell indices in their requested order.""" size = numpy.prod(grid_shape) if site_indices is None: return numpy.arange(size) indices = numpy.asarray(site_indices) if indices.ndim != 1 or not numpy.issubdtype( indices.dtype, numpy.integer): raise ValueError('site_indices must be a one-dimensional ' 'integer array') if len(numpy.unique(indices)) != len(indices): raise ValueError('site_indices must not contain duplicates') if numpy.any(indices < 0) or numpy.any(indices >= size): raise ValueError('site_indices contains an out-of-grid cell') return indices.astype(numpy.int64, copy=False) @property def input_size(self): """Number of independent values required for each realization.""" return int(self.num_imts * numpy.prod(self.embedded_shape)) @property def output_size(self): """Number of correlated values returned for each realization.""" return self.num_imts * len(self.site_indices) @property def workspace_bytes_per_realization(self): """Conservative FFT workspace estimate for one realization.""" return 32 * self.input_size + 8 * self.output_size
[docs] def batch_size(self, memory_budget): """Return how many realizations fit in the workspace budget.""" memory_budget = int(memory_budget) available = memory_budget - self.spectral_root.nbytes required = self.workspace_bytes_per_realization if available < required: raise ValueError( 'The circulant embedding requires at least ' f'{self.spectral_root.nbytes + required} workspace bytes') return max(1, available // required)
[docs] def apply(self, samples): """Apply the embedding to columns of independent normal values.""" samples = numpy.asarray(samples) if samples.ndim != 2 or samples.shape[0] != self.input_size: raise ValueError( f'Expected samples with shape ({self.input_size}, E), got ' f'{samples.shape}') num_events = samples.shape[1] # Convert IMT-major columns to (event, y, x, IMT), leaving the last # axis ready for the small spectral matrix multiplication below. white = samples.reshape( self.num_imts, *self.embedded_shape, num_events) white = white.transpose(3, 1, 2, 0) transformed = numpy.fft.rfft2(white, axes=(1, 2)) # Correlate the IMTs independently at every spatial frequency. correlated = numpy.einsum( 'yxij,eyxj->eyxi', self.spectral_root, transformed) del transformed fields = numpy.fft.irfft2( correlated, s=self.embedded_shape, axes=(1, 2)) del correlated nx = self.grid_shape[1] # Discard the periodic padding, apply the optional spatial mask, and # restore the IMT-major ordering expected by the GMF calculators. rows, columns = numpy.divmod(self.site_indices, nx) fields = fields[:, rows, columns, :] return fields.transpose(2, 1, 0).reshape( self.output_size, num_events)