From afad806cbd602b99cda3367bc8e7b9e8b218bec3 Mon Sep 17 00:00:00 2001 From: Pierfrancesco Butti <25535240+pbutti@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:38:25 +0200 Subject: [PATCH] ALICE3: ACTS tracking geometry provider Adds the Acts::TrackingGeometry that an ACTS-based reconstruction needs, built from the TGeo geometry O2 already has live in memory. ACTS stays an optional dependency: without it these packages produce no targets. O2::ACTSInterface (Detectors/ACTS/interface), no DPL dependency: - TrackingGeometryManager, a lazily-built process-wide provider following the o2::base::Propagator::Instance() / GeometryTGeo::Instance() pattern. It fetches nothing itself; callers make gGeoManager available first, normally by declaring GRPGeomRequest::Aligned. - ITrackingGeometryBuilder, the detector-specific seam, plus the element store the geometry does not own but depends on. - SurfaceIndexMap, the O2 sensor <-> ACTS surface lookup. - MagneticFieldAdapter, an Acts::MagneticFieldProvider over the O2 field. O2::ACTSWorkflow (Detectors/ACTS/workflow): ActsGeometryService, the same provider reachable through the DPL ServiceRegistry with ServiceKind::DeviceGlobal. O2::ALICE3ACTS (Detectors/Upgrades/ALICE3/ACTS): the Gen3 Blueprint builder, ported from actsO2 ActsAlgorithms/Geometry/src/ALICE3Gen3Geometry.cpp. The construction logic is unchanged so the geometry identifiers stay compatible with the standalone chain's material maps and digitisation/seeding configs; verified by decorating an O2-built geometry with actsO2's own material map. Differences from the original: it reads a TGeoManager handed in by the caller instead of importing a ROOT file, and its process-lifetime static stores became member state so several builders can coexist in one process. Sensors are identified by the TGeo node the surface was built from, not by position. A transform is not an identity: the vertex-detector petals are tube segments, so the three layers of a petal share both origin and rotation and differ only in radius, which the O2 matrix cache does not carry. Position-only matching is kept as a fallback and reports such cases as ambiguous instead of assigning them arbitrarily. macros/run_test.sh generates an ALICE 3 geometry with o2-sim and checks every TRK chip against its ACTS surface; config/gen3_geometry_config.json is the matching geometry description. Co-Authored-By: Claude Opus 5 --- Detectors/ACTS/CMakeLists.txt | 20 + Detectors/ACTS/README.md | 123 +++ Detectors/ACTS/interface/CMakeLists.txt | 26 + .../ACTSInterface/ITrackingGeometryBuilder.h | 79 ++ .../ACTSInterface/MagneticFieldAdapter.h | 67 ++ .../include/ACTSInterface/SurfaceIndexMap.h | 130 +++ .../ACTSInterface/TrackingGeometryManager.h | 145 +++ .../interface/src/MagneticFieldAdapter.cxx | 72 ++ .../ACTS/interface/src/SurfaceIndexMap.cxx | 390 +++++++ .../interface/src/TrackingGeometryManager.cxx | 231 ++++ Detectors/ACTS/workflow/CMakeLists.txt | 15 + .../ACTSWorkflow/ActsGeometryService.h | 76 ++ .../ACTS/workflow/src/ActsGeometryService.cxx | 80 ++ Detectors/CMakeLists.txt | 2 + Detectors/Upgrades/ALICE3/ACTS/CMakeLists.txt | 28 + .../ACTS/config/gen3_geometry_config.json | 146 +++ .../include/ALICE3ACTS/Gen3BlueprintBuilder.h | 77 ++ .../include/ALICE3ACTS/Gen3GeometryConfig.h | 120 +++ .../ALICE3/ACTS/macros/CMakeLists.txt | 21 + .../ACTS/macros/CheckActsTrackingGeometry.C | 135 +++ .../Upgrades/ALICE3/ACTS/macros/run_test.sh | 84 ++ .../ALICE3/ACTS/src/Gen3BlueprintBuilder.cxx | 994 ++++++++++++++++++ .../ALICE3/ACTS/src/Gen3GeometryConfig.cxx | 138 +++ Detectors/Upgrades/ALICE3/CMakeLists.txt | 1 + cmake/O2RootMacroExclusionList.cmake | 7 + 25 files changed, 3207 insertions(+) create mode 100644 Detectors/ACTS/CMakeLists.txt create mode 100644 Detectors/ACTS/README.md create mode 100644 Detectors/ACTS/interface/CMakeLists.txt create mode 100644 Detectors/ACTS/interface/include/ACTSInterface/ITrackingGeometryBuilder.h create mode 100644 Detectors/ACTS/interface/include/ACTSInterface/MagneticFieldAdapter.h create mode 100644 Detectors/ACTS/interface/include/ACTSInterface/SurfaceIndexMap.h create mode 100644 Detectors/ACTS/interface/include/ACTSInterface/TrackingGeometryManager.h create mode 100644 Detectors/ACTS/interface/src/MagneticFieldAdapter.cxx create mode 100644 Detectors/ACTS/interface/src/SurfaceIndexMap.cxx create mode 100644 Detectors/ACTS/interface/src/TrackingGeometryManager.cxx create mode 100644 Detectors/ACTS/workflow/CMakeLists.txt create mode 100644 Detectors/ACTS/workflow/include/ACTSWorkflow/ActsGeometryService.h create mode 100644 Detectors/ACTS/workflow/src/ActsGeometryService.cxx create mode 100644 Detectors/Upgrades/ALICE3/ACTS/CMakeLists.txt create mode 100644 Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json create mode 100644 Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3BlueprintBuilder.h create mode 100644 Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3GeometryConfig.h create mode 100644 Detectors/Upgrades/ALICE3/ACTS/macros/CMakeLists.txt create mode 100644 Detectors/Upgrades/ALICE3/ACTS/macros/CheckActsTrackingGeometry.C create mode 100755 Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh create mode 100644 Detectors/Upgrades/ALICE3/ACTS/src/Gen3BlueprintBuilder.cxx create mode 100644 Detectors/Upgrades/ALICE3/ACTS/src/Gen3GeometryConfig.cxx diff --git a/Detectors/ACTS/CMakeLists.txt b/Detectors/ACTS/CMakeLists.txt new file mode 100644 index 0000000000000..8a6c1adceed70 --- /dev/null +++ b/Detectors/ACTS/CMakeLists.txt @@ -0,0 +1,20 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# The whole package is optional: ACTS is an optional O2 dependency +# (see dependencies/O2Dependencies.cmake) and standard builds do not ship it. +if(NOT Acts_FOUND) + message(STATUS "ACTS not found, skipping the O2 ACTS interface") + return() +endif() + +add_subdirectory(interface) +add_subdirectory(workflow) diff --git a/Detectors/ACTS/README.md b/Detectors/ACTS/README.md new file mode 100644 index 0000000000000..e8a907aa9e56d --- /dev/null +++ b/Detectors/ACTS/README.md @@ -0,0 +1,123 @@ +# ACTS tracking geometry in O2 + +Provides the shared, immutable `Acts::TrackingGeometry` that ACTS-based +reconstruction needs, built from the TGeo geometry O2 already has live in memory. + +ACTS is an **optional** O2 dependency (`dependencies/O2Dependencies.cmake`). Without +it this package produces no targets and `O2_WITH_ACTS` is not defined, so anything +using it must be guarded. + +## Libraries + +| Target | Contents | +| --- | --- | +| `O2::ACTSInterface` | `TrackingGeometryManager` (the provider), `ITrackingGeometryBuilder` (the detector-specific seam), `SurfaceIndexMap` (sensor ↔ surface lookup), `MagneticFieldAdapter` (`Acts::MagneticFieldProvider` over the O2 field). No DPL dependency. | +| `O2::ACTSWorkflow` | `ActsGeometryService`, the same provider reachable through the DPL `ServiceRegistry`. | +| `O2::ALICE3ACTS` | `Gen3BlueprintBuilder`, the ALICE 3 builder (under `Detectors/Upgrades/ALICE3/ACTS`). | + +## Using it from a DPL task + +The manager fetches nothing itself: it needs `gGeoManager` to be live, which is what +`GRPGeomRequest::Aligned` arranges. Note that a task that only used the material LUT +before will have been passing `GRPGeomRequest::None` and has to be switched. + +```cpp +// spec factory +auto ggRequest = std::make_shared( + false, false, false, true /*GRPMagField*/, false, + o2::base::GRPGeomRequest::Aligned, inputs, true); + +void init(InitContext& ic) +{ + o2::base::GRPGeomHelper::instance().setRequest(mCCDBReq); + + o2::alice3::Gen3BlueprintBuilder::Config cfg; + cfg.geometryConfigFile = ic.options().get("acts-geometry-config"); + o2::acts::TrackingGeometryManager::instance().setBuilder( + std::make_unique(cfg)); +} + +void run(ProcessingContext& pc) +{ + o2::base::GRPGeomHelper::instance().checkUpdates(pc); // gGeoManager now live + const auto& tg = o2::acts::TrackingGeometryManager::instance().get(); // built once +} +``` + +To reach it through the registry instead, put `o2::acts::defaultServicesWithActsGeometry()` +into the `DataProcessorSpec`'s `requiredServices` and use +`pc.services().get()`. Both paths hand out the same object. + +## Mapping O2 clusters onto ACTS surfaces + +`makeSurfaceIndex()` (or `TrackingGeometryManager::getIndex(cache, tol, pathProvider)`, +which caches per detector) maps every sensor of a `DetMatrixCache`-derived geometry helper +onto a sensitive ACTS surface. + +**Pass a `SensorPathProvider` whenever the detector has one.** With it, sensors are +identified by the TGeo node the surface was built from — an identity, so it cannot +mis-assign: + +```cpp +auto* trkGeo = o2::trk::GeometryTGeo::Instance(); +trkGeo->fillMatrixCache(o2::math_utils::bit2Mask(o2::math_utils::TransformType::L2G)); +const auto& index = mgr.getIndex(*trkGeo, 1e-3, + [trkGeo](int chipID) { return std::string(trkGeo->getMatrixPath(chipID).Data()); }); +``` + +Without it the fallback matches on the sensor's local-to-global transform, which is exact +where a transform identifies a sensor but not always: the ALICE 3 vertex-detector petals +are tube segments, so the three layers of a petal share both origin and rotation and +differ only in radius, which the matrix cache does not carry. Those are reported as +ambiguous rather than assigned arbitrarily, and `makeSurfaceIndex()` throws. One surface +per sensor is enforced in both modes. + +## The Gen3 geometry description + +`Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json` carries everything +geometry-specific: sensor name globs, region boundaries, per-subsystem clustering +tolerances, passive material structures and the pinned volume IDs. All lengths in mm. + +It is tuned for the layout `run_test.sh` generates — verified against an o2-sim geometry of +that layout: the sensor globs cover every sensitive volume family (and correctly exclude +`FT3Sensor_Inactive_*`), `*EOSCard*` matches the 504 end-of-stave volumes, the passive +cylinder radii and half-lengths match the real support volumes, and the resulting +volume/layer/surface table is identical to the one actsO2's reference geometry produces. + +One known imprecision, inherited and present for both geometries: `TRK_MID_CarbonSupport` +is declared with `halfZ = 1420` while the real `TRK_MID_CARBONSUPPORT` volume has +`dz = 1410 mm`. It only affects the z-extent of that passive layer's material receiver. + +## Material + +Gen3 construction takes no `IMaterialDecorator`, so an ACTS JSON material map is applied +afterwards via `setMaterialMapFile()`. Material maps are keyed on +`Acts::GeometryIdentifier`, so a structural change to a builder invalidates existing +maps; the manager raises rather than silently producing a material-free geometry. + +## Checking a geometry + +`Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh` does the whole thing: it runs +`o2-sim-serial-run5` with the ALICE 3 layout the ACTS chain is developed against, then +builds the tracking geometry from the result and checks every TRK chip against its ACTS +surface. Run it from a scratch directory — `o2-sim` writes into `$PWD`: + +```bash +mkdir -p /tmp/acts && cd /tmp/acts +bash /Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh +``` + +The geometry description it needs is shipped in this package +(`Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json`, installed to +`$O2_ROOT/share/Detectors/Upgrades/ALICE3/ACTS/config/`) and matches the layout the script +generates. Set `GEN3_CONFIG` to point at a different geometry's config. + +Knobs: `nEvents`, `generator`, `modules`, `GEN3_CONFIG`, `SKIP_ALIGNMENT`. The last one +defaults to 1 because `o2-sim` finishes by fetching alignment from CCDB, which needs a +valid alien token and aborts without one — after the geometry file has already been +written. The ACTS geometry is built from the ideal geometry, so skipping it costs nothing. + +The macro `CheckActsTrackingGeometry.C` can also be run on its own against an existing +`o2sim_geometry.root`; it prints the volume/layer/surface table, which is the authority for +the `(volume, layer)` keys that digitisation and seeding configurations use. It needs ACTS, +Eigen and `$O2_ROOT/include` on `ROOT_INCLUDE_PATH` — `run_test.sh` sets that up. diff --git a/Detectors/ACTS/interface/CMakeLists.txt b/Detectors/ACTS/interface/CMakeLists.txt new file mode 100644 index 0000000000000..e4d167fd73706 --- /dev/null +++ b/Detectors/ACTS/interface/CMakeLists.txt @@ -0,0 +1,26 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +o2_add_library(ACTSInterface + TARGETVARNAME targetName + SOURCES src/TrackingGeometryManager.cxx + src/SurfaceIndexMap.cxx + src/MagneticFieldAdapter.cxx + PUBLIC_LINK_LIBRARIES O2::DetectorsBase + O2::DetectorsCommonDataFormats + O2::Field + O2::Framework + ROOT::Geom + Acts::Core + Acts::PluginRoot + Acts::PluginJson) + +target_compile_definitions(${targetName} PUBLIC O2_WITH_ACTS) diff --git a/Detectors/ACTS/interface/include/ACTSInterface/ITrackingGeometryBuilder.h b/Detectors/ACTS/interface/include/ACTSInterface/ITrackingGeometryBuilder.h new file mode 100644 index 0000000000000..705dfdc18150a --- /dev/null +++ b/Detectors/ACTS/interface/include/ACTSInterface/ITrackingGeometryBuilder.h @@ -0,0 +1,79 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file ITrackingGeometryBuilder.h +/// \brief Detector-agnostic interface for building an Acts::TrackingGeometry from TGeo +/// \author Paolo Butti +/// + +#ifndef ALICEO2_ACTS_ITRACKINGGEOMETRYBUILDER_H +#define ALICEO2_ACTS_ITRACKINGGEOMETRYBUILDER_H + +#include +#include + +#include "Acts/Geometry/GeometryContext.hpp" +#include "Acts/Geometry/TrackingGeometry.hpp" +#include "Acts/Surfaces/SurfacePlacementBase.hpp" + +#include "ACTSInterface/SurfaceIndexMap.h" + +class TGeoManager; + +namespace o2::acts +{ + +/// Everything a builder produces, kept together because the parts have a +/// lifetime dependency on each other. +struct TrackingGeometryOutput { + /// Non-const so the manager can still decorate it with material before + /// handing it out; it is published as shared_ptr afterwards. + std::shared_ptr geometry; + + /// Detector elements backing the sensitive surfaces. + /// + /// Acts::TrackingGeometry does NOT own these: Acts::Surface keeps a raw, + /// non-owning back-pointer to its placement (Surface::surfacePlacement()). + /// Destroying this store while \a geometry is alive is a dangling-pointer bug, + /// so it must be kept for at least as long as the geometry. + std::vector> elementStore; + + /// Sensor <-> surface lookup. May be empty if the builder cannot provide one. + SurfaceIndexMap index; +}; + +/// Builds an Acts::TrackingGeometry from a TGeo tree. +/// +/// Implementations are detector specific and live with their detector (see +/// o2::alice3::Gen3BlueprintBuilder). They are injected into +/// TrackingGeometryManager at runtime, so this library never depends on any +/// concrete detector. +class ITrackingGeometryBuilder +{ + public: + virtual ~ITrackingGeometryBuilder() = default; + + /// Build from an already-loaded TGeo tree. + /// + /// \param tgeo the geometry manager to read; implementations must not import + /// or otherwise replace it, since it is normally O2's live + /// gGeoManager shared with the rest of the workflow + /// \param gctx nominal context used while placing surfaces + virtual TrackingGeometryOutput build(TGeoManager& tgeo, const Acts::GeometryContext& gctx) = 0; + + /// Short name, used in log messages. + virtual const char* getName() const = 0; +}; + +} // namespace o2::acts + +#endif // ALICEO2_ACTS_ITRACKINGGEOMETRYBUILDER_H diff --git a/Detectors/ACTS/interface/include/ACTSInterface/MagneticFieldAdapter.h b/Detectors/ACTS/interface/include/ACTSInterface/MagneticFieldAdapter.h new file mode 100644 index 0000000000000..881dd2fd2b1e1 --- /dev/null +++ b/Detectors/ACTS/interface/include/ACTSInterface/MagneticFieldAdapter.h @@ -0,0 +1,67 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file MagneticFieldAdapter.h +/// \brief Acts::MagneticFieldProvider backed by the O2 field +/// \author Paolo Butti +/// + +#ifndef ALICEO2_ACTS_MAGNETICFIELDADAPTER_H +#define ALICEO2_ACTS_MAGNETICFIELDADAPTER_H + +#include "Acts/MagneticField/MagneticFieldProvider.hpp" + +namespace o2::field +{ +class MagneticField; +} + +namespace o2::acts +{ + +/// Exposes the O2 magnetic field to ACTS. +/// +/// Holds no field of its own: it reads the process-global field that +/// o2::base::Propagator::initFieldFromGRP() installs into +/// TGeoGlobalMagField::Instance() from the GRPMagField CCDB object. The +/// underlying object stays valid across field rescalings -- GRPGeomHelper +/// rescales the existing MagneticField in place rather than replacing it -- so +/// an adapter built once keeps returning up-to-date values. +/// +/// Units: O2 works in kGauss and cm, ACTS in its native tesla/mm system, and the +/// conversion is applied here. +class MagneticFieldAdapter : public Acts::MagneticFieldProvider +{ + public: + /// Use the field currently installed in TGeoGlobalMagField. + /// \throw std::runtime_error if no field has been initialised + MagneticFieldAdapter(); + + /// Use an explicitly provided field. + explicit MagneticFieldAdapter(o2::field::MagneticField* field); + + Cache makeCache(const Acts::MagneticFieldContext& mctx) const final; + + Acts::Result getField(const Acts::Vector3& position, Cache& cache) const final; + + const o2::field::MagneticField* getO2Field() const { return mField; } + + private: + struct CacheImpl { + }; + + o2::field::MagneticField* mField = nullptr; +}; + +} // namespace o2::acts + +#endif // ALICEO2_ACTS_MAGNETICFIELDADAPTER_H diff --git a/Detectors/ACTS/interface/include/ACTSInterface/SurfaceIndexMap.h b/Detectors/ACTS/interface/include/ACTSInterface/SurfaceIndexMap.h new file mode 100644 index 0000000000000..26b83c1737bcf --- /dev/null +++ b/Detectors/ACTS/interface/include/ACTSInterface/SurfaceIndexMap.h @@ -0,0 +1,130 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file SurfaceIndexMap.h +/// \brief Lookup between O2 sensor indices and ACTS surfaces +/// \author Paolo Butti +/// + +#ifndef ALICEO2_ACTS_SURFACEINDEXMAP_H +#define ALICEO2_ACTS_SURFACEINDEXMAP_H + +#include +#include +#include +#include +#include + +#include "Acts/Geometry/GeometryIdentifier.hpp" + +namespace Acts +{ +class Surface; +class TrackingGeometry; +class GeometryContext; +} // namespace Acts + +namespace o2::detectors +{ +class DetMatrixCache; +} + +namespace o2::acts +{ + +/// Lookup between an O2 sensor index (chip ID, as used by the detector's +/// DetMatrixCache-derived geometry helper) and the ACTS surface it sits on. +/// +/// The relation is many-to-one, not one-to-one. A planar sensor becomes its own +/// ACTS surface, but a sub-detector modelled as a whole cylindrical or disc +/// layer -- as the ALICE 3 vertex detector is -- becomes a single surface that +/// backs every chip of that layer. Hence getSurface() takes a sensor and +/// getSensorIDs() gives back a list. +/// +/// ACTS assigns the Acts::GeometryIdentifier inside +/// Acts::Blueprint::construct(), so this map can only be filled once the +/// TrackingGeometry is complete. See makeSurfaceIndex(). +class SurfaceIndexMap +{ + public: + static constexpr int InvalidSensorID = -1; + + /// \throw std::runtime_error if the sensor is already mapped + void add(int sensorID, const Acts::Surface& surface); + + /// \return nullptr if the sensor is not mapped + const Acts::Surface* getSurface(int sensorID) const; + /// \return a default-constructed identifier if the sensor is not mapped + Acts::GeometryIdentifier getGeometryId(int sensorID) const; + + /// All sensors sitting on the given surface, in increasing sensor order. + /// \return an empty vector if the surface backs no sensor + const std::vector& getSensorIDs(Acts::GeometryIdentifier geoId) const; + + bool empty() const { return mBySensor.empty(); } + /// Number of mapped sensors. + std::size_t size() const { return mBySensor.size(); } + /// Number of distinct surfaces the sensors map onto. + std::size_t getNSurfaces() const { return mByGeoId.size(); } + void clear(); + + private: + struct Entry { + const Acts::Surface* surface = nullptr; + Acts::GeometryIdentifier geoId{}; + }; + + std::unordered_map mBySensor; + std::unordered_map> mByGeoId; +}; + +/// Full TGeo node path of one sensor, e.g. o2::trk::GeometryTGeo::getMatrixPath(). +using SensorPathProvider = std::function; + +/// Build the sensor <-> surface index. +/// +/// Two strategies, and \b which \b one \b is \b used \b matters: +/// +/// - **With \p pathProvider (exact, preferred).** Each sensor is resolved to its +/// TGeo node and matched against the node every ACTS surface was built from +/// (ActsPlugins::TGeoDetectorElement::tgeoNode()). This is an identity, not a +/// measurement, so it cannot mis-assign. Repeated placements of one volume +/// share a node and are separated by their accumulated global transform. +/// +/// - **Without it (geometric fallback).** Sensors are matched on their +/// local-to-global transform. Exact where a transform identifies a sensor, but +/// it does not always: an ALICE 3 vertex-detector petal is a tube segment, so +/// all three of its layers share one origin *and* one rotation and differ only +/// in radius, which the matrix cache does not carry. Such sensors are reported +/// as ambiguous rather than assigned arbitrarily. +/// +/// Both read the same TGeo tree that the surfaces were built from, so a successful +/// match is exact rather than approximate; \p toleranceCm only guards floating-point +/// round-trips. +/// +/// \param geometry the constructed tracking geometry +/// \param gctx context used to evaluate the surface transforms +/// \param cache detector geometry helper; requires a filled L2G cache +/// (call fillMatrixCache(bit2Mask(o2::math_utils::TransformType::L2G))) +/// \param toleranceCm matching tolerance, in O2 units (cm) +/// \param pathProvider optional TGeo path lookup, as described above +/// \throw std::runtime_error if a sensor cannot be matched, if a match is +/// ambiguous, or if two sensors would claim the same surface +SurfaceIndexMap makeSurfaceIndex(const Acts::TrackingGeometry& geometry, + const Acts::GeometryContext& gctx, + const o2::detectors::DetMatrixCache& cache, + double toleranceCm = 1e-3, + SensorPathProvider pathProvider = {}); + +} // namespace o2::acts + +#endif // ALICEO2_ACTS_SURFACEINDEXMAP_H diff --git a/Detectors/ACTS/interface/include/ACTSInterface/TrackingGeometryManager.h b/Detectors/ACTS/interface/include/ACTSInterface/TrackingGeometryManager.h new file mode 100644 index 0000000000000..557a3f9ffb6fd --- /dev/null +++ b/Detectors/ACTS/interface/include/ACTSInterface/TrackingGeometryManager.h @@ -0,0 +1,145 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file TrackingGeometryManager.h +/// \brief Process-wide provider of the ACTS tracking geometry +/// \author Paolo Butti +/// + +#ifndef ALICEO2_ACTS_TRACKINGGEOMETRYMANAGER_H +#define ALICEO2_ACTS_TRACKINGGEOMETRYMANAGER_H + +#include +#include +#include +#include + +#include "Acts/Utilities/Logger.hpp" + +#include "ACTSInterface/ITrackingGeometryBuilder.h" + +namespace o2::acts +{ + +/// Provider of the shared, immutable Acts::TrackingGeometry. +/// +/// Follows the same pattern as o2::base::Propagator::Instance(), +/// o2::base::GRPGeomHelper::instance() and o2::its::GeometryTGeo::Instance(): +/// a process-wide lazily-built singleton fed from the geometry O2 already has +/// live in memory. It fetches nothing by itself -- the caller is responsible for +/// making gGeoManager available first, normally by declaring +/// o2::base::GRPGeomRequest::Aligned and calling +/// o2::base::GRPGeomHelper::instance().checkUpdates(pc). +/// +/// Typical use in a DPL task: +/// \code +/// void init(InitContext&) { +/// o2::base::GRPGeomHelper::instance().setRequest(mCCDBReq); +/// TrackingGeometryManager::instance().setBuilder(std::make_unique(cfg)); +/// } +/// void run(ProcessingContext& pc) { +/// o2::base::GRPGeomHelper::instance().checkUpdates(pc); // gGeoManager now live +/// const auto& tg = TrackingGeometryManager::instance().get(); // built once +/// } +/// \endcode +/// +/// Thread safety: build() is not re-entrant and is expected to run once, before +/// any parallel processing. Once built the geometry is immutable and all +/// accessors are safe to call concurrently. +class TrackingGeometryManager +{ + public: + static TrackingGeometryManager& instance(); + + /// Install the detector-specific builder. Must be called before the first build. + void setBuilder(std::unique_ptr builder); + bool hasBuilder() const { return mBuilder != nullptr; } + + /// Path to an ACTS JSON material map, applied after construction. + /// Empty (the default) means no material decoration. + void setMaterialMapFile(std::string fileName) { mMaterialMapFile = std::move(fileName); } + const std::string& getMaterialMapFile() const { return mMaterialMapFile; } + + void setLogLevel(Acts::Logging::Level level) { mLogLevel = level; } + + /// Build from the live gGeoManager. + /// \throw std::runtime_error if no builder is set or no geometry is loaded + void build(); + + /// Build from a TGeo file, for macros and tests running outside a workflow. + /// Loads the file through o2::base::GeometryManager, so it ends up in + /// gGeoManager exactly as it would inside a workflow. + void buildFromFile(std::string_view geomFileName); + + bool isBuilt() const { return mGeometry != nullptr; } + void clear(); + + /// Builds on first call if not built yet. + std::shared_ptr getShared(); + const Acts::TrackingGeometry& get(); + + /// Sensor <-> surface lookup, as filled by the builder. Empty if the builder + /// did not provide one -- which is the normal case when the geometry spans + /// several detectors, since each has its own sensor numbering. Use the + /// overload below for those. + const SurfaceIndexMap& getIndex(); + + /// Sensor <-> surface lookup for one detector, built from its geometry helper + /// on first request and cached per detector afterwards. + /// + /// Pass \p pathProvider whenever the detector offers one -- it makes the match an + /// identity rather than a geometric inference, see makeSurfaceIndex(): + /// \code + /// auto* trkGeo = o2::trk::GeometryTGeo::Instance(); + /// mgr.getIndex(*trkGeo, 1e-3, + /// [trkGeo](int id) { return std::string(trkGeo->getMatrixPath(id).Data()); }); + /// \endcode + /// + /// \param cache a DetMatrixCache-derived helper with a filled L2G cache, + /// e.g. o2::trk::GeometryTGeo::Instance() + /// \param toleranceCm matching tolerance, see makeSurfaceIndex() + /// \param pathProvider optional TGeo path lookup for exact identification + const SurfaceIndexMap& getIndex(const o2::detectors::DetMatrixCache& cache, + double toleranceCm = 1e-3, + SensorPathProvider pathProvider = {}); + + /// Context the geometry was built with. Carries no alignment payload: O2 bakes + /// alignment into the TGeo matrices before the ACTS geometry is built, so the + /// nominal transforms are already the aligned ones. + const Acts::GeometryContext& getNominalContext() const { return mNominalContext; } + + /// Number of surfaces that received material during decoration. Zero when no + /// material map was configured. + std::size_t getNDecoratedSurfaces() const { return mNDecoratedSurfaces; } + + private: + TrackingGeometryManager(); + + void doBuild(); + void applyMaterialMapImpl(Acts::TrackingGeometry& geometry); + + std::unique_ptr mBuilder; + std::string mMaterialMapFile; + Acts::Logging::Level mLogLevel = Acts::Logging::INFO; + + Acts::GeometryContext mNominalContext; + std::shared_ptr mGeometry; + std::vector> mElementStore; + SurfaceIndexMap mIndex; + /// Per-detector indices, keyed by o2::detectors::DetID. + std::unordered_map mDetIndices; + std::size_t mNDecoratedSurfaces = 0; +}; + +} // namespace o2::acts + +#endif // ALICEO2_ACTS_TRACKINGGEOMETRYMANAGER_H diff --git a/Detectors/ACTS/interface/src/MagneticFieldAdapter.cxx b/Detectors/ACTS/interface/src/MagneticFieldAdapter.cxx new file mode 100644 index 0000000000000..f2e23ba2be554 --- /dev/null +++ b/Detectors/ACTS/interface/src/MagneticFieldAdapter.cxx @@ -0,0 +1,72 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file MagneticFieldAdapter.cxx +/// \author Paolo Butti +/// + +#include "ACTSInterface/MagneticFieldAdapter.h" + +#include + +#include + +#include "Acts/Definitions/Units.hpp" +#include "Field/MagneticField.h" +#include "Framework/Logger.h" + +namespace o2::acts +{ + +namespace +{ +/// O2 stores the field in kGauss, ACTS in its native unit system. +constexpr double kGaussToActs = 0.1 * Acts::UnitConstants::T; +} // namespace + +MagneticFieldAdapter::MagneticFieldAdapter() + : MagneticFieldAdapter(static_cast(TGeoGlobalMagField::Instance()->GetField())) +{ +} + +MagneticFieldAdapter::MagneticFieldAdapter(o2::field::MagneticField* field) : mField(field) +{ + if (mField == nullptr) { + throw std::runtime_error( + "o2::acts::MagneticFieldAdapter: no O2 magnetic field available. Request GRPMagField in the " + "GRPGeomRequest of the workflow, or initialise the field explicitly with " + "o2::base::Propagator::initFieldFromGRP()."); + } + LOG(info) << "ACTS magnetic field adapter bound to O2 field '" << mField->GetName() << "'"; +} + +Acts::MagneticFieldProvider::Cache MagneticFieldAdapter::makeCache(const Acts::MagneticFieldContext& /*mctx*/) const +{ + // The O2 field parametrisation keeps its own internal caches and offers no + // per-client cache handle, so there is nothing to carry here. + return Acts::MagneticFieldProvider::Cache(std::in_place_type); +} + +Acts::Result MagneticFieldAdapter::getField(const Acts::Vector3& position, + Cache& /*cache*/) const +{ + const double xyz[3] = {position[0] / Acts::UnitConstants::cm, + position[1] / Acts::UnitConstants::cm, + position[2] / Acts::UnitConstants::cm}; + double bxyz[3] = {0., 0., 0.}; + mField->Field(xyz, bxyz); + + return Acts::Result::success( + Acts::Vector3{bxyz[0] * kGaussToActs, bxyz[1] * kGaussToActs, bxyz[2] * kGaussToActs}); +} + +} // namespace o2::acts diff --git a/Detectors/ACTS/interface/src/SurfaceIndexMap.cxx b/Detectors/ACTS/interface/src/SurfaceIndexMap.cxx new file mode 100644 index 0000000000000..4df98afe3de88 --- /dev/null +++ b/Detectors/ACTS/interface/src/SurfaceIndexMap.cxx @@ -0,0 +1,390 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file SurfaceIndexMap.cxx +/// \author Paolo Butti +/// + +#include "ACTSInterface/SurfaceIndexMap.h" + +#include +#include +#include +#include +#include + +#include "Acts/Definitions/Units.hpp" +#include "Acts/Geometry/GeometryContext.hpp" +#include "Acts/Geometry/TrackingGeometry.hpp" +#include "Acts/Surfaces/BoundaryTolerance.hpp" +#include "Acts/Surfaces/Surface.hpp" +#include "Acts/Surfaces/SurfacePlacementBase.hpp" +#include "ActsPlugins/Root/TGeoDetectorElement.hpp" + +#include +#include +#include + +#include "DetectorsCommonDataFormats/DetMatrixCache.h" +#include "Framework/Logger.h" + +namespace o2::acts +{ + +namespace +{ +const std::vector gNoSensors{}; +} // namespace + +void SurfaceIndexMap::add(int sensorID, const Acts::Surface& surface) +{ + const auto geoId = surface.geometryId(); + auto [it, inserted] = mBySensor.try_emplace(sensorID, Entry{&surface, geoId}); + if (!inserted) { + throw std::runtime_error("o2::acts::SurfaceIndexMap: sensor " + std::to_string(sensorID) + + " is already mapped"); + } + mByGeoId[geoId].push_back(sensorID); +} + +const Acts::Surface* SurfaceIndexMap::getSurface(int sensorID) const +{ + const auto it = mBySensor.find(sensorID); + return it == mBySensor.end() ? nullptr : it->second.surface; +} + +Acts::GeometryIdentifier SurfaceIndexMap::getGeometryId(int sensorID) const +{ + const auto it = mBySensor.find(sensorID); + return it == mBySensor.end() ? Acts::GeometryIdentifier{} : it->second.geoId; +} + +const std::vector& SurfaceIndexMap::getSensorIDs(Acts::GeometryIdentifier geoId) const +{ + const auto it = mByGeoId.find(geoId); + return it == mByGeoId.end() ? gNoSensors : it->second; +} + +void SurfaceIndexMap::clear() +{ + mBySensor.clear(); + mByGeoId.clear(); +} + +namespace +{ + +/// Uniform grid hash over per-sensor surface centres, so matching stays linear +/// in the number of sensors instead of quadratic. +class CentreLookup +{ + public: + explicit CentreLookup(double cellSize) : mCellSize(cellSize) {} + + void add(const Acts::Surface* surface, const Acts::Vector3& position) + { + mEntries.push_back({surface, position}); + mCells[cellOf(position)].push_back(mEntries.size() - 1); + } + + /// \return the closest surface within \p tolerance, or nullptr + const Acts::Surface* findNearest(const Acts::Vector3& position, double tolerance) const + { + const auto cell = cellOf(position); + const Acts::Surface* best = nullptr; + double bestDist2 = tolerance * tolerance; + for (std::int64_t dx = -1; dx <= 1; ++dx) { + for (std::int64_t dy = -1; dy <= 1; ++dy) { + for (std::int64_t dz = -1; dz <= 1; ++dz) { + const auto it = mCells.find(Cell{cell.x + dx, cell.y + dy, cell.z + dz}); + if (it == mCells.end()) { + continue; + } + for (const auto idx : it->second) { + const double dist2 = (mEntries[idx].position - position).squaredNorm(); + if (dist2 <= bestDist2) { + bestDist2 = dist2; + best = mEntries[idx].surface; + } + } + } + } + } + return best; + } + + std::size_t size() const { return mEntries.size(); } + + private: + struct Entry { + const Acts::Surface* surface = nullptr; + Acts::Vector3 position; + }; + struct Cell { + std::int64_t x = 0, y = 0, z = 0; + bool operator==(const Cell&) const = default; + }; + struct CellHash { + std::size_t operator()(const Cell& c) const noexcept + { + std::size_t h = static_cast(c.x) * 0x9e3779b97f4a7c15ULL; + h ^= static_cast(c.y) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + h ^= static_cast(c.z) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + return h; + } + }; + + Cell cellOf(const Acts::Vector3& p) const + { + return Cell{static_cast(std::floor(p[0] / mCellSize)), + static_cast(std::floor(p[1] / mCellSize)), + static_cast(std::floor(p[2] / mCellSize))}; + } + + double mCellSize = 1.; + std::vector mEntries; + std::unordered_map, CellHash> mCells; +}; + +} // namespace + +namespace +{ + +/// Collect the sensitive surfaces, split by whether their centre identifies them. +struct SensitiveSurfaces { + CentreLookup byCentre; + std::vector layerSurfaces; + std::unordered_map> byNode; + std::size_t nWithoutNode = 0; + + explicit SensitiveSurfaces(double cellSize) : byCentre(cellSize) {} +}; + +SensitiveSurfaces collectSensitive(const Acts::TrackingGeometry& geometry, + const Acts::GeometryContext& gctx, double cellSize) +{ + SensitiveSurfaces out(cellSize); + geometry.apply([&](const Acts::Surface& surface) { + const auto* placement = surface.surfacePlacement(); + if (placement == nullptr || !placement->isSensitive()) { + return; + } + const auto* tgeoElement = dynamic_cast(placement); + if (tgeoElement != nullptr) { + out.byNode[&tgeoElement->tgeoNode()].push_back(&surface); + } else { + ++out.nWithoutNode; + } + const auto type = surface.type(); + if (type == Acts::Surface::SurfaceType::Cylinder || type == Acts::Surface::SurfaceType::Disc) { + out.layerSurfaces.push_back(&surface); + } else { + out.byCentre.add(&surface, surface.center(gctx)); + } + }); + return out; +} + +/// Global translation of an O2 sensor, in ACTS units, plus its rotation. +struct SensorPlacement { + Acts::Vector3 position; + Acts::RotationMatrix3 rotation; +}; + +SensorPlacement sensorPlacementOf(const o2::detectors::DetMatrixCache& cache, int sensorID) +{ + double rot[9] = {0.}; + double tra[3] = {0.}; + cache.getMatrixL2G(sensorID).GetComponents(rot[0], rot[1], rot[2], tra[0], + rot[3], rot[4], rot[5], tra[1], + rot[6], rot[7], rot[8], tra[2]); + SensorPlacement out; + out.position = Acts::Vector3{tra[0] * Acts::UnitConstants::cm, tra[1] * Acts::UnitConstants::cm, + tra[2] * Acts::UnitConstants::cm}; + out.rotation << rot[0], rot[1], rot[2], rot[3], rot[4], rot[5], rot[6], rot[7], rot[8]; + return out; +} + +/// Resolve a sensor through its TGeo node. Exact: the node is an identity, not a +/// measurement. Repeated placements of one volume share a node, so those are +/// separated by the accumulated global translation. +/// \return the surface, or nullptr with \p ambiguous set when several remain +const Acts::Surface* matchByNode(const SensitiveSurfaces& surfaces, const std::string& path, + const Acts::GeometryContext& gctx, const SensorPlacement& placement, + double tolerance, bool& ambiguous) +{ + ambiguous = false; + if (gGeoManager == nullptr) { + throw std::runtime_error("o2::acts::makeSurfaceIndex: no TGeo geometry loaded"); + } + gGeoManager->PushPath(); + const bool ok = gGeoManager->cd(path.c_str()); + const TGeoNode* node = ok ? gGeoManager->GetCurrentNode() : nullptr; + gGeoManager->PopPath(); + if (node == nullptr) { + return nullptr; + } + + const auto it = surfaces.byNode.find(node); + if (it == surfaces.byNode.end()) { + return nullptr; + } + if (it->second.size() == 1) { + return it->second.front(); + } + const Acts::Surface* best = nullptr; + for (const auto* surface : it->second) { + if ((surface->localToGlobalTransform(gctx).translation() - placement.position).norm() <= tolerance) { + if (best != nullptr) { + ambiguous = true; + return nullptr; + } + best = surface; + } + } + return best; +} + +/// Resolve a sensor from its transform alone. Used when no TGeo path is available. +/// \return the surface, or nullptr with \p ambiguous set when several match +const Acts::Surface* matchByPlacement(const SensitiveSurfaces& surfaces, + const Acts::GeometryContext& gctx, + const SensorPlacement& placement, double tolerance, + bool& ambiguous) +{ + ambiguous = false; + + // Rotations are dimensionless; a generous bound on the TGeoHMatrix -> Eigen round trip. + constexpr double kRotationTolerance = 1e-9; + + if (const auto* surface = surfaces.byCentre.findNearest(placement.position, tolerance); + surface != nullptr) { + return surface; + } + + // A sensor modelled as a whole cylinder or disc: its origin is generally not a + // useful key (an ALICE 3 vertex-detector petal is a tube segment, so its origin + // is on the beam axis), so the full transform is compared instead. Every match + // is collected rather than taking the first: the three layers of one petal share + // both origin and rotation and differ only in radius, which the matrix cache does + // not carry, so those are genuinely unresolvable this way and must be reported. + const Acts::Surface* best = nullptr; + for (const auto* surface : surfaces.layerSurfaces) { + const auto& transform = surface->localToGlobalTransform(gctx); + if ((transform.translation() - placement.position).norm() <= tolerance && + (transform.rotation() - placement.rotation).norm() <= kRotationTolerance) { + if (best != nullptr) { + ambiguous = true; + return nullptr; + } + best = surface; + } + } + return best; +} + +} // namespace + +SurfaceIndexMap makeSurfaceIndex(const Acts::TrackingGeometry& geometry, + const Acts::GeometryContext& gctx, + const o2::detectors::DetMatrixCache& cache, + double toleranceCm, + SensorPathProvider pathProvider) +{ + if (!cache.isBuilt()) { + throw std::runtime_error("o2::acts::makeSurfaceIndex: the detector matrix cache is not built"); + } + if (cache.getCacheL2G().getSize() == 0) { + throw std::runtime_error( + "o2::acts::makeSurfaceIndex: the L2G matrix cache is empty. Call fillMatrixCache() with the " + "L2G bit set before building the index."); + } + + // ACTS works in mm, O2 in cm; Acts::UnitConstants::cm is exactly that factor. + const double tolerance = toleranceCm * Acts::UnitConstants::cm; + + const auto surfaces = collectSensitive(geometry, gctx, std::max(tolerance, 1e-6)); + const int nSensors = cache.getSize(); + + LOGP(info, + "Matching {} {} sensors against {} sensitive ACTS surfaces ({} per-sensor, {} whole-layer) " + "by {} (tolerance {} cm)", + nSensors, cache.getDetID().getName(), surfaces.byCentre.size() + surfaces.layerSurfaces.size(), + surfaces.byCentre.size(), surfaces.layerSurfaces.size(), + pathProvider ? "TGeo node" : "placement", toleranceCm); + if (pathProvider && surfaces.nWithoutNode != 0) { + LOGP(warn, "{} sensitive surfaces have no TGeo detector element and cannot be matched by node", + surfaces.nWithoutNode); + } + + SurfaceIndexMap index; + std::size_t nUnmatched = 0; + std::size_t nAmbiguous = 0; + std::unordered_map claimedBy; + std::ostringstream failures; + int nReported = 0; + + for (int sensorID = 0; sensorID < nSensors; ++sensorID) { + const auto placement = sensorPlacementOf(cache, sensorID); + + bool ambiguous = false; + const Acts::Surface* match = + pathProvider + ? matchByNode(surfaces, pathProvider(sensorID), gctx, placement, tolerance, ambiguous) + : matchByPlacement(surfaces, gctx, placement, tolerance, ambiguous); + + if (match == nullptr) { + ambiguous ? ++nAmbiguous : ++nUnmatched; + if (nReported < 10) { + failures << "\n sensor " << sensorID << " at (" + << placement.position[0] / Acts::UnitConstants::cm << ", " + << placement.position[1] / Acts::UnitConstants::cm << ", " + << placement.position[2] / Acts::UnitConstants::cm << ") cm " + << (ambiguous ? "matches several sensitive ACTS surfaces" : "matches no sensitive ACTS surface"); + ++nReported; + } + continue; + } + + // One surface per sensor. Relaxing this hides exactly the failure the + // ambiguity check above is there to catch, so it is enforced. + const auto geoId = match->geometryId(); + const auto [it, inserted] = claimedBy.try_emplace(geoId, sensorID); + if (!inserted) { + throw std::runtime_error( + "o2::acts::makeSurfaceIndex: sensors " + std::to_string(it->second) + " and " + + std::to_string(sensorID) + " both resolve to the ACTS surface with geometry id " + + std::to_string(geoId.value()) + + ". They are indistinguishable by the information available, so the index would be wrong."); + } + index.add(sensorID, *match); + } + + if (nUnmatched != 0 || nAmbiguous != 0) { + std::string hint; + if (nAmbiguous != 0 && !pathProvider) { + hint = + " Pass a SensorPathProvider (e.g. GeometryTGeo::getMatrixPath) so sensors are identified by " + "their TGeo node instead of their placement."; + } + throw std::runtime_error("o2::acts::makeSurfaceIndex: of " + std::to_string(nSensors) + + " sensors, " + std::to_string(nUnmatched) + " matched no surface and " + + std::to_string(nAmbiguous) + " were ambiguous." + hint + failures.str()); + } + + LOGP(info, "Sensor index built: {} sensors on {} surfaces", index.size(), index.getNSurfaces()); + + return index; +} + +} // namespace o2::acts diff --git a/Detectors/ACTS/interface/src/TrackingGeometryManager.cxx b/Detectors/ACTS/interface/src/TrackingGeometryManager.cxx new file mode 100644 index 0000000000000..29a83210952d0 --- /dev/null +++ b/Detectors/ACTS/interface/src/TrackingGeometryManager.cxx @@ -0,0 +1,231 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file TrackingGeometryManager.cxx +/// \author Paolo Butti +/// + +#include "ACTSInterface/TrackingGeometryManager.h" + +#include +#include +#include + +#include +#include + +#include "Acts/Geometry/GeometryIdentifier.hpp" +#include "Acts/Geometry/TrackingVolume.hpp" +#include "Acts/Surfaces/Surface.hpp" +#include "ActsPlugins/Json/JsonMaterialDecorator.hpp" +#include "ActsPlugins/Json/MaterialMapJsonConverter.hpp" + +#include "CommonUtils/NameConf.h" +#include "DetectorsBase/GeometryManager.h" +#include "DetectorsCommonDataFormats/DetMatrixCache.h" +#include "Framework/Logger.h" + +namespace o2::acts +{ + +namespace +{ +/// Whether the file holds a geometry under one of the names +/// o2::base::GeometryManager::loadGeometry() accepts. +bool hasO2GeometryObject(std::string_view fileName) +{ + TFile file(std::string(fileName).c_str()); + if (file.IsZombie()) { + throw std::runtime_error("o2::acts::TrackingGeometryManager: cannot open '" + std::string(fileName) + "'"); + } + return file.GetKey(std::string(o2::base::NameConf::CCDBOBJECT).c_str()) != nullptr || + file.GetKey(std::string(o2::base::NameConf::GEOMOBJECTNAME_FAIR).c_str()) != nullptr; +} +} // namespace + +TrackingGeometryManager::TrackingGeometryManager() + : mNominalContext(Acts::GeometryContext::dangerouslyDefaultConstruct()) +{ +} + +TrackingGeometryManager& TrackingGeometryManager::instance() +{ + static TrackingGeometryManager inst; + return inst; +} + +void TrackingGeometryManager::setBuilder(std::unique_ptr builder) +{ + if (isBuilt()) { + throw std::runtime_error( + "o2::acts::TrackingGeometryManager: cannot change the builder after the geometry was built. " + "Call clear() first if this is really intended."); + } + mBuilder = std::move(builder); +} + +void TrackingGeometryManager::clear() +{ + // Order matters: the surfaces hold non-owning back-pointers into the element + // store, so the geometry must go first. + mGeometry.reset(); + mElementStore.clear(); + mIndex.clear(); + mDetIndices.clear(); + mNDecoratedSurfaces = 0; +} + +void TrackingGeometryManager::build() +{ + if (isBuilt()) { + LOG(info) << "ACTS tracking geometry is already built, nothing to do"; + return; + } + if (!o2::base::GeometryManager::isGeometryLoaded()) { + throw std::runtime_error( + "o2::acts::TrackingGeometryManager: no TGeo geometry is loaded. Declare " + "o2::base::GRPGeomRequest::Aligned in the workflow's GRPGeomRequest and call " + "GRPGeomHelper::instance().checkUpdates(pc) before asking for the ACTS geometry, or use " + "buildFromFile() outside a workflow."); + } + doBuild(); +} + +void TrackingGeometryManager::buildFromFile(std::string_view geomFileName) +{ + if (isBuilt()) { + LOG(info) << "ACTS tracking geometry is already built, nothing to do"; + return; + } + if (o2::base::GeometryManager::isGeometryLoaded()) { + LOG(warn) << "A TGeo geometry is already loaded, ignoring the requested file and using it"; + build(); + return; + } + + LOGP(info, "Loading TGeo geometry from '{}'", geomFileName); + if (hasO2GeometryObject(geomFileName)) { + o2::base::GeometryManager::loadGeometry(geomFileName); + } else { + // Not an o2-sim / CCDB geometry file. GeometryManager::loadGeometry() only + // accepts those two object names and aborts on anything else, so fall back to + // a plain import; this is the path for geometries exported from GDML, which + // the standalone actsO2 chain uses. + LOGP(warn, "'{}' holds no '{}' or '{}' object: importing it as a plain TGeo file", + geomFileName, o2::base::NameConf::CCDBOBJECT, o2::base::NameConf::GEOMOBJECTNAME_FAIR); + if (TGeoManager::Import(std::string(geomFileName).c_str()) == nullptr) { + throw std::runtime_error("o2::acts::TrackingGeometryManager: cannot import TGeo geometry from '" + + std::string(geomFileName) + "'"); + } + } + build(); +} + +void TrackingGeometryManager::doBuild() +{ + if (mBuilder == nullptr) { + throw std::runtime_error( + "o2::acts::TrackingGeometryManager: no builder installed. Call setBuilder() with the " + "detector-specific ITrackingGeometryBuilder before requesting the geometry."); + } + + LOGP(info, "Building the ACTS tracking geometry with the '{}' builder", mBuilder->getName()); + auto output = mBuilder->build(*gGeoManager, mNominalContext); + if (output.geometry == nullptr) { + throw std::runtime_error(std::string("o2::acts::TrackingGeometryManager: builder '") + + mBuilder->getName() + "' returned no geometry"); + } + + mElementStore = std::move(output.elementStore); + mIndex = std::move(output.index); + + if (!mMaterialMapFile.empty()) { + // Must happen while the geometry is still non-const: Gen3 construction takes + // no IMaterialDecorator, so material can only be injected afterwards. + applyMaterialMapImpl(*output.geometry); + } + + mGeometry = std::const_pointer_cast(output.geometry); + + LOGP(info, "ACTS tracking geometry ready: {} mapped sensors, {} surfaces with material", + mIndex.size(), mNDecoratedSurfaces); +} + +void TrackingGeometryManager::applyMaterialMapImpl(Acts::TrackingGeometry& geometry) +{ + if (!std::filesystem::exists(mMaterialMapFile)) { + throw std::runtime_error("o2::acts::TrackingGeometryManager: material map '" + mMaterialMapFile + + "' does not exist"); + } + + Acts::MaterialMapJsonConverter::Config converterConfig; + converterConfig.context = mNominalContext; + const Acts::JsonMaterialDecorator decorator(converterConfig, mMaterialMapFile, mLogLevel); + + mNDecoratedSurfaces = 0; + geometry.apply([&](Acts::Surface& surface) { + decorator.decorate(surface); + if (surface.surfaceMaterial() != nullptr) { + ++mNDecoratedSurfaces; + } + }); + + if (mNDecoratedSurfaces == 0) { + // Material maps are keyed on Acts::GeometryIdentifier, which is assigned during + // construction. Any structural change to the builder shifts those ids and would + // otherwise silently yield a material-free geometry. + throw std::runtime_error( + "o2::acts::TrackingGeometryManager: material map '" + mMaterialMapFile + + "' matched no surface. It was produced for a different geometry structure and must be " + "regenerated for this one."); + } +} + +std::shared_ptr TrackingGeometryManager::getShared() +{ + if (!isBuilt()) { + build(); + } + return mGeometry; +} + +const Acts::TrackingGeometry& TrackingGeometryManager::get() +{ + return *getShared(); +} + +const SurfaceIndexMap& TrackingGeometryManager::getIndex() +{ + if (!isBuilt()) { + build(); + } + return mIndex; +} + +const SurfaceIndexMap& TrackingGeometryManager::getIndex(const o2::detectors::DetMatrixCache& cache, + double toleranceCm, + SensorPathProvider pathProvider) +{ + if (!isBuilt()) { + build(); + } + const int detID = cache.getDetID(); + const auto it = mDetIndices.find(detID); + if (it != mDetIndices.end()) { + return it->second; + } + auto [inserted, ok] = mDetIndices.emplace( + detID, makeSurfaceIndex(*mGeometry, mNominalContext, cache, toleranceCm, std::move(pathProvider))); + return inserted->second; +} + +} // namespace o2::acts diff --git a/Detectors/ACTS/workflow/CMakeLists.txt b/Detectors/ACTS/workflow/CMakeLists.txt new file mode 100644 index 0000000000000..34c9182cf57f4 --- /dev/null +++ b/Detectors/ACTS/workflow/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +o2_add_library(ACTSWorkflow + SOURCES src/ActsGeometryService.cxx + PUBLIC_LINK_LIBRARIES O2::ACTSInterface + O2::Framework) diff --git a/Detectors/ACTS/workflow/include/ACTSWorkflow/ActsGeometryService.h b/Detectors/ACTS/workflow/include/ACTSWorkflow/ActsGeometryService.h new file mode 100644 index 0000000000000..020fadf2349c8 --- /dev/null +++ b/Detectors/ACTS/workflow/include/ACTSWorkflow/ActsGeometryService.h @@ -0,0 +1,76 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file ActsGeometryService.h +/// \brief DPL service exposing the ACTS tracking geometry through the ServiceRegistry +/// \author Paolo Butti +/// + +#ifndef ALICEO2_ACTS_ACTSGEOMETRYSERVICE_H +#define ALICEO2_ACTS_ACTSGEOMETRYSERVICE_H + +#include +#include + +#include "Acts/Geometry/TrackingGeometry.hpp" + +#include "ACTSInterface/SurfaceIndexMap.h" +#include "ACTSInterface/TrackingGeometryManager.h" +#include "Framework/ServiceSpec.h" + +namespace o2::acts +{ + +/// Registry-facing handle on the ACTS tracking geometry. +/// +/// This is a thin shim over TrackingGeometryManager: both access paths hand out +/// the very same geometry object, so a workflow can mix them freely. What the +/// service adds is DPL-owned lifetime (the geometry is released on device exit +/// rather than at static destruction) and retrieval through the registry: +/// +/// \code +/// auto& actsGeo = pc.services().get(); +/// const auto& tg = actsGeo.getGeometry(); +/// \endcode +/// +/// Registered with ServiceKind::DeviceGlobal: one instance per device process, +/// shared by every data processor in it, and safe to read from several threads +/// once built. +class ActsGeometryService +{ + public: + /// Builds on first use. Requires gGeoManager to be live and a builder to have + /// been installed on TrackingGeometryManager. + const Acts::TrackingGeometry& getGeometry() const; + std::shared_ptr getGeometryShared() const; + + const SurfaceIndexMap& getIndex() const; + + const Acts::GeometryContext& getNominalContext() const; + + bool isBuilt() const; + + /// Escape hatch for configuration (builder, material map) from init(). + static TrackingGeometryManager& getManager() { return TrackingGeometryManager::instance(); } +}; + +/// ServiceSpec for ActsGeometryService, to be put into +/// DataProcessorSpec::requiredServices. +o2::framework::ServiceSpec actsGeometryServiceSpec(); + +/// CommonServices::defaultServices() plus the ACTS geometry service. Use as the +/// requiredServices of a DataProcessorSpec that wants registry access. +std::vector defaultServicesWithActsGeometry(); + +} // namespace o2::acts + +#endif // ALICEO2_ACTS_ACTSGEOMETRYSERVICE_H diff --git a/Detectors/ACTS/workflow/src/ActsGeometryService.cxx b/Detectors/ACTS/workflow/src/ActsGeometryService.cxx new file mode 100644 index 0000000000000..2ff3e5475fa4b --- /dev/null +++ b/Detectors/ACTS/workflow/src/ActsGeometryService.cxx @@ -0,0 +1,80 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file ActsGeometryService.cxx +/// \author Paolo Butti +/// + +#include "ACTSWorkflow/ActsGeometryService.h" + +#include "Framework/CommonServices.h" +#include "Framework/Logger.h" +#include "Framework/ServiceHandle.h" +#include "Framework/ServiceRegistryRef.h" +#include "Framework/TypeIdHelpers.h" + +using namespace o2::framework; + +namespace o2::acts +{ + +const Acts::TrackingGeometry& ActsGeometryService::getGeometry() const +{ + return TrackingGeometryManager::instance().get(); +} + +std::shared_ptr ActsGeometryService::getGeometryShared() const +{ + return TrackingGeometryManager::instance().getShared(); +} + +const SurfaceIndexMap& ActsGeometryService::getIndex() const +{ + return TrackingGeometryManager::instance().getIndex(); +} + +const Acts::GeometryContext& ActsGeometryService::getNominalContext() const +{ + return TrackingGeometryManager::instance().getNominalContext(); +} + +bool ActsGeometryService::isBuilt() const +{ + return TrackingGeometryManager::instance().isBuilt(); +} + +ServiceSpec actsGeometryServiceSpec() +{ + return ServiceSpec{ + .name = "acts-geometry", + .init = [](ServiceRegistryRef, DeviceState&, fair::mq::ProgOptions&) -> ServiceHandle { + // Nothing is built here: the TGeo geometry is not available yet at service + // init time. The first getGeometry() call, from run(), triggers the build. + return ServiceHandle{TypeIdHelpers::uniqueId(), new ActsGeometryService(), + ServiceKind::DeviceGlobal, "acts-geometry"}; + }, + .configure = CommonServices::noConfiguration(), + .exit = [](ServiceRegistryRef, void* service) { + TrackingGeometryManager::instance().clear(); + delete reinterpret_cast(service); + }, + .kind = ServiceKind::DeviceGlobal}; +} + +std::vector defaultServicesWithActsGeometry() +{ + auto services = CommonServices::defaultServices(); + services.push_back(actsGeometryServiceSpec()); + return services; +} + +} // namespace o2::acts diff --git a/Detectors/CMakeLists.txt b/Detectors/CMakeLists.txt index 09e784b0338ee..9d2ce93b7561e 100644 --- a/Detectors/CMakeLists.txt +++ b/Detectors/CMakeLists.txt @@ -64,6 +64,8 @@ endif() o2_data_file(COPY Geometry DESTINATION Detectors) +add_subdirectory(ACTS) + if(ENABLE_UPGRADES) add_subdirectory(Upgrades) else() diff --git a/Detectors/Upgrades/ALICE3/ACTS/CMakeLists.txt b/Detectors/Upgrades/ALICE3/ACTS/CMakeLists.txt new file mode 100644 index 0000000000000..bb3209e3d59c5 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# Concrete ALICE 3 builder for the detector-agnostic O2::ACTSInterface. +# Skipped, like that package, when ACTS is not available. +if(NOT Acts_FOUND) + return() +endif() + +o2_add_library(ALICE3ACTS + SOURCES src/Gen3BlueprintBuilder.cxx + src/Gen3GeometryConfig.cxx + PUBLIC_LINK_LIBRARIES O2::ACTSInterface + nlohmann_json::nlohmann_json) + +add_subdirectory(macros) + +# Geometry description consumed at runtime by Gen3BlueprintBuilder. Installed to +# $O2_ROOT/share/Detectors/Upgrades/ALICE3/ACTS/config/ +o2_data_file(COPY config DESTINATION Detectors/Upgrades/ALICE3/ACTS) diff --git a/Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json b/Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json new file mode 100644 index 0000000000000..7d6afb73339c4 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json @@ -0,0 +1,146 @@ +{ + "_description": "Gen3 ACTS tracking-geometry description for the ALICE 3 layout produced by Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh (TRKBase.layoutVD=kIRISFullCyl, TRKBase.layoutMLOT=kSimplifiedRealistic, FT3Base.layoutFT3=kSegmentedStave, segmented barrel TOFs, endcap TOFs off). All lengths in mm. Originally from actsO2 geometries/geometry_2026_08_06_realistic_OT_with_eosCards_4percBarrel_4percDiscs_Gen3; verified against an o2-sim geometry of the same layout, which yields an identical volume/layer/surface table.", + "_comment": "ALICE 3 Gen3 (Blueprint) geometry configuration. Loaded by buildALICE3GeometryGen3. Copy this file next to o2sim_geometry.root (default name gen3_geometry_config.json) or pass its path as configFile. All lengths in MILLIMETRES.", + "sensitiveMatches": [ + "*TRKSensor*", + "*ITOFSensor*", + "*OTOFSensor*", + "*FT3Sensor_Active*" + ], + "endOfStaveMatches": [ + "*EOSCard*" + ], + "endOfStaveRTol": 20.0, + "axesThinZ": "XYZ", + "axesThinY": "ZXY", + "rInnerCoreMax": 390.0, + "rMainMax": 821.0, + "zCentralMax": 1427.0, + "zMainMax": 2500.0, + "tolVertexDetector": 5.0, + "tolTrkBarrel": 15.0, + "tolItof": 5.0, + "tolOtof": 15.0, + "tolFt3Disc": 20.0, + "fwdDiscRMax": 690.0, + "matBinsPhi": 40, + "matBinsZ": 160, + "matBinsR": 20, + "passiveCylinders": [ + { + "name": "VD_OuterWall", + "r": 48.1, + "halfZ": 350.0, + "zCentre": 0.0 + }, + { + "name": "TRK_MID_CarbonSupport", + "r": 394.0, + "halfZ": 1420.0, + "zCentre": 0.0 + }, + { + "name": "TRK_OT_CarbonSupport", + "r": 822.0, + "halfZ": 1400.0, + "zCentre": 0.0 + } + ], + "forwardCylinders": [ + { + "name": "TRK_OuterDisks_Services", + "r": 707.8, + "zMin": 1490.0, + "zMax": 3500.0 + }, + { + "name": "TRK_MIDFWD_Services", + "r": 748.0, + "zMin": 1435.0, + "zMax": 3500.0 + }, + { + "name": "TRK_OuterBarrel_Services", + "r": 787.7, + "zMin": 1429.0, + "zMax": 3478.0 + } + ], + "passiveDiscs": [ + { + "name": "TRK_OUTERBARREL_Services_neg", + "z": -1427.0, + "rMin": 450.0, + "rMax": 690.0 + }, + { + "name": "TRK_OUTERBARREL_Services_pos", + "z": 1427.0, + "rMin": 450.0, + "rMax": 690.0 + }, + { + "name": "TRK_MIDBARFWD_Services_neg", + "z": -1431.0, + "rMin": 385.0, + "rMax": 690.0 + }, + { + "name": "TRK_MIDBARFWD_Services_pos", + "z": 1431.0, + "rMin": 385.0, + "rMax": 690.0 + }, + { + "name": "TRK_MIDBARCON_Services_pos", + "z": 650.0, + "rMin": 57.0, + "rMax": 390.0 + }, + { + "name": "EoS_card_disk0_pos", + "z": 835.0, + "rMin": 300.0, + "rMax": 365.0 + }, + { + "name": "EoS_card_disk1_pos", + "z": 1065.0, + "rMin": 300.0, + "rMax": 365.0 + }, + { + "name": "EoS_card_disk2_pos", + "z": 1285.0, + "rMin": 300.0, + "rMax": 365.0 + }, + { + "name": "EoS_card_disk0_neg", + "z": -835.0, + "rMin": 300.0, + "rMax": 365.0 + }, + { + "name": "EoS_card_disk1_neg", + "z": -1065.0, + "rMin": 300.0, + "rMax": 365.0 + }, + { + "name": "EoS_card_disk2_neg", + "z": -1285.0, + "rMin": 300.0, + "rMax": 365.0 + } + ], + "volumeIds": { + "fwdNeg": 20, + "ft3InnerNeg": 30, + "innerBarrel": 40, + "outerTrackerBarrel": 50, + "ft3InnerPos": 60, + "fwdPos": 70, + "otof": 90 + } +} diff --git a/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3BlueprintBuilder.h b/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3BlueprintBuilder.h new file mode 100644 index 0000000000000..8b04225ee10c8 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3BlueprintBuilder.h @@ -0,0 +1,77 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file Gen3BlueprintBuilder.h +/// \brief ALICE 3 Gen3 ("Blueprint") ACTS tracking geometry builder +/// \author Paolo Butti +/// +/// Ported from actsO2 (ActsAlgorithms/Geometry/src/ALICE3Gen3Geometry.cpp). +/// The construction logic is unchanged, so the volume/layer identifiers it +/// produces stay compatible with the material maps, digitisation and seeding +/// JSON files of the standalone actsO2 chain. The differences are: +/// +/// - it reads a TGeoManager handed in by the caller (normally O2's live +/// gGeoManager) instead of importing a ROOT file itself; +/// - the process-lifetime static stores of the standalone version are member +/// state, so several builders can coexist in one process. +/// + +#ifndef ALICEO2_ALICE3_GEN3BLUEPRINTBUILDER_H +#define ALICEO2_ALICE3_GEN3BLUEPRINTBUILDER_H + +#include + +#include "Acts/Utilities/Logger.hpp" + +#include "ACTSInterface/ITrackingGeometryBuilder.h" + +namespace o2::alice3 +{ + +/// Builds the ALICE 3 tracking geometry (TRK vertex detector and barrel, FT3 +/// discs, inner and outer TOF) with the ACTS Gen3 Blueprint API. +class Gen3BlueprintBuilder : public o2::acts::ITrackingGeometryBuilder +{ + public: + struct Config { + /// Path to gen3_geometry_config.json. All geometry-specific numbers (sensor + /// name globs, region boundaries, clustering tolerances, passive structures, + /// pinned volume IDs) come from there; see Gen3GeometryConfig.h. + std::string geometryConfigFile; + + /// Build the passive layers and attach proto material. Must match what was + /// used when the material map was produced, otherwise the geometry + /// identifiers shift and the map no longer applies. + bool withMaterial = true; + + /// Optional path for a graphviz dump of the blueprint tree. + std::string graphvizFile; + + Acts::Logging::Level logLevel = Acts::Logging::INFO; + }; + + explicit Gen3BlueprintBuilder(Config config); + + o2::acts::TrackingGeometryOutput build(TGeoManager& tgeo, + const Acts::GeometryContext& gctx) override; + + const char* getName() const override { return "alice3-gen3"; } + + const Config& getConfig() const { return mConfig; } + + private: + Config mConfig; +}; + +} // namespace o2::alice3 + +#endif // ALICEO2_ALICE3_GEN3BLUEPRINTBUILDER_H diff --git a/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3GeometryConfig.h b/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3GeometryConfig.h new file mode 100644 index 0000000000000..2701c793b6cb4 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/include/ALICE3ACTS/Gen3GeometryConfig.h @@ -0,0 +1,120 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file Gen3GeometryConfig.h +/// \brief Geometry-specific data for the ALICE 3 Gen3 ACTS blueprint builder +/// \author Paolo Butti +/// +/// Ported from actsO2 (ActsAlgorithms/Geometry/include/Gen3GeometryConfig.hpp). +/// +/// Runtime geometry-specific parameters for Gen3BlueprintBuilder, loaded from a +/// JSON file instead of being hard-coded. The construction logic stays in the +/// builder; this struct only carries DATA measured from the GDML. +/// +/// All lengths in MILLIMETRES - the ACTS base unit, so the builder uses these +/// values directly (1_mm == 1.0). To target a different geometry, supply a +/// different JSON file; see loadGen3GeometryConfig() and the example +/// gen3_geometry_config.json shipped next to each geometry. +/// + +#ifndef ALICEO2_ALICE3_GEN3GEOMETRYCONFIG_H +#define ALICEO2_ALICE3_GEN3GEOMETRYCONFIG_H + +#include +#include +#include +#include + +namespace o2::alice3::gen3cfg +{ + +// Barrel-like passive cylinder, centred on z = zCentre (usually 0). +struct PassiveCylinderCfg { + std::string name; + double r = 0.; + double halfZ = 0.; + double zCentre = 0.; +}; + +// Forward service shell: a cylinder present only at large |z|. zMin/zMax are +// |z| ranges; the builder mirrors it to both sides. +struct ForwardCylinderCfg { + std::string name; + double r = 0.; + double zMin = 0.; + double zMax = 0.; +}; + +// Passive service disc at a fixed z, spanning [rMin, rMax]. +struct PassiveDiscCfg { + std::string name; + double z = 0.; + double rMin = 0.; + double rMax = 0.; +}; + +/// Complete geometry description. One instance per geometry, loaded from JSON. +struct Gen3GeometryConfig { + // Sensor / end-of-stave TGeo volume-name globs. + std::vector sensitiveMatches; + std::vector endOfStaveMatches; + double endOfStaveRTol = 0.; // mm, radial match window + + // Planar-sensor axis strings (parsed into TGeoAxes at runtime). + std::string axesThinZ; // e.g. "XYZ" (thin in Z) + std::string axesThinY; // e.g. "ZXY" (thin in Y) + + // Blueprint region boundaries (mm). + double rInnerCoreMax = 0.; + double rMainMax = 0.; + double zCentralMax = 0.; + double zMainMax = 0.; + + // Per-subsystem clustering tolerances (mm). + double tolVertexDetector = 0.; + double tolTrkBarrel = 0.; + double tolItof = 0.; + double tolOtof = 0.; + double tolFt3Disc = 0.; + + // Forward-disc rMax clip (mm). Informational: the passive-disc rMax values + // below already carry the clipped number. + double fwdDiscRMax = 0.; + + // Proto-material (material receiver) binning on the layer faces. + std::size_t matBinsPhi = 0; + std::size_t matBinsZ = 0; + std::size_t matBinsR = 0; + + // Passive material structures (flexible, geometry-dependent sets). + std::vector passiveCylinders; + std::vector forwardCylinders; + std::vector passiveDiscs; + + // Pinned volume IDs, one per region. + std::uint64_t volFwdNeg = 0; + std::uint64_t volFt3InnerNeg = 0; + std::uint64_t volInnerBarrel = 0; + std::uint64_t volOuterTrackerBarrel = 0; + std::uint64_t volFt3InnerPos = 0; + std::uint64_t volFwdPos = 0; + std::uint64_t volOtof = 0; +}; + +/// Load the Gen3 geometry configuration from a JSON file. +/// Throws std::runtime_error if the file cannot be opened/parsed or if any +/// required field is missing (the JSON is the single source of truth). +Gen3GeometryConfig loadGen3GeometryConfig(const std::string& jsonPath); + +} // namespace o2::alice3::gen3cfg + +#endif // ALICEO2_ALICE3_GEN3GEOMETRYCONFIG_H diff --git a/Detectors/Upgrades/ALICE3/ACTS/macros/CMakeLists.txt b/Detectors/Upgrades/ALICE3/ACTS/macros/CMakeLists.txt new file mode 100644 index 0000000000000..a15c69d108f68 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/macros/CMakeLists.txt @@ -0,0 +1,21 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# Compile-only: running it needs an o2sim geometry file, which does not exist at +# build time. When ACTS is missing this directory is not added at all, so the +# macro is exempted from the "every macro must be tested" check in +# cmake/O2RootMacroExclusionList.cmake. +o2_add_test_root_macro(CheckActsTrackingGeometry.C + PUBLIC_LINK_LIBRARIES O2::ACTSInterface + O2::ALICE3ACTS + O2::TRKBase + LABELS acts upgrades + COMPILE_ONLY) diff --git a/Detectors/Upgrades/ALICE3/ACTS/macros/CheckActsTrackingGeometry.C b/Detectors/Upgrades/ALICE3/ACTS/macros/CheckActsTrackingGeometry.C new file mode 100644 index 0000000000000..5641781e43f03 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/macros/CheckActsTrackingGeometry.C @@ -0,0 +1,135 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file CheckActsTrackingGeometry.C +/// \brief Build the ACTS tracking geometry from a TGeo file and check it against O2 +/// \author Paolo Butti +/// +/// Standalone check, outside any workflow. It +/// 1. loads the TGeo geometry through o2::base::GeometryManager, +/// 2. builds the ACTS tracking geometry with the ALICE 3 Gen3 builder, +/// 3. prints the volume/layer/surface table -- the authority for the +/// (volume, layer) keys the digitisation and seeding JSON files use, +/// 4. locates every O2 TRK chip on an ACTS surface and, for the chips that own +/// their surface, compares the surface centre with the chip origin. That +/// validates the whole TGeo -> ACTS transform chain including the cm -> mm +/// scaling. +/// +/// Usage: +/// \code +/// root -l -q 'CheckActsTrackingGeometry.C+("o2sim_geometry-aligned.root", \ +/// "gen3_geometry_config.json")' +/// \endcode + +#if !defined(__CLING__) || defined(__ROOTCLING__) + +#include +#include +#include + +#include + +#include "Acts/Definitions/Units.hpp" +#include "Acts/Geometry/TrackingGeometry.hpp" +#include "Acts/Surfaces/Surface.hpp" + +#include "ACTSInterface/SurfaceIndexMap.h" +#include "ACTSInterface/TrackingGeometryManager.h" +#include "ALICE3ACTS/Gen3BlueprintBuilder.h" +#include "DetectorsBase/GeometryManager.h" +#include "MathUtils/Utils.h" +#include "TRKBase/GeometryTGeo.h" + +#endif + +int CheckActsTrackingGeometry(const std::string& geomFile = "o2sim_geometry-aligned.root", + const std::string& gen3ConfigFile = "gen3_geometry_config.json", + const std::string& materialMapFile = "", + double toleranceCm = 1e-3) +{ + auto& manager = o2::acts::TrackingGeometryManager::instance(); + + o2::alice3::Gen3BlueprintBuilder::Config builderConfig; + builderConfig.geometryConfigFile = gen3ConfigFile; + builderConfig.withMaterial = true; + manager.setBuilder(std::make_unique(builderConfig)); + if (!materialMapFile.empty()) { + manager.setMaterialMapFile(materialMapFile); + } + + manager.buildFromFile(geomFile); + const auto& geometry = manager.get(); + + // The TRK geometry helper needs its local-to-global matrices for the check below. + auto* trkGeo = o2::trk::GeometryTGeo::Instance(); + trkGeo->fillMatrixCache(o2::math_utils::bit2Mask(o2::math_utils::TransformType::L2G)); + + // Identify the chips by their TGeo node rather than by position: the vertex + // detector petals are tube segments, so all three layers of a petal share both + // origin and rotation and a placement-only match cannot separate them. + const auto& index = manager.getIndex(*trkGeo, toleranceCm, [trkGeo](int chipID) { + return std::string(trkGeo->getMatrixPath(chipID).Data()); + }); + + std::printf("\n--- ACTS <-> O2 sensor check ---\n"); + std::printf("TRK chips : %d\n", trkGeo->getNumberOfChips()); + std::printf("mapped to ACTS surfaces: %zu (on %zu surfaces)\n", index.size(), index.getNSurfaces()); + if (static_cast(index.size()) != trkGeo->getNumberOfChips()) { + std::printf("FAILED: not every chip is mapped\n"); + return 1; + } + + // Compare the ACTS surface centre with the chip origin, for the chips that own + // their surface. Chips sitting on a whole-layer cylinder or disc (the vertex + // detector petals) are skipped: that surface's centre is on the beam axis, not + // at the chip. makeSurfaceIndex() already enforced the tolerance, so this only + // reports the accuracy actually achieved. + double maxResidualCm = 0.; + int worstChip = -1; + int nPerSensor = 0; + int nOnLayerSurface = 0; + for (int chipID = 0; chipID < trkGeo->getNumberOfChips(); ++chipID) { + const auto* surface = index.getSurface(chipID); + if (surface == nullptr) { + std::printf("FAILED: chip %d has no surface\n", chipID); + return 1; + } + if (surface->type() == Acts::Surface::SurfaceType::Cylinder || + surface->type() == Acts::Surface::SurfaceType::Disc) { + ++nOnLayerSurface; + continue; + } + ++nPerSensor; + double rot[9] = {0.}; + double tra[3] = {0.}; + trkGeo->getMatrixL2G(chipID).GetComponents(rot[0], rot[1], rot[2], tra[0], + rot[3], rot[4], rot[5], tra[1], + rot[6], rot[7], rot[8], tra[2]); + const auto centre = surface->center(manager.getNominalContext()); + const double residual = std::hypot(centre[0] / Acts::UnitConstants::cm - tra[0], + centre[1] / Acts::UnitConstants::cm - tra[1], + centre[2] / Acts::UnitConstants::cm - tra[2]); + if (residual > maxResidualCm) { + maxResidualCm = residual; + worstChip = chipID; + } + } + std::printf("chips on their own surface : %d\n", nPerSensor); + std::printf("chips on a layer surface : %d\n", nOnLayerSurface); + std::printf("max |surface centre - chip origin| : %.3e cm (chip %d)\n", maxResidualCm, worstChip); + + std::printf("surfaces with material : %zu\n", manager.getNDecoratedSurfaces()); + std::printf("geometry version : %s\n", + geometry.geometryVersion() == Acts::TrackingGeometry::GeometryVersion::Gen3 ? "Gen3" : "Gen1"); + std::printf("--- OK ---\n"); + return 0; +} diff --git a/Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh b/Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh new file mode 100755 index 0000000000000..7a54162e4dd3b --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/macros/run_test.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# Generate an ALICE 3 geometry with o2-sim and build the ACTS tracking geometry +# from it, then check every TRK chip against its ACTS surface. +# +# Run it from a scratch directory - o2-sim writes its output into $PWD: +# mkdir -p /tmp/acts && cd /tmp/acts +# $O2_ROOT/share/macro/... or simply: bash /run_test.sh +# +# Requires an environment with O2 and ACTS (e.g. alienv enter O2/latest). + +set -o errexit +set -o pipefail + +nEvents=${nEvents:-1} +generator=${generator:-pythia8hi} +modules=${modules:-"A3IP TRK FT3 TF3"} + +# Detector layouts. This is the configuration the ACTS chain is developed +# against: a fully cylindrical vertex detector (as in the standalone actsO2 +# geometries), simplified-realistic ML/OT, segmented FT3 staves and segmented +# barrel TOFs with the endcap TOFs switched off. +layout="TRKBase.layoutVD=kIRISFullCyl" +layout+=";TRKBase.layoutMLOT=kSimplifiedRealistic" +layout+=";FT3Base.layoutFT3=kSegmentedStave" +layout+=";IOTOFBase.segmentedInnerTOF=true" +layout+=";IOTOFBase.segmentedOuterTOF=true" +layout+=";IOTOFBase.enableBackwardTOF=false" +layout+=";IOTOFBase.enableForwardTOF=false" + +# o2-sim ends by fetching alignment from CCDB, which needs a valid alien token +# and aborts without one - after the geometry file has already been written. +# Skip it: the ACTS geometry is built from the ideal geometry anyway. Set +# SKIP_ALIGNMENT=0 if you do want the alignment step. +if [ "${SKIP_ALIGNMENT:-1}" = "1" ]; then + layout+=";align-geom.mDetectors=none" +fi + +echo "=== o2-sim: generating the ALICE 3 geometry ===" +o2-sim-serial-run5 -n "${nEvents}" -g "${generator}" -m ${modules} \ + --configKeyValues "${layout}" 2>&1 | tee sim_alice3.log + +macroDir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +# The Gen3 blueprint builder needs the geometry description that goes with the +# geometry file. The one shipped with this package matches the layout above; set +# GEN3_CONFIG to use a different geometry's gen3_geometry_config.json. +if [ -n "${GEN3_CONFIG}" ]; then + gen3Config=${GEN3_CONFIG} +elif [ -f "${O2_ROOT}/share/Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json" ]; then + gen3Config="${O2_ROOT}/share/Detectors/Upgrades/ALICE3/ACTS/config/gen3_geometry_config.json" +elif [ -f "${macroDir}/../config/gen3_geometry_config.json" ]; then + gen3Config="${macroDir}/../config/gen3_geometry_config.json" # running from a source checkout +else + gen3Config=gen3_geometry_config.json +fi +if [ ! -f "${gen3Config}" ]; then + echo "ERROR: no Gen3 geometry config at '${gen3Config}'." >&2 + echo " Set GEN3_CONFIG to the gen3_geometry_config.json for this geometry." >&2 + exit 1 +fi +echo "Using Gen3 geometry config: ${gen3Config}" + +# The macro includes ACTS and O2 headers that are not on ROOT's search path by +# default. +export ROOT_INCLUDE_PATH="${ACTS_ROOT}/include:${EIGEN3_ROOT}/include/eigen3:${O2_ROOT}/include:${ROOT_INCLUDE_PATH}" + +# Compile the macro here rather than in place: ACLiC drops its .so and dictionary +# next to the macro, which would litter the source tree. +cp "${macroDir}/CheckActsTrackingGeometry.C" . + +echo "=== ACTS: building the tracking geometry and checking the sensor index ===" +root.exe -b -q "CheckActsTrackingGeometry.C+(\"o2sim_geometry.root\",\"${gen3Config}\")" \ + 2>&1 | tee CheckActsTrackingGeometry.log diff --git a/Detectors/Upgrades/ALICE3/ACTS/src/Gen3BlueprintBuilder.cxx b/Detectors/Upgrades/ALICE3/ACTS/src/Gen3BlueprintBuilder.cxx new file mode 100644 index 0000000000000..b5bd66142c904 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/src/Gen3BlueprintBuilder.cxx @@ -0,0 +1,994 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file Gen3BlueprintBuilder.cxx +/// \author Paolo Butti +/// +/// Ported from actsO2 (ActsAlgorithms/Geometry/src/ALICE3Gen3Geometry.cpp). +/// +/// Gen3 ("Blueprint") tracking geometry for ALICE 3. Geometry-specific numbers +/// live in the JSON loaded into Gen3GeometryConfig; this file is construction +/// logic. +/// +/// Stacking rule: Gen3 volume stacks only expand (R-stack -> common z halfZ, +/// Z-stack -> common radius), so children must not overlap along the stack axis. +/// A flat [negEC | barrel | posEC] split fails because OTOF (halfZ 3400) would +/// pad the barrel out to |z|<3400 and swallow the FT3 discs. Layers are instead +/// separated radially where they overlap in z, and by z where they overlap in r. +/// Gap volumes between children are inserted automatically. +/// +/// Core R-stack (|z| <= 3400) volume ID +/// |- Main Z-stack (r <~ 860) +/// | |- FwdNeg R-stack FT3 outer discs + fwd shells 20 +/// | |- Central R-stack (|z| <= 1283.5) +/// | | |- InnerCore Z-stack (r <~ 400) +/// | | | |- MiddleDisksNeg FT3 inner discs 30 +/// | | | |- InnerBarrel R-stack vertex + TRK(70..300) + ITOF 40 +/// | | | |- MiddleDisksPos FT3 inner discs 60 +/// | | |- OuterTrackerBarrel R-stack TRK 450,600,800 50 +/// | |- FwdPos R-stack 70 +/// |- OTOF cylinder r = 920 90 +/// +/// Volume IDs are pinned per region (config volumeIds), layer IDs increment from +/// 1 within each. Layers are discovered by clustering sensor positions, per +/// subsystem (see Subsystem / clusterBy) - staggered TRK rows and FT3 double +/// planes merge into one layer each. +/// + +#include "ALICE3ACTS/Gen3BlueprintBuilder.h" + +// Geometry-specific parameters (radii, z, tolerances, sensor names, axes, +// volume IDs, passive cylinders/discs) are loaded at runtime from a JSON file +// into a Gen3GeometryConfig. Supply a different JSON to target a different +// geometry - no recompilation needed. +#include "ALICE3ACTS/Gen3GeometryConfig.h" + +#include "Acts/Definitions/Units.hpp" +#include "Acts/Geometry/Blueprint.hpp" +#include "Acts/Geometry/BlueprintOptions.hpp" +#include "Acts/Geometry/ContainerBlueprintNode.hpp" +#include "Acts/Geometry/GeometryContext.hpp" +#include "Acts/Geometry/CylinderVolumeBounds.hpp" +#include "Acts/Geometry/GeometryIdentifierBlueprintNode.hpp" +#include "Acts/Geometry/LayerBlueprintNode.hpp" +#include "Acts/Geometry/TrackingGeometry.hpp" +#include "Acts/Geometry/TrackingVolume.hpp" +#include "Acts/Material/ProtoSurfaceMaterial.hpp" +#include "Acts/Utilities/BinUtility.hpp" +#include "Acts/Utilities/BinningType.hpp" +#include "Acts/Geometry/VolumeAttachmentStrategy.hpp" +#include "Acts/Geometry/VolumeResizeStrategy.hpp" +#include "Acts/Surfaces/CylinderBounds.hpp" +#include "Acts/Surfaces/CylinderSurface.hpp" +#include "Acts/Surfaces/DiscBounds.hpp" +#include "Acts/Surfaces/DiscSurface.hpp" +#include "Acts/Surfaces/Surface.hpp" +#include "Acts/Utilities/AxisDefinitions.hpp" +#include "Acts/Utilities/ProtoAxis.hpp" +#include "ActsPlugins/Root/TGeoAxes.hpp" +#include "ActsPlugins/Root/TGeoDetectorElement.hpp" +#include "ActsPlugins/Root/TGeoLayerBuilder.hpp" +#include "ActsPlugins/Root/TGeoParser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TGeoBBox.h" +#include "TGeoManager.h" +#include "TGeoMatrix.h" +#include "TGeoNode.h" +#include "TGeoVolume.h" + +namespace o2::alice3 +{ + +using namespace Acts; +// The Blueprint API (Blueprint, BlueprintNode, ContainerBlueprintNode, +// LayerBlueprintNode, GeometryIdentifierBlueprintNode) lives in +// Acts::Experimental in the ACTS version O2 builds against, and was later moved +// up into Acts, leaving deprecated aliases behind. Pulling in both namespaces +// keeps the unqualified names below resolving either way -- the alias and the +// class denote the same entity, so the lookup is not ambiguous. +using namespace Acts::Experimental; +using namespace Acts::UnitLiterals; +using namespace o2::alice3::gen3cfg; + +namespace +{ + +// -------------------------------------------------------------------------- +// build state +// -------------------------------------------------------------------------- +/// Per-build state threaded through the helpers below. +/// +/// NOTE (O2 port): the standalone actsO2 builder kept the config, the detector +/// elements and the passive surfaces in function-local statics, which made it +/// non-reentrant and tied their lifetime to the process. Inside a DPL device +/// that is not acceptable, so they live here and are owned by the caller. +struct BuildState { + const Gen3GeometryConfig& cfg; + std::vector>& passiveSurfaces; +}; + +// Generic knobs (not geometry-specific). +constexpr double kUnitScalor = 10.0; // TGeo cm -> ACTS mm + +const ExtentEnvelope kLayerEnvelope = + ExtentEnvelope{{.z = {2. * 1_mm, 2. * 1_mm}, .r = {2. * 1_mm, 2. * 1_mm}}}; + +// binning of the proto-material (material receiver) on the layer faces is +// geometry-config data: see cfg.matBinsPhi / matBinsZ / matBinsR. + +// -------------------------------------------------------------------------- +// surface loading + grouping +// -------------------------------------------------------------------------- +// Non-const: setSurfaces() needs the non-const surface() overload (the const +// one yields shared_ptr, which will not convert). +using ElementPtr = std::shared_ptr; + +/// One reconstruction layer: surfaces at a common radius (cylinder) or z (disc) +struct LayerGroup { + bool isDisc = false; + double key = 0.; // radius (cylinder) or z (disc), in mm + std::vector> surfaces; + std::string name; // set for passive layers; sensitive ones are auto-named + bool isPassive = false; + // Extra material z-extent from attached End-of-Stave cards; + // sentinels (max < min) mean "none". Used by assignProtoMaterial to stretch + // the representative cylinder in z. + double matZMin = 1e9; + double matZMax = -1e9; +}; + +/// Build the passive cylinder layers from the hardcoded table above. +std::vector makePassiveLayers(BuildState& st) { + std::vector out; + for (const auto& pc : st.cfg.passiveCylinders) { + auto surface = Surface::makeShared( + Transform3{Translation3{Vector3{0., 0., pc.zCentre * 1_mm}}}, + pc.r * 1_mm, pc.halfZ * 1_mm); + st.passiveSurfaces.push_back(surface); + LayerGroup g; + g.isDisc = false; + g.key = pc.r * 1_mm; + g.surfaces = {surface}; + g.name = pc.name; + g.isPassive = true; + out.push_back(std::move(g)); + } + return out; +} + +/// Build the forward service shells for one side (negative or positive z). +std::vector makeForwardShells(BuildState& st, bool negative) { + std::vector out; + for (const auto& fc : st.cfg.forwardCylinders) { + const double halfZ = 0.5 * (fc.zMax - fc.zMin); + const double zc = (negative ? -1.0 : 1.0) * 0.5 * (fc.zMax + fc.zMin); + auto surface = Surface::makeShared( + Transform3{Translation3{Vector3{0., 0., zc * 1_mm}}}, fc.r * 1_mm, + halfZ * 1_mm); + st.passiveSurfaces.push_back(surface); + LayerGroup g; + g.isDisc = false; + g.key = fc.r * 1_mm; + g.surfaces = {surface}; + g.name = std::string{fc.name} + (negative ? "_neg" : "_pos"); + g.isPassive = true; + out.push_back(std::move(g)); + } + return out; +} + +/// Build the passive service-disc layers from the hardcoded table above. +std::vector makePassiveDiscs(BuildState& st) { + std::vector out; + for (const auto& pd : st.cfg.passiveDiscs) { + auto surface = Surface::makeShared( + Transform3{Translation3{Vector3{0., 0., pd.z * 1_mm}}}, pd.rMin * 1_mm, + pd.rMax * 1_mm); + st.passiveSurfaces.push_back(surface); + LayerGroup g; + g.isDisc = true; + g.key = pd.z * 1_mm; + g.surfaces = {surface}; + g.name = pd.name; + g.isPassive = true; + out.push_back(std::move(g)); + } + return out; +} + +/// Which detector a converted element belongs to. +/// +/// Clustering is done per subsystem because no single tolerance works: merging +/// the staggered TRK rows (188.62/200.30, 11.68 mm apart) needs a tolerance +/// that would swallow ITOF (only 10.38 mm outside) and merge the vertex-detector +/// cylinders (7.00 mm apart). Each subsystem gets its own tolerance instead. +enum class Subsystem { VertexDetector, TrkBarrel, Itof, Otof, Ft3Disc }; + +/// Classify from the TGeo *volume* name (what TGeoParser matched against). +Subsystem classify(std::string_view volumeName) { + if (volumeName.find("PETALCASE") != std::string_view::npos) { + return Subsystem::VertexDetector; // the 3 VD cylinders + } + if (volumeName.find("ITOFSensor") != std::string_view::npos) { + return Subsystem::Itof; + } + if (volumeName.find("OTOFSensor") != std::string_view::npos) { + return Subsystem::Otof; + } + if (volumeName.find("FT3Sensor") != std::string_view::npos) { + return Subsystem::Ft3Disc; + } + return Subsystem::TrkBarrel; // TRKSensor0 chips +} + +/// Radial (barrel) or longitudinal (disc) clustering tolerance per subsystem. +/// The values are geometry-specific and live in the config header (kTol*); the +/// mapping from Subsystem to value is behaviour and stays here. +double clusterTolerance(const BuildState& st, Subsystem s) { + switch (s) { + case Subsystem::VertexDetector: + return st.cfg.tolVertexDetector * 1_mm; + case Subsystem::TrkBarrel: + return st.cfg.tolTrkBarrel * 1_mm; + case Subsystem::Itof: + return st.cfg.tolItof * 1_mm; + case Subsystem::Otof: + return st.cfg.tolOtof * 1_mm; + case Subsystem::Ft3Disc: + return st.cfg.tolFt3Disc * 1_mm; + } + return st.cfg.tolVertexDetector * 1_mm; +} + +/// One converted element together with the TGeo volume name it came from. +struct SensitiveElement { + ElementPtr element; + Subsystem subsystem; +}; + +/// Parse the TGeo file and convert all sensitive nodes into ACTS surfaces. +/// (No logging here: the ACTS_* macros need a local `logger()` accessor, which +/// is set up via ACTS_LOCAL_LOGGER in the entry point below.) +std::vector loadSensitiveElements(TGeoManager& tgeo, const BuildState& st) { + std::vector elements; + + // NOTE (O2 port): the standalone actsO2 builder called TGeoManager::Import() + // here. Inside O2 the TGeo tree is the live gGeoManager shared with the rest + // of the workflow, so it is passed in and must never be replaced. + auto* topVolume = tgeo.GetTopVolume(); + if (topVolume == nullptr) { + throw std::runtime_error("ALICE3 Gen3: no top volume in the TGeo geometry"); + } + + TGeoHMatrix gmatrix = TGeoIdentity(topVolume->GetName()); + + ActsPlugins::TGeoParser::Options options; + options.volumeNames = {topVolume->GetName()}; + options.targetNames = st.cfg.sensitiveMatches; + options.unit = kUnitScalor; + + ActsPlugins::TGeoParser::State state; + state.volume = topVolume; + state.onBranch = true; + + ActsPlugins::TGeoParser::select(state, options, gmatrix); + + // Build the TGeoAxes (an ACTS type) from the config axis strings at runtime. + const auto axesThinY = ActsPlugins::TGeoAxes::parse(st.cfg.axesThinY); + const auto axesThinZ = ActsPlugins::TGeoAxes::parse(st.cfg.axesThinZ); + + elements.reserve(state.selectedNodes.size()); + for (const auto& snode : state.selectedNodes) { + auto identifier = ActsPlugins::TGeoDetectorElement::Identifier(); + const Subsystem sub = classify(snode.node->GetVolume()->GetName()); + // barrel-type chips are thin in TGeo Y, the FT3 chips and the VD tubes + // are handled by the default "XYZ" - see the axes* comment above + const auto axes = (sub == Subsystem::TrkBarrel || sub == Subsystem::Itof || + sub == Subsystem::Otof) + ? axesThinY + : axesThinZ; + elements.push_back(SensitiveElement{ + ActsPlugins::TGeoLayerBuilder::defaultElementFactory( + identifier, *snode.node, *snode.transform, axes, kUnitScalor, + nullptr), + sub}); + } + + if (elements.empty()) { + throw std::runtime_error( + "ALICE3 Gen3: no sensitive elements found in the TGeo geometry. Check " + "'sensitiveMatches' in the Gen3 geometry config."); + } + return elements; +} + +/// Group sorted (value, surface) pairs into clusters, splitting wherever two +/// CONSECUTIVE values differ by more than `tol`. +/// +/// Gap-based (not "within tol of the first key"): order-independent, and a +/// cluster may be wider than `tol` - needed for OTOF, whose four staggered radii +/// span 6.36 mm but are only 1.5-2.8 mm apart consecutively. +std::vector>>> clusterBy( + std::vector>> items, double tol) { + std::vector>>> out; + if (items.empty()) { + return out; + } + std::ranges::sort(items, {}, &std::pair>::first); + + std::vector> current{items.front().second}; + double sum = items.front().first; + double previous = items.front().first; + + auto flush = [&]() { + out.emplace_back(sum / static_cast(current.size()), + std::move(current)); + current.clear(); + }; + + for (std::size_t i = 1; i < items.size(); ++i) { + const double v = items[i].first; + if (v - previous > tol) { + flush(); + sum = 0.; + } + current.push_back(items[i].second); + sum += v; + previous = v; + } + flush(); + return out; +} + +/// Cluster the surfaces into layers: cylinders by radius, discs by z, separately +/// within each subsystem (see Subsystem). +void groupSurfaces(const BuildState& st, const std::vector& elements, + const GeometryContext& gctx, + std::vector& cylinders, + std::vector& discs) { + using Keyed = std::vector>>; + std::map cylByR, discByZ; + + for (const auto& [el, sub] : elements) { + auto surf = el->surface().getSharedPtr(); + const auto stype = surf->type(); + + if (stype == Surface::SurfaceType::Cylinder) { + // vertex-detector layers: full cylinders (PETALCASE tubes) + cylByR[sub].emplace_back(surf->bounds().values()[CylinderBounds::eR], + surf); + } else if (stype == Surface::SurfaceType::Disc) { + discByZ[sub].emplace_back(surf->center(gctx).z(), surf); + } else { + // planar sensors (TRK / ITOF / OTOF chips, FT3 petals): + // normal mostly along z -> disc, else cylinder + const auto c = surf->center(gctx); + const auto n = surf->normal(gctx, c, Vector3::UnitZ()); + if (std::abs(n.z()) > 0.7) { + discByZ[sub].emplace_back(c.z(), surf); + } else { + cylByR[sub].emplace_back(std::hypot(c.x(), c.y()), surf); + } + } + } + + for (auto& [sub, keyed] : cylByR) { + for (auto& [r, s] : clusterBy(std::move(keyed), clusterTolerance(st, sub))) { + cylinders.push_back({false, r, std::move(s)}); + } + } + for (auto& [sub, keyed] : discByZ) { + for (auto& [z, s] : clusterBy(std::move(keyed), clusterTolerance(st, sub))) { + discs.push_back({true, z, std::move(s)}); + } + } + // subsystems were processed independently -> restore a global ordering + std::ranges::sort(cylinders, {}, &LayerGroup::key); + std::ranges::sort(discs, {}, &LayerGroup::key); +} + +// -------------------------------------------------------------------------- +// End-of-Stave passive readout cards +// -------------------------------------------------------------------------- + +/// Bounding-box extent of one End-of-Stave card, in ACTS mm (global frame). +struct EndOfStaveBox { + double rMin, rMax, zMin, zMax; +}; + +/// Parse the passive End-of-Stave cards (kEndOfStaveMatches) and return the +/// bounding-box r/z extent of each in global mm. They are NOT converted to +/// surfaces - only their extent is needed to stretch a barrel layer's material +/// representative in z. Empty match list -> no-op. Reuses the TGeoManager +/// already imported by loadSensitiveElements. +std::vector loadEndOfStaveExtents(TGeoManager& tgeo, const BuildState& st) { + std::vector out; + if (st.cfg.endOfStaveMatches.empty()) { + return out; + } + auto* topVolume = tgeo.GetTopVolume(); + if (topVolume == nullptr) { + return out; + } + TGeoHMatrix gmatrix = TGeoIdentity(topVolume->GetName()); + ActsPlugins::TGeoParser::Options options; + options.volumeNames = {topVolume->GetName()}; + options.targetNames = st.cfg.endOfStaveMatches; + options.unit = kUnitScalor; + ActsPlugins::TGeoParser::State state; + state.volume = topVolume; + state.onBranch = true; + ActsPlugins::TGeoParser::select(state, options, gmatrix); + + for (const auto& snode : state.selectedNodes) { + // bounding box in the volume's local frame (cm), transformed to global + auto* bb = dynamic_cast(snode.node->GetVolume()->GetShape()); + if (bb == nullptr) { + continue; // non-box shapes (e.g. composites) skipped + } + const double dx = bb->GetDX(), dy = bb->GetDY(), dz = bb->GetDZ(); + const double* o = bb->GetOrigin(); + EndOfStaveBox e{1e9, -1e9, 1e9, -1e9}; + for (int sx = -1; sx <= 1; sx += 2) { + for (int sy = -1; sy <= 1; sy += 2) { + for (int sz = -1; sz <= 1; sz += 2) { + const double loc[3] = {o[0] + sx * dx, o[1] + sy * dy, o[2] + sz * dz}; + double glo[3] = {0., 0., 0.}; + snode.transform->LocalToMaster(loc, glo); + const double r = std::hypot(glo[0], glo[1]) * kUnitScalor; + const double z = glo[2] * kUnitScalor; + e.rMin = std::min(e.rMin, r); + e.rMax = std::max(e.rMax, r); + e.zMin = std::min(e.zMin, z); + e.zMax = std::max(e.zMax, z); + } + } + } + out.push_back(e); + } + return out; +} + +/// Attach each End-of-Stave card to the barrel SENSITIVE cylinder layer whose +/// radius lies within kEndOfStaveRTol of the card's radial span, recording the +/// card's z-extent as a material-extent hint on that layer. +void attachEndOfStave(const BuildState& st, std::vector& cylinders, + const std::vector& boxes) { + for (const auto& b : boxes) { + LayerGroup* best = nullptr; + double bestD = st.cfg.endOfStaveRTol; + for (auto& g : cylinders) { + if (g.isPassive) { + continue; + } + const double d = (g.key < b.rMin) ? (b.rMin - g.key) + : (g.key > b.rMax) ? (g.key - b.rMax) + : 0.0; + if (d <= bestD) { + bestD = d; + best = &g; + } + } + if (best != nullptr) { + best->matZMin = std::min(best->matZMin, b.zMin); + best->matZMax = std::max(best->matZMax, b.zMax); + } + } +} + +// -------------------------------------------------------------------------- +// blueprint assembly +// -------------------------------------------------------------------------- + +void configureContainer(ContainerBlueprintNode& node) { + node.setAttachmentStrategy(VolumeAttachmentStrategy::Gap); + node.setResizeStrategies(VolumeResizeStrategy::Gap, + VolumeResizeStrategy::Gap); +} + +/// R-stack whose children are NOT z-aligned (the forward regions). +/// +/// `Gap` attachment must not be used: the inserted r-gap inherits its inner +/// neighbour's halfZ but is centred on the stack's FIRST child, so an off-centre +/// long neighbour makes the gap stick out and synchronizeZBounds() (a union over +/// all children, gaps included) inflates the region. For the forward regions the +/// gap between the two service shells dragged the inner edge from 1428 to 858 mm, +/// into Central -> "Volumes overlap in z". `Midpoint` expands the two neighbours +/// until they touch instead of inserting a volume, so no spurious child exists. +void configureUnalignedRContainer(ContainerBlueprintNode& node) { + node.setAttachmentStrategy(VolumeAttachmentStrategy::Midpoint); + node.setResizeStrategies(VolumeResizeStrategy::Gap, + VolumeResizeStrategy::Gap); +} + +/// Build the cylinder / disc surface that carries a planar-sensor layer's +/// material. Only for planar layers - for one already a cylinder/disc it would +/// land exactly on top of the existing surface. See assignProtoMaterial(). +std::shared_ptr makeRepresentativeSurface(bool isDisc, double rmin, + double rmax, double zmin, + double zmax) { + if (isDisc) { + const double z = 0.5 * (zmin + zmax); + return Surface::makeShared( + Transform3{Translation3{Vector3{0., 0., z}}}, rmin, rmax); + } + const double r = 0.5 * (rmin + rmax); + const double zc = 0.5 * (zmin + zmax); + const double hz = 0.5 * (zmax - zmin); + return Surface::makeShared( + Transform3{Translation3{Vector3{0., 0., zc}}}, r, hz); +} + +/// Mark a layer's surfaces as binned material receivers (proto material). +/// +/// Proto material is put on the surfaces directly, NOT via +/// MaterialDesignatorBlueprintNode::configureFace(): portal-face material is +/// rejected when the face is merged during stacking. It also must NOT go on the +/// sensitive planar sensors (ConvexPolygonBounds, which adjustBinUtility() +/// rejects), so a planar layer gets a synthetic cylinder/disc receiver instead +/// - see the needsRepresentative branch. These receivers, plus the passive +/// cylinders/discs, are what extractMaterialSurfaces() returns for mapping. +void assignProtoMaterial(BuildState& st, LayerGroup& group, const GeometryContext& gctx) { + constexpr float kPi = std::numbers::pi_v; + + double rmin = 1e9, rmax = -1e9, zmin = 1e9, zmax = -1e9; + for (const auto& s : group.surfaces) { + const auto c = s->center(gctx); + const auto vals = s->bounds().values(); + if (s->type() == Surface::SurfaceType::Cylinder && vals.size() >= 2) { + const double r = vals[CylinderBounds::eR]; + const double hz = vals[CylinderBounds::eHalfLengthZ]; + rmin = std::min(rmin, r); + rmax = std::max(rmax, r); + zmin = std::min(zmin, c.z() - hz); + zmax = std::max(zmax, c.z() + hz); + } else if (s->type() == Surface::SurfaceType::Disc && vals.size() >= 2) { + rmin = std::min(rmin, vals[0]); + rmax = std::max(rmax, vals[1]); + zmin = std::min(zmin, c.z()); + zmax = std::max(zmax, c.z()); + } else { + // Planar sensor (stave / petal). Use the true corner positions, not the + // centre: an FT3 petal spans ~200..680 mm in r, so a centre-only extent + // would give a badly undersized representative disc. + const auto poly = s->polyhedronRepresentation(gctx, 1u); + for (const auto& v : poly.vertices) { + const double r = std::hypot(v.x(), v.y()); + rmin = std::min(rmin, r); + rmax = std::max(rmax, r); + zmin = std::min(zmin, v.z()); + zmax = std::max(zmax, v.z()); + } + } + } + // Approach: stretch the material extent along z to cover the End-of-Stave + // cards attached to this barrel layer (their bounding box reaches past the + // outermost sensor). Affects both the z-binning and the representative's + // half-length below. No-op unless a card was attached (see attachEndOfStave). + if (group.matZMax > group.matZMin) { + zmin = std::min(zmin, group.matZMin); + zmax = std::max(zmax, group.matZMax); + } + + // guard against degenerate ranges (single-radius cylinder, single-z disc) + if (!(rmax > rmin)) { + rmin -= 1.; + rmax += 1.; + } + if (!(zmax > zmin)) { + zmin -= 1.; + zmax += 1.; + } + + BinUtility bu; + if (group.isDisc) { + bu = BinUtility(st.cfg.matBinsR, static_cast(rmin), + static_cast(rmax), open, AxisDirection::AxisR); + bu += BinUtility(st.cfg.matBinsPhi, -kPi, kPi, closed, AxisDirection::AxisPhi); + } else { + bu = BinUtility(st.cfg.matBinsPhi, -kPi, kPi, closed, AxisDirection::AxisPhi); + bu += BinUtility(st.cfg.matBinsZ, static_cast(zmin), + static_cast(zmax), open, AxisDirection::AxisZ); + } + + auto material = std::make_shared(bu); + + // A synthetic receiver is only needed for PLANAR sensors (adjustBinUtility() + // rejects their ConvexPolygonBounds); cylinders/discs carry material directly. + // Not cosmetic: a barrel layer is a single cylinder (rmin==rmax), so a + // representative would land exactly on the sensor - two coincident cylinders + // the navigator can't disambiguate, and the layer would drop out of the + // propagated material tracks. + const bool needsRepresentative = + !group.isPassive && + std::ranges::any_of(group.surfaces, [](const auto& s) { + return s->type() != Surface::SurfaceType::Cylinder && + s->type() != Surface::SurfaceType::Disc; + }); + + if (!needsRepresentative) { + // passive layers, and sensitive layers already made of cylinders / discs + for (const auto& s : group.surfaces) { + s->assignSurfaceMaterial(material); + } + return; + } + + // Planar-sensor layer: the material goes on a synthetic cylinder / disc + // spanning the layer, NOT on the sensors. See the note above. + auto representative = + makeRepresentativeSurface(group.isDisc, rmin, rmax, zmin, zmax); + representative->assignSurfaceMaterial(material); + st.passiveSurfaces.push_back(representative); + group.surfaces.push_back(representative); +} + +void addLayer(BlueprintNode& parent, const std::string& nameIn, + const LayerGroup& group) { + const std::string name = group.name.empty() ? nameIn : group.name; + parent.addLayer(name, [&group](LayerBlueprintNode& layer) { + layer.setSurfaces(group.surfaces); + layer.setLayerType(group.isDisc ? LayerBlueprintNode::LayerType::Disc + : LayerBlueprintNode::LayerType::Cylinder); + layer.setEnvelope(kLayerEnvelope); + // Pin x,y to the beam line: the default centre-of-gravity placement leaves a + // tiny x/y residual for a phi-segmented layer, which + // CylinderVolumeStack::checkVolumeAlignment rejects ("not aligned: + // translation in x or y"). Keep z on - it places each disc at its own z. + layer.setUseCenterOfGravity(false, false, true); + }); +} + +} // namespace + +// --------------------------------------------------------------------------- +// entry point +// --------------------------------------------------------------------------- +// Details on the workflow (how much of the following is the "official" ACTS +// Gen3 workflow, and how much is ours): +// +// The official workflow is only two obligatory things: build a tree of +// BlueprintNodes, then call root.construct() (which runs Build/Connect/Finalize +// and does all portal wiring, gap insertion, navigation policies and geo-id +// assignment). The steps of this function map onto that as: +// +// (numbered in the order they run below) +// # step status +// - ------------------------------------------------- -------------------- +// 1 parse TGeo -> surfaces (loadSensitiveElements) official plugin; +// axes selection is ours +// 2 cluster surfaces -> layers (groupSurfaces) ours +// 3 proto-material via synthetic surfaces + passives ours (workaround) +// 4 partition layers into tree regions (pick/discsIn) ours (topology) +// 5 Blueprint root + envelope official +// 6 assemble container/layer tree + pin IDs official API + use +// 7 root.construct() official +// 8 geo-id table dump ours (diagnostic) +// +// (Step 3 runs before assembly so each receiver is grouped into its layer.) +// +// The Gen3 construction engine is used cleanly and completely. The distance +// from "official" is almost entirely that we lack a detector description: if the +// ALICE 3 geometry were available to ACTS as DD4hep or via the TGeo blueprint +// backend, steps 2, 4 and most of the step-6 tweaks would disappear (they only +// exist because we reconstruct the layer/region structure from a bare +// o2sim_geometry.root that carries none), and only the material approach +// (step 3) would remain as a real deviation. +// --------------------------------------------------------------------------- + +Gen3BlueprintBuilder::Gen3BlueprintBuilder(Config config) : mConfig(std::move(config)) {} + +o2::acts::TrackingGeometryOutput Gen3BlueprintBuilder::build(TGeoManager& tgeo, + const Acts::GeometryContext& gctxIn) +{ + using enum AxisDirection; + + // defines a local `logger()` accessor used by the ACTS_* macros + ACTS_LOCAL_LOGGER(getDefaultLogger("ALICE3Gen3", mConfig.logLevel)); + ACTS_INFO("ALICE3 Gen3: building tracking geometry from TGeo geometry '" << tgeo.GetName() << "'"); + + // Load the geometry configuration (JSON) - the single source of truth for all + // geometry-specific parameters. + if (mConfig.geometryConfigFile.empty()) { + throw std::runtime_error( + "ALICE3 Gen3: no geometry config file given. Point Config::geometryConfigFile at the " + "gen3_geometry_config.json shipped with the geometry."); + } + ACTS_INFO("ALICE3 Gen3: loading geometry config from " << mConfig.geometryConfigFile); + const Gen3GeometryConfig cfg = loadGen3GeometryConfig(mConfig.geometryConfigFile); + + o2::acts::TrackingGeometryOutput output; + std::vector> passiveSurfaces; + BuildState st{cfg, passiveSurfaces}; + + const auto& gctx = gctxIn; + const bool withMaterial = mConfig.withMaterial; + + // === STEP 1: parse TGeo -> surfaces (official plugin; axes selection ours) = + auto elements = loadSensitiveElements(tgeo, st); + ACTS_INFO("ALICE3 Gen3: converted " << elements.size() << " sensitive detector elements"); + + // === STEP 2: cluster surfaces -> layers (ours) ========================= + std::vector cylinders, discs; + groupSurfaces(st, elements, gctx, cylinders, discs); + + // === STEP 3: proto-material via synthetic surfaces + passives (ours) ==== + // Done here, before assembly, so each receiver is grouped with its layer. + // add the passive material cylinders and re-sort by radius, so that the + // region assignment below places them automatically + if (withMaterial) { + for (auto& pg : makePassiveLayers(st)) { + cylinders.push_back(std::move(pg)); + } + std::ranges::sort(cylinders, {}, &LayerGroup::key); + for (auto& pg : makePassiveDiscs(st)) { + discs.push_back(std::move(pg)); + } + std::ranges::sort(discs, {}, &LayerGroup::key); + ACTS_INFO("ALICE3 Gen3: added " << cfg.passiveCylinders.size() << " passive cylinders and " + << cfg.passiveDiscs.size() << " passive discs"); + + // Approach: stretch barrel sensor-layer material extents in z to cover the + // passive End-of-Stave cards (endOfStaveMatches). No-op if the list is empty. + const auto eosBoxes = loadEndOfStaveExtents(tgeo, st); + attachEndOfStave(st, cylinders, eosBoxes); + if (!eosBoxes.empty()) { + ACTS_INFO("ALICE3 Gen3: attached " << eosBoxes.size() + << " End-of-Stave card(s) to barrel layer material extents"); + } + + // Attach the material receivers. NOTE: non-const - for a sensitive layer + // this appends the synthetic representative cylinder/disc to g.surfaces. + for (auto& g : cylinders) { + assignProtoMaterial(st, g, gctx); + } + for (auto& g : discs) { + assignProtoMaterial(st, g, gctx); + } + ACTS_INFO("ALICE3 Gen3: proto material assigned to " << cylinders.size() + discs.size() << " layers"); + } + + ACTS_INFO("ALICE3 Gen3: " << cylinders.size() << " cylinder layers, " << discs.size() << " disc layers"); + for (const auto& g : cylinders) { + ACTS_DEBUG(" cylinder r=" << g.key << " n=" << g.surfaces.size() + << (g.isPassive ? " [passive] " + g.name : std::string{" [sensitive]"})); + } + for (const auto& g : discs) { + ACTS_DEBUG(" disc z=" << g.key << " n=" << g.surfaces.size() + << (g.isPassive ? " [passive] " + g.name : std::string{" [sensitive]"})); + } + + // Keep the detector elements alive: their surfaces hold only a raw back-pointer + // to them. Ownership goes out with the output, so the caller decides the lifetime. + output.elementStore.reserve(elements.size()); + for (const auto& se : elements) { + output.elementStore.push_back(se.element); + } + + // === STEP 4: partition layers into tree regions (ours; topology) ======= + auto pick = [](const std::vector& in, auto&& pred) { + std::vector out; + std::ranges::copy_if(in, std::back_inserter(out), pred); + return out; + }; + + const auto innerBarrel = pick(cylinders, [&cfg](const LayerGroup& g) { return g.key < cfg.rInnerCoreMax; }); + const auto midBarrel = pick(cylinders, [&cfg](const LayerGroup& g) { + return g.key >= cfg.rInnerCoreMax && g.key < cfg.rMainMax; + }); + const auto otof = pick(cylinders, [&cfg](const LayerGroup& g) { return g.key >= cfg.rMainMax; }); + + auto discsIn = [&](double lo, double hi, bool negative) { + return pick(discs, [lo, hi, negative](const LayerGroup& g) { + const double az = std::abs(g.key); + return az >= lo && az < hi && ((g.key < 0) == negative); + }); + }; + const auto ft3InnerNeg = discsIn(0., cfg.zCentralMax, true); + const auto ft3InnerPos = discsIn(0., cfg.zCentralMax, false); + const auto ft3OuterNeg = discsIn(cfg.zCentralMax, cfg.zMainMax, true); + const auto ft3OuterPos = discsIn(cfg.zCentralMax, cfg.zMainMax, false); + + // === STEP 5: Blueprint root + envelope (official) ====================== + Blueprint::Config bpCfg; + // The inner r envelope pulls the world's rMin to 0, else there is a hole + // around the beam line (innermost volume starts at r=3.01) and tracks from the + // origin abort with NavigatorError:3 (NoStartVolume). PadBlueprintNode clamps + // std::max(0, rMin - rEnv[0]), so any value >= 3.01 gives rMin=0; 20 is used + // for headroom against the innermost volume moving outward. + constexpr double kWorldRInnerEnvelope = 20. * 1_mm; // >= innermost rMin + bpCfg.envelope = ExtentEnvelope{{.z = {20. * 1_mm, 20. * 1_mm}, .r = {kWorldRInnerEnvelope, 20. * 1_mm}}}; + Blueprint root{bpCfg}; + + // Ordering predicates that make the layer IDs deterministic (otherwise they + // follow traversal order). + const auto byMidRadius = [](const TrackingVolume& a, const TrackingVolume& b) { + auto midR = [](const TrackingVolume& v) { + const auto& cb = dynamic_cast(v.volumeBounds()); + using enum CylinderVolumeBounds::BoundValues; + return 0.5 * (cb.get(eMinR) + cb.get(eMaxR)); + }; + return midR(a) < midR(b); + }; + const auto byZ = [&gctx](const TrackingVolume& a, const TrackingVolume& b) { + return a.localToGlobalTransform(gctx).translation().z() < + b.localToGlobalTransform(gctx).translation().z(); + }; + + /// Wrap a region in a GeometryIdentifierBlueprintNode: pin its volume ID and + /// number its layers from 1 in the given order. + auto pinIds = [](BlueprintNode& parent, GeometryIdentifier::Value volumeId, + const auto& compare) -> GeometryIdentifierBlueprintNode& { + auto& node = parent.withGeometryIdentifier(); + node.setAllVolumeIdsTo(volumeId).incrementLayerIds(1).sortBy(compare); + return node; + }; + + // Name a layer "_L", i from 0 within the region. The region prefix + // avoids the duplicate-volume-name throw. CAUTION: i is NOT the pinned layer + // ID (incrementLayerIds counts passives and shells too) - the identifier table + // printed after construction is the authority for (volume, layer). + auto layerName = [](const std::string& region, std::size_t i) { + return region + "_L" + std::to_string(i); + }; + + // === STEP 6: assemble container/layer tree + pin IDs (official API + use) = + auto& core = root.addCylinderContainer("Core", AxisR); + configureContainer(core); + + auto& main = core.addCylinderContainer("Main", AxisZ); + configureContainer(main); + + // Each forward region is an R-stack: an inner z-stack of the discs, plus the + // forward service shells as radial neighbours at the same z. + auto addForwardRegion = [&](const std::string& name, const std::vector& regionDiscs, + bool negative, GeometryIdentifier::Value volumeId) { + if (regionDiscs.empty()) { + return; + } + // The whole forward region - discs AND service shells - shares one volume + // ID. Sorting by z numbers the discs outward from the interaction point and + // puts the two service shells last. + auto& fwd = pinIds(main, volumeId, byZ).addCylinderContainer(name, AxisR); + // disc stack and service shells are not z-aligned -> no Gap attachment + configureUnalignedRContainer(fwd); + + const std::string discRegion = negative ? "OuterDisksNeg" : "OuterDisksPos"; + auto& discStack = fwd.addCylinderContainer(discRegion, AxisZ); + configureContainer(discStack); + + // Inner-z end must Expand, not Gap. The disc stack defines the region's + // inner z edge, so it is resized to a bound it already has; update()'s gap + // guard (`newMinZ < oldMinZ`, no tolerance) then makes a ~0-halfZ gap from a + // 1e-13 sliver, whose two coincident disc portals abort with "Have no portal + // for NegativeDisc". Expand absorbs the sliver. The outer-z end keeps Gap (it + // spans out to |z|=3502). Inner end is maxZ for z<0, minZ for z>0. + discStack.setResizeStrategies(negative ? VolumeResizeStrategy::Gap : VolumeResizeStrategy::Expand, + negative ? VolumeResizeStrategy::Expand : VolumeResizeStrategy::Gap); + for (std::size_t i = 0; i < regionDiscs.size(); ++i) { + addLayer(discStack, layerName(discRegion, i), regionDiscs[i]); + } + + if (withMaterial) { + for (auto& shell : makeForwardShells(st, negative)) { + assignProtoMaterial(st, shell, gctx); + addLayer(fwd, shell.name, shell); + } + } + }; + + addForwardRegion("FwdNeg", ft3OuterNeg, true, cfg.volFwdNeg); + + auto& central = main.addCylinderContainer("Central", AxisR); + configureContainer(central); + + auto& innerCore = central.addCylinderContainer("InnerCore", AxisZ); + configureContainer(innerCore); + + // Each inner-FT3 side gets its own z-container so it can carry a volume ID; + // no new geometry (the discs are contiguous in z). + if (!ft3InnerNeg.empty()) { + auto& ft3NegNode = + pinIds(innerCore, cfg.volFt3InnerNeg, byZ).addCylinderContainer("MiddleDisksNeg", AxisZ); + configureContainer(ft3NegNode); + for (std::size_t i = 0; i < ft3InnerNeg.size(); ++i) { + addLayer(ft3NegNode, layerName("MiddleDisksNeg", i), ft3InnerNeg[i]); + } + } + + auto& innerBarrelNode = + pinIds(innerCore, cfg.volInnerBarrel, byMidRadius).addCylinderContainer("InnerBarrel", AxisR); + configureContainer(innerBarrelNode); + for (std::size_t i = 0; i < innerBarrel.size(); ++i) { + addLayer(innerBarrelNode, layerName("InnerBarrel", i), innerBarrel[i]); + } + + if (!ft3InnerPos.empty()) { + auto& ft3PosNode = + pinIds(innerCore, cfg.volFt3InnerPos, byZ).addCylinderContainer("MiddleDisksPos", AxisZ); + configureContainer(ft3PosNode); + for (std::size_t i = 0; i < ft3InnerPos.size(); ++i) { + addLayer(ft3PosNode, layerName("MiddleDisksPos", i), ft3InnerPos[i]); + } + } + + if (!midBarrel.empty()) { + auto& midNode = pinIds(central, cfg.volOuterTrackerBarrel, byMidRadius) + .addCylinderContainer("OuterTrackerBarrel", AxisR); + configureContainer(midNode); + for (std::size_t i = 0; i < midBarrel.size(); ++i) { + addLayer(midNode, layerName("OuterTrackerBarrel", i), midBarrel[i]); + } + } + + addForwardRegion("FwdPos", ft3OuterPos, false, cfg.volFwdPos); + + // OTOF likewise needs a container of its own to carry the pinned ID. + if (!otof.empty()) { + auto& otofNode = pinIds(core, cfg.volOtof, byMidRadius).addCylinderContainer("OTOF", AxisR); + configureContainer(otofNode); + for (std::size_t i = 0; i < otof.size(); ++i) { + addLayer(otofNode, layerName("OTOF", i), otof[i]); + } + } + + if (!mConfig.graphvizFile.empty()) { + std::ofstream fh(mConfig.graphvizFile); + root.graphviz(fh); + ACTS_INFO("ALICE3 Gen3: blueprint written to " << mConfig.graphvizFile); + } + + // === STEP 7: construct - runs Build/Connect/Finalize (official) ========= + BlueprintOptions options; + auto trackingGeometry = root.construct(options, gctx, logger()); + ACTS_INFO("ALICE3 Gen3: tracking geometry constructed"); + + // The passive cylinders/discs and the synthetic material receivers need no + // external holder: Acts::TrackingVolume owns its surfaces by shared_ptr, so + // they stay alive with the geometry. Only the detector elements do, because + // the Surface -> element link is a raw back-pointer. + + // === STEP 8: geometry identifier table (ours; diagnostic) ============== + // These are the (volume, layer) keys the digitisation / seeding json must + // use. Printed at INFO because getting them wrong fails silently: a + // GeometryHierarchyMap entry that matches nothing simply never fires. + ACTS_INFO("ALICE3 Gen3: geometry identifier table"); + ACTS_INFO(" volume layer nSurfaces name"); + trackingGeometry->apply([&](const TrackingVolume& volume) { + const auto gid = volume.geometryId(); + const std::size_t nSurfaces = volume.surfaces().size(); + if (nSurfaces == 0) { + return; // container / gap volume, nothing to address + } + std::stringstream ss; + ss << std::setw(9) << gid.volume() << std::setw(7) << gid.layer() << std::setw(11) << nSurfaces + << " " << volume.volumeName(); + ACTS_INFO(ss.str()); + }); + + output.geometry = std::shared_ptr(std::move(trackingGeometry)); + return output; +} + +} // namespace o2::alice3 diff --git a/Detectors/Upgrades/ALICE3/ACTS/src/Gen3GeometryConfig.cxx b/Detectors/Upgrades/ALICE3/ACTS/src/Gen3GeometryConfig.cxx new file mode 100644 index 0000000000000..54585a307cda1 --- /dev/null +++ b/Detectors/Upgrades/ALICE3/ACTS/src/Gen3GeometryConfig.cxx @@ -0,0 +1,138 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +/// +/// \file Gen3GeometryConfig.cxx +/// \author Paolo Butti +/// +/// Ported from actsO2 (ActsAlgorithms/Geometry/src/Gen3GeometryConfig.cpp). +/// +/// JSON loader for Gen3GeometryConfig. The JSON is the single source of truth: +/// a missing file, a parse error, or a missing required key is a hard error. +/// + +#include "ALICE3ACTS/Gen3GeometryConfig.h" + +#include +#include +#include + +#include + +namespace o2::alice3::gen3cfg +{ + +namespace +{ + +using nlohmann::json; + +/// Return j[key] or throw a clear error naming the missing key. +const json& require(const json& j, const char* key) { + const auto it = j.find(key); + if (it == j.end()) { + throw std::runtime_error( + std::string("Gen3 config: missing required key '") + key + "'"); + } + return *it; +} + +template +T get(const json& j, const char* key) { + try { + return require(j, key).get(); + } catch (const json::exception& e) { + throw std::runtime_error(std::string("Gen3 config: key '") + key + + "' has the wrong type (" + e.what() + ")"); + } +} + +PassiveCylinderCfg parsePassiveCylinder(const json& j) { + return {get(j, "name"), get(j, "r"), + get(j, "halfZ"), get(j, "zCentre")}; +} + +ForwardCylinderCfg parseForwardCylinder(const json& j) { + return {get(j, "name"), get(j, "r"), + get(j, "zMin"), get(j, "zMax")}; +} + +PassiveDiscCfg parsePassiveDisc(const json& j) { + return {get(j, "name"), get(j, "z"), + get(j, "rMin"), get(j, "rMax")}; +} + +} // namespace + +Gen3GeometryConfig loadGen3GeometryConfig(const std::string& jsonPath) { + std::ifstream in(jsonPath); + if (!in.is_open()) { + throw std::runtime_error("Gen3 config: cannot open JSON file '" + jsonPath + + "'"); + } + + json j; + try { + in >> j; + } catch (const std::exception& e) { + throw std::runtime_error("Gen3 config: failed to parse '" + jsonPath + + "': " + e.what()); + } + + Gen3GeometryConfig c; + + c.sensitiveMatches = get>(j, "sensitiveMatches"); + c.endOfStaveMatches = get>(j, "endOfStaveMatches"); + c.endOfStaveRTol = get(j, "endOfStaveRTol"); + + c.axesThinZ = get(j, "axesThinZ"); + c.axesThinY = get(j, "axesThinY"); + + c.rInnerCoreMax = get(j, "rInnerCoreMax"); + c.rMainMax = get(j, "rMainMax"); + c.zCentralMax = get(j, "zCentralMax"); + c.zMainMax = get(j, "zMainMax"); + + c.tolVertexDetector = get(j, "tolVertexDetector"); + c.tolTrkBarrel = get(j, "tolTrkBarrel"); + c.tolItof = get(j, "tolItof"); + c.tolOtof = get(j, "tolOtof"); + c.tolFt3Disc = get(j, "tolFt3Disc"); + + c.fwdDiscRMax = get(j, "fwdDiscRMax"); + + c.matBinsPhi = get(j, "matBinsPhi"); + c.matBinsZ = get(j, "matBinsZ"); + c.matBinsR = get(j, "matBinsR"); + + for (const auto& e : require(j, "passiveCylinders")) { + c.passiveCylinders.push_back(parsePassiveCylinder(e)); + } + for (const auto& e : require(j, "forwardCylinders")) { + c.forwardCylinders.push_back(parseForwardCylinder(e)); + } + for (const auto& e : require(j, "passiveDiscs")) { + c.passiveDiscs.push_back(parsePassiveDisc(e)); + } + + const json& v = require(j, "volumeIds"); + c.volFwdNeg = get(v, "fwdNeg"); + c.volFt3InnerNeg = get(v, "ft3InnerNeg"); + c.volInnerBarrel = get(v, "innerBarrel"); + c.volOuterTrackerBarrel = get(v, "outerTrackerBarrel"); + c.volFt3InnerPos = get(v, "ft3InnerPos"); + c.volFwdPos = get(v, "fwdPos"); + c.volOtof = get(v, "otof"); + + return c; +} + +} // namespace o2::alice3::gen3cfg diff --git a/Detectors/Upgrades/ALICE3/CMakeLists.txt b/Detectors/Upgrades/ALICE3/CMakeLists.txt index f587772a1885b..ae9ba918dbdce 100644 --- a/Detectors/Upgrades/ALICE3/CMakeLists.txt +++ b/Detectors/Upgrades/ALICE3/CMakeLists.txt @@ -19,4 +19,5 @@ add_subdirectory(AOD) add_subdirectory(IOTOF) add_subdirectory(RICH) add_subdirectory(MID) +add_subdirectory(ACTS) add_subdirectory(macros) diff --git a/cmake/O2RootMacroExclusionList.cmake b/cmake/O2RootMacroExclusionList.cmake index ab79a802a6e3a..5fea628edd22f 100644 --- a/cmake/O2RootMacroExclusionList.cmake +++ b/cmake/O2RootMacroExclusionList.cmake @@ -79,6 +79,13 @@ if(NOT ENABLE_UPGRADES) list(APPEND O2_ROOT_MACRO_EXCLUSION_LIST ${upgradeMacros}) endif() +if(NOT Acts_FOUND) + # ACTS is an optional dependency: Detectors/Upgrades/ALICE3/ACTS is not added + # to the build without it, so its o2_add_test_root_macro is never called + o2_get_list_of_macros(${CMAKE_SOURCE_DIR}/Detectors/Upgrades/ALICE3/ACTS actsMacros) + list(APPEND O2_ROOT_MACRO_EXCLUSION_LIST ${actsMacros}) +endif() + list(REMOVE_DUPLICATES O2_ROOT_MACRO_EXCLUSION_LIST) # check exclusion list contains only existing macros