Skip to content

Method specification (slope_quality/v1)

This page specifies lunartrace.slope_quality/v1, the method implemented in src/lunartrace/science/slope_quality.py. Every rule below is taken from that module unless a different source is named. The method counts raster cells inside regions of interest (ROIs) that pass validity and inclusion tests at a set of thresholds, and it summarises the slope values of the included cells.

The method takes three inputs: a slope raster SS in degrees, a slope RMS raster EE in degrees, and an ROI shapefile {Pk}k=0K−1\{P_k\}_{k=0}^{K-1} with polygons in metres. Both rasters must share one north-up grid (see Grid and cell centres). Grids are compared exactly and never resampled (check_compatible).

Let the grid have HH rows and WW columns, so N=HWN = HW cells. Cells are indexed by column c∈{0,…,W−1}c \in \{0,\ldots,W-1\} and row r∈{0,…,H−1}r \in \{0,\ldots,H-1\}. Write sc,r=S[r,c]s_{c,r} = S[r,c] and ec,r=E[r,c]e_{c,r} = E[r,c].

The grid transform is an affine map (a,b,c0,d,e,f)(a, b, c_0, d, e, f) from pixel corner coordinates to projected metres. The method requires a north-up square grid, b=d=0b = d = 0, a>0a > 0 and e=−ae = -a (check_compatible). With pixel size aa metres, the cell centre of column cc and row rr is

xc=c0+a(c+12),yr=f−a(r+12).(1)x_c = c_0 + a\left(c + \tfrac{1}{2}\right), \qquad y_r = f - a\left(r + \tfrac{1}{2}\right). \tag{1}

Here ff is the transform’s ff coefficient, so c0c_0 and ff are the upper-left corner of the grid. The inverse map used for ROI vertices is col=(x−c0)/a−12\text{col} = (x - c_0)/a - \tfrac12 and row=(f−y)/a−12\text{row} = (f - y)/a - \tfrac12 (_check_lattice_polygon). Equation (1) is the same map read in the forward direction.

The production grid is 3200×32003200 \times 3200 cells at a=5a = 5 m with transform (5,0,−19000,0,−5,−4000)(5, 0, -19000, 0, -5, -4000) (see Source data).

Missing values are NaN after loading (load_grid). NaN fails every predicate below, so a missing value is never treated as zero.

Slope validity, for a sample ss in degrees:

VS(s)=1 ⁣[ isfinite⁡(s) ∧ 0≤s≤90 ].(2)V_S(s) = \mathbf{1}\!\left[\,\operatorname{isfinite}(s)\ \wedge\ 0 \le s \le 90\,\right]. \tag{2}

Both endpoints are valid (valid_slope).

Slope RMS validity, for a sample ee in the units of the slope RMS raster:

VE(e)=1 ⁣[ isfinite⁡(e) ∧ e>0 ].(3)V_E(e) = \mathbf{1}\!\left[\,\operatorname{isfinite}(e)\ \wedge\ e > 0\,\right]. \tag{3}

Zero is invalid (valid_err).

Joint validity of a cell is

J(c,r)=VS ⁣(sc,r)∧VE ⁣(ec,r).(4)J(c,r) = V_S\!\left(s_{c,r}\right) \wedge V_E\!\left(e_{c,r}\right). \tag{4}

These predicates are applied cell by cell. The source note that no NaN cells were observed in the production slope rasters does not replace the check (see Source data).

For a finite threshold τ≥0\tau \ge 0 the inclusion predicate is

Iτ(c,r)=J(c,r)∧1 ⁣[ ec,r≤τ ].(5)I_\tau(c,r) = J(c,r) \wedge \mathbf{1}\!\left[\,e_{c,r} \le \tau\,\right]. \tag{5}

The comparison is inclusive and performed directly on the slope RMS values, with no unit conversion (inclusion_mask). Thresholds are validated by check_taus: the list must be non-empty, and every value must be finite and non-negative. Zero is accepted. The default thresholds are τ∈{0.5, 2.0, 25.0}\tau \in \{0.5,\ 2.0,\ 25.0\} (DEFAULT_TAUS).

Each polygon PkP_k is a single Polygon with no interior rings (load_rois). Membership of a cell is decided by its centre (xc,yr)(x_c, y_r) from equation (1). The closed rule is

Mk(c,r)=1 ⁣[ (xc,yr)∈Pk‾ ],(6)M_k(c,r) = \mathbf{1}\!\left[\,(x_c, y_r) \in \overline{P_k}\,\right], \tag{6}

where Pk‾\overline{P_k} is the interior together with the boundary. This matters because ROI vertices lie on cell centres, so a strictly interior test would omit boundary cells (roi_mask docstring).

The method realises equation (6) with three steps, in the order used by roi_mask:

  1. Lattice precondition. Every exterior vertex must lie exactly on a cell centre, with no floating-point tolerance, and every consecutive exterior vertex pair, including the closing edge, must differ by at most one cell in column and in row. Otherwise the polygon fails closed with MethodInputError (_check_lattice_polygon).
  2. Linework validity. The polygon is passed through shapely.make_valid(..., method="linework") (_closed_region). This keeps pinch lobes as separate parts and keeps collapsed spurs as lines, so that every ring segment is preserved.
  3. Buffered centre-inside test. The geometry is buffered by δ=0.5\delta = 0.5 m with mitre joins and rasterised with all_touched=False (roi_mask).

The buffer δ=0.5\delta = 0.5 m is fixed in metres. It is chosen so that a centre on the boundary falls inside the buffer, while a centre not on the boundary lies more than δ\delta from it. Under the lattice precondition, any centre not on the boundary lies at least 5/2≈3.545/\sqrt{2} \approx 3.54 m from it on the 5 m grid (slope_quality module docstring). The margin is the reason the test equals “inside or on the boundary”.

Missing values do not affect membership. A cell with a NaN slope is still a member of Pk‾\overline{P_k} and is counted in roi_cells (slope_quality module docstring).

The union row uses set union over the cells, not a sum over ROIs:

U(c,r)=⋁kMk(c,r).(7)U(c,r) = \bigvee_{k} M_k(c,r). \tag{7}

A cell that lies in several ROIs is therefore counted once (roi_union). With no ROIs, UU is all false. The number of cells in the union is in general less than the sum of the per-ROI roi_cells.

For a membership mask MM (one ROI or UU), the method forms these counts over the masked cells only:

nroi=∑M,nS=∑M VS(s),nE=∑M VE(e),nJ=∑M J,nI=∑M Iτ.(8)\begin{aligned} n_{\text{roi}} &= \textstyle\sum M, & n_{S} &= \textstyle\sum M\,V_S(s), & n_{E} &= \textstyle\sum M\,V_E(e), \\ n_{J} &= \textstyle\sum M\,J, & n_{I} &= \textstyle\sum M\,I_\tau. && \end{aligned} \tag{8}

The included fraction is a valid-cell fraction:

fτ={nI/nJ,nJ>0,null,nJ=0.(9)f_\tau = \begin{cases} n_I / n_J, & n_J > 0,\\ \text{null}, & n_J = 0. \end{cases} \tag{9}

fτf_\tau is undefined, not zero, when nJ=0n_J = 0. It is exactly zero when nJ>0n_J > 0 and no joint cell passes τ\tau (_row). It counts grid cells and is not an area fraction. Polar stereographic cells are not equal-area, and no area correction is applied (see Limitations).

Let {x(1)≤⋯≤x(n)}\{x_{(1)} \le \cdots \le x_{(n)}\} be the sorted included slope values, with n=nIn = n_I. For a probability p∈{0.05,0.25,0.5,0.75,0.95}p \in \{0.05, 0.25, 0.5, 0.75, 0.95\} the inverted-CDF quantile (Hyndman and Fan type 1) is

Q(p)=x(⌈np⌉).(10)Q(p) = x_{(\lceil np \rceil)}. \tag{10}

Each quantile is an observed sample value. The code calls numpy.quantile(..., method="inverted_cdf") (_row), which returns the smallest observed value whose empirical CDF is at least pp. The quantile keys are str(p).

The mean is computed in float64:

xˉ=1nI∑i=1nIxi.(11)\bar{x} = \frac{1}{n_I}\sum_{i=1}^{n_I} x_i. \tag{11}

The histogram has 45 bins of width 2 degrees with edges Ej=2jE_j = 2j for j=0,…,45j = 0,\ldots,45. The count in bin jj is

hj=#{ xi:Ej≤xi<Ej+1 },j=0,…,43,h44=#{ xi:88≤xi≤90 }.(12)h_j = \#\left\{\,x_i : E_j \le x_i < E_{j+1}\,\right\}, \quad j = 0,\ldots,43, \qquad h_{44} = \#\left\{\,x_i : 88 \le x_i \le 90\,\right\}. \tag{12}

The final bin is closed, so it includes 9090 (HISTOGRAM_EDGES; np.histogram). Every included value is in [0,90][0, 90], so no value falls outside the bins.

When nI=0n_I = 0, the quantiles and mean are null and the histogram is a list of 45 zeros. This is distinct from nJ=0n_J = 0, which makes fτf_\tau null (_row).

The following block gives the per-ROI and union procedure in the order that evaluate executes it.

Input : S (H x W), E (H x W), polygons P_0 .. P_{K-1}, taus T
Output: rows[(label, tau)] for label in {"0".."K-1", "union"} and tau in T
1 check_compatible(S, E) # same shape, transform, CRS; north-up square
2 T <- check_taus(T) # finite, >= 0, non-empty
3 for k in 0..K-1:
4 check lattice precondition on P_k # fail closed
5 M_k <- rasterise(buffer(make_valid_linework(P_k), 0.5 m, mitre), all_touched=False)
6 U <- OR_k M_k # set union; all-false if K = 0
7 for (label, M) in [("0", M_0), ..., ("union", U)]:
8 for tau in T:
9 s <- S[M]; e <- E[M] # masked samples, NaN retained
10 vs <- finite(s) and 0 <= s <= 90
11 ve <- finite(e) and e > 0
12 j <- vs and ve
13 inc <- j and (e <= tau)
14 v <- s[inc] # included slopes only
15 rows.append(counts(M, vs, ve, j, inc),
16 fraction = sum(inc)/sum(j) if sum(j) > 0 else null,
17 quantiles = inverted_cdf(v, P) if sum(inc) > 0 else null,
18 mean = mean(v) if sum(inc) > 0 else null,
19 histogram = hist(v, edges 0..90 step 2) )
20 return rows

Let N=HWN = HW, KK the number of ROIs, TT the number of thresholds, MroiM_{\text{roi}} the number of cells in a mask and kk the number of included cells.

  • Rasterisation. Each ROI mask is produced over the grid, so mask construction is O(KN)O(KN) time. The primary path (GDAL) stores one boolean mask of NN bytes per ROI and one for the union. The lattice check is O(V)O(V) in the number of exterior vertices.
  • Per (ROI, τ\tau) statistics. The validity, joint and inclusion predicates are elementwise over the MroiM_{\text{roi}} masked cells, so each pair costs O(Mroi)O(M_{\text{roi}}).
  • Quantiles and histogram. Sorting the kk included values costs O(klog⁡k)O(k \log k) for each pair. The histogram costs O(k)O(k).
  • Total. O(KN+(K+1) T Mˉ+∑klog⁡k)O(KN + (K+1)\,T\,\bar{M} + \sum k\log k) time, where Mˉ\bar M is the mean mask size. Memory is dominated by the two float64 grids and the boolean masks.

For the production grid, N=32002=10 240 000N = 3200^2 = 10\,240\,000 cells. One float64 grid occupies 8N=81 920 0008N = 81\,920\,000 bytes, about 78.1 MiB. The two raster inputs together therefore occupy about 156 MiB as float64 arrays before masks are formed (load_grid returns float64 copies).

Grid. A 3×33 \times 3 grid with 5 m pixels and origin (c0,f)=(0,0)(c_0, f) = (0, 0), so the transform is (5,0,0,0,−5,0)(5, 0, 0, 0, -5, 0). Cell (c,r)(c, r) has centre (5(c+12), −5(r+12))(5(c+\tfrac12),\ -5(r+\tfrac12)). Columns run left to right and rows top to bottom.

Slope ss (degrees):

row r \ col c012
01020NaN
1309540
2506070

Slope RMS ee (degrees):

row r \ col c012
00.52.01.0
10.03.025.0
20.4NaN30.0

ROIs. Vertices are given as cell-centre indices (c,r)(c, r).

  • P0P_0: (0,0),(1,0),(2,0),(2,1),(1,1),(1,2),(0,2),(0,1),(0,0)(0,0), (1,0), (2,0), (2,1), (1,1), (1,2), (0,2), (0,1), (0,0). This is an L-shape with every edge a one-cell step. By the closed rule it contains the 8 cells (0,0),(1,0),(2,0),(0,1),(1,1),(2,1),(0,2),(1,2)(0,0), (1,0), (2,0), (0,1), (1,1), (2,1), (0,2), (1,2). The corner (2,2)(2,2) is outside.
  • P1P_1: (1,1),(2,1),(2,2),(1,2),(1,1)(1,1), (2,1), (2,2), (1,2), (1,1). It contains (1,1),(2,1),(2,2),(1,2)(1,1), (2,1), (2,2), (1,2).

The two ROIs share three cells, (1,1),(2,1),(1,2)(1,1), (2,1), (1,2), so the union has 8+4−3=98 + 4 - 3 = 9 cells.

ROI 0 counts (equations 2–5, 8).

  • nroi=8n_{\text{roi}} = 8.
  • Slope valid (VSV_S): (0,0),(1,0),(0,1),(2,1),(0,2),(1,2)(0,0), (1,0), (0,1), (2,1), (0,2), (1,2), so nS=6n_S = 6. Cells (2,0)(2,0) (NaN) and (1,1)(1,1) (95>9095 > 90) fail.
  • RMS valid (VEV_E): (0,0),(1,0),(2,0),(1,1),(2,1),(0,2)(0,0), (1,0), (2,0), (1,1), (2,1), (0,2), so nE=6n_E = 6. Cells (0,1)(0,1) (00, not >0> 0) and (1,2)(1,2) (NaN) fail.
  • Joint (JJ): (0,0),(1,0),(2,1),(0,2)(0,0), (1,0), (2,1), (0,2), so nJ=4n_J = 4.
τ\tauincluded cellsnI/nJn_I/n_JQ(0.05)Q(0.05)Q(0.25)Q(0.25)Q(0.5)Q(0.5)Q(0.75)Q(0.75)Q(0.95)Q(0.95)meannon-zero histogram bins
0.5{(0,0),(0,2)}\{(0,0), (0,2)\}2/4 = 0.5101010505030bin 5: 1; bin 25: 1
2.0{(0,0),(1,0),(0,2)}\{(0,0), (1,0), (0,2)\}3/4 = 0.75101020505080/3 ≈ 26.67bin 5: 1; bin 10: 1; bin 25: 1
25.0{(0,0),(1,0),(2,1),(0,2)}\{(0,0), (1,0), (2,1), (0,2)\}4/4 = 1.0101020405030bin 5: 1; bin 10: 1; bin 20: 1; bin 25: 1

The bin index is ⌊s/2⌋\lfloor s/2 \rfloor, so slope 10 is in bin 5, 20 in bin 10, 40 in bin 20 and 50 in bin 25. Quantiles follow equation (10). For example, at τ=2.0\tau = 2.0 the sorted values are 10,20,5010, 20, 50 with n=3n = 3. Then ⌈3⋅0.5⌉=2\lceil 3 \cdot 0.5\rceil = 2, so Q(0.5)=20Q(0.5) = 20, and ⌈3⋅0.75⌉=3\lceil 3 \cdot 0.75\rceil = 3, so Q(0.75)=50Q(0.75) = 50.

The boundary inclusion is visible at τ=2.0\tau = 2.0 and τ=25.0\tau = 25.0. Cell (1,0)(1,0) has e=2.0e = 2.0, which is included at τ=2.0\tau = 2.0 because the comparison is ≤\le. Cell (2,1)(2,1) has e=25.0e = 25.0, which is included at τ=25.0\tau = 25.0.

ROI 1 counts. nroi=4n_{\text{roi}} = 4, nS=3n_S = 3, nE=3n_E = 3, nJ=2n_J = 2 (cells (2,1)(2,1) and (2,2)(2,2)).

  • τ=0.5\tau = 0.5 and τ=2.0\tau = 2.0: no joint cell has e≤τe \le \tau. So nI=0n_I = 0, fτ=0/2=0.0f_\tau = 0/2 = 0.0 (not null, because nJ>0n_J > 0), and the quantiles, mean and histogram are null, null and all zero.
  • τ=25.0\tau = 25.0: (2,1)(2,1) with e=25≤25e = 25 \le 25 is included, and (2,2)(2,2) with e=30e = 30 is not. So nI=1n_I = 1, f=1/2=0.5f = 1/2 = 0.5, all five quantiles equal 40, the mean is 40, and bin 20 has count 1.

Union (equation 7). nroi=9n_{\text{roi}} = 9. The union joint set is {(0,0),(1,0),(2,1),(0,2),(2,2)}\{(0,0), (1,0), (2,1), (0,2), (2,2)\}, so nJ=5n_J = 5, and nS=nE=7n_S = n_E = 7. The union includes (2,2)(2,2) in JJ but never in II, because its e=30e = 30 exceeds 25.

τ\taunIn_Ifτf_\taustatistics
0.522/5 = 0.4same as ROI 0 at τ=0.5\tau = 0.5
2.033/5 = 0.6same as ROI 0 at τ=2.0\tau = 2.0
25.044/5 = 0.8same as ROI 0 at τ=25.0\tau = 25.0

The union has nroi=9n_{\text{roi}} = 9 rather than 8+4=128 + 4 = 12. The three shared cells are counted once, which is the set-union rule of equation (7).

evaluate returns one row per (ROI, τ\tau) pair, grouped by ROI in shapefile order and then by threshold, followed by the union rows. Each row has the fields roi, tau, roi_cells, valid_slope_cells, valid_err_cells, joint_cells, included_cells, included_fraction, slope_quantiles, slope_mean and slope_histogram (Row). Its canonical JSON form has sorted keys, compact separators, allow_nan=False and a trailing LF (canonical_json). The same inputs and thresholds therefore give the same bytes.