From 0459a7abeaa5c1397e934d4487fed35e405943ef Mon Sep 17 00:00:00 2001 From: Oliver Lantwin Date: Tue, 14 Jul 2026 14:41:26 +0200 Subject: [PATCH 1/3] feat: add smoke-test workflow The pixi smoke task referenced a script that did not exist. Add the missing pieces: a small deterministic generator writing a simulation-like RNTuple covering all detector IDs (no Geant4 or aegir needed, so CI stays self-contained), a workflow running the full digitise-and-output chain over it, and a script asserting the outputs are produced. Run it in CI after the build. --- .github/workflows/pixi-build.yml | 2 +- CMakeLists.txt | 16 +++++++ scripts/smoke.sh | 20 +++++++++ tools/make_test_input.cpp | 73 ++++++++++++++++++++++++++++++++ workflows/smoke.jsonnet | 33 +++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100755 scripts/smoke.sh create mode 100644 tools/make_test_input.cpp create mode 100644 workflows/smoke.jsonnet diff --git a/.github/workflows/pixi-build.yml b/.github/workflows/pixi-build.yml index 5092a3b..93fd3a2 100644 --- a/.github/workflows/pixi-build.yml +++ b/.github/workflows/pixi-build.yml @@ -16,4 +16,4 @@ jobs: build: uses: ShipSoft/.github/.github/workflows/pixi-cmake-build.yml@main with: - tasks: '["configure", "build", "install", "test"]' + tasks: '["configure", "build", "install", "test", "smoke"]' diff --git a/CMakeLists.txt b/CMakeLists.txt index 719baa6..cdcaf8a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,22 @@ target_link_libraries( spdlog::spdlog ) +# Smoke-test input generator (build-only, not installed) +add_executable(make_test_input tools/make_test_input.cpp) +target_include_directories( + make_test_input + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" +) +target_include_directories( + make_test_input + SYSTEM + PRIVATE "${Random123_INCLUDE_DIR}" +) +target_link_libraries( + make_test_input + PRIVATE SHiP::SHiPDataModel ROOT::RIO ROOT::ROOTNTuple +) + # Install include(GNUInstallDirs) diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..fa9f5da --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 CERN for the benefit of the SHiP Collaboration +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +# Quick smoke test for CI: generate a small simulation-like input, run the +# digitisation workflow end to end, and check that the outputs were produced. +set -euo pipefail + +./build/make_test_input smoke_input.root 10 + +phlex -c workflows/smoke.jsonnet + +for f in smoke_digi_output.root smoke_digi_validation.root; do + if ! [ -s "$f" ]; then + echo "missing output: $f" >&2 + exit 1 + fi +done +echo "Smoke test OK" diff --git a/tools/make_test_input.cpp b/tools/make_test_input.cpp new file mode 100644 index 0000000..8e050bf --- /dev/null +++ b/tools/make_test_input.cpp @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 CERN for the benefit of the SHiP Collaboration +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +// make_test_input.cpp — deterministic simulation-like input for smoke tests +// +// Writes an RNTuple in the layout produced by the simulation chain +// (sim_particles + sim_hits per event), with hits covering all detector +// IDs, so the digitisation workflow can run without Geant4 or aegir. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char* argv[]) { + std::string const filename = argc > 1 ? argv[1] : "smoke_input.root"; + auto const n_events = argc > 2 ? std::stoul(argv[2]) : 10UL; + + auto model = ROOT::RNTupleModel::Create(); + auto particles = model->MakeField>("sim_particles"); + auto hits = model->MakeField>("sim_hits"); + auto writer = ROOT::RNTupleWriter::Recreate(std::move(model), "events", filename); + + constexpr SHiP::detector_id detectors[] = { + SHiP::detector_id::UpstreamTagger, SHiP::detector_id::SurroundTagger, + SHiP::detector_id::StrawTubes, SHiP::detector_id::Calorimeter, + SHiP::detector_id::TimingDetector}; + + for (std::uint32_t event = 0; event < n_events; ++event) { + Shannon::PhiloxRng rng{0, 0x7E57DA7A, event}; + + particles->clear(); + hits->clear(); + + auto const n_tracks = 1 + static_cast(rng.uniform(0.0, 3.0)); + for (int track = 0; track < n_tracks; ++track) { + SHiP::SimParticle particle; + particle.trackId = track; + particle.parentId = -1; + particle.pdgCode = 13; + particle.momentum = {rng.uniform(-0.5, 0.5), rng.uniform(-0.5, 0.5), + rng.uniform(10.0, 100.0)}; + particle.energy = particle.momentum[2]; + particles->push_back(particle); + + for (auto const detector : detectors) { + SHiP::SimHit hit; + hit.detectorId = static_cast(detector); + hit.trackId = track; + hit.pdgCode = particle.pdgCode; + hit.position = {rng.uniform(-500.0, 500.0), rng.uniform(-500.0, 500.0), + rng.uniform(0.0, 10000.0)}; + hit.momentum = particle.momentum; + hit.energyDeposit = rng.uniform(0.0, 0.1); + hit.time = rng.uniform(0.0, 100.0); + hit.pathLength = rng.uniform(0.0, 10.0); + hits->push_back(hit); + } + } + writer->Fill(); + } + + std::printf("Wrote %lu events to %s\n", n_events, filename.c_str()); + return 0; +} diff --git a/workflows/smoke.jsonnet b/workflows/smoke.jsonnet new file mode 100644 index 0000000..dcebeda --- /dev/null +++ b/workflows/smoke.jsonnet @@ -0,0 +1,33 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + spill: { parent: 'job', total: 10 }, + }, + }, + + sources: { + rntuple_source: { + cpp: 'read_sim_file', + input_file: 'smoke_input.root', + ntuple_name: 'events', + layer: 'spill', + }, + }, + + modules: { + digitise_hits: { + cpp: 'digitise_hits', + layer: 'spill', + seed: 42, + }, + digitised_output: { + cpp: 'digitised_output_module', + mode: 'digi', + rntuple_file: 'smoke_digi_output.root', + histo_file: 'smoke_digi_validation.root', + creator: 'digitise_hits', + layer: 'spill', + }, + }, +} From a90cab4eb86f8d72566900b61a948c0e26eeed42 Mon Sep 17 00:00:00 2001 From: Oliver Lantwin Date: Wed, 22 Jul 2026 12:06:47 +0200 Subject: [PATCH 2/3] fix: remove stale smoke outputs and validate contents with ROOT Delete previous digitisation outputs before running phlex so stale files cannot pass the checks, and verify with PyROOT that the expected per-detector RNTuples and validation histograms exist and have entries. --- scripts/smoke.sh | 4 ++ scripts/validate_smoke.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 scripts/validate_smoke.py diff --git a/scripts/smoke.sh b/scripts/smoke.sh index fa9f5da..2425f67 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -9,6 +9,9 @@ set -euo pipefail ./build/make_test_input smoke_input.root 10 +# Remove outputs from previous runs so stale files cannot pass the checks. +rm -f smoke_digi_output.root smoke_digi_validation.root + phlex -c workflows/smoke.jsonnet for f in smoke_digi_output.root smoke_digi_validation.root; do @@ -17,4 +20,5 @@ for f in smoke_digi_output.root smoke_digi_validation.root; do exit 1 fi done +python scripts/validate_smoke.py smoke_digi_output.root smoke_digi_validation.root echo "Smoke test OK" diff --git a/scripts/validate_smoke.py b/scripts/validate_smoke.py new file mode 100644 index 0000000..da014e9 --- /dev/null +++ b/scripts/validate_smoke.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 CERN for the benefit of the SHiP Collaboration +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +"""Validate the smoke-test outputs of the digitisation workflow. + +Checks that the digitised-output file contains all per-detector RNTuples and +that the validation file contains all histograms, each with entries. +""" + +import os +import sys + +import ROOT + +NTUPLES = [ + "calorimeter_hits", + "sbt_hits", + "straw_tubes_hits", + "timing_detector_hits", + "ubt_hits", +] + +HISTOGRAMS = [ + "h_ubt_multiplicity", + "h_sbt_multiplicity", + "h_straw_tubes_multiplicity", + "h_calorimeter_multiplicity", + "h_timing_detector_multiplicity", + "h_hit_x", + "h_hit_y", + "h_hit_z", +] + + +def check_ntuples(path): + errors = [] + for name in NTUPLES: + try: + reader = ROOT.RNTupleReader.Open(name, path) + except Exception as e: + errors.append(f"{path}: cannot open RNTuple '{name}': {e}") + continue + if reader.GetNEntries() == 0: + errors.append(f"{path}: RNTuple '{name}' has no entries") + return errors + + +def check_histograms(path): + try: + file = ROOT.TFile.Open(path) + except OSError as e: + return [f"{path}: cannot open file: {e}"] + if not file or file.IsZombie(): + return [f"{path}: cannot open file"] + errors = [] + for name in HISTOGRAMS: + hist = file.Get(name) + if not hist: + errors.append(f"{path}: missing histogram '{name}'") + elif hist.GetEntries() == 0: + errors.append(f"{path}: histogram '{name}' has no entries") + return errors + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + errors = check_ntuples(sys.argv[1]) + check_histograms(sys.argv[2]) + for error in errors: + print(error, file=sys.stderr) + sys.stdout.flush() + sys.stderr.flush() + # Skip interpreter shutdown to dodge a PyROOT RNTuple teardown crash. + os._exit(1 if errors else 0) From 5073f64e6f09917d5ce21c8508838437dd8e346f Mon Sep 17 00:00:00 2001 From: Oliver Lantwin Date: Wed, 22 Jul 2026 12:06:47 +0200 Subject: [PATCH 3/3] fix: reject event counts exceeding the uint32_t event counter --- tools/make_test_input.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/make_test_input.cpp b/tools/make_test_input.cpp index 8e050bf..e4a76c6 100644 --- a/tools/make_test_input.cpp +++ b/tools/make_test_input.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,11 @@ int main(int argc, char* argv[]) { std::string const filename = argc > 1 ? argv[1] : "smoke_input.root"; auto const n_events = argc > 2 ? std::stoul(argv[2]) : 10UL; + // The event loop counts (and seeds the RNG) with a uint32_t. + if (n_events > std::numeric_limits::max()) { + std::fprintf(stderr, "n_events out of range: %lu\n", n_events); + return 1; + } auto model = ROOT::RNTupleModel::Create(); auto particles = model->MakeField>("sim_particles");