Examples

The scripts are in the examples/ folder. Short examples of the common use cases are also at the top of the API reference.

Writing

Surfaces and profiles, with metadata and a trailer, in different revisions, data types and representations.

"""Write surfaces and profiles: python examples/write.py [folder]."""

import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path

import numpy as np

import sdfio

folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(tempfile.mkdtemp())
folder.mkdir(parents=True, exist_ok=True)

# Heights and intervals are in metres; NaN marks a point that was not measured.
surface = 1e-6 * np.random.default_rng(0).normal(size=(480, 640))
surface[100:110, 200:210] = np.nan

# A profile is a surface with one row; its y interval is meaningless but still needs a value.
x = np.arange(500) * 2e-6
profile = 1e-6 * np.sin(2.0 * np.pi * x / 100e-6)

# Defaults: ISO-2.0, binary, 64-bit floats.
sdfio.write(folder / "surface.sdf", surface, x_scale=1e-6, y_scale=1e-6)
sdfio.write(folder / "profile.sdf", profile.reshape(1, -1), x_scale=2e-6, y_scale=2e-6)

# The metadata is a template for the header. Dates are UTC. ISO-2.0 wants the trailer as
# Name = Value fields; the older revisions take any 7-bit text.
metadata = sdfio.Metadata(
    revision=sdfio.Revision.ISO_2_0,
    manufacturer_id="Acme",
    create_date=datetime(2026, 3, 1, 9, 30, tzinfo=UTC),
    z_resolution=1e-9,
    data_type=sdfio.DataType.INT16,
)
trailer = sdfio.format_tagged_fields({"OperatorName": "Jane Doe", "PartName": "Gauge 7"})

# Integer types store multiples of z_scale. The default, "auto", is the smallest that fits the data.
sdfio.write(
    folder / "surface_int16.sdf",
    surface,
    x_scale=1e-6,
    y_scale=1e-6,
    metadata=metadata,
    trailer=trailer,
)

# Other revisions, data types and representations; each revision allows only some data types.
variants = [
    ("surface_iso1_ascii", sdfio.Revision.ISO_1_0, sdfio.DataType.INT32, sdfio.FileFormat.ASCII),
    ("surface_bcr_int16", sdfio.Revision.BCR_1_0, sdfio.DataType.INT16, sdfio.FileFormat.BINARY),
    ("surface_float32", sdfio.Revision.ISO_2_0, sdfio.DataType.BINARY32, sdfio.FileFormat.BINARY),
]
for name, revision, data_type, file_format in variants:
    sdfio.write(
        folder / f"{name}.sdf",
        surface,
        x_scale=1e-6,
        y_scale=1e-6,
        format=file_format,
        metadata=sdfio.Metadata(revision=revision, data_type=data_type),
        trailer=trailer,
    )

print("written to", folder)
for path in sorted(folder.glob("*.sdf")):
    print(f"{path.name:22s}{path.stat().st_size:>10} bytes")

Reading

The heights, the points, the header and the trailer of a file, from a path, a stream or bytes.

"""Read a file and look at its heights, header and trailer: python examples/read.py."""

import io
from pathlib import Path

import sdfio

# The sample files of the repository; use the paths of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-71-2026"
path = SAMPLES / "iso2_surface_binary.sdf"

sdf = sdfio.read(path)
print(
    sdf.header.revision, sdf.header.format.name.lower(), sdf.header.data_type.name, sdf.data.shape
)

# Heights and axes are in metres; points that were not measured are NaN.
print("x runs to", sdf.x_axis[-1], "m, y to", sdf.y_axis[-1], "m")

# Joins the axes and the heights; without the missing points it is an (N, 3) point cloud.
points = sdf.points(drop_invalid=True)
print("measured points:", len(points), "of", sdf.data.size)

header = sdf.header
print(header.manufacturer_id, header.create_date, "z scale:", header.z_scale)

# ``trailer`` is the raw text; ``trailer_fields`` parses Name = Value lines, as ISO-2.0 requires.
print("trailer:", dict(sdf.trailer_fields))

profile = sdfio.read(SAMPLES / "iso2_profile_binary.sdf")
print("profile:", profile.is_profile, profile.data.shape)

# Streams and bytes work as well as paths.
with open(path, "rb") as stream:
    from_stream = sdfio.read(stream)
from_bytes = sdfio.SdfFile.loads(path.read_bytes())
print("same data from a stream and from bytes:", from_stream == from_bytes == sdf)

# All errors of sdfio are SdfError.
try:
    sdfio.read(io.BytesIO(b"not an SDF file at all"))
except sdfio.SdfError as error:
    print("not read:", error)

Modifying

Changing the values, making files of parts of a file, and converting it to another revision, data type or representation.

"""Change a file, make files of parts of it, and convert it: python examples/modify.py."""

import io

import numpy as np

import sdfio

# ``SdfFile.from_array`` takes the arguments of ``write`` and gives the file without writing it.
heights = 1e-6 * (np.random.default_rng(5).normal(size=(200, 300)) + 5.0)
sdf = sdfio.SdfFile.from_array(heights, x_scale=1e-6, y_scale=1e-6)

# The data is the file's own NumPy array.
sdf.data -= sdf.data.mean()

# The part keeps the header and trailer, and shares the heights with sdf.
part = sdf.with_data(sdf.data[50:150, 100:250])

# Every second point, with the matching scale.
coarse = sdf.with_data(sdf.data[:, ::2], x_scale=2 * sdf.header.x_scale)

# A slice keeps the row axis, so a row is a profile.
row = sdf.with_data(sdf.data[40:41])
print("crop:", part.data.shape, "coarse:", coarse.data.shape, "row is a profile:", row.is_profile)

# A z scale that does not fit the new type is kept by default and the file can't be written;
# ``refit_z_scale`` keeps it when the data fits and otherwise takes the smallest that fits.
older = coarse.with_revision(
    sdfio.Revision.ISO_1_0, data_type=sdfio.DataType.INT32, refit_z_scale=True
)
print("converted to", older.header.revision, older.header.data_type.name)

for file_format in sdfio.FileFormat:
    buffer = io.BytesIO()
    older.save(buffer, format=file_format)
    print(f"{file_format.name.lower():>6}: {len(buffer.getvalue())} bytes")

# A data type that only ISO-2.0 has is refused by ISO-1.0.
try:
    coarse.with_revision(sdfio.Revision.ISO_1_0, data_type=sdfio.DataType.INT8)
except sdfio.SdfFormatError as error:
    print("refused:", error)

Plotting

"""Plot the data of SDF files with matplotlib: python examples/plot.py [figure.png]."""

import sys
from pathlib import Path

import matplotlib.pyplot as plt

import sdfio

# The sample files of the repository; use the path of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-71-2026"
surface = sdfio.read(SAMPLES / "iso2_surface_binary.sdf")
profile = sdfio.read(SAMPLES / "iso2_profile_binary.sdf")

# Shape (rows, columns, 3): x, y and the height of each point.
points = surface.points()

# Metres are scaled to micrometres for the labels; NaN stays blank.
figure = plt.figure(figsize=(14, 4.5), layout="constrained")

# y grows with the row, so the origin is in the lower left corner.
x, y = surface.x_axis * 1e6, surface.y_axis * 1e6
step_x, step_y = surface.header.x_scale * 1e6, surface.header.y_scale * 1e6
extent = (x[0] - step_x / 2, x[-1] + step_x / 2, y[0] - step_y / 2, y[-1] + step_y / 2)
axes = figure.add_subplot(1, 3, 1)
image = axes.imshow(surface.data * 1e6, origin="lower", extent=extent)
figure.colorbar(image, ax=axes, label="z / um")
axes.set(xlabel="x / um", ylabel="y / um", title="Surface: Height Map")

# The missing region is a hole.
scaled = points * 1e6
axes = figure.add_subplot(1, 3, 2, projection="3d")
axes.plot_surface(
    scaled[..., 0], scaled[..., 1], scaled[..., 2], rstride=1, cstride=1, cmap="viridis"
)
axes.set(xlabel="x / um", ylabel="y / um", title="Surface: 3-D View")
axes.set_zlabel("z / um", labelpad=8)
axes.set_box_aspect((3, 2, 1))  # the heights are smaller than the lateral size

# Missing points break the line.
axes = figure.add_subplot(1, 3, 3)
axes.plot(profile.x_axis * 1e6, profile.data[0] * 1e6)
axes.set(xlabel="x / um", ylabel="z / um", title="Profile")

if len(sys.argv) > 1:
    figure.savefig(sys.argv[1])
    print("figure written:", sys.argv[1])
else:
    plt.show()