A Tunnel Inventory

This end-to-end inventory example models an underground tunnel deployment from survey files through enriched patches. A 1500 m telemetry cable feeds trenched fiber, a buried coil, and three instrumented boreholes.

Figure 1: The deployment as surveyed. The upper panel is optical distance along the fiber; the lower one is the tunnel’s own survey grid.

Hardware uses YAML files; tabular tracks use CSV. All example files are bundled with DASCore.

import io
import tempfile
from pathlib import Path

import numpy as np
import pandas as pd

import dascore as dc
from dascore.examples import tunnel_inventory_files, write_tunnel_inventory

files = tunnel_inventory_files(repaired=False)
root = Path(tempfile.mkdtemp()) / "tunnel_inventory"
write_tunnel_inventory(root, repaired=False)


def show(name):
    """Print one of the inventory's files."""
    print(files[name])


def table(name):
    """Read one of the inventory's CSV files back as a frame."""
    return pd.read_csv(io.StringIO(files[name]))


PATH = "fiber_arrays/XT.TUN1/path.00"

What the drawing says

Reading Figure 1 along the fiber, from the interrogator:

Table 1: The path, waypoint by waypoint.
From To What the light passes through Optical
instrument room A telemetry cable 1500 m
A B drop into the trench 2.5 m
B C trenched sensing fiber 25 m
C the buried coil 10 m
C D trenched sensing fiber 25 m
D E rise out of the trench 2.5 m
E 3, 2, 1 six links between splice boxes, couplers, and borehole heads 15 m each
three boreholes, down and back 40 m each

A 20 m borehole holds 40 m of sensing fiber because the cable returns to the surface.

Figure 2: The borehole cable: armored pigtail above ground, rock-coupled fiber below, and a turnaround enclosure at the bottom.

The trenched cable is wound helically, so ground distance \(d_r\) is shorter than optical distance \(d_o\):

\[ d_r \approx d_o \cos \phi \approx 0.886\, d_o \]

Thus 25 m of fiber covers 22.15 m of tunnel. The geometry track stores this mapping so consumers use coordinates directly.

The hardware

Cables, enclosures, and interrogators live under resources/; filenames become referenced resource_id values.

resources/*.yaml — the hardware, from the purchase orders
for name in sorted(x for x in files if x.startswith("resources/")):
    print(f"# {name}")
    print(files[name])
# resources/borehole-cable.yaml
object_type: Cable
name: borehole sensing cable
manufacturer: Nerve Sensors
model: Epsilon
fiber_count: 4
description: Rock-coupled downhole cable with an armored pigtail.

# resources/connecting-cable.yaml
object_type: Cable
name: tunnel connecting cable
manufacturer: Corning
model: MIC tight-buffered 4F OS2
fiber_count: 4
description: The links between boxes, couplers, and borehole heads.

# resources/das-interrogator.yaml
object_type: Interrogator
name: tunnel DAS interrogator
manufacturer: Sintela
model: Onyxia
instrument_type: DAS interrogator

# resources/splice-box-a.yaml
object_type: Enclosure
name: tunnel splice box at A
enclosure_type: box

# resources/splice-box-e.yaml
object_type: Enclosure
name: tunnel splice box at E
enclosure_type: box

# resources/telemetry-cable.yaml
object_type: Cable
name: tunnel telemetry cable
manufacturer: Corning
model: MIC tight-buffered 4F OS2
fiber_count: 4
description: The run in from the instrument room.

# resources/trench-cable.yaml
object_type: Cable
name: helically wound trench cable
manufacturer: Silixa
model: HWC
fiber_count: 1

# resources/turnaround-1.yaml
object_type: Enclosure
name: tunnel borehole 1 turnaround housing
enclosure_type: housing

# resources/turnaround-2.yaml
object_type: Enclosure
name: tunnel borehole 2 turnaround housing
enclosure_type: housing

# resources/turnaround-3.yaml
object_type: Enclosure
name: tunnel borehole 3 turnaround housing
enclosure_type: housing

The inventory envelope defines a local x, y, z survey grid in meters, with z positive upward.

show("inventory.yaml")
show("fiber_arrays/XT.TUN1/attrs.yaml")
object_type: Inventory
coordinate_reference_system:
  authority: local
  code: tunnel
  name: tunnel engineering grid
  coordinate_labels: [x, y, z]
  units: [meter, meter, meter]

object_type: FiberArray
name: tunnel fiber array

The path, as a table

Optical components tile the path with distance_min and distance_max. Point components such as splices state one distance. Location code 00 names the path.00 directory.

path.00/optical_components.csv
components_csv = files[f"{PATH}/optical_components.csv"]

table(f"{PATH}/optical_components.csv").head(9)
object_type distance_min distance_max name container
0 FiberSegment 0.0 1500.0 telemetry lead-in telemetry-cable
1 Splice 1500.0 NaN splice at box A splice-box-a
2 FiberSegment 1500.0 1502.5 drop into the trench trench-cable
3 FiberSegment 1502.5 1527.5 trench B to the coil trench-cable
4 FiberSegment 1527.5 1537.5 cable coil at C trench-cable
5 FiberSegment 1537.5 1562.5 trench from the coil to D trench-cable
6 FiberSegment 1562.5 1565.0 rise out of the trench trench-cable
7 Splice 1565.0 NaN splice at box E splice-box-e
8 FiberSegment 1565.0 1580.0 link E to borehole 3 connecting-cable

object_type selects the row model, distances determine order, and container values reference resources. Other tracks use the same optical distances:

def spans(csv_text):
    """Map each component's name to the optical interval it covers."""
    frame = pd.read_csv(io.StringIO(csv_text))
    # A point component states one distance; the other end is the same one.
    ends = frame["distance_max"].fillna(frame["distance_min"])
    return dict(zip(frame["name"], zip(frame["distance_min"], ends)))


at = spans(components_csv)

print("borehole 3 sensing fiber:", at["borehole 3 down"][0], "to", at["borehole 3 up"][1])
borehole 3 sensing fiber: 1580.0 to 1620.0

Where the fiber is

Geometry pairs surveyed control points with the fiber segments between them.

path.00/geometry.csv — the survey points, paired with the fiber between them
geometry = table(f"{PATH}/geometry.csv")

geometry.head(8)
name distance x y z
0 drop into the trench 1500.0 100.00 100.00 0.0
1 drop into the trench 1502.5 100.00 97.79 -0.5
2 trench B to the coil 1502.5 100.00 97.79 -0.5
3 trench B to the coil 1527.5 122.15 97.79 -0.5
4 trench from the coil to D 1537.5 122.15 97.79 -0.5
5 trench from the coil to D 1562.5 144.30 97.79 -0.5
6 rise out of the trench 1562.5 144.30 97.79 -0.5
7 rise out of the trench 1565.0 144.30 100.00 0.0

Unsurveyed slack links and the coil remain nan rather than receiving invented positions. Control-point interpolation captures the helical mapping without storing a wind angle.

How it is attached to the ground

Coupling is interval metadata: coupling_type is controlled, while medium and attachment add detail.

path.00/coupling.csv
coupling = table(f"{PATH}/coupling.csv")

coupling
distance_min distance_max coupling_type medium attachment depth
0 1500.0 1502.5 trench soil direct_burial 0.5
1 1502.5 1527.5 trench soil direct_burial 0.5
2 1537.5 1562.5 trench soil direct_burial 0.5
3 1562.5 1565.0 trench soil direct_burial 0.5
4 1527.5 1537.5 coiled soil NaN 0.5
5 1580.0 1620.0 outside_borehole_casing rock cemented NaN
6 1650.0 1690.0 outside_borehole_casing rock cemented NaN
7 1720.0 1760.0 outside_borehole_casing rock cemented NaN

The coil is coiled, not trench, because it does not sample a unique position.

Everything else

Labels become selectable patch coordinates. Here section identifies installation type and borehole numbers the holes.

path.00/labels.csv
labels = table(f"{PATH}/labels.csv")

labels
distance_min distance_max group value
0 1500.0 1502.5 section trench
1 1502.5 1527.5 section trench
2 1537.5 1562.5 section trench
3 1562.5 1565.0 section trench
4 1527.5 1537.5 section coil
5 1580.0 1620.0 section borehole
6 1580.0 1620.0 borehole 3
7 1650.0 1690.0 section borehole
8 1650.0 1690.0 borehole 2
9 1720.0 1760.0 section borehole
10 1720.0 1760.0 borehole 1

Each label group must use one value type.

How the instrument was set up

An acquisition describes an instrument configuration on the path. Its filename yields acquisition key XT.TUN1.00.DAS; distance_map maps instrument distance to optical distance and remains explicit even when it is the identity.

show("acquisitions/XT.TUN1.00.DAS.yaml")
object_type: Acquisition
data_category: DAS
data_type: strain_rate
data_units: 1/s
interrogator: das-interrogator
gauge_length: 10.0
spatial_interval: 1.0
sample_rate: 250.0
distance_map:
  instrument_distance: [0.0, 2000.0]
  distance: [0.0, 2000.0]

Reading it back

Loading validates schemas, resource references, and track bounds.

inventory = dc.inventory(root)

path = inventory.networks[0].fiber_arrays[0].optical_paths[0]
print(f"{path.optical_length:.1f} m of fiber in {len(path.optical_components)} components")

names = inventory.get_names()
print("coords:", [x for x in names.coords if "." not in x])
1775.0 m of fiber in 26 components
coords: ['distance', 'x', 'y', 'z', 'optical_components', 'geometry', 'coupling', 'section', 'borehole']

Seeing it

Plot tracks against optical distance:

inventory.viz.path(distance=(1495, 1780), show=True);

Or plot the local survey grid:

inventory.viz.map(x="x", y="z", color="section", show=True);

What the data gets out of it

A patch joins the inventory by acquisition key and time.

patch = dc.get_example_patch(
    "random_das",
    acquisition_key="XT.TUN1.00.DAS",
    time_min="2024-06-01",
    shape=(1776, 200),
)
spool = dc.spool(patch).attach_inventory(inventory)

Attaching reads no data; it makes inventory coordinates selectable:

boreholes = spool.select(section="borehole")

print(f"{len(boreholes)} runs of borehole fiber")
for sub in boreholes:
    distance = sub.get_coord("distance")
    print(f"  {distance.min()} to {distance.max()} m, {sub.shape[0]} channels")
3 runs of borehole fiber
  1580 to 1620 m, 41 channels
  1650 to 1690 m, 41 channels
  1720 to 1760 m, 41 channels

The three disjoint borehole runs return three patches.

Spool.enrich is what puts the metadata on a patch:

enriched = spool.enrich()[0]

print("gauge length:", enriched.attrs.gauge_length)
print("coordinates:", sorted(enriched.coords.coord_map))
gauge length: 10.0
coordinates: ['borehole', 'coupling', 'distance', 'section', 'time', 'x', 'y', 'z']

Enrichment places surveyed channels and leaves unsurveyed ones unknown:

depth = enriched.get_coord("z").values
along = enriched.get_coord("x").values

assert depth[1590] == -10.0
assert np.isclose(along[1510], 100.0 + 7.5 * 0.886)
assert np.isnan(depth[1000])

A repair, and what it does to the data

In September, two splices and a two-meter patch cord repair a break 15 m into the trench. A new dated path epoch preserves the earlier geometry; its component table replaces one row with five and shifts downstream optical distances by two meters.

EPOCH = "fiber_arrays/XT.TUN1/path.00@2024-09-01"

files = tunnel_inventory_files(repaired=True)
write_tunnel_inventory(root, repaired=True)

repaired_components = table(f"{EPOCH}/optical_components.csv")
repaired_csv = files[f"{EPOCH}/optical_components.csv"]

index = int(repaired_components.index[repaired_components["name"] == "trench B to the break"][0])
repaired_components.iloc[index - 1 : index + 5]
object_type distance_min distance_max name container
2 FiberSegment 1500.0 1502.5 drop into the trench trench-cable
3 FiberSegment 1502.5 1517.5 trench B to the break trench-cable
4 Splice 1517.5 NaN repair splice near side repair-box
5 FiberSegment 1517.5 1519.5 repair patch cord repair-cord
6 Splice 1519.5 NaN repair splice far side repair-box
7 FiberSegment 1519.5 1529.5 trench from the break to the coil trench-cable

Generate all affected tracks from the repair description rather than editing distances independently.

path.00@2024-09-01 — the same tracks, against the new distances
repaired_at = spans(repaired_csv)

repaired_geometry = table(f"{EPOCH}/geometry.csv")
print(repaired_geometry.head(6).to_string(index=False))

repaired = dc.inventory(root)
for epoch in repaired.networks[0].fiber_arrays[0].optical_paths:
    began = "the beginning" if np.isnat(epoch.time_min) else str(epoch.time_min)[:10]
    print(f"from {began}: {epoch.optical_length:.1f} m")
                             name  distance      x      y    z
             drop into the trench    1500.0 100.00 100.00  0.0
             drop into the trench    1502.5 100.00  97.79 -0.5
            trench B to the break    1502.5 100.00  97.79 -0.5
            trench B to the break    1517.5 113.29  97.79 -0.5
trench from the break to the coil    1519.5 113.29  97.79 -0.5
trench from the break to the coil    1529.5 122.15  97.79 -0.5
from the beginning: 1775.0 m
from 2024-09-01: 1777.0 m

Plot the epochs:

repaired.viz.timeline(show=True);

A patch spanning the epoch boundary matches both paths. Spool.conform_to_inventory splits it so each result has one valid description:

straddling = dc.get_example_patch(
    "random_das",
    acquisition_key="XT.TUN1.00.DAS",
    time_min="2024-08-31T23:59:59",
    shape=(1776, 500),
)
crossed = dc.spool(straddling).attach_inventory(repaired)

print("patches before:", len(crossed))
conformed = crossed.conform_to_inventory()
print("patches after: ", len(conformed))
print(conformed.get_contents()[["time_min", "time_max"]].to_string(index=False))
patches before: 1
patches after:  2
           time_min                time_max
2024-08-31 23:59:59 2024-08-31 23:59:59.996
2024-09-01 00:00:00 2024-09-01 00:00:00.996

Each piece enriches with the geometry valid at its recording time:

before, after = conformed.enrich()

assert before.get_coord("z").values[1590] == -10.0
assert after.get_coord("z").values[1590] == -8.0

assert np.isclose(
    before.get_coord("x").values[1510], after.get_coord("x").values[1510]
)

Shipping it

Author with the directory form; ship a single YAML file beside an archive with Inventory.io.to_yaml. dc.inventory reads either.

text = repaired.io.to_yaml()

print(f"{len(text.splitlines())} lines of yaml")
assert dc.inventory(text) == repaired
954 lines of yaml

Spools auto-discover .inventory/, .inventory.yaml, .inventory.yml, or JSON from model_dump_json as .inventory.json.

Modeling rules

  • Model one continuous run with a shared break history as one fiber array and path.
  • Components are on the optical path; resources are owned objects, such as housings, that components reference.
  • Represent winding and other layout details in geometry rather than custom arithmetic.
  • Add a new epoch for a repair instead of editing history.