ACTS · Acts::SurfaceArray
A surface array bins the modules of one layer on a representative surface, and answers, for a track crossing it, which of them the track could hit. Two questions decide whether that works: which bins hold a surface, and how far from the crossing bin the module it hits can be.
The figures below are live. The prose that surrounds them is deliberately thin — the written description of the class, its axes, its bounds and its cache is in the reference page.
Registration is an area question: does this module overlap this bin? The fill that preceded fillSurfaceFootprint answered it with two point tests. Surface → grid: project the module's reference position, register that one bin. Grid → surface: flood-fill outward from it, keeping every bin whose centre projects onto the module. One direction handles a module smaller than a bin; the other handles a module larger than a bin, and the intuition was that they covered each other.
They do not. The first samples one point of the module against the grid, the second samples one point of the bin against the module, and two point tests do not compose into a set-overlap test. What the flood fill computes is the true footprint eroded by up to half a bin on each axis — and a module can be smaller than a bin on one axis while spanning many bins on the other, which is where the erosion stops being a boundary ring and starts swallowing the surface whole.
The three fills side by side, on the same module. Drag it inside any panel, or use the sliders; the presets walk through the three regimes.
fillSurfaceToBinMapping
the reference position's bin, and nothing else
fillBinToSurfaceMapping
flood fill, keep bins whose centre is on the module
fillSurfaceFootprint
sample the outline, span-fill each phi column
What this view simplifies: it shows a phi sector with hard edges, so a module pushed off the side is clipped. The real phi axis is Closed and a sample past ±pi wraps to the bin on the other side. The r axis is Bound, where the wrap clamps into the edge bin rather than dropping the sample, which is why dragging past the top or bottom leaves no holes.
A lookup that widens every query to the neighbouring bins repairs a half-bin erosion, because the lost bins are all one step from a kept one — and it charges 3×3 candidates on every query to do it. It cannot repair a gap deeper than the expansion, and in the anisotropic regime the gap is not one bin.
The footprint fill walks the surface's polyhedron. That polyhedron approximates a curved edge with straight segments on a global phi grid of 4 × quarterSegments steps, so at quarterSegments = 1 a full ring is a square inscribed in the circle and the fill loses everything outside it. Planar bounds — rectangle, trapezoid — ignore the argument entirely, which is why the generic detector never showed this.
quarterSegments · full ring, r 200–300 mm, unrolled in phi
| quarterSegments | outline falls short by |
|---|
Measured against a converged outline, per phi, for a full ring of r 200–300 mm. A radial sector of ±0.15 rad loses 0.84 mm at q = 1 through q = 8 alike — its phi range contains no multiple of pi/2, so nothing is added until the global grid is fine enough to drop a point inside it.
Spanning a column assumes an unbroken interval in r; a concave projection can include extra cells. Sampling can also miss cells between outline points on sufficiently fine grids. The optional overfill construction parameter expands each matched cell by a radius in bins: zero preserves the footprint, one includes immediate neighbors and diagonals, and n reaches n cells along each axis. It wraps the phi seam and stops at nonperiodic grid edges.
On the generic detector, across all 48 layers against brute-force truth: hits lost at |eta| = 3.5 go from 4.9% to zero. With the ±1 expansion still in place that costs 9.0 → 25.0 candidates per query at eta = 0. With the expansion switched off, the same scan gives 3.9 candidates on the disc and 9.0 on the barrel with nothing lost: today's cost, for strictly better coverage.
A barrel layer is not a cylinder. Its modules sit staggered either side of one, and the array bins them by projecting each module radially onto the representative cylinder in between. A lookup intersects that same cylinder and reads the bin it lands in.
Those two points are not the same point. A track crossing at an angle enters the layer, travels a chord through its thickness and leaves somewhere else in z. At normal incidence the displacement is nothing; at eta = 3 the chord is ten times the layer half-thickness and the crossing bin no longer holds the module the track hits. The window around the crossing bin is what closes the gap, and the question is how wide it has to be.
Everything below is computed from the controls, not quoted: z(r) = r sinh eta, so the slide from the representative surface to a module offset by Δr is Δr sinh eta, and the chord the window is sized from is tolerance × sinh eta.
Those two are one crossing at one angle. Below, the same geometry with the angle, the stagger, the binning and the module length in your hands, and three window policies scored against the modules the track actually crosses.
no window
what main serves — #5186's 1/|n·d| term is capped at 1 by every caller, so it never fires
bins spanned by ±tolerance sinh eta, clamped to NeighborWindow max 2 in z
The stagger and the tolerance are tied, as they are in the creator: SurfaceArrayCreator takes the tolerance from protoLayer.range(AxisR) * 0.5, so it is the stagger plus the module half-thickness. That is why the window always covers the slide it needs to — the same radial extent sets both.
One eta proves nothing. Sweeping the whole range at the current stagger and binning gives the miss rate each policy carries, and what it charges in candidates to get there.
| policy | misses | candidates |
|---|
Miss rate over 400 eta samples from 0 to 3.5, mean candidates per query over the same. A miss is a track whose module is registered only in bins the lookup never reads — the hit is unrecoverable, no later stage sees it.
Measuring the slide is not free. As first written it went through two full Surface::intersect calls to project the ends of the chord, and that tripled the price of a lookup. Taking the same step from localCartesianToBoundLocalDerivative instead — the reference frame with the curvilinear metric already folded in — costs one Jacobian apply.
| variant | ns / lookup | surfaces returned |
|---|---|---|
| before, fixed ±1 | 76 | 14.2 |
| window from two projections | 245 | 3.14 |
| window from the surface metric | 128 | 3.14 |
| + Jacobian without the angle | 110 | 3.14 |
| + grid scale resolved once | 104 | 3.14 |
| + rigid placement inverses | 80 | 3.14 |
Toy barrel, 60 × 20 bins, 3.28M lookups, RelWithDebInfo with assertions forced on. The narrower window returns 4.5× fewer candidates, and each one it drops is an intersection the caller does not run — so even the 245 ns version paid for itself. The remaining 80 ns is roughly what the fixed window cost before any of this.
What this simplifies: one phi slice, straight rays from the origin, and a barrel of identical modules on two radii. A real track curves and starts off-axis, which moves the angular axis too — the reason the window's phi bound is 1 rather than 0 even though a straight ray from the origin never needs it. The disc case swaps the axes: the slide runs in r, and the same metric returns (dr, dphi) without a per-surface-type case.