Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Compression Safeguards

Authors
Affiliations
University of Helsinki
European Centre for Medium-Range Weather Forecasts
University of Helsinki
version-badgecitation-badgesource-badgedocumentation-badgepitch-badge

Compression Safeguards [1] are a user-centric framework that empowers users to (1) declare which properties of their data they need lossy compression to preserve, and then (2) guarantees that these safety requirements are always upheld when (3) compressing with any user-chosen compressor. When wrapped in the appropriate safeguards, even unsafe compressors can be used safely.

During compression, the safeguards work by checking if the compressor-decompressed data would violate the user’s safety requirements. If there are any violations, the safeguards produce pointwise corrections, which are then checked again. These corrections are typically losslessly compressed and stored alongside the compressed data. During decompression, the corrections are applied, thereby ensuring that the decompressed result satisfies all user safety requirements. Importantly, the corrections are are not just lossless encodings of the original data but are instead chosen to compress well, based on an analysis of the safety requirements.

The compression safeguards are implemented in the compression-safeguards Python package, with convenient user-facing frontends for a safeguarded meta-compressor in numcodecs-safeguards and for working with chunked data in xarray-safeguards. These implementations can safeguard are variety of safety requirements, including regionally varying error bounds over the data xx and quantities of interest f(x)f(x), missing values, isosurfaces, and much more. The documentation provides several examples for inspiration, e.g. for preserving the relative vorticity computed over a compressed u-v wind field.

While the safeguards work best when safeguarding an existing scientific compressor, they can also be used to perform all of the compression themselves by using a compressor that decompresses to a constant-zero array, which then needs to be corrected for (almost) all data elements. Such a compressor is provided by the numcodecs_zero.ZeroCodec.

from pathlib import Path

import netCDF4
import numpy as np
import xarray as xr
data = Path("data")
import earthkit.plots

from quickplot import quickplot

Importing the SafeguardedCodec meta-compressor

from numcodecs_safeguards import SafeguardedCodec
from numcodecs_zero import ZeroCodec
?SafeguardedCodec
Init signature: SafeguardedCodec( *, codec: dict[str, None | int | float | str | bool | list['JSON'] | dict[str, 'JSON']] | numcodecs.abc.Codec, safeguards: collections.abc.Collection[dict[str, None | int | float | str | bool | list['JSON'] | dict[str, 'JSON']] | compression_safeguards.safeguards.abc.Safeguard], fixed_constants: collections.abc.Mapping[str | compression_safeguards.utils.bindings.Parameter, int | float | numpy.number | numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.number]]] | compression_safeguards.utils.bindings.Bindings = <compression_safeguards.utils.bindings.Bindings object at 0x1146fc340>, lossless: None | dict[str, None | int | float | str | bool | list['JSON'] | dict[str, 'JSON']] | numcodecs_safeguards.lossless.Lossless = None, compute: None | dict[str, None | int | float | str | bool | list['JSON'] | dict[str, 'JSON']] = None, _version: None | str | semver.version.Version = None, ) -> None Docstring: An adaptor codec that uses [`Safeguards`][compression_safeguards.api.Safeguards] to guarantee certain properties / safety requirements are upheld by the wrapped codec. Parameters ---------- codec : dict[str, JSON] | Codec The codec that will be wrapped with safeguards. It can either be passed as a codec configuration [`dict`][dict], which is passed to [`numcodecs.registry.get_codec(config)`][numcodecs.registry.get_codec], or an already initialized [`Codec`][numcodecs.abc.Codec]. If you want to wrap a sequence or stack of codecs, you can use the [`numcodecs_combinators.stack.CodecStack`][numcodecs_combinators.stack.CodecStack] combinator. The codec must be deterministic during decoding (but can be non-deterministic during encoding) such that decoding the same bytes always produces the same bitwise equivalent decoded result. It is desirable to perform lossless compression after applying the safeguards (rather than before), e.g. by customising the [`Lossless.for_codec`][..lossless.Lossless.for_codec] field of the `lossless` parameter. The `codec` combined with its `lossless` encoding must encode to a 1D buffer of bytes. It is also recommended that the `codec` can [`decode`][numcodecs.abc.Codec.decode] without receiving the output data type and shape via the `out` parameter. If the `codec` does not fulfil these requirements, it can be wrapped inside the [`numcodecs_combinators.framed.FramedCodecStack`][numcodecs_combinators.framed.FramedCodecStack] combinator. It is also possible to compress the data with *just* the safeguards (i.e. without a `codec` that provides proper lossy compression) by passing [`numcodecs_zero.ZeroCodec()`][numcodecs_zero.ZeroCodec] or `dict(id="zero")` to `codec`. The zero codec only encodes the data type and shape, not the data values themselves, and decodes to all- zero values, forcing the safeguards to correct (almost) all values. With this configuration, the safeguards thus act as a safe lossy compressor in their own right, as any size reduction comes from the `lossless` compression of the safeguards corrections (which the safeguards produce to be highly-compressible, if possible). safeguards : Collection[dict[str, JSON] | Safeguard] The safeguards that will be applied to the codec. They can either be passed as a safeguard configuration [`dict`][dict] or an already initialized [`Safeguard`][compression_safeguards.safeguards.abc.Safeguard]. Please refer to the [`SafeguardKind`][compression_safeguards.safeguards.SafeguardKind] for an enumeration of all supported safeguards. The `SafeguardedCodec` supports safeguards with late-bound parameters, e.g. the [`SelectSafeguard`][compression_safeguards.safeguards.combinators.select.SelectSafeguard], but they must be provided as `fixed_constants` that are *fixed* and must be compatible with *any* data that will be encoded with this codec. Therefore, fixed constants should only be used for late-bound parameters that can be fixed across all uses of the codec. fixed_constants : Mapping[str | Parameter, Value] | Bindings Mapping of parameter names to *fixed* constant scalars or arrays that will be provided as late-bound parameters to the safeguards. The mapping must resolve all late-bound parameters of the safeguards and include no extraneous parameters. The provided values must have a compatible shape and values for *any* data that will be encoded with this codec, otherwise [`encode`][.encode] will fail. You can use the [`update_fixed_constants`][.update_fixed_constants] method inside a `with` statement to temporarily update the late-bound parameters. The fixed constants are included in the [config][.get_config] of the codec. While [`int`][int] and [`float`][float] scalars are included as-is, numpy scalars and arrays are encoded to the losslessly compressed [`.npz`][numpy.savez_compressed] format and stored in a `data:application/x-npz;base64,<data>` [data URI](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data). lossless : None | dict[str, JSON] | Lossless, optional The lossless encoding that is applied after the codec and the safeguards: - [`Lossless.for_codec`][..lossless.Lossless.for_codec] specifies the lossless encoding that is applied to the encoded output of the wrapped `codec`. By default, no additional lossless encoding is applied. - [`Lossless.for_corrections`][..lossless.Lossless.for_corrections] specifies the lossless encoding that is applied to the corrections that the safeguards produce. By default, Zstandard compression is applied after entropy coding. The lossless encoding must encode to a 1D buffer of bytes. compute : None | dict[str, JSON] | Compute, optional Compute configuration with options that may affect the compression ratio and time cost of computing the safeguards corrections. While these options can change the particular corrections that are produced, the resulting corrections always satisfy the safety requirements. _version : ... The version of the codec. Do not provide this parameter explicitly. Raises ------ ValueError if `codec` wraps another `SafeguardedCodec`, which may create a printer problem. LateBoundParameterResolutionError if `fixed_constants` does not resolve all late-bound parameters of the safeguards or includes any extraneous parameters. ... if instantiating the `codec` or a safeguard raises an exception. File: ~/egu26-compression-sc2.5/.venv/lib/python3.13/site-packages/numcodecs_safeguards/__init__.py Type: ABCMeta Subclasses:

Bounding the pointwise absolute error

A pointwise absolute error bound can be guaranteed using the eb safeguard with type abs:

eb_abs = 0.1

SafeguardedCodec(
    codec=ZeroCodec(),  # for example, any numcodecs codec works
    safeguards=[
        {"kind": "eb", "type": "abs", "eb": eb_abs},
    ],
)
SafeguardedCodec(codec=ZeroCodec(), safeguards=[ErrorBoundSafeguard(type='abs', eb=0.1, equal_nan=False)], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

Bounding the pointwise relative error

A pointwise relative error bound can be guaranteed using the eb safeguard with type rel:

eb_rel = 0.01  # 1 %

SafeguardedCodec(
    codec=ZeroCodec(),  # for example, any numcodecs codec works
    safeguards=[
        {"kind": "eb", "type": "rel", "eb": eb_rel},
    ],
)
SafeguardedCodec(codec=ZeroCodec(), safeguards=[ErrorBoundSafeguard(type='rel', eb=0.01, equal_nan=False)], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

Bounding the pointwise ratio error

A pointwise ratio / logarithmic error bound can be guaranteed using the eb safeguard with type ratio:

eb_ratio = 1.01  # between x / 1.01 and x * 1.01

SafeguardedCodec(
    codec=ZeroCodec(),  # for example, any numcodecs codec works
    safeguards=[
        {"kind": "eb", "type": "ratio", "eb": eb_ratio},
    ],
)
SafeguardedCodec(codec=ZeroCodec(), safeguards=[ErrorBoundSafeguard(type='ratio', eb=1.01, equal_nan=False)], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

Preserving Missing Values

The error-bounding safeguards automatically preserve NaN missing values. By default, the exact NaN bit pattern is preserved. To only preserve whether a value is NaN but not its exact bit pattern, the error bounding safeguards accept a equal_nan=True option.

If the codec that is wrapped in safeguards raises an exception when the data includes NaN values, the numcodecs_replace.ReplaceFilterCodec can be used to first replace all NaN values, while leaving it up to the safeguards to reconstruct the NaN values during decompression:

from numcodecs_combinators.stack import CodecStack
from numcodecs_replace import Replacement, ReplaceFilterCodec

eb_abs = 0.1

SafeguardedCodec(
    # the replace filter codec must be used *inside* the safeguarded codec
    #  so that the safeguards see the NaN values in the original data and
    #  can reconstruct them during decompression
    codec=CodecStack(
        ReplaceFilterCodec(replacements={np.nan: "finite_mean"}),
        ZeroCodec(),  # for example, any numcodecs codec works
    ),
    safeguards=[
        {"kind": "eb", "type": "abs", "eb": eb_abs},
    ],
)
SafeguardedCodec(codec=CodecStack(ReplaceFilterCodec(replacements={nan: 'finite_mean'}), ZeroCodec()), safeguards=[ErrorBoundSafeguard(type='abs', eb=0.1, equal_nan=False)], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

Missing values / other special values that use a different sentinel value than NaN can be preserved using the same safeguard, e.g. together with an absolute error bound:

eb_abs = 0.1

sentinel = 9999

SafeguardedCodec(
    codec=ZeroCodec(),  # for example, any numcodecs codec works
    safeguards=[
        {"kind": "eb", "type": "abs", "eb": eb_abs},
        {"kind": "same", "value": sentinel, "exclusive": True},
    ],
)
SafeguardedCodec(codec=ZeroCodec(), safeguards=[ErrorBoundSafeguard(type='abs', eb=0.1, equal_nan=False), SameValueSafeguard(value=9999, exclusive=True)], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

The exclusive mode guarantees that every missing value in the input is still missing after decompression and that every non-missing value in the input stays non-missing after decompression.

Preserving Quantities of Interest (QoI)

An error bound over a quantity of interest f(x)f(x) can be guaranteed using the qoi_eb_pw (for pointwise QoIs) and qoi_eb_stencil (for QoIs that are computed over a local neighbourhood around each point) safeguards. For instance, preserving an absolute error bound over x2x^2 is as simple as:

eb_abs_qoi = 0.1

SafeguardedCodec(
    codec=ZeroCodec(),  # for example, any numcodecs codec works
    safeguards=[
        {"kind": "qoi_eb_pw", "qoi": "square(x)", "type": "abs", "eb": eb_abs_qoi},
    ],
)
SafeguardedCodec(codec=ZeroCodec(), safeguards=[PointwiseQuantityOfInterestErrorBoundSafeguard(qoi='square(x)', type='abs', eb=0.1, qoi_dtype='lossless')], fixed_constants={}, lossless=Lossless(for_codec=None, for_corrections=PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))), compute=Compute(unstable_iterative=False, unstable_lossless_corrections=False))

Like in numpy, we could have also used x**2 instead of the specialised square(x) function, though such specialised functions may provide higher performance and better-compressible corrections.

Preserving so much more

The compression safeguards can also be used to preserve isosurfaces, regions of interest, combinations of different safety requirements, topology via the local order of points in a neighbourhood [2], and so much more. Please see the documentation and examples for inspiration.

Example

# Load the data
ds = xr.open_dataset(
    data / "hplp" / "hplp_sfc_regridded_t_025deg_levels_steps_204_216_228_240.nc",
    engine="netcdf4",
    decode_timedelta=True,
)
da = ds["2t"]
eb_abs = 0.1  # 0.1 K

codec = SafeguardedCodec(
    codec=ZeroCodec(),  # safeguards need to correct constant zero prediction
    safeguards=[
        # bound absolute error over the data
        {"kind": "eb", "type": "abs", "eb": eb_abs},
        # preserve global minimum and maximum
        {"kind": "sign", "offset": "$x_min"},
        {"kind": "sign", "offset": "$x_max"},
    ],
)
# encode and decode the data
da_enc = codec.encode(da.values)
da_dec = da.copy(data=codec.decode(da_enc))
# plot a comparison figure
fig = earthkit.plots.Figure(
    size=(15, 4),
    rows=1,
    columns=3,
)

quickplot(da, fig.add_map(0, 0), title="Original {default_title}")
quickplot(
    da_dec,
    fig.add_map(0, 1),
    title="Safeguarded(0)",
    cr=da.nbytes / np.array(da_enc).nbytes,
)
quickplot(da_dec - da, fig.add_map(0, 2), error=True, title="Compression Error")

fig.show()
<Figure size 1500x400 with 6 Axes>

Chunked Example

The Compression Safeguards can also be used to preserve safety requirements across chunk boundaries in chunked data using e.g. the xarray-safeguards package. For this example, we rechunk the data into chunks (mostly) of size 70x100 and then bound the relative error over the first-order second-order-accurate central difference along the periodic longitude axis.

da_chunked = da.chunk(lat=70, lon=100)
display(da_chunked.data)
da_chunked.dims
Loading...
('time', 'lat', 'lon')
import xarray_safeguards
eb_rel_qoi = 0.01  # 1 % over the quantity of interest

safeguards = [
    {
        "kind": "qoi_eb_stencil",
        "qoi": """
            return finite_difference(
                x,                        # over the data
                order=1,                  # 1st order derivative
                accuracy=2,               # 2nd order accurate
                type=0,                   # central
                axis=-1,                  # longitude axis
                grid_centre=c["$d_lon"],  # coordinate, provided by xarray-safeguards
                grid_period=360,          # periodic over 360 degrees
            );
        """,
        "neighbourhood": [
            # periodic longitude axis, -1,+1 stencil for finite difference
            {"axis": -1, "before": 1, "after": 1, "boundary": "wrap"},
        ],
        "type": "rel",
        "eb": eb_rel_qoi,
    }
]
from numcodecs_combinators.stack import CodecStack

# lazily produce a prediction, i.e. the lossy decompressed data
da_prediction = CodecStack(
    ZeroCodec(),  # for example, any numcodecs codec works
).encode_decode_data_array(da_chunked)
# lazily produce the correction, later persist the result
da_correction = xarray_safeguards.produce_data_array_correction(
    data=da_chunked,
    prediction=da_prediction,
    safeguards=safeguards,
).persist()
# lazily apply the correction, later persist the result
da_corrected = xarray_safeguards.apply_data_array_correction(
    prediction=da_prediction,
    correction=da_correction,
).persist()
# xarray-safeguards, like compression-safeguards, performs no lossless
# compression of the corrections
# here we use the lossless corrections compression from numcodecs-safeguards
import numcodecs_safeguards

da_correction_nbytes = np.array(
    numcodecs_safeguards.lossless._default_lossless_for_corrections().encode(
        da_correction.values
    )
).nbytes
def differentiate_along_longitude(da: xr.DataArray) -> xr.DataArray:
    da_wrapped = da.pad(lon=1, mode="wrap").assign_coords(
        lon=da.lon.pad(lon=1, mode="reflect", reflect_type="odd"),
    )

    da_dXdLon = da_wrapped.differentiate("lon")
    da_dXdLon.attrs.update(
        long_name=f"{da.long_name} derivative along longitude",
        units=f"{da.units} degree**-1",
    )

    return da_dXdLon.sel(lon=slice(da.lon.min(), da.lon.max()))
# plot a comparison figure
fig = earthkit.plots.Figure(
    size=(15, 4),
    rows=1,
    columns=3,
)

da_deriv = differentiate_along_longitude(da_chunked.compute())
da_corrected_deriv = differentiate_along_longitude(da_corrected.compute())

quickplot(
    da_deriv,
    fig.add_map(0, 0),
    title="Original {default_title}",
    error=True,
    vrange=(-10, 10),
)
quickplot(
    da_corrected_deriv,
    fig.add_map(0, 1),
    title="Safeguarded(0), chunked",
    cr=da.nbytes / da_correction_nbytes,
    error=True,
    vrange=(-10, 10),
)
quickplot(
    ((da_corrected_deriv - da_deriv) / da_deriv).assign_attrs(
        long_name="relative error", units="%"
    ),
    fig.add_map(0, 2),
    error=True,
    title="Relative Compression Error",
)

fig.show()
<Figure size 1500x400 with 6 Axes>
Footnotes
  1. Tyree, J., Köhler, D., Underwood, R., Bouvier, C., Reichelt, T., Järvinen, H., & Klöwer, M. (2026). Compression Safeguards - Towards Safe, Trusted, and Fearless Lossy Compression of Earth Science Data. EGU General Assembly 2026. Available from: Tyree et al. (2026).

  2. Inspired by: Fallin, A., Gorski, N., Agarwal, T., Wang, B., Gopalakrishnan, G., & Burtscher, M. (2026). Fast Topology-Aware Lossy Data Compression with Full Preservation of Critical Points and Local Order. arXiv. Available from: Fallin et al. (2026).

References
  1. Tyree, J., Köhler, D., Underwood, R., Bouvier, C., Reichelt, T., Järvinen, H., & Klöwer, M. (2026). Compression Safeguards - Towards Safe and Fearless Lossy Compression of Earth System Data. 10.5194/egusphere-egu26-9673
  2. Fallin, A., Gorski, N., Agarwal, T., Wang, B., Gopalakrishnan, G., & Burtscher, M. (2026). Fast Topology-Aware Lossy Data Compression with Full Preservation of Critical Points and Local Order. arXiv. 10.48550/ARXIV.2603.26968