7.5. Structural measurements#

StructuralMeasurement, LinearMeasurement, and FaultTrace record field structural evidence against a profile position – the same role Borehole plays for lithology, but for strike, dip, and fault geometry instead of depth intervals. StructuralModel collects all three per profile. None of the four are electromagnetic, and none of them place themselves anywhere except along the profile – see Package concepts for why x is always a profile-relative metre value here, never a latitude/longitude pair, and for how clone()/update() re-validate an edited StructuralMeasurement (they do, because it overrides validate(); not every class in this package does).

7.5.1. Recording a planar measurement#

A planar feature – bedding, foliation, a joint, a fault plane – is recorded as strike, dip, and dip direction, as a compass and clinometer would actually report it:

>>> from pycsamt.geology import StructuralMeasurement
>>> bedding = StructuralMeasurement(
...     x=500.0, kind="bedding",
...     strike_deg=45.0, dip_deg=30.0, dip_direction_deg=135.0,
... )
>>> bedding
StructuralMeasurement(x=500.0 m, 'bedding', 45/30->135)

kind is free text – 'bedding', 'foliation', 'joint', 'cleavage', 'contact', 'fault_plane', 'unconformity', or a project-specific label – matching how lithology is free text rather than an enforced enumeration. strike_deg and dip_direction_deg are compass bearings in [0, 360) degrees, not reduced modulo 180: this module intentionally does not reuse MT geoelectric strike’s axial (-90, 90] convention or the axial mod-180 convention pycsamt.ai.geology.lenses uses for synthetic ellipse geometry, because a raw compass reading is directed, not axial, and reducing it early would throw that information away before it can be cross-checked.

That cross-check is why dip_direction_deg is stored at all rather than leaving strike to imply it through the right-hand rule: dip_azimuth_ok requires dip_direction_deg to sit within dip_direction_tolerance_deg (20 degrees by default) of strike_deg + 90 or strike_deg - 90, which catches a transposed field-notebook entry that recording only one of the two numbers never would:

>>> bedding.dip_azimuth_ok
True
>>> StructuralMeasurement(
...     x=500.0, kind="bedding",
...     strike_deg=45.0, dip_deg=30.0, dip_direction_deg=200.0,
... )
Traceback (most recent call last):
    ...
ValueError: dip_direction_deg (200.0) is not within 20.0 deg of strike_deg (45.0) +/- 90 -- check for a transposed strike/dip-direction reading.

Drawing the strike line, the two acceptance wedges, and both the accepted and rejected dip_direction_deg values on a compass rose makes the geometry concrete:

View the strike/dip-direction geometry figure source codeClick to inspect and copy the complete code
 1def make_structural_measurement_geometry() -> None:
 2    """Write the strike/dip-direction compass diagram.
 3
 4    Shows the same ``bedding`` measurement used on the page (strike 45,
 5    dip direction 135, which ``dip_azimuth_ok``), the two +/-20 degree
 6    acceptance wedges centred on ``strike +/- 90`` that
 7    ``dip_direction_deg`` is checked against, and the rejected
 8    ``dip_direction_deg=200`` example that falls outside both wedges.
 9    """
10    bedding = StructuralMeasurement(
11        x=500.0, kind="bedding", strike_deg=45.0, dip_deg=30.0,
12        dip_direction_deg=135.0,
13    )
14    tol = bedding.dip_direction_tolerance_deg
15    strike = bedding.strike_deg
16    dipdir_ok = bedding.dip_direction_deg
17    dipdir_bad = 200.0
18
19    fig, ax = plt.subplots(figsize=(6, 6), subplot_kw={"projection": "polar"})
20    ax.set_theta_zero_location("N")
21    ax.set_theta_direction(-1)
22    ax.set_ylim(0, 1)
23    ax.set_yticklabels([])
24    ax.set_xticks(np.deg2rad([0, 90, 180, 270]))
25    ax.set_xticklabels(["N", "E", "S", "W"], fontsize=11)
26
27    for center in (strike + 90.0, strike - 90.0):
28        lo, hi = np.deg2rad(center - tol), np.deg2rad(center + tol)
29        theta = np.linspace(lo, hi, 30)
30        ax.fill_between(theta, 0, 1, color="#27AE60", alpha=0.18)
31
32    for ang in (strike, strike + 180.0):
33        ax.plot(
34            [np.deg2rad(ang), np.deg2rad(ang)], [0, 1],
35            color="#2C3E50", linewidth=2.5, solid_capstyle="round", zorder=3,
36        )
37
38    ax.annotate(
39        "", xy=(np.deg2rad(dipdir_ok), 0.92), xytext=(0, 0),
40        arrowprops=dict(arrowstyle="-|>", color="#1F618D", linewidth=2.5, mutation_scale=22),
41    )
42    ax.annotate(
43        "", xy=(np.deg2rad(dipdir_bad), 0.92), xytext=(0, 0),
44        arrowprops=dict(arrowstyle="-|>", color="#C0392B", linewidth=2.5, mutation_scale=22),
45    )
46
47    ax.text(np.deg2rad(dipdir_ok), 1.08, f"dip_direction_deg={dipdir_ok:.0f}\n(accepted)",
48            ha="center", va="center", fontsize=9, color="#1F618D")
49    ax.text(np.deg2rad(dipdir_bad), 1.15, f"dip_direction_deg={dipdir_bad:.0f}\n(rejected)",
50            ha="center", va="center", fontsize=9, color="#C0392B")
51    ax.text(np.deg2rad(strike) + 0.05, 0.55, f"strike_deg={strike:.0f}",
52            fontsize=9, color="#2C3E50")
53
54    ax.set_title(
55        "StructuralMeasurement geometry: strike, dip direction, and the\n"
56        "+/-20 deg acceptance wedges around strike +/- 90",
57        fontsize=11, pad=28,
58    )
59
60    fig.tight_layout()
61    fig.savefig(IMAGES / "structural_measurement_geometry.png", dpi=200, bbox_inches="tight")
62    plt.close(fig)
Compass diagram showing the strike line, the two dip-direction acceptance wedges, and an accepted versus a rejected dip direction.

The black line is the strike (drawn both directions, since a strike line has no single sense). The green wedges are the only dip_direction_deg values dip_azimuth_ok accepts – 20 degrees either side of strike +/- 90. 135 lands inside a wedge; 200 does not, which is exactly why the second construction above raises.#

For anyone who prefers recording only dip direction and dip – the common two-number field style – from_right_hand_rule() derives strike as dip_direction_deg - 90 under the right-hand-rule convention (facing along strike, dip direction to your right), and produces an identical, already-consistent measurement:

>>> rhr = StructuralMeasurement.from_right_hand_rule(
...     x=500.0, kind="bedding",
...     dip_direction_deg=135.0, dip_deg=30.0,
... )
>>> rhr.strike_deg == bedding.strike_deg
True

7.5.2. Recording a linear measurement#

A linear feature – a fold axis, a lineation, a slickenline – has no dip direction to cross-check against, so LinearMeasurement is simpler: just a compass trend and a plunge below horizontal.

>>> from pycsamt.geology import LinearMeasurement
>>> fold_axis = LinearMeasurement(
...     x=500.0, kind="fold_axis", trend_deg=210.0, plunge_deg=15.0,
... )
>>> fold_axis
LinearMeasurement(x=500.0 m, 'fold_axis', 210/15)

7.5.3. Fault traces#

FaultTrace is coarser than the two leaf measurements above – it is not a stereonet reading but a statement about where a fault crosses this profile, which side dropped, and by how much:

>>> from pycsamt.geology import FaultTrace
>>> fault = FaultTrace(
...     x=500.0, dip_deg=70.0, downthrown_side="right",
...     sense="normal", throw_m=12.0, evidence="resistivity offset",
... )
>>> fault
FaultTrace(x=500.0 m, dip=70 deg, down=right, throw=12.0 m)

dip_deg here is the apparent dip in the 2-D section, not necessarily the fault’s true 3-D dip – the angle a single EM profile can actually constrain, unless the line happens to run perpendicular to strike. Pass the independently known strike_deg (from surface mapping or a borehole) alongside it when the true attitude matters; FaultTrace keeps the two separate rather than conflating them. throw_m is always non-negative – direction is carried entirely by downthrown_side, so a negative throw would be redundant with, and could contradict, that field:

>>> FaultTrace(x=500.0, dip_deg=70.0, downthrown_side="right", throw_m=-5.0)
Traceback (most recent call last):
    ...
ValueError: throw_m (-5.0) must be >= 0; direction is carried by downthrown_side, not the sign of throw_m.

This is the class meant to plug into the structural-continuity question Interpretation workflow already asks during misfit review – “do apparent boundary offsets align with known structures?” – with an actual record rather than an unbacked checklist item: evidence names what supports the pick ('resistivity offset', 'borehole', 'surface mapping'), and sense ('normal', 'reverse', 'strike_slip', or the default 'unknown') records the kinematics where known.

7.5.4. Collecting evidence along a profile#

StructuralModel holds every planar measurement, linear measurement, and fault trace for one profile together:

>>> from pycsamt.geology import StructuralModel
>>> model = StructuralModel()
>>> model.add_planar(StructuralMeasurement(
...     x=200.0, kind="bedding",
...     strike_deg=40.0, dip_deg=25.0, dip_direction_deg=130.0,
... ))
>>> model.add_planar(StructuralMeasurement(
...     x=650.0, kind="bedding",
...     strike_deg=50.0, dip_deg=35.0, dip_direction_deg=140.0,
... ))
>>> model.add_fault(fault)
>>> model.add_fault(FaultTrace(
...     x=900.0, dip_deg=60.0, downthrown_side="left",
...     evidence="surface mapping",
... ))
>>> model
StructuralModel(2 planar, 0 linear, 2 faults)

Drawn as an actual cross section – fault traces tilted toward their downthrown_side at their apparent dip_deg, bedding measurements as strike/dip markers at the surface – this is the picture StructuralModel is meant to back the structural-continuity review mentioned above with:

View the structural evidence section figure source codeClick to inspect and copy the complete code
 1def make_structural_evidence_section() -> None:
 2    """Write the profile-section figure for a ``StructuralModel``.
 3
 4    Draws the two bedding measurements and two fault traces from the
 5    "Collecting evidence along a profile" example as an actual cross
 6    section: fault traces as dipping line segments (apparent dip,
 7    tilted toward ``downthrown_side``) and bedding measurements as
 8    triangular strike/dip markers at the surface.
 9    """
10    model = StructuralModel()
11    model.add_planar(StructuralMeasurement(
12        x=200.0, kind="bedding", strike_deg=40.0, dip_deg=25.0, dip_direction_deg=130.0,
13    ))
14    model.add_planar(StructuralMeasurement(
15        x=650.0, kind="bedding", strike_deg=50.0, dip_deg=35.0, dip_direction_deg=140.0,
16    ))
17    fault1 = FaultTrace(
18        x=500.0, dip_deg=70.0, downthrown_side="right", sense="normal",
19        throw_m=12.0, evidence="resistivity offset",
20    )
21    model.add_fault(fault1)
22    model.add_fault(FaultTrace(
23        x=900.0, dip_deg=60.0, downthrown_side="left", evidence="surface mapping",
24    ))
25
26    fig, ax = plt.subplots(figsize=(9, 5))
27
28    z_max = 80.0
29    for f in model.faults:
30        direction = 1.0 if f.downthrown_side == "right" else -1.0
31        z = np.array([0.0, z_max])
32        dx = direction * z / np.tan(np.deg2rad(f.dip_deg))
33        x_line = f.x + dx
34        color = _SENSE_COLOR[f.sense]
35        ax.plot(x_line, z, color=color, linewidth=2.5, zorder=3)
36        ax.annotate(
37            "down", xy=(x_line[1] + 25 * direction, z[1] * 0.35),
38            xytext=(x_line[1], z[1] * 0.35),
39            color=color, fontsize=8, va="center",
40            arrowprops=dict(arrowstyle="-|>", color=color, lw=1.2),
41        )
42        label = f"{f.sense}\ndip {f.dip_deg:.0f} deg"
43        if f.throw_m is not None:
44            label += f"\nthrow {f.throw_m:.0f} m"
45        ax.text(f.x, -6.0, label, ha="center", va="bottom", fontsize=8, color=color)
46
47    for m in model.planar:
48        ax.plot(m.x, 0.0, marker="v", color="#117864", markersize=10, zorder=4)
49        ax.text(
50            m.x, -6.0, f"{m.kind}\n{m.strike_deg:.0f}/{m.dip_deg:.0f}->{m.dip_direction_deg:.0f}",
51            ha="center", va="bottom", fontsize=8, color="#117864",
52        )
53
54    ax.set_xlim(0, 1050)
55    ax.set_ylim(z_max, -18)
56    ax.set_xlabel("Profile position x (m)")
57    ax.set_ylabel("Depth (m)")
58    ax.set_title("Structural evidence along the profile (StructuralModel)")
59    ax.grid(alpha=0.25)
60    ax.axhline(0.0, color="0.3", linewidth=0.8)
61
62    handles = [plt.Line2D([0], [0], color=c, lw=2.5, label=s) for s, c in _SENSE_COLOR.items()]
63    handles.append(plt.Line2D([0], [0], marker="v", color="#117864", linestyle="none",
64                              markersize=9, label="bedding measurement"))
65    ax.legend(handles=handles, loc="upper center", bbox_to_anchor=(0.5, -0.14),
66              fontsize=8, ncol=4, frameon=False)
67
68    fig.tight_layout()
69    fig.savefig(IMAGES / "structural_evidence_section.png", dpi=200, bbox_inches="tight")
70    plt.close(fig)
Cross-section view of two fault traces and two bedding measurements along the profile.

The normal fault at x=500 dips toward its downthrown (right) side; the fault of unknown sense at x=900 dips toward its downthrown (left) side. Both bedding markers sit at the surface (z=None was not overridden), labelled with their recorded strike/dip/dip-direction.#

within() restricts a model to a profile span – useful for reviewing only the segment around one inversion panel or station cluster – and returns a new StructuralModel rather than mutating in place:

>>> model.within(0.0, 600.0)
StructuralModel(1 planar, 0 linear, 1 faults)

nearest() answers “what is the closest piece of evidence of this kind to this position,” with an optional max_distance so a distant match is not returned silently as if it were locally relevant:

>>> model.nearest(520.0, kind="faults")
FaultTrace(x=500.0 m, dip=70 deg, down=right, throw=12.0 m)
>>> model.nearest(520.0, kind="faults", max_distance=5.0) is None
True
>>> model.nearest(300.0, kind="planar")
StructuralMeasurement(x=200.0 m, 'bedding', 40/25->130)

Like Borehole and RockDatabase, from_csv() loads field data from disk – here, up to three independent CSV files, one per evidence type, so a project can maintain separate spreadsheets for planar readings, linear readings, and fault picks and combine only the ones that exist:

x,dip_deg,downthrown_side,sense,throw_m,evidence
500.0,70.0,right,normal,12.0,resistivity offset
900.0,60.0,left,unknown,,surface mapping
>>> model_csv = StructuralModel.from_csv(faults_path="structure/faults.csv")
>>> model_csv
StructuralModel(0 planar, 0 linear, 2 faults)
>>> for f in model_csv.faults:
...     print(f)
FaultTrace(x=500.0 m, dip=70 deg, down=right, throw=12.0 m)
FaultTrace(x=900.0 m, dip=60 deg, down=left, throw=?)

The second row’s blank throw_m becomes None (unknown), not zero – from_csv treats an empty field the same way from_csv() and from_csv() treat their own optional columns elsewhere in this package. planar_path and linear_path work the same way, with the column sets from Recording a planar measurement and Recording a linear measurement respectively; any path left as None simply yields an empty list for that evidence type rather than an error.

7.5.5. Where to go next#

Interpretation workflow is where fault and structural evidence actually gets weighed against inversion results during misfit review. Rock resistivity database and Borehole logs cover the other two data families in this package.