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 _local_crs(lons, lats):
    """Return a transverse Mercator projection centred on the sites."""
    latitude = float(numpy.median(lats))
    if not -80 <= latitude <= 84:
        raise ValueError('Circulant embedding requires sites within the '
                         'latitude range [-80, 84]')
    # No zone boundaries: the central meridian is the middle of the extent.
    # The scale error is about 0.07% at 400 km from the central meridian.
    central = (float(numpy.min(lons)) + float(numpy.max(lons))) / 2
    return (f'+proj=tmerc +lon_0={central:.6f} +k=1 +datum=WGS84 '
            '+units=m +no_defs')


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: """Map geographic sites to the regular lattice used by CE. ``from_sites`` projects all coordinates with one transverse Mercator projection, centred on the middle of the longitude extent unless ``crs`` (a PROJ string) is given, then fits and validates an axis-aligned lattice. ``site_indices`` maps the original site order to flattened grid cells; holes remain unused cells in the enclosing rectangle. CE constructs stationary covariance lags from Euclidean distances between lattice cells. A fixed row/column offset must have the same displacement everywhere, giving the repeated covariance structure required by the FFT. Geographic grids built along parallels and meridians do not generally satisfy this condition. Substituting geographic pairwise distances would in general lose that structure, rather than merely change the projection. The projection is a local planar approximation to surface geometry, not an intrinsic requirement of CE; another suitable planar projection would require consistent support throughout grid detection and sampling. The covariance sampled here uses projected distances, not exact geodesic distances. ``grid_shape`` and ``spacing`` follow row/column order, with spacing in kilometres. ``projected_origin`` is northing/easting in metres, ``crs`` is the PROJ string of the projection, and ``maximum_error`` is the largest distance in metres between an input site and its fitted lattice position. """ grid_shape: tuple spacing: tuple site_indices: numpy.ndarray crs: str projected_origin: tuple maximum_error: float
[docs] @classmethod def from_sites(cls, sites, max_cell_ratio=MAX_GRID_CELL_RATIO, crs=None): """Infer a square 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') if crs is None: crs = _local_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 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)