From 08c5eaaef40c113cd957d55264fbbb5aa8748d67 Mon Sep 17 00:00:00 2001 From: Sandro Wenzel Date: Thu, 17 Sep 2026 00:06:13 +0200 Subject: [PATCH] Shift the first orbit along with the ITS ramp-up start of run This fixes a problem in the ITS ramp-up anchoring shift and adds a unit test. - The shift moved --sor and --timestamp but left --first-orbit at the orbit of start-of-run, while the timeframes a job simulates are placed by orbit. - A job at production offset 0 therefore still sampled the first orbit of the run, where ITS/Calib/TimeDeadMap masks all 24120 chips, and wrote no ITS digits at all: no ITS tracks, no reconstructed collisions, and an AOD whose collision table is empty. - HBFUtils.startTime and HBFUtils.orbitFirst also disagreed by the ramp-up duration. - Both coordinates now move together in shift_anchor_past_ITS_rampup. - For run 571781 with SPLITID=1 the first orbit moves from 20505888 to 20562116, past the 34080 orbits that run's dead map masks. https://its.cern.ch/jira/browse/O2-6565 https://its.cern.ch/jira/browse/O2-6894 Co-Authored-By: Claude Opus 5 --- MC/bin/o2dpg_sim_workflow_anchored.py | 25 ++++++++-- MC/bin/tests/test_anchoring_rampup.py | 69 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 MC/bin/tests/test_anchoring_rampup.py diff --git a/MC/bin/o2dpg_sim_workflow_anchored.py b/MC/bin/o2dpg_sim_workflow_anchored.py index e6ad5a803..c8a815598 100755 --- a/MC/bin/o2dpg_sim_workflow_anchored.py +++ b/MC/bin/o2dpg_sim_workflow_anchored.py @@ -320,6 +320,24 @@ def retrieve_ITS_RampDuration(ccdbreader, timestamp): print("WARNING: ITS ramp duration vector is empty, using 0") return 0 +def milliseconds_to_orbits(milliseconds): + """ + Number of complete LHC orbits contained in a time span given in milliseconds. + Rounded up, so that a span used to skip something is never cut short. + """ + return int(math.ceil(1000. * milliseconds / LHCOrbitMUS)) + +def shift_anchor_past_ITS_rampup(run_start, first_orbit, ITS_rampup): + """ + Moves the anchoring point past the ITS ramp-up period, in both of its + coordinates, and returns the pair (start of run in ms, first orbit). + The two have to move together: the timeframes a job simulates are placed by + orbit (first orbit plus the production offset), so shifting only the + timestamp leaves a job at production offset 0 inside the ramp, where the ITS + time-dead map masks every chip. + """ + return run_start + ITS_rampup, first_orbit + milliseconds_to_orbits(ITS_rampup) + def retrieve_MinBias_CTPScaler_Rate(raw_rate_at, finaltime, trig_eff_arg, NBunches, ColSystem, eCM, run_number = -1): """ Turns the raw CTP counting rate at finaltime (in milliseconds) into the interaction rate for @@ -590,10 +608,10 @@ def main(): run_start = GLOparams["SOR"] run_end = GLOparams["EOR"] - # Adjust start of run using ITS ramp-up period + # Adjust the anchoring point using the ITS ramp-up period ITS_rampup = retrieve_ITS_RampDuration(ccdbreader, run_start) print(f"ITS ramp-up time: {ITS_rampup} ms") - effective_run_start = run_start + ITS_rampup + effective_run_start, effective_first_orbit = shift_anchor_past_ITS_rampup(run_start, GLOparams["FirstOrbit"], ITS_rampup) mid_run_timestamp = (effective_run_start + run_end) // 2 # -------- @@ -676,6 +694,7 @@ def main(): # this is anchored to print ("Determined start-of-run to be: ", effective_run_start) + print ("Determined first orbit to be: ", effective_first_orbit) print ("Determined end-of-run to be: ", run_end) print ("Determined timestamp to be : ", timestamp) print ("Determined offset to be : ", prod_offset) @@ -721,7 +740,7 @@ def main(): # needs to be handled as further below: energyarg = (" -eCM " + str(eCM)) if A1 == A2 else (" -eA " + str(eA) + " -eB " + str(eB)) forwardargs += " -tf " + str(args.tf) + " --sor " + str(effective_run_start) + " --timestamp " + str(timestamp) + " --production-offset " + str(prod_offset) + " -run " + str(args.run_number) + " --run-anchored --first-orbit " \ - + str(GLOparams["FirstOrbit"]) + " --orbitsPerTF " + str(GLOparams["OrbitsPerTF"]) + str(energyarg) + + str(effective_first_orbit) + " --orbitsPerTF " + str(GLOparams["OrbitsPerTF"]) + str(energyarg) # the following options can be overwritten/influenced from the outside if not '-col' in forwardargs: forwardargs += ' -col ' + ColSystem diff --git a/MC/bin/tests/test_anchoring_rampup.py b/MC/bin/tests/test_anchoring_rampup.py new file mode 100644 index 000000000..2dac50f68 --- /dev/null +++ b/MC/bin/tests/test_anchoring_rampup.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Offline test: the ITS ramp-up shift must move the first orbit, not only the timestamp.""" +import os +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) + +os.environ.setdefault("O2DPG_ROOT", os.path.dirname(os.path.dirname(os.path.dirname(HERE)))) + +try: + import o2dpg_sim_workflow_anchored as anchored # needs ROOT +except ImportError as exc: # pragma: no cover + anchored = None + IMPORT_ERROR = exc + +# Run 571781 of LHC26e9, whose SPLITID=1 job reconstructed no collisions at all: +# the ITS time-dead map masks all 24120 chips up to orbit 20539968 and the job was +# placed at the run's first orbit. See https://its.cern.ch/jira/browse/O2-6894 +FIRST_ORBIT = 20505888 +SOR = 1778806732526 +ITS_RAMPUP_MS = 5000 +FIRST_ALIVE_ORBIT = 20539968 + + +@unittest.skipIf(anchored is None, "o2dpg_sim_workflow_anchored not importable (needs ROOT)") +class TestRampUpShift(unittest.TestCase): + + def test_orbits_from_milliseconds(self): + orbit_ms = anchored.LHCOrbitMUS / 1000. + self.assertEqual(anchored.milliseconds_to_orbits(0), 0) + # a span of one orbit minus an epsilon still has to cover a full orbit + self.assertEqual(anchored.milliseconds_to_orbits(0.5 * orbit_ms), 1) + self.assertEqual(anchored.milliseconds_to_orbits(orbit_ms), 1) + + def test_both_coordinates_move(self): + """A ramp-up of a few seconds must move the orbit as well as the timestamp.""" + start, orbit = anchored.shift_anchor_past_ITS_rampup(SOR, FIRST_ORBIT, ITS_RAMPUP_MS) + self.assertEqual(start, SOR + ITS_RAMPUP_MS) + self.assertGreater(orbit, FIRST_ORBIT) + + def test_nothing_moves_without_a_ramp(self): + self.assertEqual(anchored.shift_anchor_past_ITS_rampup(SOR, FIRST_ORBIT, 0), + (SOR, FIRST_ORBIT)) + + def test_shifted_orbit_is_never_inside_the_ramp(self): + """The shifted orbit must sit at or after the shifted timestamp, never before.""" + for ramp_ms in (0, 1, 500, ITS_RAMPUP_MS, 30000): + start, orbit = anchored.shift_anchor_past_ITS_rampup(SOR, FIRST_ORBIT, ramp_ms) + time_of_orbit = SOR + (orbit - FIRST_ORBIT) * anchored.LHCOrbitMUS / 1000. + self.assertGreaterEqual(time_of_orbit, start, + f"orbit shift falls short of the ramp for {ramp_ms} ms") + + def test_shift_agrees_with_the_timestamp_to_orbit_conversion(self): + """Closure: the shifted orbit is what main() derives from the shifted timestamp.""" + start, orbit = anchored.shift_anchor_past_ITS_rampup(SOR, FIRST_ORBIT, ITS_RAMPUP_MS) + # this is the conversion main() uses for the exclude_timestamp() check + derived = FIRST_ORBIT + int((start - SOR) / (anchored.LHCOrbitMUS / 1000.)) + self.assertLessEqual(abs(orbit - derived), 1) + + def test_split_id_one_clears_the_its_dead_window(self): + """Regression: the first job of a production must not sample the dead window.""" + _, orbit = anchored.shift_anchor_past_ITS_rampup(SOR, FIRST_ORBIT, ITS_RAMPUP_MS) + self.assertGreater(orbit, FIRST_ALIVE_ORBIT) + + +if __name__ == "__main__": + unittest.main()