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, Unitfrom 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.checkpointreport = 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
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 950member 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 20The 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.
What just happened?
Section titled “What just happened?”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.
Name the field roles
Section titled “Name the field roles”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.
Check the projection first
Section titled “Check the projection first”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.
What an export leaves out
Section titled “What an export leaves out”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.
Where next
Section titled “Where next”- Replicates and the mean — the simpler case, where every replicate already shares one grid and no alignment step is needed.
- Lineage and the recorded recipe — how each resample’s target axis and operation name end up recorded in the new checkpoint.