ACTS
Experiment-independent tracking
Loading...
Searching...
No Matches
Material mapping

Material mapping

This page is the canonical reference for the ACTS Examples material mapping chain, and a step-by-step guide to producing a map for a detector of your own. It assumes you have the detector described both as an ACTS Acts::TrackingGeometry and as a detailed simulation geometry that Geant4 can navigate (typically DD4hep, which gives you both from one source).

For the conceptual picture — what the mapping does and why it is structured the way it is — see Material mapping.

The chain is:

geometry ──▶ designate surfaces ──▶ record (Geant4) ──▶ map ──▶ use ──▶ validate

Only the designation step is detector-specific work, and it is the only step that differs between Gen1 and Gen3. The rest is running three scripts:

  • Recording: Examples/Scripts/Python/material_recording.py
  • Mapping: Examples/Scripts/Python/material_mapping.py
  • Validation: Examples/Scripts/Python/material_validation.py

End to end for the Open Data Detector, which already has its surfaces designated and so skips step 1:

python material_recording.py -n 1000 -t 1000 -o odd_material_geant4
python material_mapping.py -n 1000000 -i odd_material_geant4.root -o odd_material
python material_validation.py -n 1000 -t 1000 -m odd_material_map.root -o odd_material_validated -p
Remarks
Run these from Examples/Scripts/Python, or prefix each with python Examples/Scripts/Python/<script>.py ... from the repository root.

The rest of this page walks through the same chain for a detector that does not have a map yet.

Step 1: designate which surfaces carry material

Mapping only writes material onto surfaces you have explicitly designated as material-carrying. A fresh detector designates nothing, so this is where you start.

How you designate depends on which geometry generation you build with, and it is the only part of this guide that does. Steps 2 to 5 are identical either way.

  • Gen1 — designation is a post-hoc annotation of an already-built geometry: dump it to JSON, flip flags, feed the file back in. Follow steps 1a to 1d below.
  • Gen3 — designation happens in the blueprint, during construction. There is no JSON round-trip. Skip to Designating material in Gen3.

1a. Dump the geometry to JSON

python Examples/Scripts/Python/geometry.py

This writes geometry-map.json in the current directory (among other outputs). The relevant part is in runGeometry() in Examples/Scripts/Python/geometry.py, which configures a MaterialMapJsonConverter and a JsonMaterialWriter.

Important
The converter must be configured with processNonMaterial=True. Surfaces that do not already carry material are otherwise skipped entirely (Plugins/Json/src/MaterialMapJsonConverter.cpp:383), so they never appear in the dump and you have nothing to switch on. geometry.py already sets this; if you write your own dumping script, do not omit it.

Point the script at your own detector by replacing the getOpenDataDetector() call at the bottom of the file.

1b. Reduce it to an editable config

geometry-map.json has one entry per surface, which for a real detector is thousands of entries. writeMapConfig.py collapses it into one representative entry per surface category per volume:

python Examples/Scripts/MaterialMapping/writeMapConfig.py geometry-map.json config-map.json

1c. Edit the config

In config-map.json, for every surface category you want to map:

  • set "mapMaterial": true
  • set the "bins" entries under "binUtility" -> "binningdata" to the granularity you want

A homogeneous surface is 1 x 1. Start coarse: bins with no hits produce no material, and a fine binning with too few recorded tracks gives you a map full of holes.

1d. Write the choices back

python Examples/Scripts/MaterialMapping/configureMap.py geometry-map.json config-map.json
Warning
This rewrites geometry-map.json in place. Keep a copy of the pristine dump, or be ready to regenerate it with geometry.py.

It also only copies the bin counts back, not the bin ranges or axis types — those come from the geometry dump. Editing "min", "max" or "type" in config-map.json has no effect.

geometry-map.json is now your mapping configuration: the same file you pass as --matconfig in step 3.

Designating material in Gen3

In Gen3 the geometry is built from a blueprint, and material is designated as part of that construction rather than annotated onto the result. Steps 1a to 1d do not apply: there is no geometry dump to edit, and no --matconfig file. You edit the blueprint code instead.

Acts::BlueprintNode::addMaterial inserts a Acts::MaterialDesignatorBlueprintNode, which wraps exactly one child and marks up that child's volume faces as it is connected:

parent.addMaterial("PixelMaterial", [&](auto& mat) {
using enum Acts::AxisDirection;
// Two proto axes per face: the mapping fills these bins in later. The axes
// are deferred: only the bin count is fixed here, the range and boundary
// type are taken from the portal surface during mapping.
mat.configureFace(OuterCylinder,
mat.configureFace(NegativeDisc, AxisSpec::DeferredEquidistant(15, AxisR),
mat.configureFace(PositiveDisc, AxisSpec::DeferredEquidistant(15, AxisR),
// The material node wraps exactly one child: the volume it applies to.
mat.addCylinderContainer("Pixel", Acts::AxisDirection::AxisZ,
[&](auto& /*pixel*/) {
// ... build the pixel detector here
});
});

The two-axis form of Acts::MaterialDesignatorBlueprintNode::configureFace attaches a Acts::ProtoGridSurfaceMaterial — a binning specification with no content, which is exactly what the Gen1 JSON route produces via ProtoSurfaceMaterial. It is the direct equivalent of setting "mapMaterial": true plus a bin count, and it is what you want if you intend to run the mapping.

There is a second form taking an Acts::ISurfaceMaterial directly:

// Beryllium, 0.8 mm thick.
auto beampipeMaterial =
std::make_shared<const Acts::HomogeneousSurfaceMaterial>(
352.8_mm, 407_mm, 9.012, 4, 1.848_g / 1_cm3),
0.8_mm));
parent.addMaterial("BeampipeMaterial", [&](auto& bpMat) {
bpMat.configureFace(OuterCylinder, beampipeMaterial);
bpMat.addStaticVolume(
Acts::Transform3::Identity(),
std::make_shared<Acts::CylinderVolumeBounds>(0_mm, 25_mm, 1000_mm),
"Beampipe");
});

That assigns real material immediately, so there is nothing to map — useful for a beam pipe or a support tube whose material you already know. GenericDetector uses this throughout (Examples/Detectors/GenericDetector/src/GenericDetector.cpp).

Both snippets are taken from docs/examples/material_designation.cpp, which is compiled as part of the docs-examples target, so they cannot drift from the API.

Faces are named per volume-bounds type: Acts::CylinderVolumeBounds::Face (OuterCylinder, InnerCylinder, PositiveDisc, NegativeDisc), Acts::CuboidVolumeBounds::Face (NegativeXFace, PositiveXFace, …) and likewise for trapezoid and diamond bounds.

Things to know before you use it:

  • It designates volume faces (portals) only. Material goes onto portal->surface(). There is no blueprint equivalent for designating sensitive surfaces — if you need material on those, it has to come from the detector description itself.
  • Cylinder phi planes are rejected. NegativePhiPlane and PositivePhiPlane throw "Phi plane faces are not supported" (Core/src/Geometry/MaterialDesignator.hpp).
  • Python only exposes the mapping form. The bindings in Python/Core/src/GeometryGen3.cpp bind the two-axis configureFace overloads for cylinder and cuboid faces. The direct-ISurfaceMaterial overloads are C++ only.
  • Do not designate a face that gets merged. If a designated portal face has to be merged during container stacking, construction aborts: the material cannot be carried onto the larger merged surface. Move the designation to a face that is not merged, typically the enclosing container's face. Setting Acts::BlueprintOptions::keepGoingOnMaterialMergeFailure downgrades this to a warning, but it is lossy — the material is discarded and the surface is tagged with a Acts::MergedMaterialMarker. Treat it as a debugging aid, not a fix.
Warning
Blueprint-built geometry ignores material decorators. Acts::Blueprint::construct passes a null decorator to the Acts::TrackingGeometry constructor, so the --matconfig / IMaterialDecorator route in steps 1 and 3 has no effect on a Gen3 geometry. Concretely, getOpenDataDetector(gen3=True) builds its decorator and then drops it (Python/Examples/python/odd.py), and the Gen3 OpenDataDetector config has no materialDecorator field at all. Designating in the blueprint is currently the only route.

Once construction is done the two generations converge completely. The designated material sits on ordinary surfaces, so hasMaterial() is true, trackingGeometry.extractMaterialSurfaces() collects them, and Acts::BinnedSurfaceMaterialAccumulator consumes ProtoGridSurfaceMaterial alongside ProtoSurfaceMaterial. Steps 2 to 5 below are unchanged — just omit --matconfig in step 3, since the geometry already carries the designation.

Step 2: record the material with Geant4

python Examples/Scripts/Python/material_recording.py -n 1000 -t 1000 -o mydet_geant4

Shoots geantinos through the detailed geometry and records what they traverse, into mydet_geant4.root. -n is events, -t is tracks per event, so the above is a million tracks; --eta-range and --phi-range restrict the solid angle.

This step is independent of your surface choices — you only need to redo it if the detailed geometry changes, not when you retune binning. It is by far the slowest step, so record generously once and reuse the file.

Step 3: run the mapping

python Examples/Scripts/Python/material_mapping.py \
-n 1000000 -i mydet_geant4.root --matconfig geometry-map.json -o mydet_material

--matconfig loads your configuration through acts.IMaterialDecorator.fromFile(), which puts an Acts::ProtoSurfaceMaterial (a binning specification with no content yet) on every surface you enabled. On a Gen3 geometry, omit --matconfig — the blueprint already designated the surfaces, and the decorator would be ignored anyway. The script then reads those surfaces back out:

# The surfaces the mapping writes onto, and reads back when mapping again.
materialSurfaces = trackingGeometry.extractMaterialSurfaces()

and hands the resulting list to Acts::IntersectionMaterialAssigner and Acts::BinnedSurfaceMaterialAccumulator. That list is the only geometry input the mapper gets, which is what makes it generation-agnostic.

Outputs:

File Contents
mydet_material_map.json the material map, human-readable
mydet_material_map.root the same map, for production use
mydet_material_mapped.root recorded interactions that found a surface
mydet_material_unmapped.root recorded interactions that did not

_unmapped.root is the one to look at when something is wrong. A large unmapped fraction means the material had nowhere to go — see Troubleshooting.

Step 4: use the map

import acts
from acts.examples.odd import getOpenDataDetectorDirectory, getOpenDataDetector
# Any of .json, .cbor or .root produced by the mapping step works here.
material_map = getOpenDataDetectorDirectory() / "data/odd-material-maps.root"
decorator = acts.IMaterialDecorator.fromFile(material_map)
detector = getOpenDataDetector(materialDecorator=decorator)
trackingGeometry = detector.trackingGeometry()

Substitute your own detector and the map you just produced. This snippet comes from docs/examples/test_material_map.py, which runs as part of the pytest suite.

.json, .cbor and .root are all accepted. This is exactly how the ODD picks up data/odd-material-maps.root by default.

Step 5: validate

python Examples/Scripts/Python/material_validation.py \
-n 1000 -t 1000 -m mydet_material_map.root -o mydet_validated -p

This re-records material, now from your mapped map instead of from Geant4, so you can compare the two. -p additionally runs a real propagator with a navigator and writes mydet_validated_propagated.root.

Comparing the default and -p outputs is worth doing: the default collection is navigation-independent, so a difference between them is a navigation problem (material the navigator does not reach), not a mapping problem.

To compare against the Geant4 input:

python Examples/Scripts/MaterialMapping/material_comparison.py
root -l Examples/Scripts/MaterialMapping/Mat_map.C

Examples/Scripts/MaterialMapping/material_mapping_check.py -i mydet_material_mapped.root plots how far each interaction was moved to reach its assigned surface, which is the quickest way to spot material being attached to the wrong thing.

What you are aiming for looks like this — Geant4, mapped, validated and propagated profiles of t_X0 against eta, with a ratio panel:

Overlay and ratio for material profiles.

Tuning

Iterating on binning does not require re-recording. Edit config-map.json, re-run configureMap.py and step 3 against the same recorded file.

Rules of thumb:

  • Bins that receive no tracks stay empty. If your map has holes, either coarsen the binning or record more tracks.
  • Boundary and approach surfaces generally need less granularity than sensitive layers.
  • Check _unmapped.root after every change, not just at the end.

Limitations you should know about

These are properties of the current navigation-less mapper, not of your setup.

Volume material is not produced. Acts::MaterialMapper retrieves volume assignments from the assignment finder and then discards them; only surface material is accumulated, and finalizeMaps() returns an empty volume map (Core/src/Material/MaterialMapper.cpp). ACTS currently has no way to map volume material.

mappingType is not honoured. The "mappingType" key round-trips through the JSON and is stored on the material, but the current assignment does plain nearest-intersection matching — its own comment says "no pre/post matching" (Core/src/Material/MaterialInteractionAssignment.cpp). PreMapping, PostMapping and Sensor have no effect. To steer assignment, use the globalVetos, localVetos and reAssignments hooks in Acts::MaterialInteractionAssignment::Options.

Troubleshooting

Everything lands in _unmapped.root. Nothing was designated. On Gen1, confirm that geometry-map.json actually contains "mapMaterial": true somewhere — if you forgot processNonMaterial=True in step 1a, or forgot to run configureMap.py in step 1d, the file will be syntactically fine and semantically empty. On Gen3, check that you are not passing --matconfig and expecting it to do something: blueprint geometry ignores decorators, so the designation has to be in the blueprint itself.

Gen3 construction throws on a material merge. A designated portal face is being merged during container stacking. Move the designation outward to the enclosing container's face rather than reaching for keepGoingOnMaterialMergeFailure, which discards the material.

The map is full of empty bins. Too fine a binning for the number of recorded tracks. Coarsen it, or record more.

Material appears on the wrong surface. Expected when candidate surfaces are close together: assignment picks the nearest intersection with no notion of "before" or "after". Use material_mapping_check.py to see the assignment distances, and the veto/re-assignment hooks to correct specific surfaces.

Validation disagrees with Geant4, but only with -p. That is navigation, not mapping. The mapped material is fine; the navigator is not finding all of it.

Worked example, and the tests that guard this

Python/Examples/tests/test_material_mapping.py runs the whole chain against the ODD and is the most reliable executable reference for it. The ODD itself ships a finished map at data/odd-material-maps.root.

The chain is additionally guarded by tests in Python/Examples/tests:

  • conftest.py — the material_recording_session fixture generates reusable Geant4 material tracks.
  • test_examples.pytest_material_recording and test_material_mapping.
  • root_file_hashes.txt — reference hashes for the workflow outputs.

Those tests are the executable reference for expected output structure and for regression tracking. If this page and they disagree, they are right.