9.12. Provenance and Reproducibility#
Provenance records explain what happened during acquisition and make a field session reproducible after the instruments have left the site. In an IoT workflow, that provenance manifest should include station occupation metadata, devices, quality control decisions, raw-file integrity hashes, processing steps, and the environment used to create the manifest itself.
This page uses one real file from the repository demo data,
data/AMT/WILLY_DATA/L18PLT/18-001A.edi, for the raw-file hash. The
IoT telemetry packets are synthetic because provenance is
documenting acquisition events around the data, not reprocessing the
EDI file itself.
9.12.1. Hash A Raw File#
Use hash_raw_file() to create an integrity
record. It streams the file in fixed-size chunks through SHA-256 rather
than loading it whole, so \(\mathrm{digest} =
\mathrm{SHA256}(\mathrm{bytes})\) comes out identically however the file
is read — in one pass or in chunks, on this machine or another. The full
digest is stored in manifests; the example prints only a prefix to keep
the documentation readable.
>>> import json
>>> from pathlib import Path
>>> from pycsamt.iot import hash_raw_file
>>> raw_file = Path("data/AMT/WILLY_DATA/L18PLT/18-001A.edi")
>>> raw_hash = hash_raw_file(str(raw_file))
>>> print(json.dumps({
... "name": raw_hash["name"],
... "bytes": raw_hash["bytes"],
... "algo": raw_hash["algo"],
... "digest_prefix": raw_hash["digest"][:16],
... }, indent=2))
{
"name": "18-001A.edi",
"bytes": 22758,
"algo": "sha256",
"digest_prefix": "a7bc0c7b290c1847"
}
9.12.2. Log QC Decisions#
Use log_qc_decision() for normalized audit
records. It standardizes station IDs, channel names, decisions, reasons,
timestamps, and optional acquisition windows into the same shape every
time, which matters later: these records are exactly what feeds the
hash chain built further down this page.
>>> from pycsamt.iot import log_qc_decision
>>> qc_reject = log_qc_decision(
... "001A", "reject", channel="ey",
... reasons=["finite_coverage_below_threshold"],
... operator="pyCSAMT documentation", timestamp=1_700_000_120.0,
... window=(1_700_000_060.0, 1_700_000_120.0),
... )
>>> print(json.dumps(qc_reject, indent=2))
{
"station": "001A",
"channel": "ey",
"decision": "reject",
"reasons": [
"finite_coverage_below_threshold"
],
"operator": "pyCSAMT documentation",
"logged_utc": "2023-11-14T22:15:20+00:00",
"window": [
1700000060.0,
1700000120.0
]
}
9.12.3. Building A Session Manifest#
The easiest route is to build a field session, add devices,
stations, and packets, then call
to_manifest(). The manifest can
still be enriched afterward with raw-file hashes, extra QC decisions,
and processing steps. created_utc and each decision’s logged_utc
are overridden below to fixed timestamps purely so this page’s output is
reproducible; a real deployment would leave them at their real wall-clock
values.
>>> from pycsamt.iot import (
... DeviceConfig, FieldSession, MonitoringConfig, StationConfig, TelemetryPacket,
... )
>>> device = DeviceConfig(
... "l18-node-01", station="001A", protocol="file", sample_rate_hz=512.0,
... channels=["ex", "ey", "hx", "hy"], role="recorder",
... metadata={
... "instrument_serial": "DOC-REC-001", "firmware": "demo-2.0",
... "source_edi": raw_file.as_posix(),
... },
... )
>>> station = StationConfig(
... "001A", profile="L18", position_m=0.0, channels=["ex", "ey", "hx", "hy"],
... dipole_length_m=50.0, ex_azimuth_deg=90.0, ey_azimuth_deg=0.0,
... device_ids=[device.device_id], operator="pyCSAMT documentation",
... notes="Station id and raw EDI file are from the repository demo data.",
... )
>>> session = FieldSession(
... "WILLY-L18-PROVENANCE-DEMO", devices=[device], stations=[station],
... method="amt", operator="pyCSAMT documentation",
... monitoring_config=MonitoringConfig(
... method="amt", required_channels=["ex", "ey", "hx", "hy"],
... frequency_band_hz=(1.0, 1000.0),
... ),
... metadata={"real_data_path": "data/AMT/WILLY_DATA/L18PLT"},
... )
>>> for index, (accepted, decision, reasons) in enumerate(
... [(True, "accept", []), (False, "reject", ["finite_coverage_below_threshold"])]
... ):
... _ = session.add_packet(
... TelemetryPacket.from_device(
... device, timestamp=1_700_000_000.0 + 60.0 * index,
... survey_id=session.survey_id, kind="qc",
... payload={
... "method": "amt", "station": "001A",
... "channels": ["ex", "ey", "hx", "hy"],
... "frequency_band_hz": [1.0, 1000.0], "accepted": accepted,
... "decision": decision, "reasons": reasons,
... "battery_v": 12.4 - 0.1 * index,
... },
... )
... )
>>> manifest = session.to_manifest()
>>> manifest.created_utc = "2023-11-14T22:13:20+00:00"
>>> manifest.environment = {
... "python": "3.10", "platform": "documentation", "machine": "example",
... }
>>> for record in manifest.records:
... for item in record.qc_decisions:
... item["logged_utc"] = "2023-11-14T22:13:20+00:00"
>>> _ = manifest.add_file(str(raw_file))
>>> _ = manifest.add_qc_decision(
... station="001A", decision="reject", channel="ey",
... reasons=["finite_coverage_below_threshold"],
... operator="pyCSAMT documentation", timestamp=1_700_000_120.0,
... window=(1_700_000_060.0, 1_700_000_120.0),
... )
>>> manifest.add_processing_step("loaded station metadata from L18 demo line")
>>> manifest.add_processing_step("recorded synthetic IoT QC packet decisions")
>>> manifest_dict = manifest.as_dict()
>>> print(json.dumps({
... "survey_id": manifest_dict["survey_id"],
... "method": manifest_dict["method"],
... "operator": manifest_dict["operator"],
... "tool_version": manifest_dict["tool_version"],
... "n_records": len(manifest_dict["records"]),
... "n_devices": len(manifest_dict["devices"]),
... "n_files": len(manifest_dict["files"]),
... "n_qc_decisions": len(manifest_dict["qc_decisions"]),
... "content_hash_prefix": manifest_dict["content_hash"][:16],
... }, indent=2))
{
"survey_id": "WILLY-L18-PROVENANCE-DEMO",
"method": "amt",
"operator": "pyCSAMT documentation",
"tool_version": "2.2.1",
"n_records": 1,
"n_devices": 1,
"n_files": 1,
"n_qc_decisions": 3,
"content_hash_prefix": "0d109730c14707c4"
}
n_qc_decisions is 3, not 2, even though only one explicit
add_qc_decision call happened at this level: to_manifest()
already folded the two packet-derived QC decisions into the station
record, and add_qc_decision above appended a third, manifest-level
entry alongside them — the two lists are related but not the same one.
That content_hash is what makes the manifest self-checking. Every
field of the payload — including tool_version above — is serialised
to a canonical JSON encoding \(C(\cdot)\): keys sorted, no
surrounding or separating whitespace, so the same content always
produces the same bytes regardless of field-insertion order. The
content hash is then
i.e. the hash covers every field of the manifest except itself, which
is what lets a reviewer strip content_hash back out, recompute
\(H\), and compare.
9.12.4. Verifying The Content Hash#
Doing exactly that independently of pyCSAMT’s own bookkeeping confirms the manifest above was not altered after export:
>>> import hashlib
>>> payload_no_hash = {k: v for k, v in manifest_dict.items() if k != "content_hash"}
>>> canonical = json.dumps(
... payload_no_hash, sort_keys=True, default=str, separators=(",", ":")
... )
>>> recomputed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
>>> print(f"recomputed == content_hash: {recomputed == manifest_dict['content_hash']}")
recomputed == content_hash: True
>>> print(f"canonical JSON length: {len(canonical)} bytes")
canonical JSON length: 2362 bytes
Because tool_version is part of what gets hashed, the same
acquisition inputs will still produce a different content_hash
after a pyCSAMT upgrade — the digest is a snapshot of the manifest
bytes, not a content-only fingerprint of the survey. Pin or record
tool_version alongside any digest you archive for long-term
comparison, rather than expecting it to stay constant across releases.
This dependency is sharper than it first looks: tool_version comes
from importlib.metadata.version(), which resolves package metadata
by scanning installed distributions — a stale duplicate .dist-info
directory left behind by a previous install can make that lookup
ambiguous and version-dependent output like this genuinely
non-deterministic on an otherwise unchanged machine. Keep an
environment’s package metadata clean for exactly this reason before
archiving a digest for long-term comparison.
9.12.5. Inspect A Station Record#
Each station record collects occupation metadata and QC decisions for that station. Session-derived records are convenient when packets already carry station, method, channel, and battery metadata.
>>> record = manifest.as_dict()["records"][0]
>>> print(json.dumps({
... "station_id": record["station_id"],
... "operator": record["operator"],
... "sample_rate_hz": record["sample_rate_hz"],
... "battery_status": record["battery_status"],
... "accepted_band_hz": record["accepted_band_hz"],
... "qc_decisions": record["qc_decisions"],
... }, indent=2))
{
"station_id": "001A",
"operator": "pyCSAMT documentation",
"sample_rate_hz": null,
"battery_status": null,
"accepted_band_hz": [
1.0,
1000.0
],
"qc_decisions": [
{
"station": "001A",
"channel": null,
"decision": "accept",
"reasons": [],
"operator": "pyCSAMT documentation",
"logged_utc": "2023-11-14T22:13:20+00:00",
"window": null
},
{
"station": "001A",
"channel": null,
"decision": "reject",
"reasons": [
"finite_coverage_below_threshold"
],
"operator": "pyCSAMT documentation",
"logged_utc": "2023-11-14T22:13:20+00:00",
"window": null
}
]
}
sample_rate_hz and battery_status are null here for a more
specific reason than “the data is missing”: both packets above were sent
with kind="qc", and neither sample_rate_hz nor battery_v is a
field the QC payload schema recognises — sample_rate_hz belongs to
the acquisition (data) payload and battery_v to the health
payload, so those keys are silently ignored when folded into the station
aggregate even though the payload dictionaries above do carry a
battery_v entry. Sending at least one health packet, the way Telemetry Transports does
alongside its qc packets, is what actually populates
battery_status.
9.12.6. Building A Manual Audit#
Use ProvenanceRecord and
build_acquisition_manifest() when you are
assembling a manifest from a station sheet, field notes, and file hashes
instead of a live field session. The resulting manifest gets the
same \(H(\cdot)\) treatment as above:
>>> from pycsamt.iot import ProvenanceRecord, build_acquisition_manifest
>>> qc_accept = log_qc_decision(
... "001A", "accept", channel="ex",
... reasons=["finite_coverage_ok", "spike_fraction_ok"],
... operator="pyCSAMT documentation", timestamp=1_700_000_060.0,
... window=(1_700_000_000.0, 1_700_000_060.0),
... )
>>> manual_record = ProvenanceRecord(
... station_id="001A", instrument_serial="DOC-REC-001", firmware="demo-2.0",
... operator="pyCSAMT documentation", ex_azimuth_deg=90.0, ey_azimuth_deg=0.0,
... occupation_start=1_700_000_000.0, occupation_end=1_700_000_120.0,
... sample_rate_hz=512.0, gps_quality="locked", battery_status="12.3 V",
... accepted_band_hz=(1.0, 1000.0),
... field_notes="Manual audit record built from station sheet and QC logs.",
... qc_decisions=[qc_accept, qc_reject],
... processing_steps=["edge finite-coverage screening", "operator review"],
... )
>>> _ = manual_record.add_raw_file(str(raw_file))
>>> manual_manifest = build_acquisition_manifest(
... "WILLY-L18-MANUAL-AUDIT", records=[manual_record], devices=[device.as_dict()],
... qc_decisions=[qc_accept, qc_reject],
... processing_steps=["manual audit assembled for documentation"],
... method="amt", operator="pyCSAMT documentation",
... metadata={"source": "documentation example"},
... )
>>> manual_manifest.created_utc = "2023-11-14T22:13:20+00:00"
>>> manual_manifest.environment = {
... "python": "3.10", "platform": "documentation", "machine": "example",
... }
>>> print(manual_manifest.as_dict()["content_hash"][:16])
8df723d18df316c7
The manual and session-derived manifests hash to different prefixes even
though they describe the same station, same instrument, and overlapping
QC decisions — survey_id alone (WILLY-L18-MANUAL-AUDIT versus
WILLY-L18-PROVENANCE-DEMO) is enough to guarantee that, since it is
one of the hashed fields.
9.12.7. Hash-Chained QC Logs#
A single content_hash tells a reviewer whether the whole manifest
still matches what was exported. A sharper question needs its own tool:
which QC entry, if any, was altered inside a running log?
hash_chain() answers it by folding each
entry’s hash into the next, the same way a chain of given blocks
would, using the same canonical-JSON \(H(\cdot)\) from above. The
first entry chains from an empty genesis hash:
Because \(h_i\) depends on \(h_{i-1}\), editing, deleting, or
reordering any entry \(e_j\) changes \(h_j\) and therefore every
hash after it — verify_hash_chain() just
recomputes the recurrence and checks it still matches what was stored:
>>> from pycsamt.iot.provenance import hash_chain, verify_hash_chain
>>> chain = hash_chain([qc_accept, qc_reject])
>>> for entry in chain:
... print(
... f"seq={entry['seq']} "
... f"prev_hash={entry['prev_hash'][:8] or '(genesis)'} "
... f"entry_hash={entry['entry_hash'][:8]}"
... )
seq=0 prev_hash=(genesis) entry_hash=f6d7c1d0
seq=1 prev_hash=f6d7c1d0 entry_hash=4e91a4f8
>>> print(f"verify_hash_chain (untouched): {verify_hash_chain(chain)}")
verify_hash_chain (untouched): True
>>> tampered = [dict(e) for e in chain]
>>> tampered[1]["reasons"] = ["tampered_after_the_fact"]
>>> print(f"verify_hash_chain (tampered): {verify_hash_chain(tampered)}")
verify_hash_chain (tampered): False
Editing only chain[1]["reasons"] was enough to break verification —
notice that entry 0 was never touched, but the chain still fails as a
whole because entry 1’s stored entry_hash no longer matches what the
recurrence recomputes for its (now-different) contents.
9.12.8. Signed Manifests#
A hash chain proves internal consistency, but not authorship: anyone can
recompute \(H(\cdot)\) and produce a chain that verifies. The
remaining question is whether someone other than the exporter can prove
they produced this manifest.
sign_mapping() answers that by wrapping the
same canonical JSON in an HMAC rather than a plain hash,
so only someone holding key could have produced that signature, and
verify_manifest() checks both the
manifest signature and the wrapped content hash in one
call:
>>> from pycsamt.iot.provenance import verify_manifest
>>> signed = manual_manifest.sign("demo-shared-key")
>>> print(f"signature_algo: {signed['signature_algo']}")
signature_algo: hmac-sha256
>>> print(f"signature prefix: {signed['signature'][:16]}")
signature prefix: 17c7bf2d87a88db9
>>> print(f"verify_manifest (untouched): {verify_manifest(signed, 'demo-shared-key')}")
verify_manifest (untouched): True
>>> tampered_signed = json.loads(json.dumps(signed))
>>> tampered_signed["manifest"]["operator"] = "someone else"
>>> print(f"verify_manifest (tampered): {verify_manifest(tampered_signed, 'demo-shared-key')}")
verify_manifest (tampered): False
>>> print(f"verify_manifest (wrong key): {verify_manifest(signed, 'not-the-key')}")
verify_manifest (wrong key): False
Both failure modes matter for different reasons: a tampered payload fails because its recomputed content hash no longer matches, while a correct payload checked against the wrong key fails the HMAC comparison even though the JSON itself is untouched. A hash chain is enough for a single-party audit trail that only needs tamper evidence; add a manifest signature when a second party needs to verify who produced the manifest, not just that it is internally consistent.
9.12.9. Exporting A Reproducibility Bundle#
Use export_reproducibility_bundle() to
write the manifest and one JSON audit per station. Set
zip_bundle=True when you also want a zip archive for hand-off. Set
include_raw=True only when you intentionally want raw files copied
into the bundle.
>>> from pycsamt.iot import export_reproducibility_bundle, export_station_audit
>>> out_dir = "docs/source/user_guide/iot/_provenance_demo_bundle"
>>> bundle = export_reproducibility_bundle(manual_manifest, out_dir, zip_bundle=True)
>>> audit_path = export_station_audit(
... manual_record, f"{out_dir}/single_station_audit.json",
... )
>>> print(json.dumps({
... "manifest_name": Path(bundle["manifest"]).name,
... "audit_count": len(bundle["audits"]),
... "zip_name": Path(bundle["zip"]).name,
... "single_station_audit": Path(audit_path).name,
... }, indent=2))
{
"manifest_name": "acquisition_manifest.json",
"audit_count": 1,
"zip_name": "_provenance_demo_bundle.zip",
"single_station_audit": "single_station_audit.json"
}
The manifest gives reviewers enough information to understand and verify the acquisition trail: which station was occupied, which device was used, which QC decisions were made, which raw file was referenced, and whether the manifest content changed after export. Raw-file hashes protect the integrity of data files; a content hash protects the manifest that describes them; a hash chain protects the order and content of a running QC log; and a manifest signature protects who is allowed to be believed as the source of all of the above. Station audits preserve the field context around those files independently of any of the four.
For production surveys, write the manifest beside the processed
products and keep the raw-file hash records even when raw files are too
large to bundle, and record the exporting tool_version alongside any
archived digest. That makes later reporting, reprocessing, and external
review much less fragile.