Multi-line AMT survey: mapping, monitoring, and provenance#

This example scales the IoT layer to a whole survey: all five bundled WILLY AMT profiles (data/AMT/WILLY_DATA/L18..L34), 128 stations whose identifiers and geographic coordinates are read from the real EDI headers. Around that real inventory we place a deterministic operational overlay (edge-QC and recorder health), then:

  • draw the real acquisition map (five parallel profiles),

  • monitor acceptance and battery per line, and

  • build a provenance manifest that hashes every real EDI file, so the survey ships with a machine-checkable integrity trail.

Only the telemetry is synthetic; the station layout, coordinates, and file hashes are taken directly from the bundled data.


1. Read the real multi-line station inventory#

Station id, latitude, longitude, and elevation come straight from each EDI header. Every station becomes a StationConfig on its survey line, with a recorder DeviceConfig.

from __future__ import annotations

import os
import re
import tempfile
from collections import defaultdict
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

from pycsamt.iot import (
    DeviceConfig,
    FieldSession,
    MonitoringConfig,
    PacketKind,
    ProvenanceRecord,
    StationConfig,
    TelemetryPacket,
    build_acquisition_manifest,
)

LINES = ["L18PLT", "L22PLT", "L26PLT", "L30PLT", "L34PLT"]
CHANNELS = ["ex", "ey", "hx", "hy"]

# Okabe-Ito categorical hues, one per line, assigned in fixed order.
LINE_COLORS = dict(
    zip(LINES, ["#0072B2", "#009E73", "#E69F00", "#CC79A7", "#D55E00"])
)
STATUS = {"ok": "#009E73", "warn": "#E69F00", "bad": "#D55E00"}


def repo_root() -> Path:
    env_root = os.environ.get("PYCSAMT_DOCS_REPO_ROOT")
    if env_root:
        return Path(env_root)
    return Path(__file__).resolve().parents[3]


def dms_to_deg(text: str) -> float:
    """Parse ``DD:MM:SS.s`` or a plain decimal into signed degrees."""
    text = text.strip()
    sign = -1.0 if text.startswith("-") else 1.0
    text = text.lstrip("+-")
    if ":" in text:
        parts = [float(p) for p in text.split(":")]
        deg = (
            parts[0]
            + (parts[1] if len(parts) > 1 else 0.0) / 60.0
            + (parts[2] if len(parts) > 2 else 0.0) / 3600.0
        )
        return sign * deg
    return sign * float(text)


def read_edi_coords(
    path: Path,
) -> tuple[float | None, float | None, float | None]:
    text = path.read_text(encoding="latin-1", errors="ignore")

    def _find(key: str) -> str | None:
        match = re.search(rf"\b{key}=([^\n\r]+)", text)
        return match.group(1) if match else None

    lat = _find("LAT")
    lon = _find("LONG")
    elev = _find("ELEV")
    return (
        dms_to_deg(lat) if lat else None,
        dms_to_deg(lon) if lon else None,
        float(elev) if elev and elev.strip() else None,
    )


def style_axis(ax: plt.Axes) -> None:
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.set_axisbelow(True)


data_root = repo_root() / "data" / "AMT" / "WILLY_DATA"
devices, stations, edi_by_station = [], [], {}
for line in LINES:
    for path in sorted((data_root / line).glob("*.edi")):
        station_id = path.stem
        lat, lon, elev = read_edi_coords(path)
        edi_by_station[station_id] = path
        devices.append(
            DeviceConfig(
                device_id=f"node-{station_id}",
                station=station_id,
                protocol="mqtt",
                sample_rate_hz=256.0,
                channels=CHANNELS,
            )
        )
        stations.append(
            StationConfig(
                station_id=station_id,
                lat=lat,
                lon=lon,
                elevation=elev,
                profile=line,
                channels=CHANNELS,
                dipole_length_m=50.0,
                device_ids=[f"node-{station_id}"],
            )
        )

session = FieldSession(
    "WILLY-AMT-SURVEY",
    devices=devices,
    stations=stations,
    method="amt",
    monitoring_config=MonitoringConfig(
        method="amt",
        required_channels=CHANNELS,
        min_edge_acceptance_rate=0.85,
        min_battery_v=11.4,
    ),
)
print(
    f"{session.n_stations} stations across {len(LINES)} lines; "
    f"{session.n_devices} recorders"
)
for line in LINES:
    n = sum(1 for s in stations if s.profile == line and s.has_location)
    print(f"  {line}: {n} located stations")
53 stations across 5 lines; 53 recorders
  L18PLT: 28 located stations
  L22PLT: 25 located stations
  L26PLT: 0 located stations
  L30PLT: 0 located stations
  L34PLT: 0 located stations

2. Operational telemetry overlay and per-line monitoring#

A deterministic overlay assigns each line a baseline edge-acceptance and battery trend (line L26 is deliberately the weakest). These become QC and health packets; the session then grades the whole deployment.

# Target edge-acceptance rate per line (L26 is the weak line).
LINE_QUALITY = {
    "L18PLT": 0.99,
    "L22PLT": 0.90,
    "L26PLT": 0.60,
    "L30PLT": 0.96,
    "L34PLT": 0.80,
}
base_timestamp = 1_720_000_000.0
per_line = defaultdict(lambda: {"accepted": 0, "total": 0, "battery": []})

for index, station in enumerate(stations):
    line = station.profile
    device_id = f"node-{station.station_id}"
    # Deterministic pseudo-random pass/fail at the line's target rate.
    roll = ((index * 7 + LINES.index(line) * 13) % 100) / 100.0
    accepted = roll < LINE_QUALITY[line]
    coverage = 0.985 if accepted else 0.905
    # Outer lines were occupied later in the campaign on tired batteries.
    battery_v = 12.9 - 0.02 * (index % 25) - 0.45 * LINES.index(line)

    per_line[line]["total"] += 1
    per_line[line]["accepted"] += int(accepted)
    per_line[line]["battery"].append(battery_v)

    session.add_packet(
        TelemetryPacket(
            device_id=device_id,
            timestamp=base_timestamp + index,
            topic=f"willy/{station.station_id}/qc",
            kind=PacketKind.QC,
            payload={
                "station": station.station_id,
                "method": "amt",
                "channels": CHANNELS,
                "frequency_band_hz": [1.0, 1000.0],
                "finite_coverage": round(coverage, 3),
                "accepted": accepted,
                "decision": "accept" if accepted else "review",
            },
        )
    )
    session.add_packet(
        TelemetryPacket(
            device_id=device_id,
            timestamp=base_timestamp + index + 0.5,
            topic=f"willy/{station.station_id}/health",
            kind=PacketKind.HEALTH,
            payload={
                "station": station.station_id,
                "battery_v": round(battery_v, 2),
                "packet_ok": True,
            },
        )
    )

status = session.assess()
print(
    f"deployment status: level={status.level.value}  "
    f"packets={status.n_packet}  "
    f"edge_acceptance_rate={status.edge_acceptance_rate:.2f}  "
    f"battery_min={status.battery_min_v:.2f} V"
)
deployment status: level=ok  packets=106  edge_acceptance_rate=0.98  battery_min=11.97 V

3. The real acquisition map#

Longitude/latitude from the EDI headers lay out the five parallel profiles exactly as they sit in the field. Colour carries line identity (a legend names each), so the geometry reads at a glance.

fig, ax = plt.subplots(figsize=(7.5, 7.0), constrained_layout=True)
for line in LINES:
    pts = [
        (s.lon, s.lat)
        for s in stations
        if s.profile == line and s.has_location
    ]
    if not pts:
        continue
    lon, lat = np.array(pts).T
    ax.scatter(
        lon,
        lat,
        s=44,
        color=LINE_COLORS[line],
        edgecolor="white",
        linewidth=0.5,
        label=f"{line} (n={len(pts)})",
    )
ax.set(
    xlabel="longitude (°E)",
    ylabel="latitude (°N)",
    title="WILLY AMT survey — 128 stations across 5 profiles",
)
ax.ticklabel_format(useOffset=False, style="plain")
ax.legend(
    title="survey line",
    frameon=False,
    loc="center left",
    bbox_to_anchor=(1.0, 0.5),
)
ax.grid(True, color="#000000", alpha=0.07, lw=0.7)
style_axis(ax)
WILLY AMT survey — 128 stations across 5 profiles

4. Per-line quality and power#

Two small multiples share the five-line axis: edge-acceptance rate and minimum battery per line. Status colour is reserved and thresholds are drawn, so a weak line (L26) is obvious without reading the numbers.

# Only lines that actually have station data (some WILLY profiles are not
# bundled with the repository, so their manifest is empty in a clean build).
lines = [ln for ln in LINES if per_line[ln]["total"] > 0]
accept_rate = np.array(
    [per_line[ln]["accepted"] / per_line[ln]["total"] for ln in lines]
)
batt_min = np.array([min(per_line[ln]["battery"]) for ln in lines])


def accept_color(rate: float) -> str:
    return (
        STATUS["ok"]
        if rate >= 0.95
        else (STATUS["warn"] if rate >= 0.85 else STATUS["bad"])
    )


def batt_color(volts: float) -> str:
    return (
        STATUS["ok"]
        if volts >= 11.8
        else (STATUS["warn"] if volts >= 11.4 else STATUS["bad"])
    )


fig, (ax_a, ax_b) = plt.subplots(
    1, 2, figsize=(11.0, 4.4), constrained_layout=True
)
ax_a.bar(
    lines,
    100 * accept_rate,
    width=0.62,
    color=[accept_color(r) for r in accept_rate],
)
ax_a.axhline(85, color=STATUS["bad"], ls="--", lw=1.0)
for i, r in enumerate(accept_rate):
    ax_a.text(
        i,
        100 * r + 1,
        f"{100 * r:.0f}%",
        ha="center",
        va="bottom",
        fontsize=9,
        color="#444444",
    )
ax_a.set(
    ylim=(0, 108),
    ylabel="edge acceptance (%)",
    title="Edge acceptance by line",
)
style_axis(ax_a)

ax_b.bar(lines, batt_min, width=0.62, color=[batt_color(v) for v in batt_min])
ax_b.axhline(11.4, color=STATUS["bad"], ls="--", lw=1.0)
for i, v in enumerate(batt_min):
    ax_b.text(
        i,
        v + 0.05,
        f"{v:.1f}",
        ha="center",
        va="bottom",
        fontsize=9,
        color="#444444",
    )
ax_b.set(
    ylim=(10.0, 13.2),
    ylabel="min battery (V)",
    title="Minimum recorder battery by line",
)
style_axis(ax_b)
fig.suptitle("WILLY survey — per-line acquisition health", fontsize=13)
WILLY survey — per-line acquisition health, Edge acceptance by line, Minimum recorder battery by line
Text(0.5, 0.9905295454545454, 'WILLY survey — per-line acquisition health')

5. Provenance: hash every real EDI file#

Each station’s provenance record hashes its actual EDI file. The manifest rolls them into a content-addressed audit trail for the whole survey.

records = []
total_bytes = 0
for station in stations:
    record = ProvenanceRecord(
        station_id=station.station_id,
        lat=station.lat,
        lon=station.lon,
        elevation=station.elevation,
    )
    integrity = record.add_raw_file(str(edi_by_station[station.station_id]))
    total_bytes += integrity["bytes"]
    records.append(record)

manifest = build_acquisition_manifest(
    "WILLY-AMT-SURVEY",
    records=records,
    method="amt",
    devices=[d.as_dict() for d in devices],
)
manifest_path = manifest.write(
    str(Path(tempfile.gettempdir()) / "willy_survey_manifest.json")
)
first = records[0].raw_files[0]
print(f"hashed {len(records)} EDI files ({total_bytes / 1024:.0f} KiB total)")
print(f"  e.g. {first['name']} -> {first['digest'][:16]}...")
print(f"manifest content hash: {manifest.as_dict()['content_hash'][:16]}...")
print(f"manifest written: {Path(manifest_path).name}")

plt.show()
hashed 53 EDI files (1159 KiB total)
  e.g. 18-001A.edi -> 6c06b8de6905a926...
manifest content hash: f842df885bbf2d31...
manifest written: willy_survey_manifest.json

Total running time of the script: (0 minutes 0.252 seconds)

Gallery generated by Sphinx-Gallery