Static Intensity Tab

Located at static_analyzer/tabs/static_intensity_tab.py, the Intensity & Density tab (implemented as the mixin class TraceIntensityTab) is one of nine analytical workspaces within the FracSTAT Static Analyzer module. It implements a circular scanline sampling procedure — commonly attributed to Mauldon and co‑workers (Mauldon, 1998; Mauldon et al., 2001) — for the unbiased estimation of two fundamental fracture‑network descriptors used throughout structural geology, reservoir characterization, and rock‑mass engineering: P21 (areal fracture intensity — cumulative trace length per unit sampling area, units [length]/[length]² = [length]⁻¹) and P20 (areal fracture density — number of fracture traces per unit sampling area, units [length]⁻², in the Pij notation of Dershowitz & Herda, 1992). P20 counts whole traces, never the straight segments a polyline trace happens to be digitised into: a single trace drawn with fifty vertices is one fracture, not fifty. Locally, the circular‑window estimator reaches the same quantity by counting trace tips inside the window, two tips per fully enclosed trace.

Unlike simple line‑scanline or box‑counting methods, the circular scanline technique is orientation‑independent: because a circle presents the same sampling window regardless of azimuth, P21 and P20 estimates computed from circular windows are not biased by preferential fracture orientation relative to the sampling geometry. This makes the method particularly well suited to anisotropic fracture networks, where a single straight scanline could over‑ or under‑sample fractures aligned sub‑parallel to it.

Within the broader FracSTAT workflow, this tab consumes the same segment‑level geometry (X1, Y1, X2, Y2, Length_m) produced by the Fracture Map tab's geometry engine, and therefore requires that a project already contain fracture trace data with real spatial coordinates — either digitized from an SVG trace map or imported from a spatially‑referenced Excel/CSV file. It complements the Connectivity tab (topological I‑Y‑X node analysis) and the Histogram/CDF tabs (length‑distribution statistics) by adding a spatially explicit measure of how fracture intensity varies across the mapped domain, rather than summarising the network as a single global statistic.

The tab produces both a spatially gridded (heatmap or kriged contour) representation of local P21/P20 and single‑value global P21/P20 statistics computed over the entire mapped area, giving the user both a map‑view understanding of intensity heterogeneity and a scalar summary suitable for reporting or comparison between datasets.

1. Purpose and Scope

The Intensity & Density tab exists to answer a question the Histogram, CDF, and Rose tabs cannot: not just how long or how oriented the fractures are, but how much fracturing exists per unit area, and how that quantity varies spatially across the mapped domain. The circular‑scanline (Mauldon) method was chosen specifically because it removes the directional sampling bias inherent in straight‑line scanlines, making the resulting P21/P20 estimates directly comparable between outcrops with different dominant fracture trends.

2. Interface Overview

The tab is built by the _build_trace_intensity_tab() method and follows the two‑column layout used throughout FracSTAT's Static Analyzer: a scrollable control sidebar on the left (approximately 280 px wide) and a plot canvas occupying the remaining space on the right. The sidebar is organised into five stacked cards, and the plot area contains a single Matplotlib figure embedded via FigureCanvasTkAgg, together with a compact toolbar.

Full view of the Intensity and Density tab showing the five sidebar cards and the rendered heatmap or contour plot
Figure 2.1 — Full view of the Intensity & Density tab, showing the five sidebar cards (Settings, Plot Options, Map Elements, Background Image, Global Results) on the left and the rendered heatmap/contour plot on the right.

2.1 Sidebar Cards

CardPurpose
SettingsSelects the statistic (P21/P20), the map rendering style (Heatmap/Contour), contour level count, axis flipping, scan‑circle overlay, circle count, and the trace/grid layering and opacity controls.
Plot OptionsCustom plot title, colour‑scale (colormap) selector with a live gradient preview swatch, and colour‑bar position.
Map ElementsColour pickers and toggle for outer/plot background, grid, trace colour, contour line colour, border colour/width, contour line width, and the show‑grid‑lines checkbox.
Background ImageEnables and configures an optional calibrated real‑world photograph drawn behind the intensity/density map, with an opacity slider.
Global ResultsRead‑only statistics panel reporting the scan grid layout, total scan‑circle count, and the two headline global statistics: Global Intensity (P21) and Global Density (P20).

2.2 Plot Toolbar

A single‑row toolbar sits above the plot canvas. It displays a static caption, "Mauldon Circular Scanline Estimations", identifying the underlying method, and a Save Image button that calls the shared save_plot_image() routine (inherited from StaticAnalyzerApp) to export the current figure as a high‑resolution PNG into the project's FracSTAT_plot_exports folder, honouring the DPI configured on the Settings page.

2.3 Plot Canvas

The canvas is a single Matplotlib Axes (self.p21_ax) hosted inside a Figure (self.p21_fig) at 9×6 inches / 100 DPI. Depending on the selected style, the axes displays a pcolormesh heatmap, a filled/line contour plot (optionally kriged), the fracture trace network as a LineCollection, scan‑circle outlines, an optional scale bar in the lower‑right corner, and — if enabled — a background photograph. A colour bar is attached via fig.colorbar(..., location=...), but its position is then explicitly overridden by a custom alignment routine, _align_colorbar_to_plot(), rather than being left at Matplotlib's default constrained‑layout slot — see the note below.

Canvas layout synchronisation: because this tab's plot card is hosted inside a CTkTabview page, the canvas widget only receives its final, real‑world pixel dimensions from Qt after the page is actually shown — an initial draw performed while the page is still hidden uses provisional (often zero‑sized) bounds, which would leave the equal‑aspect plot clipped and the colour bar misaligned. To correct this, the tab binds both <Configure> and <Map> events on the canvas widget to _schedule_p21_layout_sync(), which debounces via after_idle() and calls _sync_p21_layout() once Qt settles. _sync_p21_layout() re‑runs only the inexpensive layout portion — fig.tight_layout() followed by _align_colorbar_to_plot() — rather than the full, potentially expensive redraw, so the plot and colour bar snap into correct alignment the moment the tab becomes visible or is resized, without re‑running the scan‑circle calculation or kriging.

3. Data Input Requirements

The Intensity & Density tab reads exclusively from self.results (an alias of self._static_results), the DataFrame populated by the Fracture Map tab's geometry engine after an SVG or Excel/CSV file is loaded and processed. No file loading occurs directly within this tab; the user must first load data via the Fracture Map tab's Load SVG or Load Excel buttons in the shared workspace header.

3.1 Required Columns

ColumnDescription
X1, Y1, X2, Y2Real‑world (scaled) segment endpoint coordinates, in the project's display unit (m, cm, mm, or km).
Length_mPre‑computed Euclidean segment length in the same display unit (name retained from the original metre‑based implementation).

If any of these four columns is absent, the tab renders an empty‑state message ("Missing coordinate columns") rather than attempting a calculation.

3.2 Data‑Source Compatibility

Because circular scanline sampling requires genuine spatial geometry, the tab explicitly rejects orientation‑only Excel/CSV inputs. When the active data source is Excel and the underlying Segment objects carry a non‑null _preset_strike attribute (the internal marker used by excel_loader.py for the legacy fracture‑plane format — ID/Strike/Dip — and the Trend/Plunge pole‑orientation format), the tab displays the explanatory message:

Note: "Circular scanlines require coordinate geometry. Load an SVG or a spatial Excel/CSV with X1 Y1 X2 Y2 columns."

Spatially‑referenced Excel/CSV files (the "raw segment / results" format, containing real X1/Y1/X2/Y2 columns) are fully supported and processed identically to SVG‑derived traces, since excel_loader.py's _read_raw_segments() leaves _preset_strike as None for that format, allowing the geometry checks to pass.

3.3 No‑Data State

If self.results is None or empty, the tab calls _p21_empty_state() with the message "Load SVG fracture data to run calculations," clearing all four Global Results labels to a dash and drawing only a centred text annotation on an otherwise blank axes.

4. Settings and Controls

This section documents every interactive control exposed in the sidebar, the ui_common.qtk variable that backs it, its default value, and its effect on the calculation or rendering pipeline. All controls other than the two colour‑scale dropdowns trigger an immediate call to _draw_trace_intensity() (directly, or via a bound Return/FocusOut event for text‑entry fields), so changes are reflected as soon as the control loses focus or its command callback fires.

4.1 Settings Card

ControlWidgetVariableDefaultEffect
Estimated Intensity, P21 / Estimated Density, P20Radio button pairplot_mode_var"P21"Selects which of the two Mauldon estimators (intensity or density) is displayed as the coloured grid/contour surface and reported as the map title / colour‑bar label.
Grid Heatmap / Contour (Filled) / Contour (Lines Only)Radio button trioplot_style_var"Heatmap"Chooses the rendering style: a discrete pcolormesh grid of scan‑circle values, a smoothly interpolated/kriged filled contour, or contour lines only (no fill).
Contour LevelsText entrycontour_levels_var"15"Number of contour bands (integer) used for both the Matplotlib MaxNLocator tick calculation and, if a non‑integer step value is supplied, a fixed contour interval in data units (see Section 6.4).
Flip X axis / Flip Y axisCheckboxesflip_x_var / flip_y_varFalse / FalseCalls ax.invert_xaxis() / ax.invert_yaxis() after the axis limits are set, mirroring the map about the corresponding axis for display purposes only (does not alter underlying geometry).
Show scan circlesCheckboxshow_circles_varFalseOverlays every individual scan‑circle boundary (unfilled, thin red outline) on the plot so the user can visually verify grid coverage and circle radius relative to fracture spacing.
Number of scan circlesText entrynum_circles_var"12"Target circle count along the shorter map dimension; the algorithm (Section 6.1) derives the full 2‑D grid layout, spacing, and radius from this single value.
Show fracture traces (Map)Checkboxshow_map_varTrueToggles the LineCollection overlay of the raw fracture segments on top of (or below) the intensity/density surface.
Traces above Grid / Grid above TracesRadio button pairlayer_order_var"traces_top"Sets the Z‑order and, correspondingly, which layer receives the partial‑opacity blend from the slider below (see Section 4.1.1).
Upper layer opacitySlider (0.1–1.0)top_opacity_var0.8Alpha applied to whichever layer (traces or grid) is currently on top per Layer stacking order; the lower layer remains fully opaque. Debounced via a 100 ms after() timer to avoid re‑triggering the (potentially expensive) kriging step on every pixel of slider drag.

4.1.1 Layer Stacking and Opacity Logic

The two overlay layers — the coloured intensity/density surface ("grid") and the fracture‑trace LineCollection ("traces") — are drawn with fractional Matplotlib z‑order values (1.5 and 2.5, or 2.5 and 1.5, depending on layer_order_var) chosen so that whichever layer the user places "on top" always renders above the other while both remain safely below axis text (z‑order 3.0) and above the canvas background (z‑order 1.0). The opacity slider always applies to the top layer only; the bottom layer is always drawn at alpha = 1.0, ensuring the underlying layer is never washed out regardless of which is chosen as primary.

4.2 Plot Options Card

ControlWidgetVariableDefaultEffect
Custom title (blank = auto)Text entrytitle_var""Overrides the automatically generated plot title (e.g. "Estimated P21 (Grid Heatmap)") with user‑supplied text when non‑empty.
Colour scaleCustom dropdown (CustomColorDropdown)cmap_var / _cmap_resolved"Viridis"Selects the Matplotlib colormap applied to the heatmap/contour fill. A 104×28 px live gradient swatch previews the selected colormap. Internally resolved to a matplotlib colormap id (or the bundled Parula approximation) and cached in _cmap_resolved to avoid re‑parsing the display name on every redraw.
Colorbar positionDropdowncolorbar_pos_var"Right"Bottom / Top / Left / Right / None — passed to Matplotlib's fig.colorbar(..., location=...). Selecting None suppresses colour‑bar rendering entirely.

4.3 Map Elements Card

ControlVariableDefaultEffect
Outer BGouter_bg_var#F5F7FAColour of the Matplotlib Figure background, outside the plot axes.
Plot BGinner_bg_var#FFFFFFColour of the Axes background (i.e. inside the plot frame).
Grid Colorgrid_color_var#D9E2ECColour of the reference grid lines (only drawn when "Show grid lines" is checked).
Trace Colortrace_color_var#1E3A8AColour of the fracture‑trace LineCollection overlay.
Contour Line Colorcontour_line_color_var#000000Colour of contour isolines (and their inline numeric labels) in either contour rendering style.
Show grid linesgrid_varTrueToggles ax.grid() with the configured colour and 0.6 alpha.
Show scaleshow_scale_varFalseToggles a data‑accurate scale bar in the lower‑right of the map — see Section 4.3.1.
Scale Colorscale_color_var#1F2933Colour used to draw the scale bar (and its label/ticks).
Scale stylescale_style_var"Solid bar"Chooses how the scale bar is rendered — Solid bar (single filled block), Segmented bar (alternating filled/unfilled quarters), or Ticked line (a plain line with end ticks and no fill).
Border Colorborder_color_var#475569Colour applied to all four Matplotlib Axes spines.
Border Widthborder_width_var"1.0"Line width (points) applied to all spines; invalid entries silently fall back to 1.0.
Contour Line Widthcontour_line_width_var"0.5"Line width used for contour isolines; invalid entries fall back to 0.5.

4.3.1 Scale Bar Rendering (_add_intensity_scale_bar)

When "Show scale" is enabled, _add_intensity_scale_bar() adds a data‑accurate mpl_toolkits.axes_grid1.anchored_artists.AnchoredSizeBar anchored to the lower‑right corner of the map (loc="lower right"). The bar's physical length is chosen by a "nice number" heuristic — the largest value of the form 1, 2, or 5 × 10ⁿ that does not exceed 22% of the current X‑axis span — so the labelled length is always a round, easily read figure rather than an arbitrary fraction of the map width.

The bar's label automatically converts units: if the project's display unit is metres and the computed bar length is 1000 or greater, the label is shown in kilometres (e.g. "2 km") instead of an unwieldy metre value (e.g. "2000 m"); for any other unit, or a length under 1000 m, the label uses the display unit unchanged. The three "Scale style" options are all drawn from the same underlying bar: Solid bar leaves the default filled rectangle as‑is; Segmented bar hides that rectangle and rebuilds it as four alternating filled/unfilled quarter‑segments in the chosen scale colour and the Plot BG colour; Ticked line suppresses the vertical bar height entirely, leaving only the horizontal line and its end ticks.

4.4 Background Image Card

This card lets the user overlay a georeferenced field photograph or outcrop image beneath the intensity/density surface, exactly mirroring the equivalent controls on the Fracture Map, Connectivity, and Profile Cross‑section tabs, so that a single calibrated image can be reused across every spatial tab in the Static Analyzer without re‑uploading it.

ControlWidgetVariableBehaviour
Show real photo behind plotCheckboxp21_bg_mode_varOn check, calls _resolve_p21_bg_image_path(prompt_if_missing=True), which first tries to auto‑detect an already‑known image (this tab's own path, the Fracture Map tab's calibrated image, or the <svg_base>_bg.png sidecar written by the Trace Map tool) before opening a file‑browse dialog; if no image can be resolved, the checkbox is reset to unchecked.
Change Image…Button—Opens a file dialog restricted to common raster formats (PNG, JPG/JPEG, BMP, TIFF, WEBP) and stores the chosen path.
Status labelRead‑only label—Displays "Image: <basename>" when a valid path is set, or "No image set" otherwise.
Image opacity (0–1)Text entryp21_bg_alpha_varDefault 1.0. Alpha blended into ax.imshow(); values are clamped to [0, 1] before use.

The image is drawn with ax.imshow(extent=(xMin, xMax, yMax, yMin), origin='upper', zorder=0), which intentionally swaps the vertical extent bounds to compensate for the SVG/screen convention of Y increasing downward — matching the same fix applied consistently across every other spatially‑aware tab in the application (Fracture Map, Connectivity, Profile Cross‑section).

4.5 Global Results Card (Read‑Only)

FieldMeaning
Grid Layout (X x Y)The resolved scan‑circle grid dimensions, xNumCircle × yNumCircle, after the packing algorithm in Section 6.1 has run.
Total Scan CirclesxNumCircle × yNumCircle, the total number of circular sampling windows used.
Global Intensity (P21)The single scalar network‑wide intensity, total fracture length divided by mapped area, with a unit⁻¹ superscript matching the active display unit.
Global Density (P20)The single scalar network‑wide density, trace count (df["Trace"].nunique()) divided by mapped area, with a unit⁻² superscript. It is deliberately not a segment count: reporting len(lengths) here would make the density scale with digitisation density, so a 40‑trace network drawn with ten segments per trace would read ten times too high.
Native P21 intensity heatmap with scan-circle outlines, trace overlay and centimetre inverse units
Figure 4.1 — Native Estimated Intensity, P21 grid heatmap with fracture traces and scan-circle outlines enabled. The Number of scan circles setting is 12, from which the application derives a full two-dimensional grid, not just 12 circles in total. The colour bar is in cm⁻¹. Circle size, calibration, and sampling location affect the estimates; white cells are not coloured estimates and should not automatically be interpreted as measured zero intensity. The sidebar continues below the visible area.

5. Mathematical Background

The circular scanline method was developed to overcome a fundamental limitation of straight‑line scanlines: the number of fracture intersections recorded by a straight scanline is systematically biased against fractures that are sub‑parallel to the scanline direction (the well‑known Terzaghi orientation bias (Terzaghi, 1965; Priest, 1993)). A circular sampling window, having no preferred direction, removes this bias, provided fracture intensity is evaluated using the correct geometric estimator (Mauldon, 1998; Mauldon et al., 2001). Comparative appraisals of outcrop sampling schemes consistently find the circular-window family to be the least biased of the practical alternatives (Zeeb et al., 2013; Watkins et al., 2015).

5.1 Symbol Definitions

SymbolDescriptionUnits
rRadius of a circular scan window (rCircle in the implementation).[length]
NNumber of discrete fracture‑segment/circle‑boundary intersection points found for one scan circle.count
MNumber of fracture trace tips that fall strictly inside a scan circle. A tip is the first point of a trace's first segment or the last point of its last segment; interior polyline vertices are not tips and are excluded by _trace_tip_masks().count
ILocal (per‑circle) estimate of P21, fracture intensity.[length]⁻¹
DLocal (per‑circle) estimate of P20, fracture density.[length]⁻²
L_totSum of all segment lengths in the mapped dataset (Σ Length_m).[length]
n_tracesNumber of distinct traces in the mapped dataset, df["Trace"].nunique(). Not the segment count.count
A_mapBounding‑box area of the mapped dataset, (x_max−x_min)×(y_max−y_min).[length]²

5.2 Circle‑Based P21 (Intensity) Estimator

For a single scan circle of radius r, the Mauldon circular‑scanline estimator of areal fracture intensity is:

I = N / (4 r)

This relationship follows from integral‑geometry (Buffon‑type) arguments relating the expected number of crossings of a fracture network with the boundary of a convex sampling window to the total trace length enclosed, and holds regardless of the internal orientation distribution of the fractures — which is precisely why the method is orientation‑unbiased (Mauldon, 1998). In the implementation, N corresponds to N_circle, the count of valid line‑segment/circle‑boundary root crossings (Section 6.2), and I corresponds to one entry of the I_grid array. In the Pij notation of Dershowitz and Herda (1992) this quantity is P21: one‑dimensional features (trace length) measured over a two‑dimensional region.

5.3 Circle‑Based P20 (Density) Estimator

The companion density estimator counts fracture trace tips falling inside the circle:

D = M / (2π r²)

where M is the number of trace tips inside the window (Mauldon et al., 2001, §2). This is coded as M_circle / (2 * π * rCircle**2). The normalisation constant is 2πr², not the naive πr², because a trace contributes two tips: for a tip density of 2d per unit area over a window of area πr², the expected count is E[M] = 2d·πr², so the unbiased estimate of the trace density d is M/(2πr²). Dividing the two estimators returns the Mauldon mean trace length,

ℓ = I / D = [N / (4r)] / [M / (2π r²)] = π r N / (2 M)

which is exactly the mean‑length estimator published alongside them (Mauldon, 1998; Rohrbaugh et al., 2002), so the constants are not free parameters that could be adjusted.

Tips, not segment endpoints. M is accumulated with the per‑row boolean masks returned by _trace_tip_masks(df), so only a trace's first (X1, Y1) and last (X2, Y2) can ever be counted. Summing both endpoints of every segment instead — as an unmasked count does — charges each interior polyline vertex twice, so a trace digitised into n segments supplies 2n "tips" instead of 2 and D measures how finely the map was drawn rather than how densely it is fractured. Tips are the physically meaningful quantity here because they are where fractures actually terminate.

5.4 Global (Network‑Wide) Statistics

In addition to the spatially resolved I_grid/D_grid arrays, the tab reports two single‑value statistics computed directly from the full dataset rather than by averaging the per‑circle grid, giving an estimate that is insensitive to grid resolution or circle placement:

P21_global = L_tot / A_map P20_global = n_traces / A_map where n_traces = df["Trace"].nunique()

These are the classical, non‑circular definitions of areal intensity and density applied to the entire bounding box of the dataset, and are what is displayed in the Global Results card.

P20 is traces per unit area, not segments per unit area. In the Pij system of Dershowitz and Herda (1992), the first index is the dimension of the sampling region and the second the dimension of the feature measured: P20 is zero‑dimensional features (whole fractures) counted over a two‑dimensional region. A polyline trace is one fracture however many vertices were clicked while digitising it, so the numerator must be the distinct‑trace count. Using the row count of the segment table instead makes P20_global proportional to discretisation: a network of 20 traces in 10 000 m² is 0.002000 m⁻² whether each trace is stored as one segment or as fifty, whereas a segment count would report 0.002, 0.010, or 0.100 m⁻² for the same geology. Note also that P21_global is unaffected by this, because Σ Length_m telescopes over the vertices of a polyline and is already digitisation‑invariant.

5.5 Scan‑Circle Packing Geometry

Given a user‑specified target circle count n (num_circles_var) and the data's bounding‑box dimensions Δx = x_max−x_min and Δy = y_max−y_min, the algorithm computes a near‑tangent, non‑overlapping rectangular grid of circles. Denoting the longer of Δx, Δy as the "primary" axis (n circles are placed along it) and the shorter as the "secondary" axis:

Δ_primary = L_primary / (n + 1) r = 0.99 × Δ_primary / 2 Δ_secondary = 2 r / 0.99 n_secondary = floor( L_secondary / Δ_secondary − 1 )

The 0.99 scale factor intentionally shrinks each circle by 1% relative to exact tangency, preventing floating‑point edge cases in which adjacent circles would otherwise touch or micro‑overlap. When Δx equals Δy exactly, both axes use n circles directly with spacing based on the smaller of the two per‑axis spacings. Circle centres are then placed at:

x_i = x_min + i × Δx_circle , i = 1 … xNumCircle y_j = y_min + j × Δy_circle , j = 1 … yNumCircle

producing a regular grid of centres based on the data's bounding box, not a mapped-area polygon. This does not guarantee that every circle lies inside the surveyed domain, especially for irregular coverage or a narrow domain where the secondary circle count is clamped to one. Inspect coverage with Show scan circles enabled.

6. Algorithms and Internal Workflow

_draw_trace_intensity() is the single entry point that orchestrates data validation, grid construction, the vectorised geometric intersection test, statistic computation, and Matplotlib rendering, in that order, every time any control changes. This section walks through that pipeline in the sequence it executes.

6.1 Step 1 — Validation and Grid Layout

  1. Verify self.results is non‑empty and, if the data source is Excel, that the segments carry real spatial geometry (not orientation‑only preset strike/dip data).
  2. Verify the five required columns (X1, Y1, X2, Y2, Length_m) are present.
  3. Parse num_circles_var and contour_levels_var from their text fields, applying documented fallback defaults (12 and 15 respectively) on parse failure, and writing the corrected/parsed value back into the StringVar so the sidebar reflects what was actually used.
  4. Compute the dataset bounding box (xMin, xMax, yMin, yMax) from the min/max of both endpoint columns, and derive Δx, Δy (mapArea = Δx × Δy).
  5. Apply the packing geometry of Section 5.5 to obtain xNumCircle, yNumCircle, rCircle, and the two per‑axis centre‑spacing increments, clamping both circle counts to a minimum of 1.
  6. Build the 1‑D centre coordinate arrays x_centers, y_centers and combine them into a 2‑D meshgrid (CX, CY), which is then flattened to CX_flat, CY_flat for the vectorised intersection step.

6.2 Step 2 — Vectorised Line‑Circle Intersection Test

Rather than looping over every (segment, circle) pair in Python, the module builds two broadcastable NumPy arrays — one of shape (n_segments, 1) for the segment endpoints and one of shape (1, n_circles) for the circle centres — so that every pairwise combination is evaluated simultaneously via NumPy broadcasting. For each segment i and circle k, the standard parametric line/circle intersection is solved:

a = (x2−x1)² + (y2−y1)² b = 2[(x1−cx)(x2−x1) + (y1−cy)(y2−y1)] c = (x1−cx)² + (y1−cy)² − r² Δ = b² − 4ac t1,2 = (−b ∓ √Δ) / (2a)

A real root t is a genuine crossing of the (infinite) circle by the segment only if 0 ≤ t ≤ 1 (i.e. the crossing lies within the finite segment, not its extension) and Δ ≥ 0. The implementation additionally guards against degenerate zero‑length segments (a = 0) by substituting a = 1 for those rows before division and separately masking them out via the boolean array valid = a > 0, so no division‑by‑zero warning is ever raised. The second root is only counted as distinct when t1 ≠ t2 (t_valid2 additionally requires t1 != t2), preventing a tangent intersection from being double‑counted.

Per scan circle, the intersection count N (summed over all segments, over the segment axis) becomes N_circle. The two "endpoint inside circle" tests d₁_sq < r² and d₂_sq < r² (squared distance from each of the segment's two endpoints to the circle centre, compared against r²) are each AND‑ed with a trace‑tip mask before summing:

tip1_mask, tip2_mask = _trace_tip_masks(df) m_arr = ((d1_sq < r_sq) & tip1_mask[:, None]).astype(int) \ + ((d2_sq < r_sq) & tip2_mask[:, None]).astype(int)

so that M_circle is a count of genuine trace tips, not of segment endpoints (Section 5.3). _trace_tip_masks() groups the rows by Trace, orders each group by Segment, and sets tip1 True on the first row of the trace and tip2 True on its last; every other endpoint is an interior polyline vertex and stays False. If the frame carries no Trace column the helper degrades gracefully by treating every row as its own trace (both masks all True), which reproduces the older segment‑endpoint behaviour only in the one case where it is correct.

Both N_circle and M_circle are reshaped from a flat (n_circles,) vector back into the 2‑D (yNumCircle, xNumCircle) grid shape matching CX, CY, so that I_grid and D_grid (per Section 5.2–5.3) can be plotted directly with pcolormesh or used as scattered input to the kriging/interpolation step.

6.3 Step 3 — Global Statistics and Sidebar Update

total_length = Σ(Length_m), mapArea, n_traces = int(df["Trace"].nunique()), global_P21, and global_P20 are computed per 5.4 and pushed into the four Global Results labels, alongside the resolved grid layout (xNumCircle × yNumCircle) and total scan‑circle count. When the frame has no Trace column, n_traces falls back to len(lengths), i.e. one trace per row. The unit suffix (m, cm, mm, or km) is read live from the shared _static_map_unit_var so the displayed units always track the workspace's current display unit.

6.4 Step 4 — Rendering Dispatch

Based on plot_style_var, one of three rendering branches executes:

6.4.1 Heatmap

The active grid (I_grid or D_grid, selected by plot_mode_var) is masked wherever its value is exactly zero (np.ma.masked_where) so empty regions render as fully transparent rather than as a spurious colour at the bottom of the colour scale, and drawn with ax.pcolormesh() over cell‑edge coordinate arrays derived by expanding each centre by ±half the corresponding axis spacing.

6.4.2 Contour (Filled) / Contour (Lines Only)

Both contour styles first interpolate the sparse per‑circle grid values onto a dense 100×100 regular mesh spanning the full bounding box. Two interpolation paths are supported:

  • Ordinary Kriging (preferred) — if the pykrige package is available, an OrdinaryKriging model with a linear variogram (slope = 1.39096×10⁻⁸, nugget = 0.0) is fit to the scan‑circle centres and evaluated on the dense grid via OK.execute('grid', …), with negative kriged values clipped to zero (fracture intensity/density cannot be negative).
  • Cubic griddata fallback — if pykrige is not installed, or kriging raises an exception, scipy.interpolate.griddata(…, method='cubic', fill_value=0) is used instead, silently degrading gracefully rather than failing the redraw.

Contour levels are computed with Matplotlib's MaxNLocator when contour_levels_var parses to an integer count, or as a fixed step interval (np.arange) when the parsed value is a non‑integer float, allowing the user to request either "n bands" or "a contour every X units" behaviour from the same text field. A second, level‑filtered array with the zero level removed (plot_levels_lines) is used specifically for the isoline/label pass, so a spurious "0.000" contour is never drawn or labelled in the interior of the map, while the zero level is still retained in the filled‑colour pass (plot_levels_fill) so the colour scale correctly represents zero‑intensity regions.

For the "Contour (Filled)" style, ax.contourf() draws the shaded bands at 0.85× the layer alpha; for both contour styles, ax.contour() draws black (or user‑coloured) isolines with inline numeric labels via ax.clabel(inline=True, fontsize=8, fmt='%.3f').

6.5 Step 5 — Overlay Layers, Axis Styling, and Colour Bar

After the primary surface is drawn, the module conditionally adds: the fracture‑trace LineCollection (if Show fracture traces is checked), the individual scan‑circle outlines (if Show scan circles is checked), and the background photograph (if enabled), respecting the z‑order/opacity rules of Section 4.1.1. Axis limits are padded by 5% of each dimension, aspect is fixed to 'equal', axis flips are applied, grid lines and border/spine styling are applied from the Map Elements card, and the scale bar is added last via _add_intensity_scale_bar() (see Section 4.3.1) so it sits above every other map layer. Finally fig.tight_layout() and _align_colorbar_to_plot() commit the layout before canvas.draw_idle() renders the frame; the same tight‑layout/alignment pair is re‑run independently by the canvas layout‑sync handler described in Section 2.3 whenever the tab is resized or first shown.

6.6 Caching and Performance Optimisation

Kriging (and, to a lesser extent, cubic griddata interpolation) is computationally the most expensive step in the pipeline, particularly for large scan‑circle grids. To avoid re‑running it on every purely cosmetic redraw (e.g. a colour change), the module maintains a single‑entry cache, self._krige_cache, keyed on a tuple of (len(df), loaded file path, plot_mode, num_circles_var) — i.e. the inputs that actually influence the interpolated surface. On each contour‑style redraw, the current key is compared against the cached key; if they match, the previously computed fine_Z array is reused directly, and kriging/griddata is skipped entirely. Any change to the underlying data, the selected statistic (P21 vs P20), or the requested circle count invalidates the cache and forces recomputation.

The opacity slider additionally uses a 100 ms after() debounce (self._opacity_timer) so that dragging the slider does not trigger a full recomputation (including a potential kriging pass) on every intermediate pixel value emitted during the drag; only the final settled value (100 ms after the last movement) triggers _draw_trace_intensity().

The core intersection test itself (Section 6.2) is implemented purely with NumPy array broadcasting rather than nested Python loops, so its cost scales as O(n_segments × n_circles) in vectorised array operations rather than in interpreted Python iterations — a substantial performance benefit for networks with hundreds to thousands of segments sampled by tens of circles.

7. Statistical Methods

7.1 Point Estimators vs. Grid Interpolation

The tab distinguishes between two distinct statistical operations: (1) unbiased point estimation of P21/P20 at each discrete scan‑circle location using the Mauldon estimators of Section 5.2–5.3, and (2) spatial interpolation between those discrete point estimates for continuous‑surface visualization (heatmap cell shading is a nearest‑value/no‑interpolation display of the raw grid, whereas the contour styles interpolate). Users should be aware that the interpolated contour surface between scan‑circle centres is a smoothed visual aid, not itself a rigorously unbiased estimator at arbitrary intermediate locations — the rigorous estimate exists only at the discrete circle centres.

7.2 Ordinary Kriging

When available, Ordinary Kriging is used for its property of exact interpolation at data points combined with a principled treatment of spatial autocorrelation via the fitted linear variogram model (Cressie, 1993; Chilès and Delfiner, 2012). A linear variogram was selected as a robust, parameter‑light default appropriate for scan‑circle sampling grids of moderate size, avoiding the need for interactive variogram model selection within the GUI. Because the variogram parameters are fixed rather than fit adaptively to each dataset, users working with strongly clustered or highly heterogeneous fracture intensity fields should treat the kriged surface as a qualitative smoothing aid rather than a geostatistically rigorous best linear unbiased predictor.

7.3 Cubic Spline Fallback

In the absence of PyKrige, scipy.interpolate.griddata with method='cubic' provides a smooth, differentiable interpolation surface (Virtanen et al., 2020). Cubic griddata does not honour the nugget/nugget‑free distinction of kriging and can produce mild over/undershoot (Gibbs‑like ringing) near sharp gradients in the underlying scan‑circle values; this is an accepted trade‑off for environments without the optional PyKrige dependency.

7.4 Global vs. Local Statistics — Interpretation Guidance

The Global Results panel's P21/P20 values are computed directly from the full dataset (total length, and total trace count, each divided by the bounding‑box area) and therefore represent the exact areal intensity/density of the digitized network over its full extent — they are not themselves circular‑scanline estimates and carry no orientation‑bias correction, but they also carry no scan‑circle sampling error, since they use every available data point. The gridded/contoured map, by contrast, reveals spatial heterogeneity in intensity/density that the single global value necessarily conceals; a network can have a modest global P21 while containing localized high‑intensity damage zones clearly visible on the map.

8. Visualization Components

ElementMatplotlib ObjectDescription
Heatmap surfaceax.pcolormesh()Discrete, cell‑based colouring of the raw (non‑interpolated) I_grid/D_grid values, masked where zero.
Filled contourax.contourf()Smooth, kriged/interpolated shaded bands of the selected statistic.
Contour isolinesax.contour() + ax.clabel()Labelled black (or user‑coloured) contour lines, always available in both contour styles, and independently controllable in width/colour.
Fracture trace overlaymatplotlib.collections.LineCollectionAll digitized fracture segments drawn as a single vectorised line collection for performance, coloured per Map Elements > Trace Color.
Scan‑circle outlinesmatplotlib.patches.Circle (unfilled)One outline per scan‑circle centre, only drawn when "Show scan circles" is enabled; useful for auditing grid coverage and spacing.
Background photographax.imshow()Optional calibrated field photo drawn beneath (z‑order 0) all data layers.
Scale barmpl_toolkits.axes_grid1.anchored_artists.AnchoredSizeBarOptional data‑accurate scale bar anchored to the lower‑right of the map, drawn last (above all other layers); see Section 4.3.1.
Colour barfig.colorbar(mappable, ax=ax, location=…)Attached via Matplotlib's axis‑anchored colorbar API, but its final position, width, and axes locator are explicitly overridden by the custom _align_colorbar_to_plot() method to keep it thin and flush against the map rather than leaving Matplotlib's default (and often oversized) empty band beside it. Its tick formatter also has set_scientific(False) and set_useOffset(False) applied, so intensity/density values are always shown as plain decimal numbers instead of scientific notation or an offset.
Example heatmap rendering of P21 intensity across a mapped fracture network with scan circles overlaid
Figure 8.1 — Example heatmap rendering of P21 intensity across a mapped fracture network, with scan circles overlaid and fracture traces drawn above the grid at 80% opacity.
Example kriged filled contour rendering of P20 density with labelled isolines and a right hand colour bar
Figure 8.2 — Example kriged filled‑contour rendering of P20 density, showing labelled isolines and a right‑hand colour bar.

All text elements (titles, axis labels, colour‑bar labels, tick labels) are rendered using the workspace's currently selected font family, size, weight, and colour, which are applied globally via Matplotlib's rcParams by the shared _apply_workspace_font() mechanism common to every Static Analyzer tab — changing the font in the workspace header therefore updates this tab's plot text on the next redraw without any tab‑specific font code.

9. Advanced Features

9.1 Layer Stacking with Independent Opacity

The traces‑above‑grid / grid‑above‑traces toggle, combined with the single shared opacity slider, allows the user to visually emphasise either the raw fracture network (useful when validating that the intensity surface correctly reflects the underlying digitised traces) or the interpolated intensity/density field (useful for presentation‑quality figures where the traces would otherwise visually clutter a smooth contour surface).

9.2 Background Photograph Auto‑Detection

_autodetect_p21_bg_image_path() implements a three‑tier fallback search: (1) the path already stored in this tab's own p21_bg_image_path_var, if it still exists on disk; (2) the Fracture Map tab's calibrated background image path (_static_map_bg_image_path_var), letting a single calibration performed once on the main map propagate automatically to this tab; (3) the <svg_base>_bg.png sidecar file written automatically by the Trace Map tracing tool (tracer.py) whenever a background reference photo was used during digitisation. This layered lookup means most users will never need to manually browse for an image on this tab at all.

9.3 Adaptive Contour Level Specification

The Contour Levels field accepts either an integer (interpreted as a target band count, resolved via Matplotlib's MaxNLocator to "nice" round‑number boundaries) or a decimal value (interpreted as a fixed absolute contour interval in the data's own units, e.g. entering 0.05 draws contours every 0.05 units of P21). This dual behaviour is detected automatically from whether the parsed float has a zero fractional part.

9.4 Zero‑Level Suppression on Contour Labels

Because scan circles containing no fracture length and no trace tips correctly evaluate to I = 0 or D = 0, an unfiltered contour pass would frequently draw and label a "0.000" contour threading through every low‑intensity region of the map, adding visual noise without informative content. The implementation filters this level out of the isoline/label pass (plot_levels_lines) while retaining it in the colour‑fill pass (plot_levels_fill), so the zero‑intensity colour band is still shown, but not needlessly outlined and numerically labelled.

10. Performance Optimization

Because scan‑circle count enters the intersection test as a multiplicative factor on cost, users working with very dense fracture networks (many thousands of segments) are advised to keep the Number of scan circles control modest (10–20) unless finer spatial resolution is specifically required, since both the O(n_segments × n_circles) intersection test and, for contour styles, the kriging/interpolation step scale with the number of scan circles.

TechniqueWhere AppliedBenefit
Full vectorisation (NumPy broadcasting)Line/circle intersection test (Section 6.2)Avoids an explicit Python double loop over every (segment, circle) pair; scales efficiently to networks with thousands of segments.
Single‑entry kriging/interpolation cacheContour rendering branch (Section 6.6)Skips the most computationally expensive step (kriging or cubic griddata) whenever only cosmetic settings (colour, title, borders, etc.) have changed.
Debounced opacity sliderLayer opacity controlPrevents dozens of redundant full redraws (including potential re‑kriging) while a slider is actively being dragged; only the settled value triggers computation.
Colormap‑name resolution caching_resolve_cmap() / _cmap_resolvedAvoids repeated dynamic imports and string‑matching lookups of the active colormap on every redraw once the dropdown has fired once.
Masked‑array renderingHeatmap stylenp.ma.masked_where() lets Matplotlib skip drawing (and colour‑mapping) cells with no data, reducing rendering overhead and avoiding a misleading colour for zero‑intensity cells.

11. Practical Examples

11.1 Example 1 — Assessing Fracture‑Damage Localisation

A user has digitised a fracture trace map from an outcrop photograph showing a fault damage zone. After loading the SVG on the Fracture Map tab, they switch to Intensity & Density, select Estimated Intensity (P21), choose Grid Heatmap for an immediate, uninterpolated view of local intensity, and increase Number of scan circles to 20 to resolve the damage‑zone boundary more sharply. The heatmap reveals a clear high‑P21 corridor coincident with the mapped fault trace, while the Global Results card reports a single average P21 for the whole outcrop — illustrating why the spatial map, not the global statistic alone, is needed to identify localized damage.

11.2 Example 2 — Publication‑Quality Kriged Density Map

For a manuscript figure, the user switches Plot Mode to Estimated Density (P20), Plot Style to Contour (Filled), sets Contour Levels to 12, enables the background field photograph at 40% opacity to provide geological context, sets Layer stacking order to Grid above Traces so the smooth kriged surface is the dominant visual element, and exports the figure via Save Image at the project's configured export DPI.

11.3 Example 3 — Comparing Circular‑Scanline vs. Global Statistics

A user wants to check whether their fracture network is spatially homogeneous. They record the Global Intensity (P21) value from the Global Results card, then visually inspect the heatmap for regions markedly brighter or darker than that global value. Large, spatially coherent deviations from the global mean indicate a network that should not be treated as areally stationary for downstream flow or geomechanical modelling; near‑uniform colouring across the map supports a stationarity assumption.

Native P20 density heatmap in inverse square centimetres with fracture traces overlaid
Figure 11.1 — Estimated Density, P20 for the same SVG and scan-grid setting, with circle outlines hidden to expose spatial variation. The scale is cm⁻², unlike the P21 scale in Figure 4.1. Density is estimated from trace-tip counts in circular samples; it is not a count of every digitised segment endpoint. Compare spatial patterns, not numerical colour-bar values across P20 and P21, and do not treat either map as a permeability prediction.

12. Geological Interpretation

Fracture intensity (P21) and density (P20) are foundational inputs to discrete fracture network (DFN) modelling, fractured‑reservoir permeability estimation, rock‑mass classification (e.g. RQD‑related indices), and slope‑stability or excavation‑damage assessments. Their spatial variability, as revealed by this tab's gridded/contoured maps, has direct engineering and geological significance:

  • High‑P21 corridors frequently mark active or reactivated fault zones, relay ramps, or fold‑hinge damage zones where strain has been concentrated, and are commonly zones of enhanced permeability in naturally fractured reservoirs.
  • Elevated P20 without a correspondingly high P21 can indicate a population of short, closely spaced fractures (e.g. from hydraulic shattering or thermal cracking) rather than a few long, throughgoing structures — an important distinction for connectivity and flow‑pathway modelling.
  • Spatially uniform P21/P20 across a mapped domain supports treating the domain as statistically homogeneous for upscaling purposes, whereas strong local heterogeneity (as visualised here) motivates domain sub‑division or the use of spatially variable DFN input statistics.
  • Because the circular‑scanline estimator is explicitly designed to be orientation‑unbiased, P21/P20 values computed here are more directly comparable between outcrops or map sheets with different structural trends than values derived from straight‑line scanlines oriented arbitrarily with respect to the dominant fracture set (Terzaghi, 1965; Zeeb et al., 2013).
  • Both P21 and P20 are scale‑dependent quantities in a fractal or power‑law fracture population: the value obtained depends on the resolution at which traces were digitised and on the size of the sampling window, so comparisons between data sets are only meaningful at a matched observation scale (Ortega et al., 2006).

Users should bear in mind that all statistics in this tab are 2‑D, trace‑map‑derived measures; they characterise the exposed (or digitised) fracture trace pattern on a mapped surface (an outcrop face, a satellite image, or a core photograph laid flat) and are not automatically equivalent to volumetric (3‑D) fracture intensity (P32) without additional geometric assumptions relating trace‑plane sampling to the true 3‑D fracture population — a conversion that FracSTAT's Static Analyzer does not perform in this tab, and which requires assumptions about the fracture size and orientation distributions (Zhang and Einstein, 2000).

13. Implementation Notes

TopicNote
Class and inheritanceTraceIntensityTab is implemented as a mixin class, combined with several sibling mixins (StaticMapTab, StaticLengthsTab, StaticCDFTab, StaticAnglesTab, StaticStereonetTab, StaticFluidFlowTab, StaticGraphsTab, StaticResultsTab, WorkspaceHeaderMixin) into the single StaticAnalyzerApp class, following the shared‑mixin architecture used throughout the Static Analyzer.
Shared data sourceself.results / self._static_results is populated exclusively by the Fracture Map tab's geometry pipeline (run_static_calculations(), overridden at runtime to _static_map_run_geometry()); this tab performs no independent geometry computation of its own beyond the scan‑circle grid and intersection test.
Colormap resolution helperThe module‑level _resolve_cmap() function is duplicated (by design, for decoupling) from the equivalent helper in static_map_tab.py; both resolve a display colormap name to a matplotlib Colormap object, including the app's custom bundled Parula approximation via SlipTendencyApp._parula_colormap().
Trace‑tip helperThe module‑level _trace_tip_masks(df) returns two row‑aligned boolean arrays marking which of each segment's two endpoints is a genuine trace tip (first row's X1/Y1 and last row's X2/Y2 within each Trace group, ordered by Segment). It is the single point at which the density estimator is made independent of digitisation density; the equivalent trace‑level logic on the Connectivity tab lives in core/branch_counter.py's _trace_tip_lookup().
Palette sourceDefault background/border/grid colours are seeded once from self.plot_palette() (a method shared across the workspace) at widget‑construction time, but thereafter are fully independent, user‑editable StringVars specific to this tab.
Unit propagationThe display unit (m/cm/mm/km) is never stored locally; it is read on every draw from the shared _static_map_unit_var owned by the Fracture Map tab, ensuring the Intensity & Density tab always reports statistics in the workspace's currently active unit even if the unit is changed after this tab was last drawn.
No independent SVG/Excel loadingThis tab intentionally exposes no Load SVG / Load Excel controls; all data loading is centralized in the shared workspace header and the Fracture Map tab, per the application's overall data‑flow design.

14. Limitations

LimitationDescription
Tip‑based P20 estimatorThe local P20 estimator counts trace tips inside each scan circle rather than trace centres. This is the published circular‑window estimator (Mauldon et al., 2001) and is digitisation‑invariant, since interior polyline vertices are masked out by _trace_tip_masks(). It remains a sampling estimator, however: a long trace crossing the window without terminating in it contributes no tips, so a single circle placed on a sparse, long‑trace network can read low while the global P20 does not. Compare the map against the Global Results value (Section 7.4) before treating a local minimum as real.
Bounding‑box sampling areaBoth global statistics are normalised by the data's own bounding box, A_map = Δx × Δy, not by an independently chosen sampling window. Where the mapped domain is not rectangular, or where traces do not reach the box edges, A_map overstates the true sampled area and both P21_global and P20_global are correspondingly biased low. The Connectivity tab, by contrast, accepts an explicit sampling window for exactly this reason.
Fixed variogram modelThe fixed linear‑variogram kriging model (Section 7.2) is not adaptively fit to each dataset's own spatial autocorrelation structure; it functions as a general‑purpose smoothing interpolator rather than a geostatistically optimal predictor for every possible fracture network (Chilès and Delfiner, 2012). No kriging variance is reported, so the map carries no interpolation‑uncertainty estimate.
Indirect grid resolution controlGrid resolution is controlled indirectly, via the Number of scan circles field, which sets circle count along the shorter map dimension only — very elongated (high aspect‑ratio) datasets may produce an uneven number of circles along the two axes.
No confidence intervals on local estimatesEach per‑circle P21 and P20 value is a single count divided by a geometric constant, so its sampling error is inherently large for small N or M. No standard error, Poisson interval, or bootstrap confidence band is computed, and adjacent circles are treated as independent even where they nearly touch (Rohrbaugh et al., 2002).
Scale dependence not quantifiedResults are reported at one circle radius only. Because P21 and P20 in a power‑law fracture population depend on observation scale (Ortega et al., 2006), no multi‑radius sweep or scaling exponent is produced, and values are not comparable across data sets digitised at different resolutions.
Circles are not clipped to the mapped domainThe packing geometry (Section 5.5) insets circle centres from the bounding box but does not guarantee that every circle lies wholly inside the digitised area. A circle overlapping unmapped ground records fewer crossings than it should and reads spuriously low; inspect coverage with Show scan circles enabled.

15. References

Sources are listed alphabetically within each group. In-text citations throughout this page link to the corresponding entry below.

15.1 Fracture Intensity and Circular-Window Sampling

  • Dershowitz, W. S., & Herda, H. H. (1992). Interpretation of fracture spacing and intensity. In Proceedings of the 33rd U.S. Symposium on Rock Mechanics (pp. 757–766). Balkema, Rotterdam. — source of the Pij notation; P20 is defined as the number of fractures per unit area.
  • Mauldon, M. (1998). Estimating mean fracture trace length and density from observations in convex windows. Rock Mechanics and Rock Engineering, 31(4), 201–216. https://doi.org/10.1007/s006030050021
  • Mauldon, M., Dunne, W. M., & Rohrbaugh, M. B. (2001). Circular scanlines and circular windows: new tools for characterizing the geometry of fracture traces. Journal of Structural Geology, 23(2–3), 247–258. https://doi.org/10.1016/S0191-8141(00)00094-8
  • Ortega, O. J., Marrett, R. A., & Laubach, S. E. (2006). A scale-independent approach to fracture intensity and average spacing measurement. AAPG Bulletin, 90(2), 193–208. https://doi.org/10.1306/08250505059
  • Rohrbaugh, M. B., Dunne, W. M., & Mauldon, M. (2002). Estimating fracture trace intensity, density, and mean length using circular scan lines and windows. AAPG Bulletin, 86(12), 2089–2104. https://doi.org/10.1306/61EEDE0E-173E-11D7-8645000102C1865D
  • Zhang, L., & Einstein, H. H. (2000). Estimating the intensity of rock discontinuities. International Journal of Rock Mechanics and Mining Sciences, 37(5), 819–837. https://doi.org/10.1016/S1365-1609(00)00022-8

15.2 Sampling Bias and Survey Practice

15.3 Geostatistics, Interpolation and Software

Methods statement for the Intensity & Density tab. If a figure produced by this tab appears in a publication, the details below are what a reader needs in order to reproduce it, and are what the citations above assume:

Spatially resolved fracture intensity P21 = N/(4r) and density P20 = M/(2πr²) estimated with the circular-scanline/circular-window estimators of Mauldon (1998) and Mauldon et al. (2001), on a grid of n non-overlapping scan circles of radius r spanning the mapped bounding box of area A. Values interpolated for display by ordinary kriging with a linear variogram (PyKrige) or by cubic griddata (Virtanen et al., 2020) where PyKrige is unavailable; rendered with Matplotlib (Hunter, 2007) over NumPy (Harris et al., 2020). Report r, the circle count, the interpolation method, and the global P21/P20 alongside the map; note that the interpolated surface is a visual aid and the rigorous estimate exists only at circle centres (Section 7.1), and that these are two-dimensional trace-map measures, not P32 (Section 12).