Clock Synchronisation#

Clock synchronisation is part of acquisition quality control. A station can have excellent electrodes and clean spectra, but poor timing still damages transfer-function estimates, remote-reference work, and any comparison between stations. The pycsamt.iot.sync helpers audit device clocks against a reference such as GPS.

The examples below use synthetic timestamps. That is intentional: synchronisation checks need paired local/reference clock samples, not EDI impedance files. The synthetic devices represent common field cases: one healthy GPS-locked node, one drifting node, one node that lost GPS lock, and one badly drifting node.

What Is Measured#

offset_ms

Median local-reference timestamp offset in milliseconds.

drift_ppm

Linear clock drift in parts per million, estimated from offset change through time.

jitter_ms

Standard deviation of drift-corrected timing residuals.

gps_lock

Whether the node reports active GPS/reference lock.

quality

A compact grade: excellent, good, fair, poor, or unknown.

Build Synthetic Reference Samples#

The reference clock is sampled every 30 seconds for 10 minutes. Local device clocks are then offset, drifted, and jittered to create realistic audit cases.

 1import numpy as np
 2
 3rng = np.random.default_rng(24)
 4reference = np.arange(0.0, 600.0, 30.0)
 5
 6node_good = (
 7    reference
 8    + 0.00035
 9    + rng.normal(0.0, 0.00008, reference.size)
10)
11node_drift = (
12    reference
13    + 0.002
14    + 35e-6 * reference
15    + rng.normal(0.0, 0.00035, reference.size)
16)
17node_dropout = (
18    reference
19    + 0.0035
20    + rng.normal(0.0, 0.0012, reference.size)
21)
22node_bad = (
23    reference
24    + 0.012
25    + 110e-6 * reference
26    + rng.normal(0.0, 0.002, reference.size)
27)

Assess Individual Devices#

Use pycsamt.iot.ClockSynchronizer when you already have local and reference timestamps for one device. The thresholds in pycsamt.iot.SyncConfig define what counts as acceptable for this deployment.

 1from pycsamt.iot import ClockSynchronizer, SyncConfig, sync_status_table
 2
 3config = SyncConfig(
 4    tolerance_ms=1.0,
 5    reference="gps",
 6    max_drift_ppm=10.0,
 7    max_jitter_ms=1.0,
 8)
 9synchronizer = ClockSynchronizer(config)
10
11status = synchronizer.assess(
12    "l18-node-01",
13    node_good,
14    reference,
15    gps_lock=True,
16)
17
18table = sync_status_table(status)
19print(
20    table[
21        [
22            "device_id", "offset_ms", "drift_ppm", "jitter_ms",
23            "within_tolerance", "gps_lock", "quality",
24        ]
25    ].to_string(index=False)
26)

Output:

  device_id  offset_ms  drift_ppm  jitter_ms  within_tolerance  gps_lock   quality
l18-node-01   0.344218  -0.004302   0.068069              True      True excellent

Assess A Deployment#

For a deployment, keep one SyncStatus per node and turn the list into a table. This makes the failure mode visible: the second and fourth nodes exceed drift or offset limits, while the third node is capped at fair because GPS lock was lost.

 1statuses = [
 2    synchronizer.assess(
 3        "l18-node-01", node_good, reference, gps_lock=True
 4    ),
 5    synchronizer.assess(
 6        "l18-node-02", node_drift, reference, gps_lock=True
 7    ),
 8    synchronizer.assess(
 9        "l18-node-03", node_dropout, reference, gps_lock=False
10    ),
11    synchronizer.assess(
12        "l18-node-04", node_bad, reference, gps_lock=True
13    ),
14]
15
16table = sync_status_table(statuses)
17print(
18    table[
19        [
20            "device_id", "offset_ms", "drift_ppm", "jitter_ms",
21            "within_tolerance", "gps_lock", "quality",
22        ]
23    ].to_string(index=False)
24)

Output:

  device_id  offset_ms  drift_ppm  jitter_ms  within_tolerance  gps_lock   quality
l18-node-01   0.344218  -0.004302   0.068069              True      True excellent
l18-node-02  12.276217  35.547894   0.392135             False      True      poor
l18-node-03   3.162285  -1.116131   1.383608             False     False      fair
l18-node-04  42.897972 108.638919   1.678547             False      True      poor

Use The Batch Helper#

When references are already arranged by device, use pycsamt.iot.batch_assess_sync(). Each value can be a mapping with local, reference, and optional gps_lock fields.

 1from pycsamt.iot import batch_assess_sync
 2
 3batch = batch_assess_sync(
 4    {
 5        "l18-node-01": {
 6            "local": node_good,
 7            "reference": reference,
 8            "gps_lock": True,
 9        },
10        "l18-node-02": {
11            "local": node_drift,
12            "reference": reference,
13            "gps_lock": True,
14        },
15    },
16    config=config,
17)
18print(
19    batch[
20        ["device_id", "within_tolerance", "quality"]
21    ].to_string(index=False)
22)

Output:

  device_id  within_tolerance   quality
l18-node-01              True excellent
l18-node-02             False      poor

Detect GPS Dropout#

Use pycsamt.iot.detect_gps_dropout() to summarise lock/unlock sequences. This is different from timestamp-pair assessment: it asks whether the device had enough reference support during the acquisition period.

 1from pycsamt.iot import detect_gps_dropout
 2
 3gps_lock = [True] * 8 + [False] * 3 + [True] * 6 + [False] * 2 + [True]
 4dropout = detect_gps_dropout(
 5    gps_lock,
 6    timestamps=np.arange(len(gps_lock)) * 30.0,
 7    min_lock_fraction=0.9,
 8)
 9
10for key in [
11    "n_samples",
12    "n_locked",
13    "lock_fraction",
14    "n_dropout_events",
15    "longest_dropout_samples",
16    "longest_dropout_s",
17    "ok",
18]:
19    value = dropout[key]
20    if isinstance(value, float):
21        print(f"{key}: {value:.3f}")
22    else:
23        print(f"{key}: {value}")

Output:

n_samples: 20
n_locked: 15
lock_fraction: 0.750
n_dropout_events: 2
longest_dropout_samples: 3
longest_dropout_s: 90.000
ok: False

Plot Synchronisation Quality#

The plotting helper summarises offset, drift, jitter, GPS lock, reference support, and quality grades. For more than one figure, IoT guide pages use grids; this page has a single combined diagnostic figure.

 1from pathlib import Path
 2
 3from pycsamt.iot import plot_sync_quality
 4
 5out_dir = Path("docs/source/images/user_guide/iot")
 6out_dir.mkdir(parents=True, exist_ok=True)
 7
 8plot_sync_quality(
 9    statuses,
10    tolerance_ms=config.tolerance_ms,
11    max_drift_ppm=config.max_drift_ppm,
12    max_jitter_ms=config.max_jitter_ms,
13    figsize=(10.8, 7.2),
14    output_path=(
15        out_dir / "user-guide-iot-clock-sync-01.png"
16    ).as_posix(),
17    close=True,
18)
../../_images/user-guide-iot-clock-sync-01.png

Field Interpretation#

The first node is suitable for timing-sensitive processing. Its offset is well below the 1 ms tolerance, drift is nearly zero, and jitter is small. The second and fourth nodes should be corrected or excluded from time-critical windows because their offsets and drift exceed the configured limits. The third node has moderate offset but no GPS lock, so its grade is limited even though the drift estimate itself is not severe.

In a field workflow, store these status rows as sync telemetry packets or attach them to the acquisition manifest. That keeps the timing evidence next to the QC, power, and station metadata used by later processing.