From e334f6382eb698b638f32d495c25feab32f43ad1 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Mon, 24 Aug 2026 11:44:31 +0200 Subject: [PATCH 1/6] Give every timeframe its own slot in the collision context This fixes a problem in the timeframe index structure of the collision context and adds a unit test. - getTimeFrameBoundaries closed only one timeframe per collision, so a timeframe without collisions was left out of the index structure entirely and the collisions after it were assigned to the wrong timeframe. - The number of extracted per-timeframe contexts was therefore the number of non-empty timeframes, not the number of timeframes asked for, and the last tf/collisioncontext.root could be missing. - The scan now closes every timeframe a collision skips over and pads the result to the number of timeframes the caller asks for, so entry i always describes orbits [start + i*orbitsPerTF, start + (i+1)*orbitsPerTF). - applyMaxCollisionFilter keeps an empty timeframe empty when it re-indexes, and extractSingleTimeframe returns a valid empty context for it. - o2-steer-colcontexttool passes the number of timeframes it asked for, reports timeframes that came out empty together with the mean number of collisions per timeframe implied by the interaction rate, and refuses to continue when --noEmptyTF was requested. https://its.cern.ch/jira/browse/O2-7132 Co-Authored-By: Claude Opus 5 --- DataFormats/simulation/CMakeLists.txt | 5 + .../DigitizationContext.h | 6 +- .../simulation/src/DigitizationContext.cxx | 61 ++++++--- .../test/testDigitizationContext.cxx | 127 ++++++++++++++++++ Steer/src/CollisionContextTool.cxx | 51 ++++++- 5 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 DataFormats/simulation/test/testDigitizationContext.cxx diff --git a/DataFormats/simulation/CMakeLists.txt b/DataFormats/simulation/CMakeLists.txt index 33c91337c77e9..f9001272b70df 100644 --- a/DataFormats/simulation/CMakeLists.txt +++ b/DataFormats/simulation/CMakeLists.txt @@ -55,6 +55,11 @@ o2_target_root_dictionary( # * src/SimulationDataLinkDef.h # * and not src/SimulationDataFormatLinkDef.h +o2_add_test(DigitizationContext + SOURCES test/testDigitizationContext.cxx + COMPONENT_NAME SimulationDataFormat + PUBLIC_LINK_LIBRARIES O2::SimulationDataFormat) + o2_add_test(InteractionSampler SOURCES test/testInteractionSampler.cxx COMPONENT_NAME SimulationDataFormat diff --git a/DataFormats/simulation/include/SimulationDataFormat/DigitizationContext.h b/DataFormats/simulation/include/SimulationDataFormat/DigitizationContext.h index 0dc3806e52cf2..54cf81d452cd3 100644 --- a/DataFormats/simulation/include/SimulationDataFormat/DigitizationContext.h +++ b/DataFormats/simulation/include/SimulationDataFormat/DigitizationContext.h @@ -135,7 +135,11 @@ class DigitizationContext void applyMaxCollisionFilter(std::vector>& timeframeindices, long startOrbit, long orbitsPerTF, int maxColl, double orbitsEarly = 0.); /// get timeframe structure --> index markers where timeframe starts/ends/is_influenced_by - std::vector> calcTimeframeIndices(long startOrbit, long orbitsPerTF, double orbitsEarly = 0.) const; + /// One entry is produced per timeframe, including timeframes which contain no collision at all. + /// nTimeframes is the number of timeframes the caller asked for; when given, the result has exactly + /// that many entries, so that a timeframe without collisions keeps its own slot instead of shifting + /// all later timeframes down by one. + std::vector> calcTimeframeIndices(long startOrbit, long orbitsPerTF, double orbitsEarly = 0., long nTimeframes = -1) const; // Sample and fix interaction vertices (according to some distribution). Makes sure that same event ids // have to have same vertex, as well as event ids associated to same collision. diff --git a/DataFormats/simulation/src/DigitizationContext.cxx b/DataFormats/simulation/src/DigitizationContext.cxx index 79e36aa9fa48b..1c41dce797cc4 100644 --- a/DataFormats/simulation/src/DigitizationContext.cxx +++ b/DataFormats/simulation/src/DigitizationContext.cxx @@ -389,20 +389,33 @@ void DigitizationContext::fillQED(std::string_view QEDprefix, std::vector> getTimeFrameBoundaries(std::vector const& irecords, long startOrbit, long orbitsPerTF) +// One entry is produced per timeframe. A timeframe without collisions gets an empty range +// (first > second) rather than being left out, so that entry i always describes the timeframe +// covering orbits [startOrbit + i * orbitsPerTF, startOrbit + (i+1) * orbitsPerTF). +// nTimeframes, when positive, is the number of timeframes the caller asked for; the result is +// padded with empty timeframes (or truncated) to exactly that length. +std::vector> getTimeFrameBoundaries(std::vector const& irecords, long startOrbit, long orbitsPerTF, long nTimeframes = -1) { std::vector> result; + auto pad_and_return = [&result, nTimeframes](int index) { + if (nTimeframes > 0) { + while ((long)result.size() < nTimeframes) { + result.emplace_back(std::pair(index, index - 1)); // an empty timeframe + } + result.resize(nTimeframes); + } + return result; + }; + // the goal is to determine timeframe boundaries inside the interaction record vectors - // determine if we can do anything if (irecords.size() == 0) { - // nothing to do - return result; + return pad_and_return(0); } if (irecords.back().orbit < startOrbit) { LOG(error) << "start orbit larger than last collision entry"; - return result; + return pad_and_return((int)irecords.size()); } // skip to the first index falling within our constrained @@ -413,10 +426,13 @@ std::vector> getTimeFrameBoundaries(std::vector= startOrbit + timeframe_count * orbitsPerTF) { - // we finished one timeframe + // a collision may lie several timeframes ahead of the previous one; close every timeframe it + // skips over, as an empty one, so that the collision ends up in the timeframe it belongs to. + // (A plain "if" here closed only one timeframe per collision, which both dropped the empty + // timeframes and mis-assigned the collisions after them.) + while (irecords[right].orbit >= startOrbit + timeframe_count * orbitsPerTF) { result.emplace_back(std::pair(left, right - 1)); timeframe_count++; left = right; @@ -425,17 +441,18 @@ std::vector> getTimeFrameBoundaries(std::vector(left, right - 1)); - return result; + return pad_and_return((int)irecords.size()); } // a common helper for timeframe structure - includes indices for orbits-early (orbits from last timeframe still affecting current one) std::vector> getTimeFrameBoundaries(std::vector const& irecords, long startOrbit, long orbitsPerTF, - float orbitsEarly) + float orbitsEarly, + long nTimeframes = -1) { // we could actually use the other method first ... then do another pass to fix the early-index ... or impact index - auto true_indices = getTimeFrameBoundaries(irecords, startOrbit, orbitsPerTF); + auto true_indices = getTimeFrameBoundaries(irecords, startOrbit, orbitsPerTF, nTimeframes); std::vector> indices_with_early{}; for (int ti = 0; ti < true_indices.size(); ++ti) { @@ -447,7 +464,7 @@ std::vector> getTimeFrameBoundaries(std::vector 0. && ti > 0) { + if (orbitsEarly > 0. && ti > 0 && tf_range.first <= tf_range.second) { auto& prev_tf_range = true_indices[ti - 1]; // in this range search the smallest index which precedes // timeframe ti by not more than "orbitsEarly" orbits @@ -518,7 +535,8 @@ void DigitizationContext::applyMaxCollisionFilter(std::vector= 0 ? previndex : firstindex; index <= lastindex; ++index) { if (collCount >= maxColl) { @@ -571,6 +589,14 @@ void DigitizationContext::applyMaxCollisionFilter(std::vector(tf_indices) = (int)newrecords.size(); + std::get<1>(tf_indices) = (int)newrecords.size() - 1; + std::get<2>(tf_indices) = -1; + continue; + } if (indices_old_to_new.find(firstindex) != indices_old_to_new.end()) { std::get<0>(tf_indices) = indices_old_to_new[firstindex]; // start } @@ -588,9 +614,9 @@ void DigitizationContext::applyMaxCollisionFilter(std::vector> DigitizationContext::calcTimeframeIndices(long startOrbit, long orbitsPerTF, double orbitsEarly) const +std::vector> DigitizationContext::calcTimeframeIndices(long startOrbit, long orbitsPerTF, double orbitsEarly, long nTimeframes) const { - auto timeframeindices = getTimeFrameBoundaries(mEventRecords, startOrbit, orbitsPerTF, orbitsEarly); + auto timeframeindices = getTimeFrameBoundaries(mEventRecords, startOrbit, orbitsPerTF, orbitsEarly, nTimeframes); return timeframeindices; } @@ -710,6 +736,11 @@ DigitizationContext DigitizationContext::extractSingleTimeframe(int timeframeid, if (earlyindex >= 0) { startindex = earlyindex; } + if (endindex < startindex) { + // a timeframe without any collision: return a valid but empty context rather than + // copying a negative range + endindex = startindex; + } std::copy(mEventRecords.begin() + startindex, mEventRecords.begin() + endindex, std::back_inserter(r.mEventRecords)); std::copy(mEventParts.begin() + startindex, mEventParts.begin() + endindex, std::back_inserter(r.mEventParts)); if (mInteractionVertices.size() >= endindex) { diff --git a/DataFormats/simulation/test/testDigitizationContext.cxx b/DataFormats/simulation/test/testDigitizationContext.cxx new file mode 100644 index 0000000000000..122c13cbcdd25 --- /dev/null +++ b/DataFormats/simulation/test/testDigitizationContext.cxx @@ -0,0 +1,127 @@ +// Copyright 2019-2020 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. + +#define BOOST_TEST_MODULE Test DigitizationContext class +#define BOOST_TEST_MAIN +#define BOOST_TEST_DYN_LINK + +#include +#include "SimulationDataFormat/DigitizationContext.h" +#include + +namespace o2 +{ + +// build a context whose collisions sit at the given orbits (one collision each, source 0) +steer::DigitizationContext makeContext(std::vector const& orbits) +{ + steer::DigitizationContext ctx; + auto& records = ctx.getEventRecords(); + auto& parts = ctx.getEventParts(); + int entry = 0; + for (auto o : orbits) { + records.emplace_back(o2::InteractionTimeRecord(o2::InteractionRecord(0, o), 0.)); + parts.push_back({steer::EventPart(0, entry++)}); + } + ctx.setNCollisions(records.size()); + ctx.setMaxNumberParts(1); + return ctx; +} + +// The timeframe index structure must have one entry per timeframe asked for, and entry i must +// describe exactly the collisions falling into orbits [start + i*orbitsPerTF, start + (i+1)*orbitsPerTF). +BOOST_AUTO_TEST_CASE(TimeframeIndicesAreSlotAligned) +{ + long const orbitsPerTF = 6; + long const start = 0; + long const nTF = 5; // orbits 0..29 + + // timeframe 1 (orbits 6..11) and timeframe 4 (orbits 24..29) hold no collision + std::vector orbits{0, 3, 5, 12, 14, 17, 18, 21}; + auto ctx = makeContext(orbits); + + auto indices = ctx.calcTimeframeIndices(start, orbitsPerTF, 0., nTF); + BOOST_CHECK_EQUAL(indices.size(), (size_t)nTF); + + for (int tf = 0; tf < nTF; ++tf) { + auto first = std::get<0>(indices[tf]); + auto last = std::get<1>(indices[tf]); + long const lo = start + tf * orbitsPerTF; + long const hi = lo + orbitsPerTF; + // count what should be in this timeframe + int expected = 0; + for (auto o : orbits) { + if (o >= lo && o < hi) { + expected++; + } + } + BOOST_CHECK_EQUAL(last - first + 1, expected); + for (int i = first; i <= last; ++i) { + BOOST_CHECK(orbits[i] >= lo); + BOOST_CHECK(orbits[i] < hi); + } + } +} + +// A timeframe without collisions must survive extraction as a valid, empty context +BOOST_AUTO_TEST_CASE(EmptyTimeframeExtracts) +{ + long const orbitsPerTF = 6; + long const nTF = 3; + auto ctx = makeContext({0, 2, 13}); // timeframe 1 (orbits 6..11) is empty + auto indices = ctx.calcTimeframeIndices(0, orbitsPerTF, 0., nTF); + BOOST_CHECK_EQUAL(indices.size(), (size_t)nTF); + + auto tf0 = ctx.extractSingleTimeframe(0, indices, {}); + auto tf1 = ctx.extractSingleTimeframe(1, indices, {}); + auto tf2 = ctx.extractSingleTimeframe(2, indices, {}); + BOOST_CHECK_EQUAL(tf0.getEventRecords().size(), (size_t)2); + BOOST_CHECK_EQUAL(tf1.getEventRecords().size(), (size_t)0); + BOOST_CHECK_EQUAL(tf2.getEventRecords().size(), (size_t)1); + BOOST_CHECK_EQUAL(tf2.getEventRecords()[0].orbit, 13); +} + +// The trailing timeframes of the requested range must be present even when the last collision +// falls well before the end of the range +BOOST_AUTO_TEST_CASE(TrailingTimeframesArePresent) +{ + long const orbitsPerTF = 6; + long const nTF = 9; // this is what an 8-timeframe anchored MC job with orbitsEarly asks for + auto ctx = makeContext({1, 2, 7}); + auto indices = ctx.calcTimeframeIndices(0, orbitsPerTF, 0., nTF); + BOOST_CHECK_EQUAL(indices.size(), (size_t)nTF); + for (int tf = 2; tf < nTF; ++tf) { + BOOST_CHECK(std::get<0>(indices[tf]) > std::get<1>(indices[tf])); // empty, but present + } +} + +// applyMaxCollisionFilter must not shift timeframes when one of them is empty +BOOST_AUTO_TEST_CASE(MaxCollisionFilterKeepsSlots) +{ + long const orbitsPerTF = 6; + long const nTF = 4; + // tf0: orbits 0,1,2 tf1: empty tf2: orbits 12,13 tf3: orbit 19 + auto ctx = makeContext({0, 1, 2, 12, 13, 19}); + auto indices = ctx.calcTimeframeIndices(0, orbitsPerTF, 0., nTF); + ctx.applyMaxCollisionFilter(indices, 0, orbitsPerTF, 2, 0.); // keep at most 2 per timeframe + + BOOST_CHECK_EQUAL(indices.size(), (size_t)nTF); + BOOST_CHECK_EQUAL(std::get<1>(indices[0]) - std::get<0>(indices[0]) + 1, 2); // capped + BOOST_CHECK(std::get<0>(indices[1]) > std::get<1>(indices[1])); // still empty + BOOST_CHECK_EQUAL(std::get<1>(indices[2]) - std::get<0>(indices[2]) + 1, 2); + BOOST_CHECK_EQUAL(std::get<1>(indices[3]) - std::get<0>(indices[3]) + 1, 1); + + auto tf2 = ctx.extractSingleTimeframe(2, indices, {}); + BOOST_CHECK_EQUAL(tf2.getEventRecords().size(), (size_t)2); + BOOST_CHECK_EQUAL(tf2.getEventRecords()[0].orbit, 12); +} + +} // namespace o2 diff --git a/Steer/src/CollisionContextTool.cxx b/Steer/src/CollisionContextTool.cxx index e97eeada3fd0c..f8889d0b83e6d 100644 --- a/Steer/src/CollisionContextTool.cxx +++ b/Steer/src/CollisionContextTool.cxx @@ -20,6 +20,7 @@ #include "SimulationDataFormat/DigitizationContext.h" #include "SimConfig/InteractionDiamondParam.h" #include "DataFormatsFT0/EventsPerBc.h" +#include "CommonConstants/LHCConstants.h" #include #include #include @@ -56,7 +57,8 @@ struct Options { uint32_t firstBC = 0; // first bunch crossing (relative to firstOrbit) of the first interaction; int orbitsPerTF = 256; // number of orbits per timeframe --> used to calculate start orbit for collisions bool useexistingkinematics = false; - bool noEmptyTF = false; // prevent empty timeframes; the first interaction will be shifted backwards to fall within the range given by Options.orbits + bool noEmptyTF = false; // prevent empty timeframes; the first interaction will be shifted backwards to fall within the range given by Options.orbits + bool failOnEmptyTF = false; // stop rather than continue when a timeframe holds no collision int maxCollsPerTF = -1; // the maximal number of hadronic collisions per TF (can be used to constrain number of collisions per timeframe to some maximal value) std::string configKeyValues = ""; // string to init config key values long timestamp = -1; // timestamp for CCDB queries @@ -238,7 +240,8 @@ bool parseOptions(int argc, char* argv[], Options& optvalues) "timeframeID", bpo::value(&optvalues.tfid)->default_value(0), "Timeframe id of the first timeframe int this context. Allows to generate contexts for different start orbits")( "first-orbit", bpo::value(&optvalues.firstFractionalOrbit)->default_value(0), "First (fractional) orbit in the run (HBFUtils.firstOrbit + BC from decimal)")( "maxCollsPerTF", bpo::value(&optvalues.maxCollsPerTF)->default_value(-1), "Maximal number of MC collisions to put into one timeframe. By default no constraint.")( - "noEmptyTF", bpo::bool_switch(&optvalues.noEmptyTF), "Enforce to have at least one collision")( + "noEmptyTF", bpo::bool_switch(&optvalues.noEmptyTF), "Shift the first collision backwards so that it falls within the sampled orbit range")( + "failOnEmptyTF", bpo::bool_switch(&optvalues.failOnEmptyTF), "Stop instead of continuing when one of the timeframes asked for ends up without a collision")( "configKeyValues", bpo::value(&optvalues.configKeyValues)->default_value(""), "Semicolon separated key=value strings (e.g.: 'TPC.gasDensity=1;...')")( "with-vertices", bpo::value(&optvalues.vertexModeString)->default_value("kNoVertex"), "Assign vertices to collisions. Argument is the vertex mode. Defaults to no vertexing applied")( "timestamp", bpo::value(&optvalues.timestamp)->default_value(-1L), "Timestamp for CCDB queries / anchoring")( @@ -660,7 +663,10 @@ int main(int argc, char* argv[]) } LOG(info) << "-------- DENSE CONTEXT ------->>"; - auto timeframeindices = digicontext.calcTimeframeIndices(orbitstart, options.orbitsPerTF, options.orbitsEarly); + // the number of timeframes we were asked for; passing it makes sure that a timeframe without + // collisions keeps its own slot instead of shifting every later timeframe down by one + long const num_timeframes_asked = usetimeframelength ? (orbits_total / options.orbitsPerTF) : -1; + auto timeframeindices = digicontext.calcTimeframeIndices(orbitstart, options.orbitsPerTF, options.orbitsEarly, num_timeframes_asked); LOG(info) << "Fixed " << timeframeindices.size() << " timeframes "; for (auto p : timeframeindices) { LOG(info) << std::get<0>(p) << " " << std::get<1>(p) << " " << std::get<2>(p); @@ -684,6 +690,45 @@ int main(int argc, char* argv[]) auto numTimeFrames = timeframeindices.size(); // digicontext.finalizeTimeframeStructure(orbitstart, options.orbitsPerTF, options.orbitsEarly); + // report - and, if asked, refuse - timeframes without a single collision. A timeframe with no + // collision cannot be simulated, and the rest of the MC workflow expects one collision context + // file per timeframe, so this has to be visible here and not five hours later in the simulation. + { + std::vector empty_timeframes; + auto const first_real_tf = options.orbitsEarly > 0. ? 1 : 0; + for (int tf_id = first_real_tf; tf_id < (int)numTimeFrames; ++tf_id) { + if (std::get<0>(timeframeindices[tf_id]) > std::get<1>(timeframeindices[tf_id])) { + empty_timeframes.push_back(tf_id - first_real_tf + 1); + } + } + if (!empty_timeframes.empty()) { + std::stringstream tflist; + for (auto tf : empty_timeframes) { + tflist << " tf" << tf; + } + // the mean number of collisions in one timeframe, from the rate we were given + auto const tf_length_s = options.orbitsPerTF * o2::constants::lhc::LHCOrbitMUS * 1e-6; + double rate = 0.; + for (auto& p : ispecs) { + rate = std::max(rate, (double)p.interactionRate); + } + auto const mu_per_tf = rate * tf_length_s; + LOG(warn) << empty_timeframes.size() << " of " << (numTimeFrames - first_real_tf) + << " timeframes contain no collision:" << tflist.str(); + LOG(warn) << "with interaction rate " << rate << " Hz and " << options.orbitsPerTF + << " orbits per timeframe there are only " << mu_per_tf + << " collisions per timeframe on average, so a fraction " << std::exp(-mu_per_tf) + << " of the timeframes comes out empty"; + if (mu_per_tf > 0.) { + LOG(warn) << "use at least " << (int)std::ceil(8. / (rate * o2::constants::lhc::LHCOrbitMUS * 1e-6)) + << " orbits per timeframe to keep that fraction below 1 per mille"; + } + if (options.failOnEmptyTF) { + LOG(fatal) << "--failOnEmptyTF was requested and timeframes without collisions were produced; refusing to continue"; + } + } + } + if (options.vertexMode != o2::conf::VertexMode::kNoVertex) { switch (options.vertexMode) { case o2::conf::VertexMode::kCCDB: { From b0f2cd441207f4a9df2ee4af14ebbd752baa9a70 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Mon, 24 Aug 2026 11:44:32 +0200 Subject: [PATCH 2/6] Let the TPC looper generator accept a timeframe without collisions This fixes a problem in GenTPCLoopers::setFlatGas when the collision context of a timeframe is empty. - A timeframe holds no collision whenever the interaction rate is low enough, and the generator called exit(1) on it. - The extent of the timeframe now comes from HBFUtils in that case, which is where it is defined, instead of from the last collision. - With a single collision in the timeframe the mean interaction spacing was divided by zero; it is now taken from the interaction rate stored in the collision context. https://its.cern.ch/jira/browse/O2-7132 Co-Authored-By: Claude Opus 5 --- Generators/src/TPCLoopers.cxx | 36 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Generators/src/TPCLoopers.cxx b/Generators/src/TPCLoopers.cxx index 6e5af7c0c84d8..28a66c1a27b7d 100644 --- a/Generators/src/TPCLoopers.cxx +++ b/Generators/src/TPCLoopers.cxx @@ -398,23 +398,35 @@ void GenTPCLoopers::setFlatGas(Bool_t flat, Int_t number, Int_t nloopers_orbit) mContextFile = std::filesystem::exists("collisioncontext.root") ? TFile::Open("collisioncontext.root") : nullptr; mCollisionContext = mContextFile ? (o2::steer::DigitizationContext*)mContextFile->Get("DigitizationContext") : nullptr; mInteractionTimeRecords = mCollisionContext ? mCollisionContext->getEventRecords() : std::vector{}; + const auto& hbfUtils = o2::raw::HBFUtils::Instance(); if (mInteractionTimeRecords.empty()) { - LOG(error) << "Error: No interaction time records found in the collision context!"; - exit(1); + // A timeframe can legitimately contain no collision at all when the interaction rate is + // low. No event is transported in that case, so nothing below is ever used; take the + // extent of the timeframe from HBFUtils rather than from the (absent) collisions. + LOG(warn) << "No interaction time records in the collision context; this timeframe holds no collision"; + o2::InteractionRecord tfEndIR(0, hbfUtils.orbitFirstSampled + hbfUtils.nHBFPerTF); + mTimeEnd = tfEndIR.bc2ns(); } else { LOG(info) << "Interaction Time records has " << mInteractionTimeRecords.size() << " entries."; mCollisionContext->printCollisionSummary(); + for (int c = 0; c < (int)mInteractionTimeRecords.size() - 1; c++) { + mIntTimeRecMean += mInteractionTimeRecords[c + 1].bc2ns() - mInteractionTimeRecords[c].bc2ns(); + } + if (mInteractionTimeRecords.size() > 1) { + mIntTimeRecMean /= (mInteractionTimeRecords.size() - 1); // Average interaction time record used as reference + } else { + // a single collision gives no spacing to average; use the one implied by the rate + auto rate = mCollisionContext->getDigitizerInteractionRate(); + mIntTimeRecMean = rate > 0. ? 1.e9 / rate : (double)o2::constants::lhc::LHCOrbitNS; + LOG(info) << "Only one collision in this timeframe; taking " << mIntTimeRecMean + << " ns as the mean interaction spacing from the interaction rate"; + } + // Get the start time of the second orbit after the last interaction record + const auto& lastIR = mInteractionTimeRecords.back(); + o2::InteractionRecord finalOrbitIR(0, lastIR.orbit + 2); // Final orbit, BC = 0 + mTimeEnd = finalOrbitIR.bc2ns(); + LOG(debug) << "Final orbit start time: " << mTimeEnd << " ns while last interaction record time is " << mInteractionTimeRecords.back().bc2ns() << " ns"; } - for (int c = 0; c < mInteractionTimeRecords.size() - 1; c++) { - mIntTimeRecMean += mInteractionTimeRecords[c + 1].bc2ns() - mInteractionTimeRecords[c].bc2ns(); - } - mIntTimeRecMean /= (mInteractionTimeRecords.size() - 1); // Average interaction time record used as reference - const auto& hbfUtils = o2::raw::HBFUtils::Instance(); - // Get the start time of the second orbit after the last interaction record - const auto& lastIR = mInteractionTimeRecords.back(); - o2::InteractionRecord finalOrbitIR(0, lastIR.orbit + 2); // Final orbit, BC = 0 - mTimeEnd = finalOrbitIR.bc2ns(); - LOG(debug) << "Final orbit start time: " << mTimeEnd << " ns while last interaction record time is " << mInteractionTimeRecords.back().bc2ns() << " ns"; } } else { mFlatGasNumber = -1; From 44bed6002b38ff9fab26bda0a77a79d099bf955e Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Mon, 24 Aug 2026 11:44:32 +0200 Subject: [PATCH 3/6] Let the MCH digitiser accept a timeframe without collisions This fixes a segmentation fault in MCHDPLDigitizerTask when the collision context of a timeframe is empty. - The noise-only signal range was taken from eventRecords.front() and eventRecords.back(), which is undefined behaviour on an empty vector. - A timeframe holds no collision whenever the interaction rate is low. - The range now comes from HBFUtils in that case, so the noise covers the timeframe that is actually being digitised. https://its.cern.ch/jira/browse/O2-7132 Co-Authored-By: Claude Opus 5 --- .../DigitizerWorkflow/src/MCHDigitizerSpec.cxx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Steer/DigitizerWorkflow/src/MCHDigitizerSpec.cxx b/Steer/DigitizerWorkflow/src/MCHDigitizerSpec.cxx index e2a6397f1a2cf..51102ce2421ca 100644 --- a/Steer/DigitizerWorkflow/src/MCHDigitizerSpec.cxx +++ b/Steer/DigitizerWorkflow/src/MCHDigitizerSpec.cxx @@ -15,6 +15,7 @@ #include "DataFormatsMCH/ROFRecord.h" #include "DataFormatsParameters/GRPObject.h" #include "DetectorsBase/BaseDPLDigitizer.h" +#include "DetectorsRaw/HBFUtils.h" #include "Framework/ConfigParamRegistry.h" #include "Framework/ControlService.h" #include "Framework/DataProcessorSpec.h" @@ -102,9 +103,20 @@ class MCHDPLDigitizerTask : public o2::base::BaseDPLDigitizer } } - // generate noise-only signals between first and last collisions ± 100 BC (= 25 ADC samples) - auto firstIR = InteractionRecord::long2IR(std::max(int64_t(0), eventRecords.front().toLong() - timeOffset - 100)); - auto lastIR = InteractionRecord::long2IR(std::max(int64_t(0), eventRecords.back().toLong() - timeOffset + 100)); + // generate noise-only signals between first and last collisions ± 100 BC (= 25 ADC samples). + // A timeframe can hold no collision at all when the interaction rate is low; take the range + // from the timeframe itself in that case, since there are no collisions to take it from. + int64_t firstLong, lastLong; + if (eventRecords.empty()) { + const auto& hbf = o2::raw::HBFUtils::Instance(); + firstLong = InteractionRecord(0, hbf.orbitFirstSampled).toLong(); + lastLong = InteractionRecord(0, hbf.orbitFirstSampled + hbf.nHBFPerTF).toLong(); + } else { + firstLong = eventRecords.front().toLong(); + lastLong = eventRecords.back().toLong(); + } + auto firstIR = InteractionRecord::long2IR(std::max(int64_t(0), firstLong - timeOffset - 100)); + auto lastIR = InteractionRecord::long2IR(std::max(int64_t(0), lastLong - timeOffset + 100)); mDigitizer->addNoise(firstIR, lastIR); // digitize From 2fb004f8815f10bf89000d736770a6585861247a Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Mon, 24 Aug 2026 11:44:33 +0200 Subject: [PATCH 4/6] Write one empty TPC digit entry when a timeframe has no collision This fixes the TPC digit writer producing a file without a tree when a timeframe holds no collision. - The custom close callback only called TFile::Close inside "if (entries > 0)", and never called TFile::Write, so with nothing to write the tree never reached the file. - The result was a 942 byte file with no o2sim tree, and every reader of it failed on a missing branch rather than on an empty tree. - Each branch is now filled once with the empty default object it is bound to, so the file is an ordinary timeframe that happens to contain no digit and the readers downstream stay on their normal path. - The tree is written explicitly, the way RootTreeWriter's own close does. https://its.cern.ch/jira/browse/O2-7132 Co-Authored-By: Claude Opus 5 --- .../src/TPCDigitRootWriterSpec.cxx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Detectors/TPC/simworkflow/src/TPCDigitRootWriterSpec.cxx b/Detectors/TPC/simworkflow/src/TPCDigitRootWriterSpec.cxx index a907a73281884..a14f2c3620725 100644 --- a/Detectors/TPC/simworkflow/src/TPCDigitRootWriterSpec.cxx +++ b/Detectors/TPC/simworkflow/src/TPCDigitRootWriterSpec.cxx @@ -69,12 +69,25 @@ DataProcessorSpec getTPCDigitRootWriterSpec(std::vector const& laneConfigur LOG(warning) << "INCONSISTENT NUMBER OF ENTRIES IN BRANCH " << br->GetName() << ": " << entries << " vs " << brentries; } } - if (entries > 0) { - LOG(info) << "Setting entries to " << entries; - outputtree->SetEntries(entries); - // outputtree->Write("", TObject::kOverwrite); - outputfile->Close(); + if (entries <= 0) { + // A timeframe holds no collision at all whenever the interaction rate is low enough, and + // then no branch is filled. Write one empty entry in every branch instead of nothing, so + // that the file is an ordinary timeframe that happens to contain no digit and every reader + // downstream stays on its normal path. Each branch is bound to a default constructed object + // of its own type by RootTreeWriter, so Fill() writes exactly that. + LOG(info) << "No branch was filled, writing one empty entry per branch"; + for (TObject* entry : *brlist) { + static_cast(entry)->Fill(); + } + entries = 1; } + LOG(info) << "Setting entries to " << entries; + outputtree->SetEntries(entries); + // write the tree explicitly, the way RootTreeWriter's own close does. Closing the file alone + // leaves an empty tree without a key, so the file comes out with no tree in it at all. + // kOverwrite matters: without it a second cycle of the tree is written next to the first. + outputfile->Write("", TObject::kOverwrite); + outputfile->Close(); }; // branch definitions for RootTreeWriter spec From 41757d62a6161a873cd4c6887e13b144dc431f76 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Mon, 24 Aug 2026 11:44:33 +0200 Subject: [PATCH 5/6] Give the ITS and MFT digit file its usual shape when it is empty This fixes the digit file of a timeframe without collisions carrying a label branch of the wrong type. - The MC label branch is declared as std::vector and only becomes an IOMCTruthContainerView when a fill remaps it. - With no collision nothing is filled, the branch keeps the raw type, and a reader binding IOMCTruthContainerView gets a class mismatch from SetBranchAddress rather than an empty tree. - The close callback now writes one empty entry in every branch, remapping the label branch on the way, so the file has the same shape as an ordinary timeframe that happens to contain nothing. https://its.cern.ch/jira/browse/O2-7132 Co-Authored-By: Claude Opus 5 --- .../common/workflow/src/DigitWriterSpec.cxx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Detectors/ITSMFT/common/workflow/src/DigitWriterSpec.cxx b/Detectors/ITSMFT/common/workflow/src/DigitWriterSpec.cxx index 944432196881e..4d245608730f1 100644 --- a/Detectors/ITSMFT/common/workflow/src/DigitWriterSpec.cxx +++ b/Detectors/ITSMFT/common/workflow/src/DigitWriterSpec.cxx @@ -23,6 +23,7 @@ #include "DataFormatsITSMFT/ROFRecord.h" #include "SimulationDataFormat/ConstMCTruthContainer.h" #include "SimulationDataFormat/IOMCTruthContainerView.h" +#include "Framework/Logger.h" #include "SimulationDataFormat/MCCompLabel.h" #include #include @@ -73,6 +74,31 @@ DataProcessorSpec getDigitWriterSpec(bool mctruth, bool doStag, bool dec, bool c } nent = n; } + if (nent == 0) { + // A timeframe holds no collision at all whenever the interaction rate is low enough, and no + // branch is then filled. Write one empty entry in every branch, so that the file has the same + // shape as an ordinary timeframe that happens to contain nothing. The label branch matters + // here: it is declared as std::vector and only becomes an IOMCTruthContainerView when a + // fill remaps it, so without this a reader binding that type gets a class mismatch instead of + // an empty tree. + LOG(info) << "No branch was filled, writing one empty entry per branch"; + std::vector branches; + for (auto* o : *brArr) { + branches.push_back((TBranch*)o); + } + o2::dataformats::IOMCTruthContainerView emptyLabels; + auto* labelptr = &emptyLabels; + for (auto* br : branches) { + if (TString(br->GetName()).Contains("MCTruth")) { + auto* remapped = framework::RootTreeWriter::remapBranch(*br, &labelptr); + remapped->Fill(); + remapped->ResetAddress(); + } else { + br->Fill(); + } + } + nent = 1; + } outputtree->SetEntries(nent); // do not use TTree::Write .. as this writes to default directory (not the associated file) // instead of outputtree->Write("", TObject::kOverwrite) From bb0d88258ea5f5d488a8bf4e23eb4a9845c6e7c0 Mon Sep 17 00:00:00 2001 From: ALICE Action Bot Date: Mon, 24 Aug 2026 11:16:11 +0000 Subject: [PATCH 6/6] Please consider the following formatting changes --- DataFormats/simulation/src/DigitizationContext.cxx | 2 +- DataFormats/simulation/test/testDigitizationContext.cxx | 2 +- Steer/src/CollisionContextTool.cxx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DataFormats/simulation/src/DigitizationContext.cxx b/DataFormats/simulation/src/DigitizationContext.cxx index 1c41dce797cc4..8a298d35de3a3 100644 --- a/DataFormats/simulation/src/DigitizationContext.cxx +++ b/DataFormats/simulation/src/DigitizationContext.cxx @@ -535,7 +535,7 @@ void DigitizationContext::applyMaxCollisionFilter(std::vector= 0 ? previndex : firstindex; index <= lastindex; ++index) { diff --git a/DataFormats/simulation/test/testDigitizationContext.cxx b/DataFormats/simulation/test/testDigitizationContext.cxx index 122c13cbcdd25..af9d7bea98019 100644 --- a/DataFormats/simulation/test/testDigitizationContext.cxx +++ b/DataFormats/simulation/test/testDigitizationContext.cxx @@ -76,7 +76,7 @@ BOOST_AUTO_TEST_CASE(EmptyTimeframeExtracts) { long const orbitsPerTF = 6; long const nTF = 3; - auto ctx = makeContext({0, 2, 13}); // timeframe 1 (orbits 6..11) is empty + auto ctx = makeContext({0, 2, 13}); // timeframe 1 (orbits 6..11) is empty auto indices = ctx.calcTimeframeIndices(0, orbitsPerTF, 0., nTF); BOOST_CHECK_EQUAL(indices.size(), (size_t)nTF); diff --git a/Steer/src/CollisionContextTool.cxx b/Steer/src/CollisionContextTool.cxx index f8889d0b83e6d..5e0413b35896d 100644 --- a/Steer/src/CollisionContextTool.cxx +++ b/Steer/src/CollisionContextTool.cxx @@ -57,7 +57,7 @@ struct Options { uint32_t firstBC = 0; // first bunch crossing (relative to firstOrbit) of the first interaction; int orbitsPerTF = 256; // number of orbits per timeframe --> used to calculate start orbit for collisions bool useexistingkinematics = false; - bool noEmptyTF = false; // prevent empty timeframes; the first interaction will be shifted backwards to fall within the range given by Options.orbits + bool noEmptyTF = false; // prevent empty timeframes; the first interaction will be shifted backwards to fall within the range given by Options.orbits bool failOnEmptyTF = false; // stop rather than continue when a timeframe holds no collision int maxCollsPerTF = -1; // the maximal number of hadronic collisions per TF (can be used to constrain number of collisions per timeframe to some maximal value) std::string configKeyValues = ""; // string to init config key values