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.json.zst -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.
All three scripts default to the Gen3 (blueprint) geometry; pass --gen1 to any of them to run against the Gen1 (Layer-based) geometry instead. Recording is independent of the generation (the detailed Geant4 geometry does not change), but mapping and validation build a Gen1 or Gen3 Acts::TrackingGeometry accordingly.

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 extracts assignments with Acts::TrackingGeometryMaterial::fromGeometry and writes them using Acts::TrackingGeometryMaterialJsonConverter::toFile.

Important
Use includeNonMaterial=True when extracting assignments to include deferred proto grids for surfaces without material. geometry.py already does this.

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 Acts::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).

These 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.

For a complete, real detector exercising this end to end — beampipe, passive support structures, barrel/endcap sensitive layers and calorimeter material collectors all designated during construction — see Plugins/DD4hep/src/OpenDataDetectorBuilder.cpp. It is the Gen3 ODD's blueprint construction and the most complete worked example in the codebase.

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.

Passive volumes and material collectors

configureFace marks up faces of a blueprint node that already exists. Two helpers create such nodes in the first place, for volumes that carry material but have no sensitive detector elements of their own:

  • BlueprintBuilder::makePassiveElement converts a passive detector element (e.g. the ODD's support tube, or its pixel endplates) into a plain tube- or disc-shaped Acts::StaticBlueprintNode, as opposed to a node assembled from sensitive surfaces. The resulting node can then be wrapped in addMaterial like any other. It is available for every blueprint backend that can describe a passive element as a volume (passiveVolume()); currently that is DD4hepBackend and TGeoBlueprintBuilderBackend, which share the shape conversion in TGeoSurfaceConverter::cylinderVolume.
  • DD4hepBackend::makeMaterialCollector creates a synthetic volume with no corresponding DD4hep element at all: a catch-all surface placed just outside the tracker envelope, so that material mapping attributes interactions in nearby non-tracker material (e.g. the gap before the calorimeter) to a dedicated surface instead of misattributing them to the nearest real tracker surface. See addCaloMaterialCollector/addCaloMaterialCollectorDisc in OpenDataDetectorBuilder.cpp for how the ODD uses this for its calorimeter.

The material collector is currently DD4hep-only: the TGeo blueprint backend (ActsPlugins::TGeoBlueprintBuilderBackend) has no material-collector helper yet.

Warning
Blueprint-built geometry ignores material decorators. Acts::Blueprint::construct passes a null decorator to the Acts::TrackingGeometry constructor, so the --matconfig / Acts::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 Acts::ProtoGridSurfaceMaterial alongside Acts::ProtoSurfaceMaterial. Steps 2 to 5 below are unchanged — just omit --matconfig in step 3, since the geometry already carries the designation.

Stable surface assignment keys

The Acts::AxisSpec overloads of Acts::MaterialDesignatorBlueprintNode::configureFace accept an optional trailing key for proto materials. Existing calls without a key continue to use geometry identifiers:

material.configureFace(CylinderVolumeBounds::Face::OuterCylinder,
"tracker/barrel/outer");

In Python the argument is named materialKey. Keys identify individual surface assignments, not material compositions. They are case-sensitive, must be nonempty when supplied, and must be unique among the material surfaces of a geometry. Choose names independent of traversal order and generated geometry IDs. Repeated configuration of the same proto-designator face is an error.

Proto materials carry the key. Mapping and loading discover it by downcasting the surface's Acts::ISurfaceMaterial; Acts::Surface has no key API or storage. Standalone proto-material and surface serialization preserve the key while the placeholder is attached. Loading replaces the placeholder with real material and consumes this identity. To apply another keyed map, rebuild the geometry with its designators first. Write or combine keyed maps using Acts::TrackingGeometryMaterial, rather than reconstructing keys from decorated surfaces.

Acts::BinnedSurfaceMaterialAccumulator validates identities before mapping: it rejects duplicate keys, distinct surfaces with the same geometry ID, and merged-material markers. Repeated visits to the same shared surface are allowed. The accumulator finalizes keyed assignments separately from the legacy ID-indexed assignments in Acts::TrackingGeometryMaterial::keyedSurfaces. Acts::MaterialMapper::finalizeMaps returns those maps. Registry state and identity checks remain internal to the accumulator implementation.

For a completed geometry, use TrackingGeometryMaterial::apply (maps.apply(geometry) in Python), or the overload taking a span of mutable surfaces. These format-independent Core operations validate the selected geometry's key/ID uniqueness and resolve all assignments before updating surfaces. The individual-surface apply(surface) overload performs lookup and material assignment but cannot check uniqueness across other surfaces.

Acts::TrackingGeometryMaterialJsonConverter reads versioned JSON/CBOR into these maps. Call converter.fromFile(path).apply(geometry) in C++ or Python to apply them to a completed geometry. Python also provides acts.IMaterialDecorator.fromFile(path) for construction-time decoration. The deprecated JSON decorator remains available for legacy files, with a Python deprecation warning.

  • Applying a keyed map to Gen1 geometry is an error. Stable keys require Gen3 material designators.
  • Keyed targets require a matching keyed entry. There is no fallback to a geometry ID when a key is missing.
  • Unkeyed targets use geometry-ID assignments.
  • Unused file keys are allowed, so a larger or combined map can decorate a subset geometry.
  • An explicit vacuum payload is a valid assignment; an absent payload is not.

For the serialized representation, see Legacy material map JSON format.

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.

Recording a DD4hep detector with ddsim

For a DD4hep detector, the recording can also run directly in ddsim, on the same Geant4 geometry DDG4 builds for the full simulation. Two DDG4 actions in the ActsPluginDD4hepGeant4 library do this: the ActsMaterialRecorder stepping action collects the material tracks, and the ActsMaterialTrackWriter event action writes them in the same file format as above. The library is built when both the DD4hep and the Geant4 plugins are enabled and DD4hep comes with DDG4. With it on LD_LIBRARY_PATH, e.g. after sourcing this_acts.sh:

ddsim --steeringFile Examples/Scripts/Python/material_recording_ddsim.py \
--compactFile mydet.xml -N 1000 --outputFile mydet_geant4.root

The steering file shoots 1000 geantinos per event, uniform in \(|\eta| < 4\); the gun can be adjusted on the command line, e.g. with --gun.multiplicity, --gun.etaMin and --gun.etaMax. With --numberOfThreads, which ddsim offers from DD4hep 1.38, all threads write into the single output file. Every simulated track gets an entry, also if it crosses no recorded material.

The mapping assigns every recorded step to a surface, so material beyond the outermost material surfaces, e.g. in the calorimeters, ends up on them. To avoid this, limit the recording to a cylinder around the beam axis, e.g. at the calorimeter face, with --outputConfig.recordingMaxR, --outputConfig.recordingMinZ and --outputConfig.recordingMaxZ in mm. The z range does not need to be symmetric, and a bound that is not given does not limit the recording. Tracks are stopped where they leave the cylinder, and the step that crosses its boundary is cut there, which also makes the recording faster.

To compare two recordings track by track, they need the same geantinos. For example, add a HepMC3Writer for the generated event to material_recording.py, and replay the resulting HepMC3 file in ddsim with --inputFiles instead of the particle gun, by setting SIM.enableGun = False in a copy of the steering 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 — Gen1 only, see below
mydet_material_mapped.root recorded interactions that found a surface
mydet_material_unmapped.root recorded interactions that did not
mydet_material_eta_distance.svg diagnostic plot: mapping distance vs. η
Important
On Gen3, _map.root is not written at all. Gen3 faces carry stable material keys (see above), and the ROOT material-map writer rejects any map containing keyed surfaces (Plugins/Root/src/RootMaterialMapIo.cpp) — Gen3 maps are JSON-only. material_mapping.py sets this automatically (it only calls RootMaterialWriter when running with --gen1), so if you omit --gen1, pass the resulting _map.json to --matconfig/your own decorator, not _map.root. Gen1 never sets keys, so .root stays available there.

_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.

Note
The eta_distance.svg plot is only produced when running material_mapping.py as a script — it comes from plotEtaSurfaceDistance(), called in its __main__ block after the mapping sequencer has run and the map has been written. If you call runMaterialMapping() yourself as a library function, you do not get it for free. On Gen3, the plot excludes the ODD's calorimeter collector surfaces (see above), since they are catch-alls rather than real mapping locations and would otherwise skew the distance distribution.

Step 4: use the map

import acts
from acts.examples.odd import getOpenDataDetectorDirectory, getOpenDataDetector
# This bundled map uses ROOT; versioned JSON maps can instead be loaded
# with TrackingGeometryMaterialJsonConverter and applied to the geometry.
material_map = getOpenDataDetectorDirectory() / "data/odd-material-maps.root"
decorator = acts.root.RootMaterialDecorator(
fileName=str(material_map), level=acts.logging.INFO
)
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 Gen1 ODD picks up data/odd-material-maps.root by default.

For the Gen3 ODD, loading works slightly differently from the generic snippet above. getOpenDataDetector(gen3=True, materialDecorator=...) (Python/Examples/python/odd.py) first builds the detector without material, then pulls materialDecorator.materialMaps out of the given decorator and applies it to the constructed geometry with TrackingGeometryMaterial::apply(), now that geometry identifiers are assigned. This replaces the designated proto material in place. Unlike Gen1, there is no default material map for the Gen3 ODD — it is only applied when a decorator is explicitly passed in. The shipped data/odd-material-maps.root cannot be used for this: it is an unkeyed map, indexed by the Gen1 geometry's own identifiers, while Gen3 requires a matching keyed entry for every designated face and has no ID fallback (see above) — applying it raises Missing material for key '...'. To get a map that actually works for Gen3, run steps 2-3 above (material_recording.py + material_mapping.py, both default to Gen3) to produce a fresh _map.json, and pass that.

Step 5: validate

python Examples/Scripts/Python/material_validation.py \
-n 1000 -t 1000 -m mydet_material_map.json -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.

Running --gen1 against a detector with a calorimeter. Gen1 geometry has no calorimeter catch-all collector surface — that is a Gen3-only addition, see Designating material in Gen3 — so calorimeter material has nowhere correct to go and gets misattributed to the nearest tracker surface instead. The ODD scripts handle this automatically (buildCalorimeter=gen3 in getOpenDataDetector, Python/Examples/python/odd.py); if you adapt them for your own Gen1 script, either disable the calorimeter or add an equivalent collector.

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.py — test_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.