Skip to content

Align mixed-resolution members

Your replicates were measured on different wavenumber grids, so a plain mean over the committed checkpoints has no point-to-point correspondence and will not work.

sample.aggregate is still the route: name axis and value explicitly and let the guide’s alignment step prepare the members first, so the combined result keeps the exact input versions.

This guide assumes: you have finished the tutorial path, in particular Replicates and the mean, which covers the simple case where every replicate shares one grid, and Transform and plot a spectrum, which introduces transformation, and Look underneath a result, which introduces registered operations.

Suppose your second FTIR run of polymer-film-01 came from an older bench that records fewer points over a narrower range. Both runs are honest measurements of the same sample, but their wavenumber axes disagree. The way through is to pick one explicit analysis grid, resample every member onto it, and only then aggregate. Afterwards you read the alignment report to see exactly what interpolation each member went through.

import numpy as np
from mtrl import InstrumentRun, MaterialData, Mtrl, Unit
from mtrl.grids import alignment_report_to_text, grid_from_record
application = Mtrl(sample_id="polymer-film-01")
members = []
for name, axis in (
("ftir-replicate-01", np.linspace(2000.0, 3000.0, 101)),
("ftir-replicate-02", np.linspace(2050.0, 3000.0, 20)),
):
content = np.empty(
axis.size,
dtype=[("wavenumber", "<f8"), ("absorbance", "<f8"), ("temperature_c", "<f8")],
)
content["wavenumber"] = axis
content["absorbance"] = np.exp(-((axis - 2500.0) / 35.0) ** 2)
content["temperature_c"] = 22.5
run = InstrumentRun(run_name=name)
checkpoint = MaterialData(content=content, type="raw", checkpoint_name="spectrum")
run.add_checkpoint(checkpoint)
application.add(run)
members.append((run.run_id, checkpoint.checkpoint_id))
analysis_grid = grid_from_record(
application.get_active_record(members[1][1]), x_field="wavenumber"
)
aligned = []
for run_id, checkpoint_id in members:
resampled = application.transform(
run_id,
checkpoint_id,
"resample_linear",
parameters={
"x_field": "wavenumber",
"y_field": "absorbance",
"x_targets": analysis_grid,
},
checkpoint_name="aligned",
)
aligned.append((run_id, resampled.checkpoint_id))
print(f"aligned fields: {application.get_active_record(aligned[0][1]).content.dtype.names}")
candidate = application.aggregate(
aligned,
run_name="ftir-mean",
parameters={"x_field": "wavenumber", "y_field": "absorbance"},
)
application.add(candidate)
aggregate_checkpoint = candidate.checkpoint
report = application.alignment_report(
candidate.run_id, aggregate_checkpoint.checkpoint_id
)
print(alignment_report_to_text(report))
kinetic = []
for name, times in (
("kinetic-run-01", np.linspace(0.0, 600.0, 601)),
("kinetic-run-02", np.linspace(0.0, 600.0, 61)),
):
content = np.empty(
times.size,
dtype=[("time", "<f8"), ("value", "<f8"), ("temperature_c", "<f8")],
)
content["time"] = times
content["value"] = 1.0 - np.exp(-times / 120.0)
content["temperature_c"] = 22.5
run = InstrumentRun(run_name=name)
checkpoint = MaterialData(
content=content,
type="raw",
checkpoint_name="trace",
units=Unit.from_raw_map({"time": "s", "value": "a.u.", "temperature_c": "degC"}),
)
run.add_checkpoint(checkpoint)
application.add(run)
kinetic.append((run.run_id, checkpoint.checkpoint_id))
time_grid = grid_from_record(application.get_active_record(kinetic[1][1]), x_field="time")
projected = application.transform(
kinetic[0][0],
kinetic[0][1],
"resample_linear",
parameters={"x_field": "time", "y_field": "value", "x_targets": time_grid},
checkpoint_name="projected",
)
print(f"projected fields: {projected.content.dtype.names}")

The first printed line is aligned fields: ('wavenumber', 'absorbance'): both aligned checkpoints hold exactly the two selected fields, in binary64. The committed temperature_c field did not survive the projection. The printed report then names the aggregate, its method, its axis

Terminal window
aligned fields: ('wavenumber', 'absorbance')
alignment report: run '01a1830d-b52c-752f-98b5-58125ea47a38' checkpoint '01a1830d-b52d-72de-9447-390c96b73b73'
aggregate record '01a1830d-b52d-72de-9447-390e3a63621f' method mtrl.aggregate.mean x_field 'wavenumber'
aggregate axis: 20 points span 950
member run '01a1830d-b51e-72cb-9a5e-8e12b3dc82fe' checkpoint '01a1830d-b527-734e-b051-45c84b01b82a' record '01a1830d-b528-7640-91ce-84c4a4b5b7e9' kind resampled producing_method mtrl.resample.linear method mtrl.resample.linear basis resample-source source_record 01a1830d-b524-74d2-aff9-c71f8ad0740b source_points 101 target_points 20

The identifiers are generated per run, so yours will differ; the structure stays: the aggregate’s identity, method and axis field, its point count and span, then one line per member with its kind, producing method and grid facts. The report reads the aggregate’s recorded member references and gives one fixed-width line of interpolation facts per member in recorded order. The formatter prints recorded and derived numbers; it adds no judgements. The last printed line is projected fields: ('time', 'value'): the same projection on a kinetic trace also drops that trace’s temperature_c field.

A mean needs one absorbance value per grid point from every member. Your two runs committed different wavenumber axes, so position five in one run is not the same wavenumber as position five in the other. Rather than let an operation guess, you chose the grid yourself. Here grid_from_record reads the coarser replicate’s committed axis and returns it as a plain list of floats, which lands in provenance verbatim when passed as x_targets. The grid itself is recorded data, not a hidden default.

Each application.transform(...) call then ran resample_linear on one member and committed the result as a new checkpoint named aligned, with its own lineage recording the operation and the exact target axis. Nothing about the raw spectra changed. The aggregate call averaged the two aligned checkpoints, which now share one axis point for point, and application.add(candidate) committed the detached candidate.

Finally, application.alignment_report walked the committed aggregate’s recorded member references and reported each member’s interpolation facts. The report is read-only: it reads committed records and stores nothing — no record, no revision, and no archive state changes. Because it follows the exact recorded member references, a later correction of a member does not rewrite what the report says about the versions the aggregate used.

The recorded axis role is authoritative. For an older or manually corrected aggregate that records no effective axis field, pass it explicitly, for example application.alignment_report(run_id, checkpoint_id, x_field="wavenumber"). Omitting it in that case, or passing a name that conflicts with recorded provenance, raises InvalidAggregationError; the report never guesses an axis.

Caution — Resampling never extrapolates. Every target must lie inside each member’s own source axis range, so choose an analysis grid inside the range all members share. Here the coarse axis (2050–3000 cm^-1) sits inside the fine axis (2000–3000 cm^-1), which is why the coarse member’s own grid was a safe choice.

Tip — Interpolated values are not measurements. The aligned checkpoints are transformed records, and their lineage says so. Keep aggregating from the aligned checkpoints, and keep the raw spectra as the evidence they came from.

Resampling reads two field roles from the data, and you name both. x_field is the coordinate that the operation interpolates along. y_field is the value it interpolates. The default catalogue declares no field-name default, so a call that omits either role fails before the adapter runs; the tutorial names "wavenumber" and "absorbance" because its story is an FTIR spectrum.

The kinetic part of the example uses x_field="time" and y_field="value". The operation does not inspect dtypes or column order to find those fields, and it adds no numeric field of its own. grid_from_record(..., x_field="time") reads the coarse trace’s time axis, and the fine trace is projected onto that axis.

Resampling keeps exactly the two selected fields. Its output schema is selected-fields, so every other field leaves the result, with its unit. The aligned checkpoints above therefore hold wavenumber and absorbance only. The kinetic projection holds time and value only. Units for the selected fields survive; a unit for a dropped field does not. The new record’s provenance names both fields and the exact target list.

Interpolation stays explicit. aggregate never interpolates: it trims each member to the shared range of the selected axis and then requires exact point-to-point agreement. That is why the example resamples each member first, as a separate recorded operation.

The operation checks the selected fields before it interpolates. A missing field fails with a message that names the missing names and the available fields, for example requires field(s) ['elapsed']; available fields are ['time', 'value']. The axis field must be strictly monotonic in one direction, and the value field must hold floating-point values. Both fields must hold finite values.

The target axis must be one-dimensional, finite, and strictly monotonic in one direction, with at least two values. MTRL returns the rows in your target order. Every target must lie inside each member’s own inclusive source range. A target outside the range fails with the first offending target and the range in the message, for example received target 700.0 outside the inclusive source range [0.0, 600.0]. The target list holds at most 1,000,000 points.

Aligned members must also agree with each other before you aggregate them. They must hold the same fields and the same selected axis, point for point. A member projected with different field names cannot join the aggregate.

An exported aligned or projected checkpoint carries only its two selected fields, beside the checkpoint columns that every export writes. The export lesson explains the rest. A CSV holds the data table and nothing else: no units, no lineage, and no integrity manifest. A workbook adds manifest, metadata, lineage, and units sheets, but its lineage sheet records transformations and not an aggregate’s member list. A checkpoint whose content holds no table raises UnexportableCheckpointError when you export it directly, and a whole-container or whole-run frame raises the same error by default. That message lists every checkpoint that cannot join the frame, with its run identifier, its checkpoint identifier, and the reason, before any rows are combined, so nothing drops out silently. Pass strict=False when a partial frame is what you want: one CheckpointExportWarning names the same entries, and the frame carries the remaining checkpoints.

A content field that clashes with a label column takes a _1 suffix and stays lossless. A content field already named type_1 beside type contests that alias, so the frame and CSV paths raise UnexportableCheckpointError instead of guessing. A conversion with no label columns keeps both fields as distinct columns. The .mtrl archive stays the source of truth for every export; a frame, a CSV, and a workbook are one-way views.