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..2425f67 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,24 @@ +#!/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 + +# 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 + if ! [ -s "$f" ]; then + echo "missing output: $f" >&2 + 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) diff --git a/tools/make_test_input.cpp b/tools/make_test_input.cpp new file mode 100644 index 0000000..e4a76c6 --- /dev/null +++ b/tools/make_test_input.cpp @@ -0,0 +1,79 @@ +// 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 +#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; + // 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"); + 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', + }, + }, +}