From e6cd27fe339785e26bb0e745baae8de2a669bb09 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Fri, 14 Aug 2026 10:12:44 +0200 Subject: [PATCH 1/3] Add a worked recipe: an inventory for a tunnel deployment The tutorial shows the model and the verbs; this shows a whole deployment. Three instrumented boreholes, a helically wound trench, and a buried coil, recorded through two interrogators, written the way a field crew would keep it -- small yaml files for the hardware and csv tables for the tracks -- and then loaded, attached, selected on, and enriched. The tables are the point. Every track in the authoring format is a spreadsheet, so the page writes the csv and renders it back, rather than reciting twenty-three constructor calls a reader would skip. It also does the two things nothing else documents end to end: the geometry track holding the wind angle so no consumer downstream has to know about it, and a repair recorded as a path epoch, with a straddling patch subdivided at the moment the contractor put a bucket through the trench cable. --- docs/_static/borehole_fiber.svg | 491 ++++++ docs/_static/tunnel_deployment.svg | 2312 ++++++++++++++++++++++++++++ docs/recipes/tunnel_inventory.qmd | 498 ++++++ scripts/_templates/_quarto.yml | 5 + 4 files changed, 3306 insertions(+) create mode 100644 docs/_static/borehole_fiber.svg create mode 100644 docs/_static/tunnel_deployment.svg create mode 100644 docs/recipes/tunnel_inventory.qmd diff --git a/docs/_static/borehole_fiber.svg b/docs/_static/borehole_fiber.svg new file mode 100644 index 000000000..d8d9c94cd --- /dev/null +++ b/docs/_static/borehole_fiber.svg @@ -0,0 +1,491 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Downhole splice enclosure + Strain coupling segment: 40 m + Armored pigtails: 15 m + Fiber Cross Section + splice + Connector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_static/tunnel_deployment.svg b/docs/_static/tunnel_deployment.svg new file mode 100644 index 000000000..9245ca1b8 --- /dev/null +++ b/docs/_static/tunnel_deployment.svg @@ -0,0 +1,2312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 20 m + 1.5 m + 1.5 m + 10.0 m + 10.0 m + 10.0 m + 5 m + 1.5 m + 40 m + 0.5 m + 25 m + 25 m + 20 m + + + + + + + + + + + + + + Trenched Fiber + + + + + Telemetry Fiber + Cable Coil + + + + Connecting Fiber + + + + Instrumented Borehole + + + + Connector/Splice Box + + + Map View + (Optical Distances) + (Coordinates) + (Physical Distances) + Profile View + 1500 m + 25 m + 25 m + 10 m + 15 m + 15 m + 15 m + 15 m + 15 m + 15 m + 2.5 m + 2 m + 2.5 m + 2 m + 2.5 m + 2.5 m + 40 m + 40 m + 40 m + + Fiber Splice + Fiber Coupler + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10.0 m + 1.0 m + x=126.00 +y=100.00 + x=126.00 +y=100.00 + x=108.00 +y=100.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + + + + + + + + + + + + + + + + x=100.00 +y=100.00 + x=100.00 +y=097.79 + x=122.15 +y=097.79 + x=144.30 +y=097.79 + x=144.30 +y=100.00 + x=142.00 +y=100.00 + A + B + C + D + E + + F + + + G + + H + + + + diff --git a/docs/recipes/tunnel_inventory.qmd b/docs/recipes/tunnel_inventory.qmd new file mode 100644 index 000000000..ccc05c589 --- /dev/null +++ b/docs/recipes/tunnel_inventory.qmd @@ -0,0 +1,498 @@ +--- +title: A Tunnel Inventory +execute: + warning: false +--- + +The [inventory tutorial](../tutorial/inventory.qmd) shows the shape of the model and the verbs which use it. This recipe writes a whole inventory for one deployment, from field notes to enriched patches. It is long on purpose: real installations are made of parts that each need saying, and the point is to watch them fit together. + +The deployment is an underground tunnel instrumented to watch for floor instability and to record seismicity. Three boreholes are drilled from the tunnel floor, a cable is trenched along its length, and a coil of cable is buried at the far end as a horizontally sensitive "clump" sensor. Two interrogators share the installation: a Brillouin DSS system watching the trench for slow strain, and a Rayleigh DAS interrogator on the boreholes, the trench, and the coil. + +![Map view of the tunnel deployment. Distances along the fiber are optical; the boreholes are 20 m apart.](../_static/tunnel_deployment.svg){#fig-tunnel} + +The inventory is written the way a field crew would keep it: a directory of small YAML files for the hardware and CSV tables for the things that come in rows. Everything below runs, and nothing is downloaded. + +```{python} +import tempfile +from pathlib import Path +from textwrap import dedent + +import pandas as pd + +import dascore as dc + +root = Path(tempfile.mkdtemp()) / "tunnel_inventory" + + +def write(name, text): + """Write one file into the inventory directory.""" + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path +``` + +# What the field notes say + +The parts of the installation, as an engineer would write them down: + +| Part | What it is | Length | +|---|---|---| +| telemetry lead-in | tight-buffered 4-fiber telecom cable from the instrument room | 40 m | +| splice box | E2000 APC connectors at the tunnel entrance | — | +| borehole ×3 | rock-coupled downhole cable, turnaround at the bottom, armored pigtails | 5 + 40 + 40 + 5 m each | +| connecting fiber | telemetry cable between borehole heads | 20 m each | +| trenched fiber | helically wound sensing cable, buried 0.5 m in soil | 50 m of trench | +| coil | the same cable wound into a 1 m loop | 10 m | + +: The tunnel installation. {#tbl-parts} + +Two numbers there are worth pausing on. A borehole is 40 m deep but contributes 80 m of fiber, because the cable goes down, turns around at the bottom, and comes back up — the light travels both legs, and both legs are sensing. + +![The borehole cable: armored pigtails above ground, rock-coupled fiber below, and a turnaround at the bottom.](../_static/borehole_fiber.svg){#fig-borehole width="30%"} + +The trench is worse. Its cable is wound helically at 30° off-axis, which buys sensitivity to broadside motion at the cost of length: the fiber is longer than the trench it lies in. With a wind angle $\phi$, a trench distance $d_r$, and an optical distance $d_o$, + +$$ +d_r \approx d_o \cos \phi \approx 0.866\, d_o +$$ + +so 50 m of trench takes about 57.7 m of fiber. This is the distinction the inventory exists to keep: optical distance is what the interrogator measures, and where a channel actually *is* comes from the geometry track rather than from arithmetic on the channel number. + +# The hardware + +Cables, enclosures, and interrogators are named once and referred to from everywhere they appear. Each is a small file under `resources/`, and the file's name is the `resource_id` other files refer to it by. + +```{python} +#| code-fold: true +#| code-summary: "resources/*.yaml — the hardware, from the purchase orders" +resources = { + "telemetry-cable": ( + "object_type: Cable\n" + "name: tunnel telemetry cable\n" + "manufacturer: Corning\n" + "model: MIC tight-buffered 4F OS2\n" + "fiber_count: 4\n" + ), + "borehole-cable": ( + "object_type: Cable\n" + "name: borehole sensing cable\n" + "manufacturer: Nerve Sensors\n" + "model: Epsilon\n" + "fiber_count: 4\n" + "description: Rock-coupled downhole cable with an armored pigtail.\n" + ), + "trench-cable": ( + "object_type: Cable\n" + "name: helically wound trench cable\n" + "manufacturer: Silixa\n" + "model: HWC 30 degree\n" + "fiber_count: 1\n" + ), + "splice-box": ( + "object_type: Enclosure\nname: tunnel splice box\nenclosure_type: box\n" + ), + "turnaround": ( + "object_type: Enclosure\n" + "name: downhole turnaround housing\n" + "enclosure_type: housing\n" + ), + "das-interrogator": ( + "object_type: Interrogator\n" + "name: tunnel DAS interrogator\n" + "manufacturer: Sintela\n" + "model: Onyxia\n" + "instrument_type: DAS interrogator\n" + ), + "dss-interrogator": ( + "object_type: Interrogator\n" + "name: tunnel DSS interrogator\n" + "manufacturer: Febus\n" + "model: G1\n" + "instrument_type: Brillouin DSS interrogator\n" + ), +} +for name, text in resources.items(): + write(f"resources/{name}.yaml", text) +``` + +These stay objects rather than rows because they have nothing in common: a cable has a fiber count, an enclosure has an inner diameter, an interrogator has firmware. A table of one row with twelve mostly-empty columns would be worse than the file. + +The envelope names the document and states the coordinate reference system everything else is expressed in. This tunnel has its own survey grid rather than a global reference, so the axes are `x`, `y`, and `z` in meters, `z` positive up. + +```{python} +write( + "inventory.yaml", + "object_type: Inventory\n" + "coordinate_reference_system:\n" + " authority: local\n" + " code: tunnel\n" + " name: tunnel engineering grid\n" + " coordinate_labels: [x, y, z]\n" + " units: [meter, meter, meter]\n", +) + +write( + "fiber_arrays/XT.TUN1/attrs.yaml", + "object_type: FiberArray\nname: tunnel fiber array\n", +) +``` + +# The path, as a table + +An optical path is the ordered list of things the light passes through. Each component states its own optical length, and they tile the path end to end — which is what gives the path its length. Nothing states a start distance, because the `sequence` column already did. + +This is the table the field crew keeps in a spreadsheet, and it goes in `path.00`, the directory naming the optical path at location code `00`. + +```{python} +#| code-fold: true +#| code-summary: "path.00/optical_components.csv" +components_csv = dedent("""\ +sequence,object_type,optical_length,name,container +1,FiberSegment,40.0,telemetry lead-in,telemetry-cable +2,Connector,0.0,splice box connector,splice-box +3,FiberSegment,5.0,borehole 1 pigtail down,borehole-cable +4,FiberSegment,40.0,borehole 1 sensing down,borehole-cable +5,Splice,0.0,borehole 1 turnaround,turnaround +6,FiberSegment,40.0,borehole 1 sensing up,borehole-cable +7,FiberSegment,5.0,borehole 1 pigtail up,borehole-cable +8,FiberSegment,20.0,connecting fiber 1 to 2,telemetry-cable +9,FiberSegment,5.0,borehole 2 pigtail down,borehole-cable +10,FiberSegment,40.0,borehole 2 sensing down,borehole-cable +11,Splice,0.0,borehole 2 turnaround,turnaround +12,FiberSegment,40.0,borehole 2 sensing up,borehole-cable +13,FiberSegment,5.0,borehole 2 pigtail up,borehole-cable +14,FiberSegment,20.0,connecting fiber 2 to 3,telemetry-cable +15,FiberSegment,5.0,borehole 3 pigtail down,borehole-cable +16,FiberSegment,40.0,borehole 3 sensing down,borehole-cable +17,Splice,0.0,borehole 3 turnaround,turnaround +18,FiberSegment,40.0,borehole 3 sensing up,borehole-cable +19,FiberSegment,5.0,borehole 3 pigtail up,borehole-cable +20,FiberSegment,20.0,connecting fiber to the trench,telemetry-cable +21,FiberSegment,57.7,trenched sensing fiber,trench-cable +22,FiberSegment,10.0,clump coil,trench-cable +23,Terminator,0.0,path end, +""") + +write("fiber_arrays/XT.TUN1/path.00/attrs.yaml", "object_type: OpticalPath\n") +write("fiber_arrays/XT.TUN1/path.00/optical_components.csv", components_csv) + +pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/optical_components.csv") +``` + +Every column there is a field of the class the `object_type` column names, so the table is not a format of its own — it is the objects, written the way rows of the same shape are worth writing. The blank `container` on the terminator is a field left unset, and the ones which are filled are `resource_id`s pointing back at the files written above. + +Nothing else in the inventory has to repeat these numbers. The distances the other three tables are written against are the running totals of this one: + +```{python} +LEAD_IN = 40.0 +BOREHOLE_SPAN = {1: (45.0, 125.0), 2: (155.0, 235.0), 3: (265.0, 345.0)} +TRENCH_SPAN = (370.0, 427.7) +COIL_SPAN = (427.7, 437.7) +``` + +# Where the fiber is + +Geometry places optical distance in space, one row per control point, grouped into segments. A borehole is two straight legs, down and back up over the same hole; the trench is a straight run at 0.5 m depth. The columns after `segment` and `distance` are the axes the coordinate reference system declared, and naming any others is an error rather than an extra. + +```{python} +#| code-fold: true +#| code-summary: "path.00/geometry.csv" +geometry_csv = dedent("""\ +segment,distance,x,y,z +borehole 1 down,45.0,20.0,0.0,0.0 +borehole 1 down,85.0,20.0,0.0,-40.0 +borehole 1 up,85.0,20.0,0.0,-40.0 +borehole 1 up,125.0,20.0,0.0,0.0 +borehole 2 down,155.0,40.0,0.0,0.0 +borehole 2 down,195.0,40.0,0.0,-40.0 +borehole 2 up,195.0,40.0,0.0,-40.0 +borehole 2 up,235.0,40.0,0.0,0.0 +borehole 3 down,265.0,60.0,0.0,0.0 +borehole 3 down,305.0,60.0,0.0,-40.0 +borehole 3 up,305.0,60.0,0.0,-40.0 +borehole 3 up,345.0,60.0,0.0,0.0 +trench,370.0,0.0,0.0,-0.5 +trench,427.7,50.0,0.0,-0.5 +""") + +write("fiber_arrays/XT.TUN1/path.00/geometry.csv", geometry_csv) + +pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/geometry.csv").head(6) +``` + +The lead-in, the connecting fibers, and the coil appear nowhere in that table, and they get no position. That is the honest answer: nobody surveyed the lead-in, because it is coiled in a rack. + +The trench segment is also where the wind angle stops mattering to anyone downstream. Its two control points say that 57.7 m of fiber covers 50 m of ground; every channel between them is placed by interpolating that, and no consumer has to know about $\cos\phi$. + +# How it is attached to the ground + +Coupling is what decides whether a wiggle means anything, and it is an interval property. The controlled `coupling_type` carries the part a program can act on; `medium` and `attachment` carry the rest. + +```{python} +#| code-fold: true +#| code-summary: "path.00/coupling.csv" +coupling_csv = dedent("""\ +start_distance,end_distance,coupling_type,medium,attachment,depth +45.0,125.0,outside_borehole_casing,rock,cemented, +155.0,235.0,outside_borehole_casing,rock,cemented, +265.0,345.0,outside_borehole_casing,rock,cemented, +370.0,427.7,trench,soil,direct_burial,0.5 +427.7,437.7,coiled,soil,,0.5 +""") + +write("fiber_arrays/XT.TUN1/path.00/coupling.csv", coupling_csv) + +pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/coupling.csv") +``` + +The coil is `coiled` rather than `trench`, which is the whole reason that value exists: ten meters of fiber wound into a one-meter loop has no useful position per channel, and saying so is better than a made-up polyline. + +# Everything else + +Annotations are for what the model has no field for. Each group becomes a coordinate on the patches, named after the group, so pick names you would want to select on later. Here `section` names what kind of installation a channel belongs to and `borehole` numbers the three holes. + +```{python} +#| code-fold: true +#| code-summary: "path.00/annotations.csv" +annotations_csv = dedent("""\ +start_distance,end_distance,group,value +45.0,125.0,section,borehole +155.0,235.0,section,borehole +265.0,345.0,section,borehole +370.0,427.7,section,trench +427.7,437.7,section,coil +45.0,125.0,borehole,1 +155.0,235.0,borehole,2 +265.0,345.0,borehole,3 +""") + +write("fiber_arrays/XT.TUN1/path.00/annotations.csv", annotations_csv) + +pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/annotations.csv") +``` + +A group holds one kind of value: `section` is text everywhere and `borehole` is a number everywhere. Mixing them would be two tracks sharing a name, and is refused when the directory is read. + +# The second interrogator + +The DSS system is on its own fiber and sees only the trench, so it is a second optical path under the same fiber array, at its own location code. + +```{python} +#| code-fold: true +#| code-summary: "path.01 — the DSS fiber" +write("fiber_arrays/XT.TUN1/path.01/attrs.yaml", "object_type: OpticalPath\n") +write( + "fiber_arrays/XT.TUN1/path.01/optical_components.csv", + "sequence,object_type,optical_length,name,container\n" + "1,FiberSegment,40.0,DSS lead-in,telemetry-cable\n" + "2,FiberSegment,57.7,DSS trenched fiber,trench-cable\n" + "3,Terminator,0.0,DSS path end,\n", +) +write( + "fiber_arrays/XT.TUN1/path.01/coupling.csv", + "start_distance,end_distance,coupling_type,medium,attachment,depth\n" + "40.0,97.7,trench,soil,direct_burial,0.5\n", +) +``` + +# How each instrument was set up + +A path is the fiber; an acquisition is a configuration of an instrument recording through it. The file's name states the identity — network, fiber array, location code, and acquisition code — and `XT.TUN1.00.DAS` is what a patch recorded here carries as its `acquisition_key`. + +The `distance_map` is the join between instrument and fiber: it says where the interrogator's own distance axis lands on the path. Both instruments here are zeroed at themselves, so the map is the identity — but it is stated rather than assumed, because a re-zeroed instrument is the usual cause of metadata that is quietly off by a lead-in. + +```{python} +write( + "acquisitions/XT.TUN1.00.DAS.yaml", + "object_type: Acquisition\n" + "data_category: DAS\n" + "data_type: strain_rate\n" + "data_units: 1/s\n" + "interrogator: das-interrogator\n" + "gauge_length: 10.0\n" + "spatial_interval: 1.0\n" + "sample_rate: 250.0\n" + "distance_map:\n" + " instrument_distance: [0.0, 437.7]\n" + " distance: [0.0, 437.7]\n", +) + +write( + "acquisitions/XT.TUN1.01.DSS.yaml", + "object_type: Acquisition\n" + "data_category: DSS\n" + "data_type: strain\n" + "interrogator: dss-interrogator\n" + "spatial_interval: 1.0\n" + "distance_map:\n" + " instrument_distance: [0.0, 97.7]\n" + " distance: [0.0, 97.7]\n", +) +``` + +# Reading it back + +The directory is now an inventory, and loading it is the moment every rule is checked: that each file is what it says it is, that each `container` resolves to a resource, that no track runs off the end of its path. + +```{python} +inventory = dc.inventory(root) + +array = inventory.networks[0].fiber_arrays[0] +for path in array.optical_paths: + print(f"path {path.location_code}: {path.optical_length:.1f} m of fiber") + +names = inventory.get_names() +print("coords:", [x for x in names.coords if "." not in x]) +``` + +`section` and `borehole` are among the coordinates because the annotations table named them. `x`, `y`, and `z` are there because the CRS declares those axes and the geometry table resolves to them. + +# What the data gets out of it + +A patch recorded here carries the acquisition key, and that key with the time the patch covers is the whole of the join. + +```{python} +patch = dc.get_example_patch( + "random_das", + acquisition_key="XT.TUN1.00.DAS", + time_min="2024-06-01", + shape=(440, 500), +) +spool = dc.spool(patch).attach_inventory(inventory) +``` + +Attaching reads no data and changes no patch. What it changes is which names resolve — the fiber's own vocabulary is now among them, so the boreholes can be asked for by name: + +```{python} +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") +``` + +Three patches, not one: the three holes are three separate runs of fiber, and a patch is a dense block of channels, so a selection matching three disjoint runs gives back three patches rather than silently gluing them together. + +[`Spool.enrich`](`dascore.core.spool.Spool.enrich`) is what actually puts the metadata on a patch: + +```{python} +import numpy as np + +enriched = spool.enrich()[0] + +print("gauge length:", enriched.attrs.gauge_length) +print("coordinates:", sorted(enriched.coords.coord_map)) + +# 60 m along the fiber is 15 m down borehole 1. +depth = enriched.get_coord("z").values +assert depth[60] == -15.0 + +# The lead-in was never surveyed, so it has no position. +assert np.isnan(depth[10]) +``` + +# A repair, and what it does to the data + +In September a contractor puts a bucket through the trench cable 25 m in. It is repaired with two splices and a two-meter patch cord, and every channel past the repair now sits two meters further along the fiber than it used to. + +This is what epochs are for. The old description is not edited: a second directory is added, named for the day the repair happened, and the first path runs until the second begins. + +```{python} +#| code-fold: true +#| code-summary: "path.00@2024-09-01 — the same path, after the repair" +epoch = "fiber_arrays/XT.TUN1/path.00@2024-09-01" +write(f"{epoch}/attrs.yaml", "object_type: OpticalPath\n") +write(f"{epoch}/annotations.csv", annotations_csv.replace("427.7", "429.7")) + +# The trench is now two runs of fiber with the repair between them, and +# everything past it moves two meters. +write( + f"{epoch}/optical_components.csv", + components_csv.replace( + "21,FiberSegment,57.7,trenched sensing fiber,trench-cable\n" + "22,FiberSegment,10.0,clump coil,trench-cable\n" + "23,Terminator,0.0,path end,\n", + "21,FiberSegment,25.0,trenched fiber to the break,trench-cable\n" + "22,Splice,0.0,repair splice near side,splice-box\n" + "23,FiberSegment,2.0,repair patch cord,trench-cable\n" + "24,Splice,0.0,repair splice far side,splice-box\n" + "25,FiberSegment,32.7,trenched fiber past the break,trench-cable\n" + "26,FiberSegment,10.0,clump coil,trench-cable\n" + "27,Terminator,0.0,path end,\n", + ), +) +write( + f"{epoch}/coupling.csv", + coupling_csv.replace("370.0,427.7", "370.0,429.7").replace( + "427.7,437.7", "429.7,439.7" + ), +) +# The patch cord is coiled in the splice box, so it gets no geometry: the +# trench is described up to the break and again from it. +write( + f"{epoch}/geometry.csv", + geometry_csv.replace( + "trench,370.0,0.0,0.0,-0.5\ntrench,427.7,50.0,0.0,-0.5\n", + "trench before,370.0,0.0,0.0,-0.5\n" + "trench before,395.0,21.65,0.0,-0.5\n" + "trench after,397.0,21.65,0.0,-0.5\n" + "trench after,429.7,50.0,0.0,-0.5\n", + ), +) + +repaired = dc.inventory(root) +for path in repaired.networks[0].fiber_arrays[0].optical_paths: + if path.location_code == "00": + print(f"from {path.start_time}: {path.optical_length:.1f} m") +``` + +A patch recorded across midnight on the first of September was recorded through both. [`Spool.conform_to_inventory`](`dascore.core.spool.Spool.conform_to_inventory`) is the step which insists every patch be describable by exactly one entry, and it subdivides that patch rather than choosing for you: + +```{python} +straddling = dc.get_example_patch( + "random_das", + acquisition_key="XT.TUN1.00.DAS", + time_min="2024-08-31T23:59:59", + shape=(440, 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)) +``` + +The split is exact: every sample the patch held is in one piece or the other, and none is in both. Each piece now enriches with the geometry that was true while it was recording, which is the entire reason the repair was written as an epoch instead of an edit. + +```{python} +before, after = conformed.enrich() + +# The coil sat at 427.7 m before the repair and 429.7 m after it. +assert before.get_coord("section").values[428] == "coil" +assert after.get_coord("section").values[428] == "trench" +``` + +# Shipping it + +The directory is the authoring format — good for a deployment still being surveyed, because the tracks are spreadsheets. [`Inventory.to_yaml`](`dascore.core.inventory.Inventory.to_yaml`) writes the single-file form to ship beside a data archive, and [`dc.inventory`](`dascore.core.inventory_loader.inventory`) reads either back. + +```{python} +text = repaired.to_yaml() + +print(f"{len(text.splitlines())} lines of yaml") +assert dc.inventory(text) == repaired +``` + +Dropping either form into the data directory under the name `.inventory` means every spool opened on that directory finds it without being told. + +# Where the boundaries fell + +The choices worth restating, because a second deployment will have to make them again: + +- **Three boreholes, one fiber array.** They are one continuous run of fiber recorded as one channel axis, so they are one array with one path. Five dissimilar arms radiating from a hut, each with its own cable and its own break history, would be five fiber arrays instead — the question to ask is whether a break in one changes the others' metadata. +- **The DSS is a second path, not a second array.** Same installation, same operator, different instrument on different fiber. The location code separates them, and both stay under one array so they share its identity and its lifetime. +- **The turnaround is a component; its housing is a resource.** The splice is on the path because the light goes through it. The housing it sits in is a thing the site owns, referred to by any component inside it. +- **The repair is an epoch, not an edit.** Data recorded before it is still described by what was true then. Editing the path in place would have silently moved every channel in the archive's history. diff --git a/scripts/_templates/_quarto.yml b/scripts/_templates/_quarto.yml index 479a6e879..bc436d8d3 100644 --- a/scripts/_templates/_quarto.yml +++ b/scripts/_templates/_quarto.yml @@ -157,6 +157,11 @@ website: - recipes/parallelization.qmd - recipes/low_freq_proc.qmd + - section: "Inventory" + contents: + + - recipes/tunnel_inventory.qmd + - section: "IO" contents: From 3a007f482fc1b6416442cce920142cb14be2da9d Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Fri, 14 Aug 2026 10:49:53 +0200 Subject: [PATCH 2/3] Rebuild the recipe on the deployment the drawing specifies Codex noticed that the figures and the page described different tunnels. The drawing is not an illustration: it is a survey, with lettered waypoints, a 1500 m telemetry lead-in, fifteen-meter links between every feature, and coordinates on the tunnel's own grid. The page had a smaller deployment invented to keep the numbers round. The drawing wins. Every distance now comes from it, the winding factor is the 0.886 its coordinates are computed with, and the survey points are the ones it letters. The second interrogator is gone with them -- the drawing shows one fiber route, and a DSS path nobody surveyed would have been the same fiction in a different place. Three of its other findings come with it: the distance map ran out before the repaired path did, so the last two channels of the longer epoch resolved to nothing; the repair's patch cord was described as buried trench fiber when the point of it is that it is coiled in a splice box with no position at all; and "25 m in" meant optical meters while reading as trench meters. The tracks are now derived from the components table rather than typed against it, which is what makes the repair a five-row edit instead of forty hand-shifted distances. --- docs/recipes/tunnel_inventory.qmd | 496 +++++++++++++++++------------- 1 file changed, 277 insertions(+), 219 deletions(-) diff --git a/docs/recipes/tunnel_inventory.qmd b/docs/recipes/tunnel_inventory.qmd index ccc05c589..9fcbbe9ed 100644 --- a/docs/recipes/tunnel_inventory.qmd +++ b/docs/recipes/tunnel_inventory.qmd @@ -4,19 +4,21 @@ execute: warning: false --- -The [inventory tutorial](../tutorial/inventory.qmd) shows the shape of the model and the verbs which use it. This recipe writes a whole inventory for one deployment, from field notes to enriched patches. It is long on purpose: real installations are made of parts that each need saying, and the point is to watch them fit together. +The [inventory tutorial](../tutorial/inventory.qmd) shows the shape of the model and the verbs which use it. This recipe writes a whole inventory for one deployment, from a survey drawing to enriched patches. It is long on purpose: real installations are made of parts that each need saying, and the point is to watch them fit together. -The deployment is an underground tunnel instrumented to watch for floor instability and to record seismicity. Three boreholes are drilled from the tunnel floor, a cable is trenched along its length, and a coil of cable is buried at the far end as a horizontally sensitive "clump" sensor. Two interrogators share the installation: a Brillouin DSS system watching the trench for slow strain, and a Rayleigh DAS interrogator on the boreholes, the trench, and the coil. +The deployment is an underground tunnel instrumented to watch for floor instability and to record seismicity. A telemetry cable arrives from an instrument room 1500 m away; from the splice box the fiber drops into a trench along the tunnel floor, passes a buried coil, rises again, and then works back along the tunnel through three instrumented boreholes. -![Map view of the tunnel deployment. Distances along the fiber are optical; the boreholes are 20 m apart.](../_static/tunnel_deployment.svg){#fig-tunnel} +![The deployment as surveyed. The upper panel is optical distance along the fiber; the lower one is the tunnel's own survey grid.](../_static/tunnel_deployment.svg){#fig-tunnel} -The inventory is written the way a field crew would keep it: a directory of small YAML files for the hardware and CSV tables for the things that come in rows. Everything below runs, and nothing is downloaded. +The inventory is written the way a field crew would keep it: a directory of small YAML files for the hardware, and CSV tables for everything that comes in rows. Everything below runs, and nothing is downloaded. ```{python} +import io import tempfile from pathlib import Path from textwrap import dedent +import numpy as np import pandas as pd import dascore as dc @@ -32,36 +34,40 @@ def write(name, text): return path ``` -# What the field notes say +# What the drawing says + +Reading @fig-tunnel along the fiber, from the interrogator: -The parts of the installation, as an engineer would write them down: +| 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 | -| Part | What it is | Length | -|---|---|---| -| telemetry lead-in | tight-buffered 4-fiber telecom cable from the instrument room | 40 m | -| splice box | E2000 APC connectors at the tunnel entrance | — | -| borehole ×3 | rock-coupled downhole cable, turnaround at the bottom, armored pigtails | 5 + 40 + 40 + 5 m each | -| connecting fiber | telemetry cable between borehole heads | 20 m each | -| trenched fiber | helically wound sensing cable, buried 0.5 m in soil | 50 m of trench | -| coil | the same cable wound into a 1 m loop | 10 m | +: The path, waypoint by waypoint. {#tbl-path} -: The tunnel installation. {#tbl-parts} +Two of those numbers need a word. -Two numbers there are worth pausing on. A borehole is 40 m deep but contributes 80 m of fiber, because the cable goes down, turns around at the bottom, and comes back up — the light travels both legs, and both legs are sensing. +A borehole holds 40 m of fiber but is only 20 m deep, because the cable goes down, turns around at the bottom, and comes back up. Both legs are coupled to the rock and both are sensing. -![The borehole cable: armored pigtails above ground, rock-coupled fiber below, and a turnaround at the bottom.](../_static/borehole_fiber.svg){#fig-borehole width="30%"} +![The borehole cable: armored pigtail above ground, rock-coupled fiber below, and a turnaround enclosure at the bottom.](../_static/borehole_fiber.svg){#fig-borehole width="30%"} -The trench is worse. Its cable is wound helically at 30° off-axis, which buys sensitivity to broadside motion at the cost of length: the fiber is longer than the trench it lies in. With a wind angle $\phi$, a trench distance $d_r$, and an optical distance $d_o$, +The trench cable is wound helically off-axis, which buys sensitivity to broadside motion at the cost of length: the fiber is longer than the trench it lies in. With a wind angle $\phi$, a distance $d_r$ along the ground and $d_o$ along the fiber, $$ -d_r \approx d_o \cos \phi \approx 0.866\, d_o +d_r \approx d_o \cos \phi \approx 0.886\, d_o $$ -so 50 m of trench takes about 57.7 m of fiber. This is the distinction the inventory exists to keep: optical distance is what the interrogator measures, and where a channel actually *is* comes from the geometry track rather than from arithmetic on the channel number. +which is why the drawing's 25 m of fiber between B and C covers 22.15 m of tunnel. This is the distinction the inventory exists to keep. Optical distance is what the interrogator measures; where a channel actually *is* comes from the geometry track, and no consumer downstream ever has to know the wind angle. # The hardware -Cables, enclosures, and interrogators are named once and referred to from everywhere they appear. Each is a small file under `resources/`, and the file's name is the `resource_id` other files refer to it by. +Cables, enclosures, and interrogators are named once and referred to from everywhere they appear. Each is a small file under `resources/`, and the file's name is the `resource_id` other files use to point at it. ```{python} #| code-fold: true @@ -86,7 +92,7 @@ resources = { "object_type: Cable\n" "name: helically wound trench cable\n" "manufacturer: Silixa\n" - "model: HWC 30 degree\n" + "model: HWC\n" "fiber_count: 1\n" ), "splice-box": ( @@ -104,21 +110,14 @@ resources = { "model: Onyxia\n" "instrument_type: DAS interrogator\n" ), - "dss-interrogator": ( - "object_type: Interrogator\n" - "name: tunnel DSS interrogator\n" - "manufacturer: Febus\n" - "model: G1\n" - "instrument_type: Brillouin DSS interrogator\n" - ), } for name, text in resources.items(): write(f"resources/{name}.yaml", text) ``` -These stay objects rather than rows because they have nothing in common: a cable has a fiber count, an enclosure has an inner diameter, an interrogator has firmware. A table of one row with twelve mostly-empty columns would be worse than the file. +These stay objects rather than rows because they have nothing in common: a cable has a fiber count, an enclosure has an inner diameter, an interrogator has a serial number. A table of one row with twelve mostly-empty columns would be worse than the file. -The envelope names the document and states the coordinate reference system everything else is expressed in. This tunnel has its own survey grid rather than a global reference, so the axes are `x`, `y`, and `z` in meters, `z` positive up. +The envelope names the document and states the coordinate reference system everything is expressed in. This tunnel has its own survey grid rather than a global reference, so the axes are `x`, `y`, and `z` in meters, with `z` positive up. ```{python} write( @@ -140,90 +139,124 @@ write( # The path, as a table -An optical path is the ordered list of things the light passes through. Each component states its own optical length, and they tile the path end to end — which is what gives the path its length. Nothing states a start distance, because the `sequence` column already did. +An optical path is the ordered list of things the light passes through. Each component states its own optical length and they tile the path end to end, which is what gives the path its length. No row states a start distance, because the `sequence` column already did. -This is the table the field crew keeps in a spreadsheet, and it goes in `path.00`, the directory naming the optical path at location code `00`. +This is @tbl-path with the parts named, and it goes in `path.00` — the directory naming the optical path at location code `00`. ```{python} #| code-fold: true #| code-summary: "path.00/optical_components.csv" components_csv = dedent("""\ sequence,object_type,optical_length,name,container -1,FiberSegment,40.0,telemetry lead-in,telemetry-cable -2,Connector,0.0,splice box connector,splice-box -3,FiberSegment,5.0,borehole 1 pigtail down,borehole-cable -4,FiberSegment,40.0,borehole 1 sensing down,borehole-cable -5,Splice,0.0,borehole 1 turnaround,turnaround -6,FiberSegment,40.0,borehole 1 sensing up,borehole-cable -7,FiberSegment,5.0,borehole 1 pigtail up,borehole-cable -8,FiberSegment,20.0,connecting fiber 1 to 2,telemetry-cable -9,FiberSegment,5.0,borehole 2 pigtail down,borehole-cable -10,FiberSegment,40.0,borehole 2 sensing down,borehole-cable -11,Splice,0.0,borehole 2 turnaround,turnaround -12,FiberSegment,40.0,borehole 2 sensing up,borehole-cable -13,FiberSegment,5.0,borehole 2 pigtail up,borehole-cable -14,FiberSegment,20.0,connecting fiber 2 to 3,telemetry-cable -15,FiberSegment,5.0,borehole 3 pigtail down,borehole-cable -16,FiberSegment,40.0,borehole 3 sensing down,borehole-cable -17,Splice,0.0,borehole 3 turnaround,turnaround -18,FiberSegment,40.0,borehole 3 sensing up,borehole-cable -19,FiberSegment,5.0,borehole 3 pigtail up,borehole-cable -20,FiberSegment,20.0,connecting fiber to the trench,telemetry-cable -21,FiberSegment,57.7,trenched sensing fiber,trench-cable -22,FiberSegment,10.0,clump coil,trench-cable -23,Terminator,0.0,path end, +1,FiberSegment,1500.0,telemetry lead-in,telemetry-cable +2,Splice,0.0,splice at box A,splice-box +3,FiberSegment,2.5,drop into the trench,trench-cable +4,FiberSegment,25.0,trench B to the coil,trench-cable +5,FiberSegment,10.0,cable coil at C,trench-cable +6,FiberSegment,25.0,trench from the coil to D,trench-cable +7,FiberSegment,2.5,rise out of the trench,trench-cable +8,Splice,0.0,splice at box E,splice-box +9,FiberSegment,15.0,link E to borehole 3,telemetry-cable +10,FiberSegment,20.0,borehole 3 down,borehole-cable +11,Splice,0.0,borehole 3 turnaround,turnaround +12,FiberSegment,20.0,borehole 3 up,borehole-cable +13,FiberSegment,15.0,link borehole 3 to coupler G,telemetry-cable +14,Connector,0.0,coupler G,splice-box +15,FiberSegment,15.0,link coupler G to borehole 2,telemetry-cable +16,FiberSegment,20.0,borehole 2 down,borehole-cable +17,Splice,0.0,borehole 2 turnaround,turnaround +18,FiberSegment,20.0,borehole 2 up,borehole-cable +19,FiberSegment,15.0,link borehole 2 to coupler H,telemetry-cable +20,Connector,0.0,coupler H,splice-box +21,FiberSegment,15.0,link coupler H to borehole 1,telemetry-cable +22,FiberSegment,20.0,borehole 1 down,borehole-cable +23,Splice,0.0,borehole 1 turnaround,turnaround +24,FiberSegment,20.0,borehole 1 up,borehole-cable +25,FiberSegment,15.0,link borehole 1 back to box A,telemetry-cable +26,Terminator,0.0,path end, """) -write("fiber_arrays/XT.TUN1/path.00/attrs.yaml", "object_type: OpticalPath\n") -write("fiber_arrays/XT.TUN1/path.00/optical_components.csv", components_csv) +PATH = "fiber_arrays/XT.TUN1/path.00" +write(f"{PATH}/attrs.yaml", "object_type: OpticalPath\n") +write(f"{PATH}/optical_components.csv", components_csv) -pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/optical_components.csv") +pd.read_csv(root / PATH / "optical_components.csv").head(9) ``` -Every column there is a field of the class the `object_type` column names, so the table is not a format of its own — it is the objects, written the way rows of the same shape are worth writing. The blank `container` on the terminator is a field left unset, and the ones which are filled are `resource_id`s pointing back at the files written above. +Every column but `sequence` is a field of the class the `object_type` column names, so the table is barely a format of its own — it is the objects, written the way rows of the same shape are worth writing. `sequence` belongs to the table, and is dropped once it has put the rows in order. The blank `container` on the terminator is a field left unset; the ones which are filled are `resource_id`s pointing back at the files above. -Nothing else in the inventory has to repeat these numbers. The distances the other three tables are written against are the running totals of this one: +The other three tables are written against distances along this path, and those distances are the running totals of this one column. Adding them up by hand is how a table stops agreeing with its neighbours, so add them up once: ```{python} -LEAD_IN = 40.0 -BOREHOLE_SPAN = {1: (45.0, 125.0), 2: (155.0, 235.0), 3: (265.0, 345.0)} -TRENCH_SPAN = (370.0, 427.7) -COIL_SPAN = (427.7, 437.7) +def spans(csv_text): + """Map each component's name to the optical interval it covers.""" + frame = pd.read_csv(io.StringIO(csv_text)) + end = frame["optical_length"].cumsum() + return dict(zip(frame["name"], zip(end - frame["optical_length"], end))) + + +at = spans(components_csv) + +print("borehole 3 sensing fiber:", at["borehole 3 down"][0], "to", at["borehole 3 up"][1]) ``` # Where the fiber is -Geometry places optical distance in space, one row per control point, grouped into segments. A borehole is two straight legs, down and back up over the same hole; the trench is a straight run at 0.5 m depth. The columns after `segment` and `distance` are the axes the coordinate reference system declared, and naming any others is an error rather than an extra. +Geometry places optical distance in space, one row per control point, grouped into segments. The columns after `segment` and `distance` are the axes the coordinate reference system declared, and naming any others is an error rather than an extra. + +The survey points are the ones lettered in @fig-tunnel. Each straight run of fiber goes from one to the next, so the table is those points paired with the component that runs between them. ```{python} #| code-fold: true -#| code-summary: "path.00/geometry.csv" -geometry_csv = dedent("""\ -segment,distance,x,y,z -borehole 1 down,45.0,20.0,0.0,0.0 -borehole 1 down,85.0,20.0,0.0,-40.0 -borehole 1 up,85.0,20.0,0.0,-40.0 -borehole 1 up,125.0,20.0,0.0,0.0 -borehole 2 down,155.0,40.0,0.0,0.0 -borehole 2 down,195.0,40.0,0.0,-40.0 -borehole 2 up,195.0,40.0,0.0,-40.0 -borehole 2 up,235.0,40.0,0.0,0.0 -borehole 3 down,265.0,60.0,0.0,0.0 -borehole 3 down,305.0,60.0,0.0,-40.0 -borehole 3 up,305.0,60.0,0.0,-40.0 -borehole 3 up,345.0,60.0,0.0,0.0 -trench,370.0,0.0,0.0,-0.5 -trench,427.7,50.0,0.0,-0.5 -""") - -write("fiber_arrays/XT.TUN1/path.00/geometry.csv", geometry_csv) - -pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/geometry.csv").head(6) +#| code-summary: "path.00/geometry.csv — the survey points, paired with the fiber between them" +A = (100.00, 100.00, 0.0) +B = (100.00, 97.79, -0.5) +C = (122.15, 97.79, -0.5) +D = (144.30, 97.79, -0.5) +E = (144.30, 100.00, 0.0) +HEADS = {1: (108.00, 100.00, 0.0), 2: (126.00, 100.00, 0.0), 3: (142.00, 100.00, 0.0)} +DEPTH = 20.0 + + +def bottom(number): + """The bottom of a borehole is its head, straight down.""" + x, y, _ = HEADS[number] + return (x, y, -DEPTH) + + +def surveyed_runs(at): + """Which component runs between which two survey points.""" + runs = [ + ("drop into the trench", A, B), + ("trench B to the coil", B, C), + ("trench from the coil to D", C, D), + ("rise out of the trench", D, E), + ] + for number in (3, 2, 1): + runs.append((f"borehole {number} down", HEADS[number], bottom(number))) + runs.append((f"borehole {number} up", bottom(number), HEADS[number])) + return runs + + +def geometry_table(at, runs): + """Turn each straight run into its two control points.""" + rows = [] + for name, start, end in runs: + first, last = at[name] + rows.append((name, first, *start)) + rows.append((name, last, *end)) + return pd.DataFrame(rows, columns=["segment", "distance", "x", "y", "z"]) + + +geometry = geometry_table(at, surveyed_runs(at)) +geometry.to_csv(root / PATH / "geometry.csv", index=False) + +geometry.head(8) ``` -The lead-in, the connecting fibers, and the coil appear nowhere in that table, and they get no position. That is the honest answer: nobody surveyed the lead-in, because it is coiled in a rack. +The coil and the six 15 m links appear nowhere in that table, so their channels get no position at all. That is the honest answer rather than a gap: the links are slack cable in a tray, and ten meters of fiber wound into a one-meter loop has no useful position per channel. A made-up polyline would be worse than a `nan`. -The trench segment is also where the wind angle stops mattering to anyone downstream. Its two control points say that 57.7 m of fiber covers 50 m of ground; every channel between them is placed by interpolating that, and no consumer has to know about $\cos\phi$. +Notice also what the table does *not* say. It never mentions the wind angle. Two control points state that 25 m of fiber runs from B to C, every channel between them is placed by interpolating that, and $\cos\phi$ stops being anyone's problem at this line. # How it is attached to the ground @@ -232,75 +265,84 @@ Coupling is what decides whether a wiggle means anything, and it is an interval ```{python} #| code-fold: true #| code-summary: "path.00/coupling.csv" -coupling_csv = dedent("""\ -start_distance,end_distance,coupling_type,medium,attachment,depth -45.0,125.0,outside_borehole_casing,rock,cemented, -155.0,235.0,outside_borehole_casing,rock,cemented, -265.0,345.0,outside_borehole_casing,rock,cemented, -370.0,427.7,trench,soil,direct_burial,0.5 -427.7,437.7,coiled,soil,,0.5 -""") +def coupling_table(at, trench_parts): + """Buried in the trench, coiled at C, cemented in the boreholes.""" + rows = [ + (*at[name], "trench", "soil", "direct_burial", 0.5) for name in trench_parts + ] + rows.append((*at["cable coil at C"], "coiled", "soil", "", 0.5)) + rows.extend( + ( + at[f"borehole {number} down"][0], + at[f"borehole {number} up"][1], + "outside_borehole_casing", + "rock", + "cemented", + "", + ) + for number in (3, 2, 1) + ) + return pd.DataFrame( + rows, + columns=[ + "start_distance", + "end_distance", + "coupling_type", + "medium", + "attachment", + "depth", + ], + ) + + +TRENCH_PARTS = ( + "drop into the trench", + "trench B to the coil", + "trench from the coil to D", + "rise out of the trench", +) -write("fiber_arrays/XT.TUN1/path.00/coupling.csv", coupling_csv) +coupling = coupling_table(at, TRENCH_PARTS) +coupling.to_csv(root / PATH / "coupling.csv", index=False) -pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/coupling.csv") +coupling ``` -The coil is `coiled` rather than `trench`, which is the whole reason that value exists: ten meters of fiber wound into a one-meter loop has no useful position per channel, and saying so is better than a made-up polyline. +The coil is `coiled` rather than `trench`, which is the whole reason that value exists: it is buried in the same soil at the same depth, but a channel in it is not sampling a place. # Everything else -Annotations are for what the model has no field for. Each group becomes a coordinate on the patches, named after the group, so pick names you would want to select on later. Here `section` names what kind of installation a channel belongs to and `borehole` numbers the three holes. +Annotations are for what the model has no field for. Each group becomes a coordinate on the patches, named after the group, so pick names you would want to select on later. Here `section` says what kind of installation a channel belongs to, and `borehole` numbers the holes. ```{python} #| code-fold: true #| code-summary: "path.00/annotations.csv" -annotations_csv = dedent("""\ -start_distance,end_distance,group,value -45.0,125.0,section,borehole -155.0,235.0,section,borehole -265.0,345.0,section,borehole -370.0,427.7,section,trench -427.7,437.7,section,coil -45.0,125.0,borehole,1 -155.0,235.0,borehole,2 -265.0,345.0,borehole,3 -""") - -write("fiber_arrays/XT.TUN1/path.00/annotations.csv", annotations_csv) - -pd.read_csv(root / "fiber_arrays/XT.TUN1/path.00/annotations.csv") +def annotation_table(at, trench_parts): + """Which section a channel is in, and which borehole if it is in one.""" + rows = [(*at[name], "section", "trench") for name in trench_parts] + rows.append((*at["cable coil at C"], "section", "coil")) + for number in (3, 2, 1): + span = (at[f"borehole {number} down"][0], at[f"borehole {number} up"][1]) + rows.append((*span, "section", "borehole")) + rows.append((*span, "borehole", number)) + return pd.DataFrame( + rows, columns=["start_distance", "end_distance", "group", "value"] + ) + + +annotations = annotation_table(at, TRENCH_PARTS) +annotations.to_csv(root / PATH / "annotations.csv", index=False) + +annotations ``` -A group holds one kind of value: `section` is text everywhere and `borehole` is a number everywhere. Mixing them would be two tracks sharing a name, and is refused when the directory is read. +A group holds one kind of value: `section` is text in every row and `borehole` is a number in every row. Mixing them would be two tracks sharing a name, and is refused when the directory is read. -# The second interrogator +# How the instrument was set up -The DSS system is on its own fiber and sees only the trench, so it is a second optical path under the same fiber array, at its own location code. +A path is the fiber; an acquisition is a configuration of an instrument recording through it. The file's name states the identity — network, fiber array, location code, and acquisition code — so `XT.TUN1.00.DAS` is what a patch recorded here carries as its `acquisition_key`. -```{python} -#| code-fold: true -#| code-summary: "path.01 — the DSS fiber" -write("fiber_arrays/XT.TUN1/path.01/attrs.yaml", "object_type: OpticalPath\n") -write( - "fiber_arrays/XT.TUN1/path.01/optical_components.csv", - "sequence,object_type,optical_length,name,container\n" - "1,FiberSegment,40.0,DSS lead-in,telemetry-cable\n" - "2,FiberSegment,57.7,DSS trenched fiber,trench-cable\n" - "3,Terminator,0.0,DSS path end,\n", -) -write( - "fiber_arrays/XT.TUN1/path.01/coupling.csv", - "start_distance,end_distance,coupling_type,medium,attachment,depth\n" - "40.0,97.7,trench,soil,direct_burial,0.5\n", -) -``` - -# How each instrument was set up - -A path is the fiber; an acquisition is a configuration of an instrument recording through it. The file's name states the identity — network, fiber array, location code, and acquisition code — and `XT.TUN1.00.DAS` is what a patch recorded here carries as its `acquisition_key`. - -The `distance_map` is the join between instrument and fiber: it says where the interrogator's own distance axis lands on the path. Both instruments here are zeroed at themselves, so the map is the identity — but it is stated rather than assumed, because a re-zeroed instrument is the usual cause of metadata that is quietly off by a lead-in. +The `distance_map` is the join between instrument and fiber: it says where the interrogator's own distance axis lands on the path. This one is zeroed at itself, so the map is the identity — but it is stated rather than assumed, because a re-zeroed instrument is the usual cause of metadata that is quietly off by a lead-in. It is also stated past the end of the fiber, so that it keeps working when the fiber gets longer. ```{python} write( @@ -314,33 +356,22 @@ write( "spatial_interval: 1.0\n" "sample_rate: 250.0\n" "distance_map:\n" - " instrument_distance: [0.0, 437.7]\n" - " distance: [0.0, 437.7]\n", -) - -write( - "acquisitions/XT.TUN1.01.DSS.yaml", - "object_type: Acquisition\n" - "data_category: DSS\n" - "data_type: strain\n" - "interrogator: dss-interrogator\n" - "spatial_interval: 1.0\n" - "distance_map:\n" - " instrument_distance: [0.0, 97.7]\n" - " distance: [0.0, 97.7]\n", + " instrument_distance: [0.0, 2000.0]\n" + " distance: [0.0, 2000.0]\n", ) ``` +The telemetry cable holds four fibers and only one is in use here. A second interrogator on another of them would be a second optical path under this same fiber array, at its own location code, with its own acquisitions — the array is the installation, not the instrument. + # Reading it back -The directory is now an inventory, and loading it is the moment every rule is checked: that each file is what it says it is, that each `container` resolves to a resource, that no track runs off the end of its path. +The directory is now an inventory, and loading it is where every rule is checked: that each file is what it says it is, that each `container` resolves to a resource, that no track runs off the end of its path. ```{python} inventory = dc.inventory(root) -array = inventory.networks[0].fiber_arrays[0] -for path in array.optical_paths: - print(f"path {path.location_code}: {path.optical_length:.1f} m of fiber") +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]) @@ -357,7 +388,7 @@ patch = dc.get_example_patch( "random_das", acquisition_key="XT.TUN1.00.DAS", time_min="2024-06-01", - shape=(440, 500), + shape=(1780, 200), ) spool = dc.spool(patch).attach_inventory(inventory) ``` @@ -375,77 +406,96 @@ for sub in boreholes: Three patches, not one: the three holes are three separate runs of fiber, and a patch is a dense block of channels, so a selection matching three disjoint runs gives back three patches rather than silently gluing them together. -[`Spool.enrich`](`dascore.core.spool.Spool.enrich`) is what actually puts the metadata on a patch: +[`Spool.enrich`](`dascore.core.spool.Spool.enrich`) is what puts the metadata on a patch: ```{python} -import numpy as np - enriched = spool.enrich()[0] print("gauge length:", enriched.attrs.gauge_length) print("coordinates:", sorted(enriched.coords.coord_map)) +``` -# 60 m along the fiber is 15 m down borehole 1. +And the coordinates are the point of all of it. The channel 1590 m along the fiber is 10 m down borehole 3; the channel 1510 m along it is 6.645 m into the tunnel, which is the wind angle doing its work without having been mentioned; and the channel 1000 m along it is somewhere in a cable tray that nobody surveyed. + +```{python} depth = enriched.get_coord("z").values -assert depth[60] == -15.0 +along = enriched.get_coord("x").values -# The lead-in was never surveyed, so it has no position. -assert np.isnan(depth[10]) +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 a contractor puts a bucket through the trench cable 25 m in. It is repaired with two splices and a two-meter patch cord, and every channel past the repair now sits two meters further along the fiber than it used to. +In September a contractor puts a bucket through the trench cable, fifteen meters of fiber past box A. It is repaired with two splices and a two-meter patch cord, and every channel beyond the repair now sits two meters further along the fiber than it used to. -This is what epochs are for. The old description is not edited: a second directory is added, named for the day the repair happened, and the first path runs until the second begins. +This is what epochs are for. The old description is not edited. A second directory is added, named for the day of the repair, and the first path runs until the second begins. + +The components table is where the repair happens: one row becomes five, and the table is renumbered. ```{python} -#| code-fold: true -#| code-summary: "path.00@2024-09-01 — the same path, after the repair" -epoch = "fiber_arrays/XT.TUN1/path.00@2024-09-01" -write(f"{epoch}/attrs.yaml", "object_type: OpticalPath\n") -write(f"{epoch}/annotations.csv", annotations_csv.replace("427.7", "429.7")) +rows = pd.read_csv(io.StringIO(components_csv)).to_dict("records") +index = next(i for i, row in enumerate(rows) if row["name"] == "trench B to the coil") +rows[index : index + 1] = [ + dict(object_type="FiberSegment", optical_length=15.0, + name="trench B to the break", container="trench-cable"), + dict(object_type="Splice", optical_length=0.0, + name="repair splice near side", container="splice-box"), + dict(object_type="FiberSegment", optical_length=2.0, + name="repair patch cord", container="trench-cable"), + dict(object_type="Splice", optical_length=0.0, + name="repair splice far side", container="splice-box"), + dict(object_type="FiberSegment", optical_length=10.0, + name="trench from the break to the coil", container="trench-cable"), +] + +repaired_components = pd.DataFrame(rows) +repaired_components["sequence"] = range(1, len(repaired_components) + 1) +repaired_csv = repaired_components.to_csv(index=False) + +repaired_components.iloc[index - 1 : index + 5] +``` -# The trench is now two runs of fiber with the repair between them, and -# everything past it moves two meters. -write( - f"{epoch}/optical_components.csv", - components_csv.replace( - "21,FiberSegment,57.7,trenched sensing fiber,trench-cable\n" - "22,FiberSegment,10.0,clump coil,trench-cable\n" - "23,Terminator,0.0,path end,\n", - "21,FiberSegment,25.0,trenched fiber to the break,trench-cable\n" - "22,Splice,0.0,repair splice near side,splice-box\n" - "23,FiberSegment,2.0,repair patch cord,trench-cable\n" - "24,Splice,0.0,repair splice far side,splice-box\n" - "25,FiberSegment,32.7,trenched fiber past the break,trench-cable\n" - "26,FiberSegment,10.0,clump coil,trench-cable\n" - "27,Terminator,0.0,path end,\n", - ), +Everything else follows from that table, which is the argument for having written it this way: the three other tracks are regenerated from the new running totals rather than edited row by row. + +```{python} +#| code-fold: true +#| code-summary: "path.00@2024-09-01 — the same tracks, against the new distances" +EPOCH = "fiber_arrays/XT.TUN1/path.00@2024-09-01" +BREAK = (100.00 + 15.0 * 0.886, 97.79, -0.5) + +repaired_at = spans(repaired_csv) +repaired_trench = ( + "drop into the trench", + "trench B to the break", + "trench from the break to the coil", + "trench from the coil to D", + "rise out of the trench", ) -write( - f"{epoch}/coupling.csv", - coupling_csv.replace("370.0,427.7", "370.0,429.7").replace( - "427.7,437.7", "429.7,439.7" - ), +# The patch cord is coiled in a splice box, so the trench is surveyed up to +# the break and again from it, and the two meters between get no position. +repaired_runs = [ + ("trench B to the break", B, BREAK), + ("trench from the break to the coil", BREAK, C), +] + [x for x in surveyed_runs(repaired_at) if x[0] != "trench B to the coil"] + +write(f"{EPOCH}/attrs.yaml", "object_type: OpticalPath\n") +write(f"{EPOCH}/optical_components.csv", repaired_csv) +geometry_table(repaired_at, repaired_runs).to_csv( + root / EPOCH / "geometry.csv", index=False ) -# The patch cord is coiled in the splice box, so it gets no geometry: the -# trench is described up to the break and again from it. -write( - f"{epoch}/geometry.csv", - geometry_csv.replace( - "trench,370.0,0.0,0.0,-0.5\ntrench,427.7,50.0,0.0,-0.5\n", - "trench before,370.0,0.0,0.0,-0.5\n" - "trench before,395.0,21.65,0.0,-0.5\n" - "trench after,397.0,21.65,0.0,-0.5\n" - "trench after,429.7,50.0,0.0,-0.5\n", - ), +coupling_table(repaired_at, repaired_trench).to_csv( + root / EPOCH / "coupling.csv", index=False +) +annotation_table(repaired_at, repaired_trench).to_csv( + root / EPOCH / "annotations.csv", index=False ) repaired = dc.inventory(root) -for path in repaired.networks[0].fiber_arrays[0].optical_paths: - if path.location_code == "00": - print(f"from {path.start_time}: {path.optical_length:.1f} m") +for epoch in repaired.networks[0].fiber_arrays[0].optical_paths: + start = "the beginning" if np.isnat(epoch.start_time) else str(epoch.start_time)[:10] + print(f"from {start}: {epoch.optical_length:.1f} m") ``` A patch recorded across midnight on the first of September was recorded through both. [`Spool.conform_to_inventory`](`dascore.core.spool.Spool.conform_to_inventory`) is the step which insists every patch be describable by exactly one entry, and it subdivides that patch rather than choosing for you: @@ -455,7 +505,7 @@ straddling = dc.get_example_patch( "random_das", acquisition_key="XT.TUN1.00.DAS", time_min="2024-08-31T23:59:59", - shape=(440, 500), + shape=(1780, 500), ) crossed = dc.spool(straddling).attach_inventory(repaired) @@ -465,19 +515,27 @@ print("patches after: ", len(conformed)) print(conformed.get_contents()[["time_min", "time_max"]].to_string(index=False)) ``` -The split is exact: every sample the patch held is in one piece or the other, and none is in both. Each piece now enriches with the geometry that was true while it was recording, which is the entire reason the repair was written as an epoch instead of an edit. +The split is exact: every sample the patch held is in one piece or the other, and none is in both. Each piece then enriches with the geometry that was true while it was recording, which is the entire reason the repair was written as an epoch instead of an edit. ```{python} before, after = conformed.enrich() -# The coil sat at 427.7 m before the repair and 429.7 m after it. -assert before.get_coord("section").values[428] == "coil" -assert after.get_coord("section").values[428] == "trench" +# The same channel, ten meters down borehole 3 and then eight, because the +# repair pushed two meters of fiber in front of it. +assert before.get_coord("z").values[1590] == -10.0 +assert after.get_coord("z").values[1590] == -8.0 + +# Nothing before the break moved. +assert np.isclose( + before.get_coord("x").values[1510], after.get_coord("x").values[1510] +) ``` +Had the path been edited in place instead, that first assertion would now be false for data recorded in June. + # Shipping it -The directory is the authoring format — good for a deployment still being surveyed, because the tracks are spreadsheets. [`Inventory.to_yaml`](`dascore.core.inventory.Inventory.to_yaml`) writes the single-file form to ship beside a data archive, and [`dc.inventory`](`dascore.core.inventory_loader.inventory`) reads either back. +The directory is the authoring format, and it is the right one while a deployment is still being surveyed, because the tracks are spreadsheets. [`Inventory.to_yaml`](`dascore.core.inventory.Inventory.to_yaml`) writes the single-file form to ship beside a data archive, and [`dc.inventory`](`dascore.core.inventory_loader.inventory`) reads either back. ```{python} text = repaired.to_yaml() @@ -486,13 +544,13 @@ print(f"{len(text.splitlines())} lines of yaml") assert dc.inventory(text) == repaired ``` -Dropping either form into the data directory under the name `.inventory` means every spool opened on that directory finds it without being told. +Dropping either into the data directory means every spool opened there finds it without being told: the authoring directory as `.inventory/`, or the single file as `.inventory.yaml`, `.inventory.yml`, or `.inventory.json`. The name states the format, so a file called plainly `.inventory` is not one of the spellings. # Where the boundaries fell The choices worth restating, because a second deployment will have to make them again: - **Three boreholes, one fiber array.** They are one continuous run of fiber recorded as one channel axis, so they are one array with one path. Five dissimilar arms radiating from a hut, each with its own cable and its own break history, would be five fiber arrays instead — the question to ask is whether a break in one changes the others' metadata. -- **The DSS is a second path, not a second array.** Same installation, same operator, different instrument on different fiber. The location code separates them, and both stay under one array so they share its identity and its lifetime. - **The turnaround is a component; its housing is a resource.** The splice is on the path because the light goes through it. The housing it sits in is a thing the site owns, referred to by any component inside it. +- **The wind angle is geometry, not arithmetic.** It is two control points and a straight line, so nothing downstream computes with $\cos\phi$, and a second trench wound at a different angle needs no new code. - **The repair is an epoch, not an edit.** Data recorded before it is still described by what was true then. Editing the path in place would have silently moved every channel in the archive's history. From d3862a8cddee16fc2b70ad8bf793393e9a10d09f Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Fri, 14 Aug 2026 11:00:39 +0200 Subject: [PATCH 3/3] Give each physical thing in the tunnel its own resource A resource_id names an asset, not a kind, and the page had one splice box standing for the boxes at A and E, one turnaround housing standing for three holes down three separate boreholes, and the repair's patch cord declared as a length of the cable it was spliced into. Anyone adding a serial number or a maintenance record to one of those would have been describing all of them. The links between features also belong to the connecting cable the drawing distinguishes from the telemetry run, not to the run itself. Four more of Codex's findings: the break is fifteen meters into the trench rather than past box A, which is two and a half meters of drop away; the example patch had five channels past the end of the fiber; `.inventory.json` holds what model_dump_json writes rather than what to_yaml does; and a link's first channel does get a position, since it is the surveyed point the run before it ended on. --- docs/recipes/tunnel_inventory.qmd | 84 ++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/docs/recipes/tunnel_inventory.qmd b/docs/recipes/tunnel_inventory.qmd index 9fcbbe9ed..7d9e8c972 100644 --- a/docs/recipes/tunnel_inventory.qmd +++ b/docs/recipes/tunnel_inventory.qmd @@ -79,6 +79,15 @@ resources = { "manufacturer: Corning\n" "model: MIC tight-buffered 4F OS2\n" "fiber_count: 4\n" + "description: The run in from the instrument room.\n" + ), + "connecting-cable": ( + "object_type: Cable\n" + "name: tunnel connecting cable\n" + "manufacturer: Corning\n" + "model: MIC tight-buffered 4F OS2\n" + "fiber_count: 4\n" + "description: The links between boxes, couplers, and borehole heads.\n" ), "borehole-cable": ( "object_type: Cable\n" @@ -95,14 +104,6 @@ resources = { "model: HWC\n" "fiber_count: 1\n" ), - "splice-box": ( - "object_type: Enclosure\nname: tunnel splice box\nenclosure_type: box\n" - ), - "turnaround": ( - "object_type: Enclosure\n" - "name: downhole turnaround housing\n" - "enclosure_type: housing\n" - ), "das-interrogator": ( "object_type: Interrogator\n" "name: tunnel DAS interrogator\n" @@ -111,6 +112,20 @@ resources = { "instrument_type: DAS interrogator\n" ), } +# One enclosure per housing, because a resource_id names an asset rather +# than a kind: box A and box E are two boxes, and each borehole has its own +# turnaround down the hole. +for label, name in [ + ("splice-box-a", "splice box at A"), + ("splice-box-e", "splice box at E"), + ("turnaround-1", "borehole 1 turnaround housing"), + ("turnaround-2", "borehole 2 turnaround housing"), + ("turnaround-3", "borehole 3 turnaround housing"), +]: + kind = "box" if "splice" in label else "housing" + resources[label] = ( + f"object_type: Enclosure\nname: tunnel {name}\nenclosure_type: {kind}\n" + ) for name, text in resources.items(): write(f"resources/{name}.yaml", text) ``` @@ -149,30 +164,30 @@ This is @tbl-path with the parts named, and it goes in `path.00` — the directo components_csv = dedent("""\ sequence,object_type,optical_length,name,container 1,FiberSegment,1500.0,telemetry lead-in,telemetry-cable -2,Splice,0.0,splice at box A,splice-box +2,Splice,0.0,splice at box A,splice-box-a 3,FiberSegment,2.5,drop into the trench,trench-cable 4,FiberSegment,25.0,trench B to the coil,trench-cable 5,FiberSegment,10.0,cable coil at C,trench-cable 6,FiberSegment,25.0,trench from the coil to D,trench-cable 7,FiberSegment,2.5,rise out of the trench,trench-cable -8,Splice,0.0,splice at box E,splice-box -9,FiberSegment,15.0,link E to borehole 3,telemetry-cable +8,Splice,0.0,splice at box E,splice-box-e +9,FiberSegment,15.0,link E to borehole 3,connecting-cable 10,FiberSegment,20.0,borehole 3 down,borehole-cable -11,Splice,0.0,borehole 3 turnaround,turnaround +11,Splice,0.0,borehole 3 turnaround,turnaround-3 12,FiberSegment,20.0,borehole 3 up,borehole-cable -13,FiberSegment,15.0,link borehole 3 to coupler G,telemetry-cable -14,Connector,0.0,coupler G,splice-box -15,FiberSegment,15.0,link coupler G to borehole 2,telemetry-cable +13,FiberSegment,15.0,link borehole 3 to coupler G,connecting-cable +14,Connector,0.0,coupler G, +15,FiberSegment,15.0,link coupler G to borehole 2,connecting-cable 16,FiberSegment,20.0,borehole 2 down,borehole-cable -17,Splice,0.0,borehole 2 turnaround,turnaround +17,Splice,0.0,borehole 2 turnaround,turnaround-2 18,FiberSegment,20.0,borehole 2 up,borehole-cable -19,FiberSegment,15.0,link borehole 2 to coupler H,telemetry-cable -20,Connector,0.0,coupler H,splice-box -21,FiberSegment,15.0,link coupler H to borehole 1,telemetry-cable +19,FiberSegment,15.0,link borehole 2 to coupler H,connecting-cable +20,Connector,0.0,coupler H, +21,FiberSegment,15.0,link coupler H to borehole 1,connecting-cable 22,FiberSegment,20.0,borehole 1 down,borehole-cable -23,Splice,0.0,borehole 1 turnaround,turnaround +23,Splice,0.0,borehole 1 turnaround,turnaround-1 24,FiberSegment,20.0,borehole 1 up,borehole-cable -25,FiberSegment,15.0,link borehole 1 back to box A,telemetry-cable +25,FiberSegment,15.0,link borehole 1 back to box A,connecting-cable 26,Terminator,0.0,path end, """) @@ -254,7 +269,7 @@ geometry.to_csv(root / PATH / "geometry.csv", index=False) geometry.head(8) ``` -The coil and the six 15 m links appear nowhere in that table, so their channels get no position at all. That is the honest answer rather than a gap: the links are slack cable in a tray, and ten meters of fiber wound into a one-meter loop has no useful position per channel. A made-up polyline would be worse than a `nan`. +The coil and the six 15 m links appear nowhere in that table, so their interiors get no position at all — only the channel at the very start of a link, which is the surveyed point the run before it ended on. That is the honest answer rather than a gap: the links are slack cable in a tray, and ten meters of fiber wound into a one-meter loop has no useful position per channel. A made-up polyline would be worse than a `nan`. Notice also what the table does *not* say. It never mentions the wind angle. Two control points state that 25 m of fiber runs from B to C, every channel between them is placed by interpolating that, and $\cos\phi$ stops being anyone's problem at this line. @@ -388,7 +403,7 @@ patch = dc.get_example_patch( "random_das", acquisition_key="XT.TUN1.00.DAS", time_min="2024-06-01", - shape=(1780, 200), + shape=(1776, 200), ) spool = dc.spool(patch).attach_inventory(inventory) ``` @@ -428,24 +443,35 @@ assert np.isnan(depth[1000]) # A repair, and what it does to the data -In September a contractor puts a bucket through the trench cable, fifteen meters of fiber past box A. It is repaired with two splices and a two-meter patch cord, and every channel beyond the repair now sits two meters further along the fiber than it used to. +In September a contractor puts a bucket through the trench cable, fifteen meters of fiber into the trench. It is repaired with two splices and a two-meter patch cord, and every channel beyond the repair now sits two meters further along the fiber than it used to. This is what epochs are for. The old description is not edited. A second directory is added, named for the day of the repair, and the first path runs until the second begins. The components table is where the repair happens: one row becomes five, and the table is renumbered. ```{python} +# The repair is new hardware, so it is new resources: a patch cord, and the +# box holding the slack. +write( + "resources/repair-cord.yaml", + "object_type: Cable\nname: trench repair patch cord\nfiber_count: 1\n", +) +write( + "resources/repair-box.yaml", + "object_type: Enclosure\nname: trench repair box\nenclosure_type: box\n", +) + rows = pd.read_csv(io.StringIO(components_csv)).to_dict("records") index = next(i for i, row in enumerate(rows) if row["name"] == "trench B to the coil") rows[index : index + 1] = [ dict(object_type="FiberSegment", optical_length=15.0, name="trench B to the break", container="trench-cable"), dict(object_type="Splice", optical_length=0.0, - name="repair splice near side", container="splice-box"), + name="repair splice near side", container="repair-box"), dict(object_type="FiberSegment", optical_length=2.0, - name="repair patch cord", container="trench-cable"), + name="repair patch cord", container="repair-cord"), dict(object_type="Splice", optical_length=0.0, - name="repair splice far side", container="splice-box"), + name="repair splice far side", container="repair-box"), dict(object_type="FiberSegment", optical_length=10.0, name="trench from the break to the coil", container="trench-cable"), ] @@ -505,7 +531,7 @@ straddling = dc.get_example_patch( "random_das", acquisition_key="XT.TUN1.00.DAS", time_min="2024-08-31T23:59:59", - shape=(1780, 500), + shape=(1776, 500), ) crossed = dc.spool(straddling).attach_inventory(repaired) @@ -544,7 +570,7 @@ print(f"{len(text.splitlines())} lines of yaml") assert dc.inventory(text) == repaired ``` -Dropping either into the data directory means every spool opened there finds it without being told: the authoring directory as `.inventory/`, or the single file as `.inventory.yaml`, `.inventory.yml`, or `.inventory.json`. The name states the format, so a file called plainly `.inventory` is not one of the spellings. +Dropping either into the data directory means every spool opened there finds it without being told: the authoring directory as `.inventory/`, or the single file as `.inventory.yaml` or `.inventory.yml`. The suffix states the format rather than decorating it, so a file called plainly `.inventory` is not one of the spellings, and the third one — `.inventory.json` — holds what `model_dump_json` writes rather than what `to_yaml` does. # Where the boundaries fell